diff --git a/plugins/antigravity/plugin.json b/plugins/antigravity/plugin.json
index 51927ba3..0a0589e1 100644
--- a/plugins/antigravity/plugin.json
+++ b/plugins/antigravity/plugin.json
@@ -7,8 +7,23 @@
"icon": "icon.svg",
"brandColor": "#4285F4",
"lines": [
- { "type": "progress", "label": "Gemini Pro", "scope": "overview", "primaryOrder": 1 },
- { "type": "progress", "label": "Gemini Flash", "scope": "overview" },
- { "type": "progress", "label": "Claude", "scope": "overview" }
+ {
+ "type": "progress",
+ "label": "Gemini Pro",
+ "scope": "overview",
+ "primaryOrder": 1
+ },
+ {
+ "type": "progress",
+ "label": "Gemini Flash",
+ "scope": "overview",
+ "primaryOrder": 2
+ },
+ {
+ "type": "progress",
+ "label": "Claude",
+ "scope": "overview",
+ "primaryOrder": 3
+ }
]
-}
+}
\ No newline at end of file
diff --git a/plugins/codex/plugin.json b/plugins/codex/plugin.json
index 2d58466b..8ee9a9bc 100644
--- a/plugins/codex/plugin.json
+++ b/plugins/codex/plugin.json
@@ -7,18 +7,63 @@
"icon": "icon.svg",
"brandColor": "#74AA9C",
"links": [
- { "label": "Status", "url": "https://status.openai.com/" },
- { "label": "Usage dashboard", "url": "https://platform.openai.com/usage" }
+ {
+ "label": "Status",
+ "url": "https://status.openai.com/"
+ },
+ {
+ "label": "Usage dashboard",
+ "url": "https://platform.openai.com/usage"
+ }
],
"lines": [
- { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 },
- { "type": "progress", "label": "Weekly", "scope": "overview" },
- { "type": "progress", "label": "Spark", "scope": "detail" },
- { "type": "progress", "label": "Spark Weekly", "scope": "detail" },
- { "type": "progress", "label": "Reviews", "scope": "detail" },
- { "type": "progress", "label": "Credits", "scope": "detail" },
- { "type": "text", "label": "Today", "scope": "detail" },
- { "type": "text", "label": "Yesterday", "scope": "detail" },
- { "type": "text", "label": "Last 30 Days", "scope": "detail" }
+ {
+ "type": "progress",
+ "label": "Session",
+ "scope": "overview",
+ "primaryOrder": 1
+ },
+ {
+ "type": "progress",
+ "label": "Weekly",
+ "scope": "overview",
+ "primaryOrder": 2
+ },
+ {
+ "type": "progress",
+ "label": "Spark",
+ "scope": "detail"
+ },
+ {
+ "type": "progress",
+ "label": "Spark Weekly",
+ "scope": "detail"
+ },
+ {
+ "type": "progress",
+ "label": "Reviews",
+ "scope": "detail",
+ "primaryOrder": 3
+ },
+ {
+ "type": "progress",
+ "label": "Credits",
+ "scope": "detail"
+ },
+ {
+ "type": "text",
+ "label": "Today",
+ "scope": "detail"
+ },
+ {
+ "type": "text",
+ "label": "Yesterday",
+ "scope": "detail"
+ },
+ {
+ "type": "text",
+ "label": "Last 30 Days",
+ "scope": "detail"
+ }
]
-}
+}
\ No newline at end of file
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 4fe36e2b..c52fee65 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -74,8 +74,8 @@ vi.mock("@dnd-kit/core", () => ({
return
{children}
},
closestCenter: vi.fn(),
- PointerSensor: class {},
- KeyboardSensor: class {},
+ PointerSensor: class { },
+ KeyboardSensor: class { },
useSensor: vi.fn((_sensor: any, options?: any) => ({ sensor: _sensor, options })),
useSensors: vi.fn((...sensors: any[]) => sensors),
}))
@@ -481,7 +481,7 @@ describe("App", () => {
await waitFor(() => expect(state.renderTrayBarsIconMock).toHaveBeenCalled())
const firstCall = state.renderTrayBarsIconMock.mock.calls[0]?.[0]
expect(firstCall.providerIconUrl).toBe("icon-a")
- await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith("--%"))
+ await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith(null))
})
it("renders percent text in tray icon when native title is unavailable", async () => {
@@ -495,7 +495,7 @@ describe("App", () => {
await waitFor(() => expect(state.renderTrayBarsIconMock).toHaveBeenCalled())
const firstCall = state.renderTrayBarsIconMock.mock.calls[0]?.[0]
- expect(firstCall.percentText).toBe("--%")
+ expect(firstCall.gridCells).toEqual([])
expect(state.traySetTitleMock).not.toHaveBeenCalled()
})
@@ -546,14 +546,14 @@ describe("App", () => {
const latestCall = state.renderTrayBarsIconMock.mock.calls.at(-1)?.[0]
expect(latestCall.providerIconUrl).toBe("icon-b")
})
- await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith("70%"))
+ await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith(null))
await userEvent.click(screen.getByRole("button", { name: "Home" }))
await waitFor(() => {
const latestCall = state.renderTrayBarsIconMock.mock.calls.at(-1)?.[0]
expect(latestCall.providerIconUrl).toBe("icon-b")
})
- await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith("70%"))
+ await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith(null))
const settingsButtons = await screen.findAllByRole("button", { name: "Settings" })
await userEvent.click(settingsButtons[0])
@@ -561,7 +561,7 @@ describe("App", () => {
const latestCall = state.renderTrayBarsIconMock.mock.calls.at(-1)?.[0]
expect(latestCall.providerIconUrl).toBe("icon-b")
})
- await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith("70%"))
+ await waitFor(() => expect(state.traySetTitleMock).toHaveBeenCalledWith(null))
})
it("covers about open/close callbacks", async () => {
@@ -588,7 +588,7 @@ describe("App", () => {
})
it("logs when saving display mode fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.saveDisplayModeMock.mockRejectedValueOnce(new Error("save display mode"))
render()
@@ -653,7 +653,7 @@ describe("App", () => {
})
it("logs when saving auto-update interval fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.saveAutoUpdateIntervalMock.mockRejectedValueOnce(new Error("save interval"))
render()
const settingsButtons = await screen.findAllByRole("button", { name: "Settings" })
@@ -684,7 +684,7 @@ describe("App", () => {
})
it("logs when saving start on login fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.saveStartOnLoginMock.mockRejectedValueOnce(new Error("save start on login failed"))
render()
@@ -699,7 +699,7 @@ describe("App", () => {
})
it("logs when applying start on login setting fails on startup", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.isTauriMock.mockReturnValue(true)
state.loadStartOnLoginMock.mockResolvedValueOnce(true)
state.autostartIsEnabledMock.mockRejectedValueOnce(new Error("autostart status failed"))
@@ -713,7 +713,7 @@ describe("App", () => {
})
it("logs when updating start on login fails from settings", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.isTauriMock.mockReturnValue(true)
state.loadStartOnLoginMock.mockResolvedValueOnce(false)
state.autostartIsEnabledMock
@@ -732,7 +732,7 @@ describe("App", () => {
})
it("logs when loading display mode fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.loadDisplayModeMock.mockRejectedValueOnce(new Error("load display mode"))
render()
@@ -743,7 +743,7 @@ describe("App", () => {
})
it("logs when migrating legacy tray settings fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.migrateLegacyTraySettingsMock.mockRejectedValueOnce(new Error("migrate legacy tray"))
render()
@@ -754,7 +754,7 @@ describe("App", () => {
})
it("logs when saving theme mode fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.saveThemeModeMock.mockRejectedValueOnce(new Error("save theme"))
render()
const settingsButtons = await screen.findAllByRole("button", { name: "Settings" })
@@ -885,7 +885,7 @@ describe("App", () => {
removeAction()
await waitFor(() =>
- expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["b"] })
+ expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["b"], trayLines: {} })
)
expect(state.trackMock).toHaveBeenCalledWith("provider_toggled", { provider_id: "b", enabled: "false" })
expect(state.startBatchMock).not.toHaveBeenCalled()
@@ -901,7 +901,7 @@ describe("App", () => {
const removeAction = await triggerPluginContextAction("Beta", "b", "remove")
removeAction()
await waitFor(() =>
- expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["b"] })
+ expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["b"], trayLines: {} })
)
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Beta" })).not.toBeInTheDocument()
@@ -941,7 +941,7 @@ describe("App", () => {
})
it("handles plugin list load failure", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.invokeMock.mockImplementation(async (cmd: string) => {
if (cmd === "list_plugins") {
throw new Error("boom")
@@ -954,7 +954,7 @@ describe("App", () => {
})
it("handles initial batch failure", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.startBatchMock.mockRejectedValueOnce(new Error("fail"))
render()
const errors = await screen.findAllByText("Failed to start probe")
@@ -964,7 +964,7 @@ describe("App", () => {
it("handles enable toggle failures", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a", "b"], disabled: ["b"] })
state.startBatchMock
.mockResolvedValueOnce(["a"])
@@ -1012,8 +1012,8 @@ describe("App", () => {
observeSpy()
this.cb([], this as unknown as ResizeObserver)
}
- unobserve() {}
- disconnect() {}
+ unobserve() { }
+ disconnect() { }
} as unknown as typeof ResizeObserver
render()
@@ -1025,7 +1025,7 @@ describe("App", () => {
it("logs resize failures", async () => {
state.isTauriMock.mockReturnValue(true)
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.setSizeMock.mockRejectedValueOnce(new Error("size fail"))
render()
await waitFor(() => expect(errorSpy).toHaveBeenCalled())
@@ -1035,7 +1035,7 @@ describe("App", () => {
it("logs when saving plugin order fails", async () => {
state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a", "b"], disabled: [] })
state.savePluginSettingsMock.mockRejectedValueOnce(new Error("save order"))
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
render()
const settingsButtons = await screen.findAllByRole("button", { name: "Settings" })
await userEvent.click(settingsButtons[0])
@@ -1087,7 +1087,7 @@ describe("App", () => {
})
it("logs when tray handle cannot be loaded", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.trayGetByIdMock.mockRejectedValueOnce(new Error("no tray"))
render()
await waitFor(() => expect(errorSpy).toHaveBeenCalled())
@@ -1095,7 +1095,7 @@ describe("App", () => {
})
it("logs when tray gauge resource cannot be resolved", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.resolveResourceMock.mockRejectedValueOnce(new Error("no resource"))
render()
await waitFor(() => expect(errorSpy).toHaveBeenCalled())
@@ -1103,7 +1103,7 @@ describe("App", () => {
})
it("logs error when retry plugin batch fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
render()
await waitFor(() => expect(state.startBatchMock).toHaveBeenCalled())
@@ -1184,7 +1184,7 @@ describe("App", () => {
it("logs error when auto-update batch fails", async () => {
vi.useFakeTimers()
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.loadAutoUpdateIntervalMock.mockResolvedValueOnce(5)
state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a"], disabled: [] })
@@ -1210,7 +1210,7 @@ describe("App", () => {
})
it("logs error when loading auto-update interval fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.loadAutoUpdateIntervalMock.mockRejectedValueOnce(new Error("load interval failed"))
render()
await waitFor(() =>
@@ -1220,7 +1220,7 @@ describe("App", () => {
})
it("logs error when loading theme mode fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.loadThemeModeMock.mockRejectedValueOnce(new Error("load theme failed"))
render()
await waitFor(() =>
@@ -1230,7 +1230,7 @@ describe("App", () => {
})
it("logs error when loading start on login fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.loadStartOnLoginMock.mockRejectedValueOnce(new Error("load start on login failed"))
render()
await waitFor(() =>
@@ -1287,7 +1287,7 @@ describe("App", () => {
await waitFor(() => expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(2))
const initialCalls = state.startBatchMock.mock.calls.length
- state.startBatchMock.mockImplementation(() => new Promise(() => {}))
+ state.startBatchMock.mockImplementation(() => new Promise(() => { }))
const refreshButton = await screen.findByRole("button", { name: /Next update in/i })
await userEvent.click(refreshButton)
@@ -1302,7 +1302,7 @@ describe("App", () => {
})
it("does not leak manual refresh cooldown state when refresh-all start fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
try {
state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a"], disabled: [] })
render()
@@ -1458,7 +1458,7 @@ describe("App", () => {
})
it("logs error when loading global shortcut fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
state.loadGlobalShortcutMock.mockRejectedValueOnce(new Error("load shortcut failed"))
render()
@@ -1470,7 +1470,7 @@ describe("App", () => {
})
it("logs error when saving global shortcut fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
// Start with a shortcut so we can clear it
state.loadGlobalShortcutMock.mockResolvedValueOnce("CommandOrControl+Shift+U")
state.saveGlobalShortcutMock.mockRejectedValueOnce(new Error("save shortcut failed"))
@@ -1491,7 +1491,7 @@ describe("App", () => {
})
it("logs error when update_global_shortcut invoke fails", async () => {
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { })
// Start with a shortcut so we can clear it
state.loadGlobalShortcutMock.mockResolvedValueOnce("CommandOrControl+Shift+U")
state.invokeMock.mockImplementation(async (cmd: string) => {
@@ -1552,7 +1552,14 @@ describe("App", () => {
render()
await vi.waitFor(() => expect(state.startBatchMock).toHaveBeenCalled())
await vi.waitFor(() => expect(state.trayGetByIdMock).toHaveBeenCalled())
- await vi.waitFor(() => expect(state.renderTrayBarsIconMock).toHaveBeenCalledTimes(1))
+ await vi.waitFor(() =>
+ expect(state.renderTrayBarsIconMock).toHaveBeenCalledWith({
+ sizePx: expect.any(Number),
+ gridCells: [],
+ providerIconUrl: "icon-a",
+ hideIcon: false,
+ })
+ )
state.probeHandlers?.onResult({
providerId: "a",
@@ -1586,7 +1593,7 @@ describe("App", () => {
await waitFor(() => expect(state.traySetIconMock).toHaveBeenCalledWith({}))
expect(state.traySetIconAsTemplateMock).toHaveBeenCalledWith(true)
- expect(state.traySetTitleMock).toHaveBeenCalledWith("--%")
+ expect(state.traySetTitleMock).toHaveBeenCalledWith(null)
})
it("clears pending tray timer on unmount", async () => {
diff --git a/src/App.tsx b/src/App.tsx
index ccd63fcd..510a1b74 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -56,6 +56,8 @@ function App() {
setResetTimerDisplayMode,
setGlobalShortcut,
setStartOnLogin,
+ showTrayIcon,
+ setShowTrayIcon,
} = useAppPreferencesStore(
useShallow((state) => ({
autoUpdateInterval: state.autoUpdateInterval,
@@ -68,10 +70,12 @@ function App() {
setResetTimerDisplayMode: state.setResetTimerDisplayMode,
setGlobalShortcut: state.setGlobalShortcut,
setStartOnLogin: state.setStartOnLogin,
+ showTrayIcon: state.showTrayIcon,
+ setShowTrayIcon: state.setShowTrayIcon,
}))
)
- const scheduleProbeTrayUpdateRef = useRef<() => void>(() => {})
+ const scheduleProbeTrayUpdateRef = useRef<() => void>(() => { })
const handleProbeResult = useCallback(() => {
scheduleProbeTrayUpdateRef.current()
}, [])
@@ -97,6 +101,7 @@ function App() {
pluginStates,
displayMode,
activeView,
+ showTrayIcon,
})
useEffect(() => {
@@ -114,6 +119,7 @@ function App() {
setResetTimerDisplayMode,
setGlobalShortcut,
setStartOnLogin,
+ setShowTrayIcon,
setLoadingForPlugins,
setErrorForPlugins,
startBatch,
@@ -138,18 +144,22 @@ function App() {
handleAutoUpdateIntervalChange,
handleGlobalShortcutChange,
handleStartOnLoginChange,
+ handleShowTrayIconChange,
} = useSettingsSystemActions({
pluginSettings,
setAutoUpdateInterval,
setAutoUpdateNextAt,
setGlobalShortcut,
setStartOnLogin,
+ setShowTrayIcon,
applyStartOnLogin,
+ scheduleTrayIconUpdate,
})
const {
handleReorder,
handleToggle,
+ handleTrayLineToggle,
} = useSettingsPluginActions({
pluginSettings,
setPluginSettings,
@@ -232,6 +242,7 @@ function App() {
onRetryPlugin: handleRetryPlugin,
onReorder: handleReorder,
onToggle: handleToggle,
+ onTrayLineToggle: handleTrayLineToggle,
onAutoUpdateIntervalChange: handleAutoUpdateIntervalChange,
onThemeModeChange: handleThemeModeChange,
onDisplayModeChange: handleDisplayModeChange,
@@ -239,6 +250,7 @@ function App() {
onResetTimerDisplayModeToggle: handleResetTimerDisplayModeToggle,
onGlobalShortcutChange: handleGlobalShortcutChange,
onStartOnLoginChange: handleStartOnLoginChange,
+ onShowTrayIconChange: handleShowTrayIconChange,
}}
/>
)
diff --git a/src/components/app/app-content.tsx b/src/components/app/app-content.tsx
index 5ecda263..b141ca19 100644
--- a/src/components/app/app-content.tsx
+++ b/src/components/app/app-content.tsx
@@ -24,6 +24,7 @@ export type AppContentActionProps = {
onRetryPlugin: (id: string) => void
onReorder: (orderedIds: string[]) => void
onToggle: (id: string) => void
+ onTrayLineToggle: (id: string, lineLabel: string, checked: boolean, fallback?: string) => void
onAutoUpdateIntervalChange: (value: AutoUpdateIntervalMinutes) => void
onThemeModeChange: (mode: ThemeMode) => void
onDisplayModeChange: (mode: DisplayMode) => void
@@ -31,6 +32,7 @@ export type AppContentActionProps = {
onResetTimerDisplayModeToggle: () => void
onGlobalShortcutChange: (value: GlobalShortcut) => void
onStartOnLoginChange: (value: boolean) => void
+ onShowTrayIconChange: (value: boolean) => void
}
export type AppContentProps = AppContentDerivedProps & AppContentActionProps
@@ -42,6 +44,7 @@ export function AppContent({
onRetryPlugin,
onReorder,
onToggle,
+ onTrayLineToggle,
onAutoUpdateIntervalChange,
onThemeModeChange,
onDisplayModeChange,
@@ -49,6 +52,7 @@ export function AppContent({
onResetTimerDisplayModeToggle,
onGlobalShortcutChange,
onStartOnLoginChange,
+ onShowTrayIconChange,
}: AppContentProps) {
const { activeView } = useAppUiStore(
useShallow((state) => ({
@@ -63,6 +67,7 @@ export function AppContent({
globalShortcut,
themeMode,
startOnLogin,
+ showTrayIcon,
} = useAppPreferencesStore(
useShallow((state) => ({
displayMode: state.displayMode,
@@ -71,6 +76,7 @@ export function AppContent({
globalShortcut: state.globalShortcut,
themeMode: state.themeMode,
startOnLogin: state.startOnLogin,
+ showTrayIcon: state.showTrayIcon,
}))
)
@@ -92,6 +98,7 @@ export function AppContent({
plugins={settingsPlugins}
onReorder={onReorder}
onToggle={onToggle}
+ onTrayLineToggle={onTrayLineToggle}
autoUpdateInterval={autoUpdateInterval}
onAutoUpdateIntervalChange={onAutoUpdateIntervalChange}
themeMode={themeMode}
@@ -104,6 +111,8 @@ export function AppContent({
onGlobalShortcutChange={onGlobalShortcutChange}
startOnLogin={startOnLogin}
onStartOnLoginChange={onStartOnLoginChange}
+ showTrayIcon={showTrayIcon}
+ onShowTrayIconChange={onShowTrayIconChange}
/>
)
}
diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts
index 6c6d6598..926bd0ca 100644
--- a/src/hooks/app/use-settings-bootstrap.ts
+++ b/src/hooks/app/use-settings-bootstrap.ts
@@ -21,6 +21,7 @@ import {
migrateLegacyTraySettings,
loadPluginSettings,
loadResetTimerDisplayMode,
+ loadShowTrayIcon,
loadStartOnLogin,
loadThemeMode,
normalizePluginSettings,
@@ -42,6 +43,7 @@ type UseSettingsBootstrapArgs = {
setResetTimerDisplayMode: (value: ResetTimerDisplayMode) => void
setGlobalShortcut: (value: GlobalShortcut) => void
setStartOnLogin: (value: boolean) => void
+ setShowTrayIcon: (value: boolean) => void
setLoadingForPlugins: (ids: string[]) => void
setErrorForPlugins: (ids: string[], error: string) => void
startBatch: (pluginIds?: string[]) => Promise
@@ -56,6 +58,7 @@ export function useSettingsBootstrap({
setResetTimerDisplayMode,
setGlobalShortcut,
setStartOnLogin,
+ setShowTrayIcon,
setLoadingForPlugins,
setErrorForPlugins,
startBatch,
@@ -130,6 +133,13 @@ export function useSettingsBootstrap({
console.error("Failed to load start on login:", error)
}
+ let storedShowTrayIcon = true
+ try {
+ storedShowTrayIcon = await loadShowTrayIcon()
+ } catch (error) {
+ console.error("Failed to load show tray icon:", error)
+ }
+
try {
await applyStartOnLogin(storedStartOnLogin)
} catch (error) {
@@ -149,6 +159,7 @@ export function useSettingsBootstrap({
setResetTimerDisplayMode(storedResetTimerDisplayMode)
setGlobalShortcut(storedGlobalShortcut)
setStartOnLogin(storedStartOnLogin)
+ setShowTrayIcon(storedShowTrayIcon)
const enabledIds = getEnabledPluginIds(normalized)
setLoadingForPlugins(enabledIds)
@@ -182,6 +193,7 @@ export function useSettingsBootstrap({
setPluginSettings,
setPluginsMeta,
setResetTimerDisplayMode,
+ setShowTrayIcon,
setStartOnLogin,
setThemeMode,
startBatch,
diff --git a/src/hooks/app/use-settings-plugin-actions.ts b/src/hooks/app/use-settings-plugin-actions.ts
index 44de8a61..e5e6ea3c 100644
--- a/src/hooks/app/use-settings-plugin-actions.ts
+++ b/src/hooks/app/use-settings-plugin-actions.ts
@@ -72,8 +72,54 @@ export function useSettingsPluginActions({
startBatch,
])
+ const handleTrayLineToggle = useCallback((id: string, lineLabel: string, checked: boolean, fallback?: string) => {
+ if (!pluginSettings) return
+ const prevTrayLines = pluginSettings.trayLines || {}
+ let currentLinesForPlugin = prevTrayLines[id] || []
+
+ if (currentLinesForPlugin.length === 0 && fallback) {
+ currentLinesForPlugin = [fallback]
+ }
+
+ let nextLinesForPlugin: string[]
+ if (checked) {
+ if (!currentLinesForPlugin.includes(lineLabel)) {
+ nextLinesForPlugin = [...currentLinesForPlugin, lineLabel]
+ } else {
+ nextLinesForPlugin = currentLinesForPlugin
+ }
+ } else {
+ nextLinesForPlugin = currentLinesForPlugin.filter(l => l !== lineLabel)
+ }
+
+ const nextTrayLines = {
+ ...prevTrayLines,
+ [id]: nextLinesForPlugin,
+ }
+
+ // Clean up empty arrays to keep state minimal
+ if (nextLinesForPlugin.length === 0) {
+ delete nextTrayLines[id]
+ }
+
+ const nextSettings: PluginSettings = {
+ ...pluginSettings,
+ trayLines: nextTrayLines,
+ }
+ setPluginSettings(nextSettings)
+ scheduleTrayIconUpdate("settings", TRAY_SETTINGS_DEBOUNCE_MS)
+ void savePluginSettings(nextSettings).catch((error) => {
+ console.error("Failed to save tray line toggle:", error)
+ })
+ }, [
+ pluginSettings,
+ scheduleTrayIconUpdate,
+ setPluginSettings,
+ ])
+
return {
handleReorder,
handleToggle,
+ handleTrayLineToggle,
}
}
diff --git a/src/hooks/app/use-settings-plugin-list.test.ts b/src/hooks/app/use-settings-plugin-list.test.ts
index 8f7b32ee..fa068674 100644
--- a/src/hooks/app/use-settings-plugin-list.test.ts
+++ b/src/hooks/app/use-settings-plugin-list.test.ts
@@ -33,8 +33,8 @@ describe("useSettingsPluginList", () => {
)
expect(result.current).toEqual([
- { id: "codex", name: "Codex", enabled: true },
- { id: "cursor", name: "Cursor", enabled: false },
+ { id: "codex", name: "Codex", enabled: true, primaryCandidates: [], trayLines: [] },
+ { id: "cursor", name: "Cursor", enabled: false, primaryCandidates: [], trayLines: [] },
])
})
diff --git a/src/hooks/app/use-settings-plugin-list.ts b/src/hooks/app/use-settings-plugin-list.ts
index 618c72a0..5824d742 100644
--- a/src/hooks/app/use-settings-plugin-list.ts
+++ b/src/hooks/app/use-settings-plugin-list.ts
@@ -6,6 +6,8 @@ export type SettingsPluginState = {
id: string
name: string
enabled: boolean
+ primaryCandidates: string[]
+ trayLines: string[]
}
type UseSettingsPluginListArgs = {
@@ -26,6 +28,8 @@ export function useSettingsPluginList({ pluginSettings, pluginsMeta }: UseSettin
id,
name: meta.name,
enabled: !pluginSettings.disabled.includes(id),
+ primaryCandidates: meta.primaryCandidates || [],
+ trayLines: pluginSettings.trayLines?.[id] || [],
}
})
.filter((plugin): plugin is SettingsPluginState => Boolean(plugin))
diff --git a/src/hooks/app/use-settings-system-actions.ts b/src/hooks/app/use-settings-system-actions.ts
index fd99f11b..a1e4a72b 100644
--- a/src/hooks/app/use-settings-system-actions.ts
+++ b/src/hooks/app/use-settings-system-actions.ts
@@ -6,6 +6,7 @@ import {
saveAutoUpdateInterval,
saveGlobalShortcut,
saveStartOnLogin,
+ saveShowTrayIcon,
type AutoUpdateIntervalMinutes,
type GlobalShortcut,
type PluginSettings,
@@ -17,7 +18,9 @@ type UseSettingsSystemActionsArgs = {
setAutoUpdateNextAt: (value: number | null) => void
setGlobalShortcut: (value: GlobalShortcut) => void
setStartOnLogin: (value: boolean) => void
+ setShowTrayIcon: (value: boolean) => void
applyStartOnLogin: (value: boolean) => Promise
+ scheduleTrayIconUpdate: (source: "settings", delay: number) => void
}
export function useSettingsSystemActions({
@@ -26,7 +29,9 @@ export function useSettingsSystemActions({
setAutoUpdateNextAt,
setGlobalShortcut,
setStartOnLogin,
+ setShowTrayIcon,
applyStartOnLogin,
+ scheduleTrayIconUpdate,
}: UseSettingsSystemActionsArgs) {
const handleAutoUpdateIntervalChange = useCallback((value: AutoUpdateIntervalMinutes) => {
track("setting_changed", { setting: "auto_refresh", value: String(value) })
@@ -68,9 +73,19 @@ export function useSettingsSystemActions({
})
}, [applyStartOnLogin, setStartOnLogin])
+ const handleShowTrayIconChange = useCallback((value: boolean) => {
+ track("setting_changed", { setting: "show_tray_icon", value: value ? "true" : "false" })
+ setShowTrayIcon(value)
+ void saveShowTrayIcon(value).catch((error) => {
+ console.error("Failed to save show tray icon:", error)
+ })
+ scheduleTrayIconUpdate("settings", 200)
+ }, [setShowTrayIcon, scheduleTrayIconUpdate])
+
return {
handleAutoUpdateIntervalChange,
handleGlobalShortcutChange,
handleStartOnLoginChange,
+ handleShowTrayIconChange,
}
}
diff --git a/src/hooks/app/use-tray-icon.ts b/src/hooks/app/use-tray-icon.ts
index 2216a969..bebc3f1b 100644
--- a/src/hooks/app/use-tray-icon.ts
+++ b/src/hooks/app/use-tray-icon.ts
@@ -4,7 +4,7 @@ import { TrayIcon } from "@tauri-apps/api/tray"
import type { PluginMeta } from "@/lib/plugin-types"
import type { DisplayMode, PluginSettings } from "@/lib/settings"
import { getEnabledPluginIds } from "@/lib/settings"
-import { getTrayIconSizePx, renderTrayBarsIcon } from "@/lib/tray-bars-icon"
+import { getTrayIconSizePx, renderTrayBarsIcon, type TrayGridCell } from "@/lib/tray-bars-icon"
import { getTrayPrimaryBars } from "@/lib/tray-primary-progress"
import type { PluginState } from "@/hooks/app/types"
@@ -16,6 +16,7 @@ type UseTrayIconArgs = {
pluginStates: Record
displayMode: DisplayMode
activeView: string
+ showTrayIcon: boolean
}
export function useTrayIcon({
@@ -24,6 +25,7 @@ export function useTrayIcon({
pluginStates,
displayMode,
activeView,
+ showTrayIcon,
}: UseTrayIconArgs) {
const trayRef = useRef(null)
const trayGaugeIconPathRef = useRef(null)
@@ -37,6 +39,7 @@ export function useTrayIcon({
const pluginStatesRef = useRef(pluginStates)
const displayModeRef = useRef(displayMode)
const activeViewRef = useRef(activeView)
+ const showTrayIconRef = useRef(showTrayIcon)
const lastTrayProviderIdRef = useRef(null)
useEffect(() => {
@@ -59,6 +62,10 @@ export function useTrayIcon({
activeViewRef.current = activeView
}, [activeView])
+ useEffect(() => {
+ showTrayIconRef.current = showTrayIcon
+ }, [showTrayIcon])
+
const scheduleTrayIconUpdate = useCallback((
_reason: TrayUpdateReason,
delayMs = 0,
@@ -89,11 +96,13 @@ export function useTrayIcon({
return
}
- const maybeSetTitle = (tray as TrayIcon & { setTitle?: (value: string) => Promise }).setTitle
+ const maybeSetTitle =
+ (tray as TrayIcon & { setTitle?: (value: string | null) => Promise }).setTitle
const setTitleFn =
- typeof maybeSetTitle === "function" ? (value: string) => maybeSetTitle.call(tray, value) : null
- const supportsNativeTrayTitle = setTitleFn !== null
- const setTrayTitle = (title: string) => {
+ typeof maybeSetTitle === "function"
+ ? (value: string | null) => maybeSetTitle.call(tray, value)
+ : null
+ const setTrayTitle = (title: string | null) => {
if (setTitleFn) {
return setTitleFn(title)
}
@@ -162,25 +171,61 @@ export function useTrayIcon({
pluginId: trayProviderId,
})
- const fraction = bars[0]?.fraction
- const hasFraction = typeof fraction === "number" && Number.isFinite(fraction)
- const clampedFraction = hasFraction ? Math.max(0, Math.min(1, fraction)) : undefined
- const percentText =
- typeof clampedFraction === "number" ? `${Math.round(clampedFraction * 100)}%` : "--%"
+ const items = bars[0]?.items || []
+
+ let tooltipText: string | undefined
+ let gridCellsToRender: TrayGridCell[] = []
+ let providerIconUrlToRender = pluginsMetaRef.current.find((plugin) => plugin.id === trayProviderId)?.iconUrl
+
+ if (items.length > 0) {
+ tooltipText = items.map(item => {
+ const hasFraction = typeof item.fraction === "number" && Number.isFinite(item.fraction)
+ const clampedFraction = hasFraction ? Math.max(0, Math.min(1, item.fraction!)) : undefined
+ return `${item.label}: ${typeof clampedFraction === "number" ? Math.round(clampedFraction * 100) + '%' : '--%'}`
+ }).join("\n")
+
+ gridCellsToRender = items.map(item => {
+ const hasFraction = typeof item.fraction === "number" && Number.isFinite(item.fraction)
+ const clampedFraction = hasFraction ? Math.max(0, Math.min(1, item.fraction!)) : undefined
+ const valStr = typeof clampedFraction === "number" ? `${Math.round(clampedFraction * 100)}%` : "--%"
+
+ if (items.length === 1) {
+ return { text: valStr }
+ }
+
+ let shortLabel = item.label
+ const words = shortLabel.split(" ")
+ if (words.length > 1) {
+ shortLabel = words[words.length - 1] // e.g. "Gemini Flash" -> "Flash"
+ }
+ if (shortLabel.length > 5) {
+ shortLabel = shortLabel.substring(0, 3) // e.g. "Session" -> "Ses", "Weekly" -> "Wee"
+ }
+ // The user specifically requested a space here ("加一个空格以美化展示效果")
+ return { text: `${shortLabel} ${valStr}` }
+ })
+ }
const sizePx = getTrayIconSizePx(window.devicePixelRatio)
- const providerIconUrl =
- pluginsMetaRef.current.find((plugin) => plugin.id === trayProviderId)?.iconUrl
renderTrayBarsIcon({
sizePx,
- percentText: supportsNativeTrayTitle ? undefined : percentText,
- providerIconUrl,
+ gridCells: gridCellsToRender,
+ providerIconUrl: showTrayIconRef.current ? providerIconUrlToRender : undefined,
+ hideIcon: !showTrayIconRef.current,
})
.then(async (img) => {
await tray.setIcon(img)
await tray.setIconAsTemplate(true)
- await setTrayTitle(percentText)
+ await setTrayTitle(null) // Disabling native Title clipping entirely
+ const maybeSetTooltip =
+ (tray as TrayIcon & { setTooltip?: (value: string | null) => Promise }).setTooltip
+ if (typeof maybeSetTooltip === "function") {
+ // If tooltip is null, clear current tooltip.
+ await maybeSetTooltip.call(tray, tooltipText ?? null).catch((error) => {
+ console.error("Failed to update tray tooltip:", error)
+ })
+ }
})
.catch((e) => {
console.error("Failed to update tray icon:", e)
@@ -196,25 +241,25 @@ export function useTrayIcon({
if (trayInitializedRef.current) return
let cancelled = false
- ;(async () => {
- try {
- const tray = await TrayIcon.getById("tray")
- if (cancelled) return
- trayRef.current = tray
- trayInitializedRef.current = true
-
+ ; (async () => {
try {
- trayGaugeIconPathRef.current = await resolveResource("icons/tray-icon.png")
+ const tray = await TrayIcon.getById("tray")
+ if (cancelled) return
+ trayRef.current = tray
+ trayInitializedRef.current = true
+
+ try {
+ trayGaugeIconPathRef.current = await resolveResource("icons/tray-icon.png")
+ } catch (e) {
+ console.error("Failed to resolve tray gauge icon resource:", e)
+ }
+
+ if (cancelled) return
+ setTrayReady(true)
} catch (e) {
- console.error("Failed to resolve tray gauge icon resource:", e)
+ console.error("Failed to load tray icon handle:", e)
}
-
- if (cancelled) return
- setTrayReady(true)
- } catch (e) {
- console.error("Failed to load tray icon handle:", e)
- }
- })()
+ })()
return () => {
cancelled = true
diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts
index 574c15d1..2704921a 100644
--- a/src/lib/settings.test.ts
+++ b/src/lib/settings.test.ts
@@ -67,25 +67,26 @@ describe("settings", () => {
await expect(loadPluginSettings()).resolves.toEqual({
order: ["a"],
disabled: [],
+ trayLines: {},
})
})
it("saves settings", async () => {
- const settings = { order: ["a"], disabled: ["b"] }
+ const settings = { order: ["a"], disabled: ["b"], trayLines: { a: ["Session"] } }
await savePluginSettings(settings)
await expect(loadPluginSettings()).resolves.toEqual(settings)
})
it("normalizes order + disabled against known plugins", () => {
const plugins: PluginMeta[] = [
- { id: "a", name: "A", iconUrl: "", lines: [] },
- { id: "b", name: "B", iconUrl: "", lines: [] },
+ { id: "a", name: "A", iconUrl: "", lines: [], primaryCandidates: [] },
+ { id: "b", name: "B", iconUrl: "", lines: [], primaryCandidates: [] },
]
const normalized = normalizePluginSettings(
- { order: ["b", "b", "c"], disabled: ["c", "a"] },
+ { order: ["b", "b", "c"], disabled: ["c", "a"], trayLines: { "a": ["x"], "c": ["y"] } },
plugins
)
- expect(normalized).toEqual({ order: ["b", "a"], disabled: ["a"] })
+ expect(normalized).toEqual({ order: ["b", "a"], disabled: ["a"], trayLines: { "a": ["x"] } })
})
it("auto-disables new non-default plugins", () => {
@@ -100,11 +101,16 @@ describe("settings", () => {
})
it("compares settings equality", () => {
- const a = { order: ["a"], disabled: [] }
- const b = { order: ["a"], disabled: [] }
- const c = { order: ["b"], disabled: [] }
+ const a = { order: ["a"], disabled: [], trayLines: {} }
+ const b = { order: ["a"], disabled: [], trayLines: {} }
+ const c = { order: ["b"], disabled: [], trayLines: {} }
+ const d = { order: ["a"], disabled: [], trayLines: { "a": ["x"] } }
+ const e = { order: ["a"], disabled: [], trayLines: { "a": [] } }
+ const f = { order: ["a"], disabled: [], trayLines: { "b": [] } }
expect(arePluginSettingsEqual(a, b)).toBe(true)
expect(arePluginSettingsEqual(a, c)).toBe(false)
+ expect(arePluginSettingsEqual(a, d)).toBe(false)
+ expect(arePluginSettingsEqual(e, f)).toBe(false)
})
it("returns enabled plugin ids", () => {
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 316aa430..1cd431c8 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -8,6 +8,7 @@ export const REFRESH_COOLDOWN_MS = 300_000;
export type PluginSettings = {
order: string[];
disabled: string[];
+ trayLines?: Record;
};
export type AutoUpdateIntervalMinutes = 5 | 15 | 30 | 60;
@@ -30,6 +31,7 @@ const LEGACY_TRAY_ICON_STYLE_KEY = "trayIconStyle";
const LEGACY_TRAY_SHOW_PERCENTAGE_KEY = "trayShowPercentage";
const GLOBAL_SHORTCUT_KEY = "globalShortcut";
const START_ON_LOGIN_KEY = "startOnLogin";
+const SHOW_TRAY_ICON_KEY = "showTrayIcon";
export const DEFAULT_AUTO_UPDATE_INTERVAL: AutoUpdateIntervalMinutes = 15;
export const DEFAULT_THEME_MODE: ThemeMode = "system";
@@ -37,6 +39,7 @@ export const DEFAULT_DISPLAY_MODE: DisplayMode = "left";
export const DEFAULT_RESET_TIMER_DISPLAY_MODE: ResetTimerDisplayMode = "relative";
export const DEFAULT_GLOBAL_SHORTCUT: GlobalShortcut = null;
export const DEFAULT_START_ON_LOGIN = false;
+export const DEFAULT_SHOW_TRAY_ICON = true;
const AUTO_UPDATE_INTERVALS: AutoUpdateIntervalMinutes[] = [5, 15, 30, 60];
const THEME_MODES: ThemeMode[] = ["system", "light", "dark"];
@@ -72,6 +75,7 @@ const DEFAULT_ENABLED_PLUGINS = new Set(["claude", "codex", "cursor"]);
export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
order: [],
disabled: [],
+ trayLines: {},
};
export async function loadPluginSettings(): Promise {
@@ -80,6 +84,7 @@ export async function loadPluginSettings(): Promise {
return {
order: Array.isArray(stored.order) ? stored.order : [],
disabled: Array.isArray(stored.disabled) ? stored.disabled : [],
+ trayLines: stored.trayLines && typeof stored.trayLines === "object" ? stored.trayLines : {},
};
}
@@ -137,7 +142,14 @@ export function normalizePluginSettings(
disabled.push(id);
}
}
- return { order, disabled };
+ const trayLines = { ...(settings.trayLines ?? {}) };
+ for (const key in trayLines) {
+ if (!knownSet.has(key)) {
+ delete trayLines[key];
+ }
+ }
+
+ return { order, disabled, trayLines };
}
export function arePluginSettingsEqual(
@@ -152,6 +164,23 @@ export function arePluginSettingsEqual(
for (let i = 0; i < a.disabled.length; i += 1) {
if (a.disabled[i] !== b.disabled[i]) return false;
}
+
+ const aLines = a.trayLines || {};
+ const bLines = b.trayLines || {};
+ const aKeys = Object.keys(aLines);
+ const bKeys = Object.keys(bLines);
+ if (aKeys.length !== bKeys.length) return false;
+
+ for (const key of aKeys) {
+ if (!Object.prototype.hasOwnProperty.call(bLines, key)) return false;
+ const aArr = aLines[key] || [];
+ const bArr = bLines[key] || [];
+ if (aArr.length !== bArr.length) return false;
+ for (let i = 0; i < aArr.length; i += 1) {
+ if (aArr[i] !== bArr[i]) return false;
+ }
+ }
+
return true;
}
@@ -265,3 +294,14 @@ export async function saveStartOnLogin(value: boolean): Promise {
await store.set(START_ON_LOGIN_KEY, value);
await store.save();
}
+
+export async function loadShowTrayIcon(): Promise {
+ const stored = await store.get(SHOW_TRAY_ICON_KEY);
+ if (typeof stored === "boolean") return stored;
+ return DEFAULT_SHOW_TRAY_ICON;
+}
+
+export async function saveShowTrayIcon(value: boolean): Promise {
+ await store.set(SHOW_TRAY_ICON_KEY, value);
+ await store.save();
+}
diff --git a/src/lib/tray-bars-icon.test.ts b/src/lib/tray-bars-icon.test.ts
index c9acbff7..e1d8783a 100644
--- a/src/lib/tray-bars-icon.test.ts
+++ b/src/lib/tray-bars-icon.test.ts
@@ -46,14 +46,44 @@ describe("tray-bars-icon", () => {
expect(svg).not.toContain(" {
+ const svg = makeTrayBarsSvg({
+ sizePx: 18,
+ hideIcon: true,
+ gridCells: [],
+ })
+ const viewBox = svg.match(/viewBox="0 0 (\d+) (\d+)"/)
+ expect(viewBox).toBeTruthy()
+ if (viewBox) {
+ expect(Number(viewBox[1])).toBe(18)
+ expect(Number(viewBox[2])).toBe(18)
+ }
+ })
+
it("renders svg text when percentage is provided", () => {
const svg = makeTrayBarsSvg({
sizePx: 18,
- percentText: "70%",
+ gridCells: [{ text: "70%" }],
})
expect(svg).toContain(">70%")
})
+ it("renders at most four grid cells", () => {
+ const svg = makeTrayBarsSvg({
+ sizePx: 18,
+ gridCells: [
+ { text: "A" },
+ { text: "B" },
+ { text: "C" },
+ { text: "D" },
+ { text: "E" },
+ ],
+ })
+ expect(svg).toContain(">A")
+ expect(svg).toContain(">D")
+ expect(svg).not.toContain(">E")
+ })
+
it("renderTrayBarsIcon rasterizes SVG to an Image using canvas", async () => {
const originalImage = window.Image
const originalCreateElement = document.createElement.bind(document)
diff --git a/src/lib/tray-bars-icon.ts b/src/lib/tray-bars-icon.ts
index e54d2c89..e524d869 100644
--- a/src/lib/tray-bars-icon.ts
+++ b/src/lib/tray-bars-icon.ts
@@ -14,78 +14,143 @@ function escapeXmlText(text: string): string {
.replace(/'/g, "'")
}
-function normalizePercentText(percentText: string | undefined): string | undefined {
- if (typeof percentText !== "string") return undefined
- const trimmed = percentText.trim()
- return trimmed.length > 0 ? trimmed : undefined
+export type TrayGridCell = {
+ text: string
}
function estimateTextWidthPx(text: string, fontSize: number): number {
- // Empirical estimate for SF Pro bold numeric glyphs in tray-sized icons.
- return Math.ceil(text.length * fontSize * 0.62 + fontSize * 0.2)
+ return Math.ceil(text.length * fontSize * 0.60 + fontSize * 0.2)
}
function getSvgLayout(args: {
sizePx: number
- percentText?: string
+ gridCells: TrayGridCell[]
+ hideIcon?: boolean
}): {
width: number
height: number
pad: number
barsX: number
- textX: number
- textY: number
- fontSize: number
+ iconSize: number
+ texts: { x: number; y: number; text: string; fontSize: number }[]
} {
- const { sizePx, percentText } = args
- const hasPercentText = typeof percentText === "string" && percentText.length > 0
- const verticalNudgePx = 1
+ const { sizePx, gridCells, hideIcon = false } = args
const pad = Math.max(1, Math.round(sizePx * 0.08)) // ~2px at 24–36px
const height = sizePx
const barsX = pad
- const fontSize = Math.max(9, Math.round(sizePx * 0.72))
- const textWidth = hasPercentText ? estimateTextWidthPx(percentText, fontSize) : 0
- // Optical correction + global nudge down to align with the tray slot center.
- const textY = Math.round(sizePx / 2) + 1 + verticalNudgePx
+ const iconSize = Math.max(6, Math.round(sizePx - 2 * pad * 0.5))
- if (!hasPercentText) {
+ if (gridCells.length === 0) {
return {
+ // Keep a non-zero canvas to avoid invalid raster/image dimensions.
width: sizePx,
height,
pad,
barsX,
- textX: 0,
- textY,
- fontSize,
+ iconSize,
+ texts: [],
}
}
+ const visibleCells = gridCells.slice(0, 4)
+
+ // Define layout configuration
+ // 1 item -> 1 col, 1 row (center)
+ // 2 items -> 1 col, 2 rows (stack)
+ // 3 items -> 2 cols, (col 1: 2 rows, col 2: top row)
+ // 4 items -> 2 cols, 2 rows per col
+ const numItems = visibleCells.length
+ const useTwoCols = numItems > 2
+ const numRows = numItems > 1 ? 2 : 1
+
+ // Compute base fonts based on row count
+ const fontSize = numRows === 1 ? Math.max(9, Math.round(sizePx * 0.68)) : Math.max(8, Math.round(sizePx * 0.55))
const textGap = Math.max(2, Math.round(sizePx * 0.08))
- const textAreaWidth = Math.max(20, Math.round(sizePx * 1.5), textWidth + pad)
- const rightPad = pad
+ const startX = hideIcon ? pad : sizePx + textGap
+
+ // Measure columns and place texts
+ const texts: { x: number; y: number; text: string; fontSize: number }[] = []
+
+ let col1Width = 0
+ let col2Width = 0
+
+ // Pass 1: measure max widths
+ for (let i = 0; i < visibleCells.length; i++) {
+ const w = estimateTextWidthPx(visibleCells[i].text, fontSize)
+ if (useTwoCols && i >= 2) {
+ col2Width = Math.max(col2Width, w)
+ } else {
+ col1Width = Math.max(col1Width, w)
+ }
+ }
+
+ // Pass 2: Layout
+ // Column Gap, visual separator |
+ const colGapPx = useTwoCols ? Math.max(6, Math.round(sizePx * 0.3)) : 0
+
+ for (let i = 0; i < visibleCells.length; i++) {
+ const isCol2 = useTwoCols && i >= 2
+ const isRow2 = i % 2 === 1
+
+ // X position
+ let textX = startX
+ if (isCol2) {
+ textX = startX + col1Width + colGapPx
+ }
+
+ // Y position
+ let textY = Math.round(sizePx / 2) + 1
+ if (numRows === 2) {
+ if (!isRow2) {
+ textY = Math.round(sizePx * 0.26) + 1
+ } else {
+ textY = Math.round(sizePx * 0.78) + 1
+ }
+ }
+
+ texts.push({
+ x: textX,
+ y: textY,
+ text: visibleCells[i].text,
+ fontSize
+ })
+ }
+
+ // Include separator line text logic if using two cols
+ if (useTwoCols) {
+ texts.push({
+ x: startX + col1Width + Math.floor(colGapPx / 2),
+ y: Math.round(sizePx / 2) + 1,
+ text: "|",
+ fontSize: Math.max(10, Math.round(sizePx * 0.7))
+ })
+ }
+
+ const totalTextWidth = col1Width + (useTwoCols ? colGapPx + col2Width : 0)
return {
- width: sizePx + textGap + textAreaWidth + rightPad,
+ width: Math.round(startX + totalTextWidth + pad),
height,
pad,
barsX,
- textX: sizePx + textGap,
- textY,
- fontSize,
+ iconSize,
+ texts,
}
}
export function makeTrayBarsSvg(args: {
sizePx: number
- percentText?: string
+ gridCells?: TrayGridCell[]
providerIconUrl?: string
+ hideIcon?: boolean
}): string {
- const { sizePx, percentText, providerIconUrl } = args
- const text = normalizePercentText(percentText)
+ const { sizePx, providerIconUrl, gridCells = [], hideIcon = false } = args
+
const layout = getSvgLayout({
sizePx,
- percentText: text,
+ gridCells,
+ hideIcon,
})
const width = layout.width
@@ -96,28 +161,32 @@ export function makeTrayBarsSvg(args: {
`