Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ The app checks for new releases in-app and updates itself.

Settings → **Tray icon** picks what sits next to the clock. Settings → **Taskbar strip** puts logos with session and weekly numbers into the taskbar: choose up to 6 providers and their order, the font and size, and a color scale that warms up as you use more.

Choose **Left** to place the strip at the taskbar's left edge, or **Right** to keep it beside the notification area. **Offset (px)** adds spacing inward from that edge (0–4096, scaled with the display). The default stays Right with an 8 px offset. Placement is saved and applies to every selected taskbar. Large offsets are clamped to keep the strip on the taskbar and outside the tray; if the strip is too wide to fit, it hides. It does not reserve space for Start, Widgets or app buttons, so adjust the offset to avoid them.

## At Home on Windows 11

![The panel in the pure black dark theme and in the light theme](docs/images/themes.webp)
Expand Down
23 changes: 13 additions & 10 deletions src-tauri/src/taskbar_strip.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! Experimental taskbar strip: provider logos with session/weekly numbers inside the Windows 11
//! taskbar, left of the notification area (what macOS shows as menu bar text).
//! taskbar, at its left edge or left of the notification area.
//!
//! Windows has no API for this (deskbands are gone in Windows 11), so like TrafficMonitor each
//! strip is a layered window made a child of a taskbar: the main one (`Shell_TrayWnd`) and, when the
Expand All @@ -13,6 +13,9 @@
use std::sync::{Mutex, OnceLock};
use tauri::AppHandle;

