Skip to content
36 changes: 35 additions & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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(<App />)

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(<App />)

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
Expand Down
103 changes: 103 additions & 0 deletions src/components/app/app-shell.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="app-content" />,
}))

vi.mock("@/components/panel-footer", () => ({
PanelFooter: () => <div data-testid="panel-footer" />,
}))

vi.mock("@/components/side-nav", () => ({
SideNav: () => <div data-testid="side-nav" />,
}))

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(<AppShell {...createProps()} />)

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()
})
})
24 changes: 13 additions & 11 deletions src/components/app/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div ref={containerRef} className="flex flex-col items-center p-6 pt-1.5 bg-transparent">
<div className="tray-arrow" />
<div
className="relative bg-card rounded-xl overflow-hidden select-none w-full border shadow-lg flex flex-col"
style={maxPanelHeightPx ? { maxHeight: `${maxPanelHeightPx - ARROW_OVERHEAD_PX}px` } : undefined}
style={cardHeightPx ? { height: `${cardHeightPx}px`, maxHeight: `${cardHeightPx}px` } : undefined}
>
<div className="flex flex-1 min-h-0 flex-row">
<SideNav
Expand All @@ -82,15 +82,17 @@ export function AppShell({
isPluginRefreshAvailable={isPluginRefreshAvailable}
onReorder={onNavReorder}
/>
<div className="flex-1 flex flex-col px-3 pt-2 pb-1.5 min-w-0 bg-card dark:bg-muted/50">
<div className="flex-1 flex flex-col pt-2 pb-1.5 min-w-0 bg-card dark:bg-muted/50">
<div className="relative flex-1 min-h-0">
<div ref={scrollRef} className="h-full overflow-y-auto scrollbar-none">
<AppContent
{...appContentProps}
displayPlugins={displayPlugins}
settingsPlugins={settingsPlugins}
selectedPlugin={selectedPlugin}
/>
<div ref={scrollRef} className="panel-scroll h-full overflow-y-auto">
<div data-testid="panel-content-wrapper" className="px-3">
<AppContent
{...appContentProps}
displayPlugins={displayPlugins}
settingsPlugins={settingsPlugins}
selectedPlugin={selectedPlugin}
/>
</div>
</div>
<div
className={`pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-card dark:from-muted/50 to-transparent transition-opacity duration-200 ${canScrollDown ? "opacity-100" : "opacity-0"}`}
Expand Down
2 changes: 1 addition & 1 deletion src/components/panel-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function PanelFooter({

return (
<>
<div className="flex justify-between items-center h-8 pt-1.5 border-t">
<div className="flex justify-between items-center h-8 pt-1.5 px-3 border-t">
<VersionDisplay
version={version}
updateStatus={updateStatus}
Expand Down
62 changes: 57 additions & 5 deletions src/hooks/app/use-panel.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { act, renderHook, waitFor } from "@testing-library/react"
import { act, render, renderHook, waitFor } from "@testing-library/react"
import { createElement } from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"

const {
Expand All @@ -7,12 +8,16 @@ const {
invokeMock,
isTauriMock,
listenMock,
onMovedMock,
onScaleChangedMock,
} = vi.hoisted(() => ({
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", () => ({
Expand Down Expand Up @@ -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 () => {
Expand All @@ -69,7 +83,6 @@ describe("usePanel", () => {
setActiveView: vi.fn(),
showAbout: false,
setShowAbout,
displayPlugins: [],
})
)

Expand Down Expand Up @@ -103,7 +116,6 @@ describe("usePanel", () => {
setActiveView: vi.fn(),
showAbout: false,
setShowAbout: vi.fn(),
displayPlugins: [],
})
)

Expand Down Expand Up @@ -135,7 +147,6 @@ describe("usePanel", () => {
setActiveView: vi.fn(),
showAbout: false,
setShowAbout: vi.fn(),
displayPlugins: [],
})
)

Expand All @@ -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)
})
})
})
Loading
Loading