diff --git a/src/App.test.tsx b/src/App.test.tsx index 9c556942..fc5445db 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -8,6 +8,8 @@ const state = vi.hoisted(() => ({ isTauriMock: vi.fn(() => false), trackMock: vi.fn(), setSizeMock: vi.fn(), + windowOnMovedMock: vi.fn(), + windowOnScaleChangedMock: vi.fn(), currentMonitorMock: vi.fn(), startBatchMock: vi.fn(), savePluginSettingsMock: vi.fn(), @@ -172,7 +174,11 @@ vi.mock("@tauri-apps/api/path", () => ({ })) vi.mock("@tauri-apps/api/window", () => ({ - getCurrentWindow: () => ({ setSize: state.setSizeMock }), + getCurrentWindow: () => ({ + setSize: state.setSizeMock, + onMoved: state.windowOnMovedMock, + onScaleChanged: state.windowOnScaleChangedMock, + }), PhysicalSize: class { width: number height: number @@ -259,6 +265,8 @@ describe("App", () => { state.isTauriMock.mockReturnValue(false) state.trackMock.mockReset() state.setSizeMock.mockReset() + state.windowOnMovedMock.mockReset() + state.windowOnScaleChangedMock.mockReset() state.currentMonitorMock.mockReset() state.startBatchMock.mockReset() state.savePluginSettingsMock.mockReset() @@ -320,6 +328,8 @@ describe("App", () => { state.autostartDisableMock.mockResolvedValue(undefined) state.autostartIsEnabledMock.mockResolvedValue(false) state.renderTrayBarsIconMock.mockResolvedValue({}) + state.windowOnMovedMock.mockResolvedValue(() => {}) + state.windowOnScaleChangedMock.mockResolvedValue(() => {}) Object.defineProperty(HTMLElement.prototype, "scrollHeight", { configurable: true, get() { @@ -1168,6 +1178,30 @@ describe("App", () => { await waitFor(() => expect(state.setSizeMock).toHaveBeenCalled()) }) + it("uses the preferred stable panel height on standard monitors", async () => { + state.isTauriMock.mockReturnValue(true) + state.currentMonitorMock.mockResolvedValue({ size: { height: 1000 } }) + render() + + await waitFor(() => + expect(state.setSizeMock).toHaveBeenCalledWith( + expect.objectContaining({ width: 400, height: 560 }) + ) + ) + }) + + it("clamps the stable panel height to small monitors", async () => { + state.isTauriMock.mockReturnValue(true) + state.currentMonitorMock.mockResolvedValue({ size: { height: 500 } }) + render() + + await waitFor(() => + expect(state.setSizeMock).toHaveBeenCalledWith( + expect.objectContaining({ width: 400, height: 400 }) + ) + ) + }) + it("resizes again via ResizeObserver callback", async () => { state.isTauriMock.mockReturnValue(true) const OriginalResizeObserver = globalThis.ResizeObserver diff --git a/src/components/app/app-shell.test.tsx b/src/components/app/app-shell.test.tsx new file mode 100644 index 00000000..92d55d94 --- /dev/null +++ b/src/components/app/app-shell.test.tsx @@ -0,0 +1,103 @@ +import { render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const state = vi.hoisted(() => ({ + usePanelMock: vi.fn(), + useAppVersionMock: vi.fn(), + useAppUpdateMock: vi.fn(), +})) + +vi.mock("@/components/app/app-content", () => ({ + AppContent: () =>
, +})) + +vi.mock("@/components/panel-footer", () => ({ + PanelFooter: () =>
, +})) + +vi.mock("@/components/side-nav", () => ({ + SideNav: () =>
, +})) + +vi.mock("@/hooks/app/use-panel", () => ({ + usePanel: state.usePanelMock, +})) + +vi.mock("@/hooks/app/use-app-version", () => ({ + useAppVersion: state.useAppVersionMock, +})) + +vi.mock("@/hooks/use-app-update", () => ({ + useAppUpdate: state.useAppUpdateMock, +})) + +vi.mock("@/stores/app-ui-store", () => ({ + useAppUiStore: () => ({ + activeView: "home", + setActiveView: vi.fn(), + showAbout: false, + setShowAbout: vi.fn(), + }), +})) + +import { AppShell } from "@/components/app/app-shell" + +function createProps() { + return { + onRefreshAll: vi.fn(), + navPlugins: [], + displayPlugins: [], + settingsPlugins: [], + autoUpdateNextAt: null, + selectedPlugin: null, + onPluginContextAction: vi.fn(), + isPluginRefreshAvailable: vi.fn(), + onNavReorder: vi.fn(), + appContentProps: { + onRetryPlugin: vi.fn(), + onReorder: vi.fn(), + onToggle: vi.fn(), + onAutoUpdateIntervalChange: vi.fn(), + onThemeModeChange: vi.fn(), + onDisplayModeChange: vi.fn(), + onResetTimerDisplayModeChange: vi.fn(), + onResetTimerDisplayModeToggle: vi.fn(), + onMenubarIconStyleChange: vi.fn(), + traySettingsPreview: { + active: false, + bars: [], + iconUrl: null, + primaryLabel: null, + }, + onGlobalShortcutChange: vi.fn(), + onStartOnLoginChange: vi.fn(), + }, + } +} + +describe("AppShell", () => { + beforeEach(() => { + state.usePanelMock.mockReturnValue({ + containerRef: { current: null }, + scrollRef: { current: null }, + canScrollDown: false, + panelHeightPx: 560, + }) + state.useAppVersionMock.mockReturnValue("0.0.0-test") + state.useAppUpdateMock.mockReturnValue({ + updateStatus: { status: "idle" }, + triggerInstall: vi.fn(), + checkForUpdates: vi.fn(), + }) + }) + + it("uses the styled panel scrollbar class on the scroll region", () => { + const { container } = render() + + expect(screen.getByTestId("app-content")).toBeInTheDocument() + expect(container.querySelector(".panel-scroll")).toBeTruthy() + const contentWrapper = screen.getByTestId("panel-content-wrapper") + expect(contentWrapper).toBeTruthy() + expect(contentWrapper.querySelector("[data-testid='app-content']")).toBeTruthy() + }) +}) diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index a0d5406b..90f46825 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -54,24 +54,24 @@ export function AppShell({ containerRef, scrollRef, canScrollDown, - maxPanelHeightPx, + panelHeightPx, } = usePanel({ activeView, setActiveView, showAbout, setShowAbout, - displayPlugins, }) const appVersion = useAppVersion() const { updateStatus, triggerInstall, checkForUpdates } = useAppUpdate() + const cardHeightPx = panelHeightPx !== null ? Math.max(0, panelHeightPx - ARROW_OVERHEAD_PX) : null return (
-
+
-
- +
+
+ +
-
+
({ invokeMock: vi.fn(), isTauriMock: vi.fn(), listenMock: vi.fn(), getCurrentWindowMock: vi.fn(), currentMonitorMock: vi.fn(), + onMovedMock: vi.fn(), + onScaleChangedMock: vi.fn(), })) vi.mock("@tauri-apps/api/core", () => ({ @@ -47,11 +52,20 @@ describe("usePanel", () => { listenMock.mockReset() getCurrentWindowMock.mockReset() currentMonitorMock.mockReset() + onMovedMock.mockReset() + onScaleChangedMock.mockReset() isTauriMock.mockReturnValue(true) invokeMock.mockResolvedValue(undefined) + listenMock.mockResolvedValue(vi.fn()) currentMonitorMock.mockResolvedValue(null) - getCurrentWindowMock.mockReturnValue({ setSize: vi.fn().mockResolvedValue(undefined) }) + onMovedMock.mockResolvedValue(vi.fn()) + onScaleChangedMock.mockResolvedValue(vi.fn()) + getCurrentWindowMock.mockReturnValue({ + setSize: vi.fn().mockResolvedValue(undefined), + onMoved: onMovedMock, + onScaleChanged: onScaleChangedMock, + }) }) it("handles tray show-about event", async () => { @@ -69,7 +83,6 @@ describe("usePanel", () => { setActiveView: vi.fn(), showAbout: false, setShowAbout, - displayPlugins: [], }) ) @@ -103,7 +116,6 @@ describe("usePanel", () => { setActiveView: vi.fn(), showAbout: false, setShowAbout: vi.fn(), - displayPlugins: [], }) ) @@ -135,7 +147,6 @@ describe("usePanel", () => { setActiveView: vi.fn(), showAbout: false, setShowAbout: vi.fn(), - displayPlugins: [], }) ) @@ -150,4 +161,45 @@ describe("usePanel", () => { expect(unlistenShowAbout).toHaveBeenCalledTimes(1) }) }) + + it("recalculates panel sizing when the window scale changes", async () => { + const setSize = vi.fn().mockResolvedValue(undefined) + let scaleChangedHandler: (() => void) | null = null + + currentMonitorMock.mockResolvedValue({ size: { height: 1000 } }) + onScaleChangedMock.mockImplementation(async (handler: () => void) => { + scaleChangedHandler = handler + return vi.fn() + }) + getCurrentWindowMock.mockReturnValue({ + setSize, + onMoved: onMovedMock, + onScaleChanged: onScaleChangedMock, + }) + + function Harness() { + const { containerRef, scrollRef } = usePanel({ + activeView: "home", + setActiveView: vi.fn(), + showAbout: false, + setShowAbout: vi.fn(), + }) + + return createElement("div", { ref: containerRef }, createElement("div", { ref: scrollRef })) + } + + render(createElement(Harness)) + + await waitFor(() => { + expect(setSize).toHaveBeenCalledTimes(1) + }) + + act(() => { + scaleChangedHandler?.() + }) + + await waitFor(() => { + expect(currentMonitorMock).toHaveBeenCalledTimes(2) + }) + }) }) diff --git a/src/hooks/app/use-panel.ts b/src/hooks/app/use-panel.ts index f0838738..293a240a 100644 --- a/src/hooks/app/use-panel.ts +++ b/src/hooks/app/use-panel.ts @@ -5,6 +5,10 @@ import { getCurrentWindow, PhysicalSize, currentMonitor } from "@tauri-apps/api/ import type { ActiveView } from "@/components/side-nav" const PANEL_WIDTH = 400 +const PANEL_WINDOW_OVERHEAD_PX = 37 +const PANEL_MIN_CONTENT_HEIGHT_PX = 120 +// Keep the tray shell stable across short and long provider states; overflow should scroll inside the panel. +const PANEL_PREFERRED_HEIGHT = 560 const MAX_HEIGHT_FALLBACK_PX = 600 const MAX_HEIGHT_FRACTION_OF_MONITOR = 0.8 @@ -13,7 +17,6 @@ type UsePanelArgs = { setActiveView: (view: ActiveView) => void showAbout: boolean setShowAbout: (value: boolean) => void - displayPlugins: unknown[] } export function usePanel({ @@ -21,13 +24,13 @@ export function usePanel({ setActiveView, showAbout, setShowAbout, - displayPlugins, }: UsePanelArgs) { const containerRef = useRef(null) const scrollRef = useRef(null) const [canScrollDown, setCanScrollDown] = useState(false) - const [maxPanelHeightPx, setMaxPanelHeightPx] = useState(null) - const maxPanelHeightPxRef = useRef(null) + const [panelHeightPx, setPanelHeightPx] = useState(null) + const panelHeightPxRef = useRef(null) + const lastWindowSizeRef = useRef<{ width: number; height: number } | null>(null) useEffect(() => { if (!isTauri()) return @@ -85,11 +88,13 @@ export function usePanel({ if (!isTauri()) return const container = containerRef.current if (!container) return + const currentWindow = getCurrentWindow() + let cancelled = false + const unlisteners: (() => void)[] = [] const resizeWindow = async () => { const factor = window.devicePixelRatio const width = Math.ceil(PANEL_WIDTH * factor) - const desiredHeightLogical = Math.max(1, container.scrollHeight) let maxHeightPhysical: number | null = null let maxHeightLogical: number | null = null @@ -110,31 +115,78 @@ export function usePanel({ maxHeightPhysical = Math.floor(maxHeightLogical * factor) } - if (maxPanelHeightPxRef.current !== maxHeightLogical) { - maxPanelHeightPxRef.current = maxHeightLogical - setMaxPanelHeightPx(maxHeightLogical) + const minPanelHeightLogical = Math.min( + maxHeightLogical, + PANEL_WINDOW_OVERHEAD_PX + PANEL_MIN_CONTENT_HEIGHT_PX + ) + + // Keep the tray panel visually stable; scrolling should happen inside the shell. + const nextPanelHeightLogical = Math.max( + minPanelHeightLogical, + Math.min(PANEL_PREFERRED_HEIGHT, maxHeightLogical) + ) + + if (panelHeightPxRef.current !== nextPanelHeightLogical) { + panelHeightPxRef.current = nextPanelHeightLogical + setPanelHeightPx(nextPanelHeightLogical) } - const desiredHeightPhysical = Math.ceil(desiredHeightLogical * factor) - const height = Math.ceil(Math.min(desiredHeightPhysical, maxHeightPhysical!)) + const nextWindowSize = { + width, + height: Math.ceil(Math.min(nextPanelHeightLogical * factor, maxHeightPhysical!)), + } + + if ( + lastWindowSizeRef.current?.width === nextWindowSize.width && + lastWindowSizeRef.current?.height === nextWindowSize.height + ) { + return + } + + lastWindowSizeRef.current = nextWindowSize try { - const currentWindow = getCurrentWindow() - await currentWindow.setSize(new PhysicalSize(width, height)) + await currentWindow.setSize(new PhysicalSize(nextWindowSize.width, nextWindowSize.height)) } catch (e) { console.error("Failed to resize window:", e) } } - resizeWindow() + void resizeWindow() const observer = new ResizeObserver(() => { - resizeWindow() + void resizeWindow() }) observer.observe(container) - return () => observer.disconnect() - }, [activeView, displayPlugins]) + async function setupWindowListeners() { + const unlistenMoved = await currentWindow.onMoved(() => { + void resizeWindow() + }) + if (cancelled) { + unlistenMoved() + return + } + unlisteners.push(unlistenMoved) + + const unlistenScaleChanged = await currentWindow.onScaleChanged(() => { + void resizeWindow() + }) + if (cancelled) { + unlistenScaleChanged() + return + } + unlisteners.push(unlistenScaleChanged) + } + + void setupWindowListeners() + + return () => { + cancelled = true + observer.disconnect() + for (const fn of unlisteners) fn() + } + }, []) useEffect(() => { const el = scrollRef.current @@ -164,6 +216,6 @@ export function usePanel({ containerRef, scrollRef, canScrollDown, - maxPanelHeightPx, + panelHeightPx, } } diff --git a/src/index.css b/src/index.css index ec8200cc..1abefa73 100644 --- a/src/index.css +++ b/src/index.css @@ -158,6 +158,20 @@ body, display: none; /* Chrome / Safari / WebKit */ } +/* In-panel scrollbar: thin, no reserved idle gutter. */ +.panel-scroll { + --scrollbar-thumb: color-mix(in oklch, var(--muted-foreground) 72%, transparent); + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) transparent; +} +.panel-scroll::-webkit-scrollbar-track { + background: transparent; +} +.panel-scroll::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 999px; +} + /* Arrow pointing up toward the tray icon */ .tray-arrow { width: 0;