mod placement;
pub use placement::Placement;

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StripImage {
Expand Down Expand Up @@ -82,6 +85,7 @@ struct Shared {
/// One image per monitor scale; empty = no strip.
images: Vec<Pixels>,
choice: Choice,
placement: Placement,
/// The strip thread (for `PostThreadMessageW`); 0 while it hasn't started yet.
thread_id: u32,
thread_running: bool,
Expand Down Expand Up @@ -194,8 +198,10 @@ pub fn set_taskbar_strip(
app_handle: AppHandle,
images: Option<Vec<StripImage>>,
monitors: Option<MonitorsArg>,
placement: Option<Placement>,
) -> Result<(), String> {
let _ = APP.set(app_handle);
let placement = placement.unwrap_or_default().validate()?;
let choice = monitors
.map(Choice::try_from)
.transpose()?
Expand All @@ -212,6 +218,7 @@ pub fn set_taskbar_strip(
let show = !pixels.is_empty();
state.images = pixels;
state.choice = choice;
state.placement = placement;
#[cfg(windows)]
{
if state.thread_running {
Expand Down Expand Up @@ -285,8 +292,6 @@ mod win {

const WM_APP_RECONCILE: u32 = WM_APP + 1;
const CLASS_NAME: &str = "OpenTokenUsageTaskbarStrip";
/// Space between the strip and the notification area at 100 %.
const GAP: i32 = 8;
/// Taskbars change without telling us (Explorer restarts, monitors come and go): look again.
const RECONCILE_MS: u32 = 2000;
/// A secondary Windows 11 taskbar has no notification-area window to measure: its clock and
Expand Down Expand Up @@ -697,12 +702,12 @@ mod win {
}
}

/// Puts a strip left of its taskbar's notification area (or clock), vertically centered, and draws it.
/// Places a strip at the chosen edge, vertically centered, and draws it.
fn place(strip: &Strip) {
unsafe {
// Copy out: the calls below talk to Explorer, never hold the lock across them.
let pixels = match shared().lock() {
Ok(state) => image_for(&state.images, strip.dpi).cloned(),
let (pixels, placement) = match shared().lock() {
Ok(state) => (image_for(&state.images, strip.dpi).cloned(), state.placement),
Err(_) => return,
};
let Some(pixels) = pixels else {
Expand Down Expand Up @@ -732,12 +737,10 @@ mod win {
} else {
bar.right - scaled(SECONDARY_CLOCK_WIDTH, strip.dpi)
};
let x = right - bar.left - pixels.width - scaled(GAP, strip.dpi);
// Wider than the free space: it would cover Start and the app buttons (and catch their clicks).
if x < 0 {
let Some(x) = placement.x(right - bar.left, pixels.width, strip.dpi) else {
ShowWindow(strip.hwnd, SW_HIDE);
return;
}
};
let y = (bar.bottom - bar.top - pixels.height) / 2;
SetWindowPos(
strip.hwnd,
Expand Down
92 changes: 92 additions & 0 deletions src-tauri/src/taskbar_strip/placement.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! Taskbar-relative coordinates; offsets are logical pixels at 96 DPI.

#[derive(Clone, Copy, Debug, Default, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
enum Side {
Left,
#[default]
Right,
}

#[derive(Clone, Copy, Debug, serde::Deserialize)]
pub struct Placement {
side: Side,
offset: u32,
}

impl Default for Placement {
fn default() -> Self {
Self {
side: Side::Right,
offset: 8,
}
}
}

impl Placement {
pub fn validate(self) -> Result<Self, String> {
if self.offset > 4096 {
return Err("strip offset out of range".into());
}
Ok(self)
}

/// `available` ends at the notification area/clock, in taskbar-local physical pixels.
/// Clamp an oversized inset, but hide a strip that cannot fit at all.
pub fn x(self, available: i32, width: i32, dpi: u32) -> Option<i32> {
if width <= 0 || available < width {
return None;
}
let max_x = available - width;
let inset = (self.offset as i64 * dpi as i64 / 96).min(max_x as i64) as i32;
Some(match self.side {
Side::Left => inset,
Side::Right => max_x - inset,
})
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn preserves_default_and_scales_both_edges() {
assert_eq!(Placement::default().x(1800, 200, 96), Some(1592));
let left = Placement {
side: Side::Left,
offset: 24,
};
let right = Placement {
side: Side::Right,
offset: 24,
};
assert_eq!(left.x(1800, 200, 144), Some(36));
assert_eq!(right.x(1800, 200, 144), Some(1564));
assert_eq!(left.x(2700, 300, 144), Some(36));
}

#[test]
fn stays_inside_each_taskbar_and_out_of_the_tray() {
for side in [Side::Left, Side::Right] {
let p = Placement { side, offset: 4096 };
let x = p.x(500, 200, 480).unwrap();
assert!((0..=300).contains(&x));
assert_eq!(p.x(200, 200, 96), Some(0));
assert_eq!(p.x(100, 200, 96), None);
assert_eq!(p.x(-10, 200, 96), None);
}
}

#[test]
fn rejects_invalid_ipc_values() {
assert!(serde_json::from_str::<Placement>(r#"{"side":"center","offset":8}"#).is_err());
assert!(serde_json::from_str::<Placement>(r#"{"side":"left","offset":-1}"#).is_err());
assert!(Placement {
side: Side::Left,
offset: 4097,
}
.validate()
.is_err());
}
}
67 changes: 67 additions & 0 deletions src/components/strip-placement.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useState } from "react"
import { fireEvent, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { expect, it, vi } from "vitest"
import { StripPlacement } from "@/components/strip-placement"

function renderControls() {
const onChange = vi.fn()
function Controls() {
const [placement, setPlacement] = useState({ position: "right" as "left" | "right", offset: 8 })
return (
<StripPlacement
{...placement}
onChange={(patch) => {
onChange(patch)
setPlacement((previous) => ({ ...previous, ...patch }))
}}
/>
)
}
render(<Controls />)
return onChange
}

it("selects either side and accepts only whole, bounded offsets", () => {
const onChange = renderControls()
expect(screen.getByRole("radio", { name: "Right" })).toHaveAttribute("aria-checked", "true")
fireEvent.click(screen.getByRole("radio", { name: "Left" }))
expect(onChange).toHaveBeenLastCalledWith({ position: "left" })
expect(screen.getByRole("radio", { name: "Left" })).toHaveAttribute("aria-checked", "true")
fireEvent.click(screen.getByRole("radio", { name: "Right" }))
expect(onChange).toHaveBeenLastCalledWith({ position: "right" })

const input = screen.getByRole("spinbutton", { name: "Offset (px)" })
fireEvent.change(input, { target: { value: "120" } })
expect(onChange).toHaveBeenLastCalledWith({ offset: 120 })
expect(input).toHaveValue(120)
onChange.mockClear()
for (const value of ["", "-1", "1.5", "4097"]) fireEvent.change(input, { target: { value } })
expect(onChange).not.toHaveBeenCalled()
for (const value of ["0", "4096"]) {
fireEvent.change(input, { target: { value } })
expect(onChange).toHaveBeenLastCalledWith({ offset: Number(value) })
}
})

it("lets users clear and retype an offset, and restores invalid drafts on blur", async () => {
const user = userEvent.setup()
const onChange = renderControls()
const input = screen.getByRole("spinbutton", { name: "Offset (px)" })
await user.clear(input)
expect(input).toHaveValue(null)
expect(onChange).not.toHaveBeenCalled()
await user.type(input, "120")
expect(input).toHaveValue(120)
expect(onChange).toHaveBeenLastCalledWith({ offset: 120 })
await user.tab()
expect(input).toHaveValue(120)

await user.clear(input)
await user.tab()
expect(input).toHaveValue(120)
fireEvent.change(input, { target: { value: "4097" } })
expect(input).toHaveValue(4097)
fireEvent.blur(input)
expect(input).toHaveValue(120)
})
53 changes: 53 additions & 0 deletions src/components/strip-placement.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useState } from "react"
import { SegmentedControl } from "@/components/settings-section"
import { t } from "@/lib/i18n"
import type { SettingOption, TaskbarStripStyle } from "@/lib/settings"

const POSITIONS: SettingOption<TaskbarStripStyle["position"]>[] = [
{ value: "left", labelKey: "settings.strip.positionLeft" },
{ value: "right", labelKey: "settings.strip.positionRight" },
]

export function StripPlacement({
position,
offset,
onChange,
}: {
position: TaskbarStripStyle["position"]
offset: number
onChange: (patch: Partial<TaskbarStripStyle>) => void
}) {
// Allow empty/invalid text while editing; only valid offsets reach the saved settings.
const [draft, setDraft] = useState<string | null>(null)
return (
<div className="space-y-2">
<div>
<div className="mb-1 text-xs text-muted-foreground">{t("settings.strip.position")}</div>
<SegmentedControl
options={POSITIONS}
value={position}
onChange={(next) => onChange({ position: next })}
ariaLabel={t("settings.strip.position")}
/>
</div>
<label className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
{t("settings.strip.offset")}
<input
type="number"
min={0}
max={4096}
step={1}
value={draft ?? offset}
className="fluent-control w-24"
onBlur={() => setDraft(null)}
onChange={(event) => {
setDraft(event.currentTarget.value)
const next = event.currentTarget.valueAsNumber
if (Number.isInteger(next) && next >= 0 && next <= 4096) onChange({ offset: next })
}}
/>
</label>
<p className="text-xs text-muted-foreground">{t("settings.strip.positionHint")}</p>
</div>
)
}
2 changes: 2 additions & 0 deletions src/components/taskbar-strip-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { colorOnScale, stripPluginSettings } from "@/lib/taskbar-strip"
import { cn } from "@/lib/utils"
import { useAppPluginStore } from "@/stores/app-plugin-store"
import { useAppPreferencesStore } from "@/stores/app-preferences-store"
import { StripPlacement } from "@/components/strip-placement"

const FONT_SIZES = [10, 11, 12, 13, 14, 15]

Expand Down Expand Up @@ -295,6 +296,7 @@ export function TaskbarStripSection({ onMenubarIconStyleChange }: { onMenubarIco
)}

<StripMonitors value={style.monitors} onChange={(monitors) => update({ monitors })} />
<StripPlacement position={style.position} offset={style.offset} onChange={update} />

<div>
<div className="text-xs text-muted-foreground mb-1">
Expand Down
6 changes: 5 additions & 1 deletion src/hooks/app/use-taskbar-strip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ export function useTaskbarStrip(args: {
)
render()
.then((images) =>
invoke("set_taskbar_strip", { images, monitors: stripMonitorsArg(style.monitors) }).then(
invoke("set_taskbar_strip", {
images,
monitors: stripMonitorsArg(style.monitors),
placement: { side: style.position, offset: style.offset },
}).then(
() => (showingRef.current = images !== null)
)
)
Expand Down
9 changes: 9 additions & 0 deletions src/lib/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
loadResetTimerDisplayMode,
loadStartOnLogin,
loadTimeFormatMode,
loadTaskbarStripStyle,
migrateLegacyTraySettings,
migrateWindsurfToDevin,
loadThemeMode,
Expand All @@ -32,6 +33,7 @@ import {
saveStartOnLogin,
saveThemeMode,
saveTimeFormatMode,
saveTaskbarStripStyle,
} from "@/lib/settings"
import type { PluginMeta } from "@/lib/plugin-types"

Expand Down Expand Up @@ -69,6 +71,13 @@ describe("settings", () => {
await expect(loadPluginSettings()).resolves.toEqual(DEFAULT_PLUGIN_SETTINGS)
})

it("saves strip placement and restores it after reloading settings", async () => {
const original = await loadTaskbarStripStyle()
await saveTaskbarStripStyle({ ...original, position: "left", offset: 120 })
expect(storeSaveMock).toHaveBeenCalled()
await expect(loadTaskbarStripStyle()).resolves.toEqual({ ...original, position: "left", offset: 120 })
})

it("sanitizes stored settings", async () => {
storeState.set("plugins", { order: ["a"], disabled: "nope" })
await expect(loadPluginSettings()).resolves.toEqual({
Expand Down
10 changes: 10 additions & 0 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,9 @@ const MAX_STRIP_MONITORS = 16;

/** Look of the taskbar strip; everything the user can restyle. */
export type TaskbarStripStyle = {
/** Left taskbar edge or right notification-area edge; offset uses logical pixels. */
position: "left" | "right";
offset: number;
/** Providers shown, in strip order (own order, independent of the nav); null = first enabled ones. */
providers: string[] | null;
font: TaskbarStripFont;
Expand All @@ -530,6 +533,8 @@ export type TaskbarStripStyle = {
};

export const DEFAULT_TASKBAR_STRIP_STYLE: TaskbarStripStyle = {
position: "right",
offset: 8,
providers: null,
font: "segoe-variable",
fontSize: 12,
Expand Down Expand Up @@ -580,6 +585,11 @@ export function normalizeTaskbarStripStyle(value: unknown): TaskbarStripStyle {
: null;
return {
providers,
position: raw.position === "left" ? "left" : d.position,
offset:
typeof raw.offset === "number" && Number.isInteger(raw.offset) && raw.offset >= 0 && raw.offset <= 4096
? raw.offset
: d.offset,
font: TASKBAR_STRIP_FONTS.some((font) => font.id === raw.font) ? (raw.font as TaskbarStripFont) : d.font,
fontSize: typeof raw.fontSize === "number" && raw.fontSize >= 10 && raw.fontSize <= 15 ? raw.fontSize : d.fontSize,
bold: typeof raw.bold === "boolean" ? raw.bold : d.bold,
Expand Down
Loading
Loading