diff --git a/backlog/completed/task-336 - Fix-Hyprland-fullscreen-overlay-downward-offset.md b/backlog/completed/task-336 - Fix-Hyprland-fullscreen-overlay-downward-offset.md new file mode 100644 index 00000000..2a583cdf --- /dev/null +++ b/backlog/completed/task-336 - Fix-Hyprland-fullscreen-overlay-downward-offset.md @@ -0,0 +1,76 @@ +--- +id: TASK-336 +title: Fix Hyprland fullscreen overlay downward offset +status: Done +assignee: [] +created_date: '2026-05-04 05:42' +updated_date: '2026-05-04 06:10' +labels: + - linux + - hyprland + - overlay + - bug +dependencies: [] +references: + - src/window-trackers/hyprland-tracker.ts + - src/core/services/overlay-window-bounds.ts + - src/main/runtime/linux-mpv-fullscreen-overlay-refresh.ts +priority: medium +--- + +## Description + + +SubMiner visible overlay is slightly below mpv when mpv is fullscreen on Linux Hyprland. Align overlay bounds with mpv fullscreen client/monitor bounds. + + +## Acceptance Criteria + +- [x] #1 Hyprland fullscreen mpv overlay uses top-aligned geometry instead of inheriting a downward offset. +- [x] #2 Regression coverage captures the fullscreen Hyprland geometry case. +- [x] #3 Targeted tests pass. + + +## Implementation Notes + + +Added follow-up Hyprland placement handling after the fullscreenClient geometry fix. SubMiner overlay/stats windows now get stable titles and, on Hyprland, are resolved from `hyprctl -j clients` by current PID/title, then set floating before bounds are applied. The stats overlay reapplies bounds after showing because Hyprland cannot see the hidden window before it is mapped. + +2026-05-04 follow-up: offset remains after removing pinning. User reports stats modal still has a top gap from mpv in Hyprland fullscreen. Need inspect exact stats overlay CSS/window bounds after float-only placement. + +2026-05-04 follow-up fix: stats CSS already had zero body margin, so the remaining gap points at native Hyprland placement after float-only handling. Added exact `movewindowpixel`/`resizewindowpixel` Hyprland dispatches using the same tracked mpv bounds passed to Electron. + +2026-05-04 second follow-up: live `hyprctl -j clients` showed the SubMiner client was already full monitor size at `[0,0]`, so the remaining visible top strip was inside Electron's transparent stats surface rather than compositor geometry. Made the stats overlay BrowserWindow opaque with the stats base background. Also prevented page titles from overwriting the stable SubMiner overlay/stats titles used for Hyprland client matching. + +2026-05-04 third follow-up: user confirmed native overlay placement is correct and the remaining gap is stats-page-specific. Made stats overlay mode paint an opaque full-viewport root/background and constrained the stats app to `h-screen` with an internal scrolling main pane, so the overlay page itself covers the mpv frame from y=0. + +2026-05-04 fourth follow-up: live Hyprland data showed mpv and SubMiner shared the same outer geometry while stats content still rendered lower. Stats window placement now compensates for Electron/Wayland content insets using `getContentBounds()` versus `getBounds()`, then sends the adjusted outer bounds to Hyprland exact placement so the content area, not just the native surface, aligns to mpv. + +2026-05-04 fifth follow-up: user confirmed the offset is Hyprland-fullscreen-only and not present while mpv is windowed. Added Hyprland `setprop` decoration cleanup during exact overlay placement (`rounding 0`, `border_size 0`, `no_shadow 1`, `no_blur 1`, `decorate 0`) because fullscreen mpv has square fullscreen edges while a floating SubMiner stats window can retain Hyprland floating-window decoration. + + +## Final Summary + + +Summary: +- Treated Hyprland `fullscreenClient` as a fullscreen signal when resolving mpv overlay geometry. +- Added Hyprland window placement handling so SubMiner overlay/stats windows are set floating before bounds are applied. +- Added exact Hyprland move/resize dispatches so floating overlay/stats windows are force-aligned to the tracked mpv bounds. +- Gave overlay/stats windows stable titles for Hyprland client matching, and reapplied stats bounds after show. +- Locked overlay/stats window titles against page title changes and made the stats overlay window opaque so mpv cannot show through transparent Electron insets. +- Made the stats overlay page paint an opaque full-viewport background and added CSS regression coverage for overlay mode. +- Compensated stats overlay outer placement for Electron/Wayland content insets. +- Disabled Hyprland floating-window decoration for exact overlay placement over fullscreen mpv. +- Added regression coverage for the 28px fullscreen geometry shape and Hyprland placement dispatches. +- Added a changelog fragment for the overlay fix. + +Verification: +- `bun test src/core/services/hyprland-window-placement.test.ts src/core/services/overlay-window-config.test.ts src/core/services/stats-window.test.ts src/core/services/overlay-window-bounds.test.ts src/window-trackers/hyprland-tracker.test.ts` +- `bun run typecheck` +- `bun run changelog:lint` +- `bun run test:fast` +- `bun test stats/src/styles/globals.test.ts stats/src/lib/api-client.test.ts src/core/services/stats-window.test.ts` +- `bun run build:stats` +- `bun test src/core/services/stats-window.test.ts src/core/services/hyprland-window-placement.test.ts stats/src/styles/globals.test.ts` +- `bun test src/core/services/hyprland-window-placement.test.ts src/core/services/stats-window.test.ts stats/src/styles/globals.test.ts` + diff --git a/backlog/completed/task-339 - Stop-pinning-Hyprland-overlay-windows.md b/backlog/completed/task-339 - Stop-pinning-Hyprland-overlay-windows.md new file mode 100644 index 00000000..242316b0 --- /dev/null +++ b/backlog/completed/task-339 - Stop-pinning-Hyprland-overlay-windows.md @@ -0,0 +1,53 @@ +--- +id: TASK-339 +title: Stop pinning Hyprland overlay windows +status: Done +assignee: [] +created_date: '2026-05-04 06:07' +updated_date: '2026-05-04 06:09' +labels: + - linux + - hyprland + - overlay + - bug +dependencies: [] +references: + - src/core/services/hyprland-window-placement.ts + - src/core/services/overlay-window.ts + - src/core/services/stats-window.ts +priority: high +--- + +## Description + + +Recent Hyprland placement fix pins SubMiner overlay/stats windows, making them follow across workspaces instead of staying attached to mpv. Keep the float-for-bounds behavior, but never pin overlay windows. + + +## Acceptance Criteria + +- [x] #1 Hyprland placement dispatches set floating state only and does not dispatch pin. +- [x] #2 Regression coverage proves pinned clients are unpinned or at least not re-pinned by SubMiner. +- [x] #3 Targeted tests and typecheck pass. + + +## Implementation Notes + + +Changed Hyprland placement dispatch construction so unpinned overlay windows only get `setfloating`; pinned overlay windows get a single `pin` dispatch to toggle the bad prior pinned state off. This preserves floating placement for bounds while keeping overlay windows workspace-local with mpv. + + +## Final Summary + + +Summary: +- Stopped re-pinning Hyprland overlay/stats windows during placement. +- Added cleanup behavior for previously pinned SubMiner windows by toggling pin only when Hyprland reports `pinned: true`. +- Updated regression coverage and added a changelog fragment. + +Verification: +- `bun test src/core/services/hyprland-window-placement.test.ts src/core/services/overlay-window-config.test.ts src/core/services/stats-window.test.ts src/core/services/overlay-window-bounds.test.ts src/window-trackers/hyprland-tracker.test.ts` +- `bun run typecheck` +- `bun run changelog:lint` +- `bun run test:fast` + diff --git a/backlog/tasks/task-336 - Fix-Hyprland-fullscreen-overlay-downward-offset.md b/backlog/tasks/task-336 - Fix-Hyprland-fullscreen-overlay-downward-offset.md deleted file mode 100644 index 90ddf7fa..00000000 --- a/backlog/tasks/task-336 - Fix-Hyprland-fullscreen-overlay-downward-offset.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -id: TASK-336 -title: Fix Hyprland fullscreen overlay downward offset -status: Done -assignee: [] -created_date: '2026-05-04 05:42' -updated_date: '2026-05-04 05:56' -labels: - - linux - - hyprland - - overlay - - bug -dependencies: [] -references: - - src/window-trackers/hyprland-tracker.ts - - src/core/services/overlay-window-bounds.ts - - src/main/runtime/linux-mpv-fullscreen-overlay-refresh.ts -priority: medium ---- - -## Description - - -SubMiner visible overlay is slightly below mpv when mpv is fullscreen on Linux Hyprland. Align overlay bounds with mpv fullscreen client/monitor bounds. - - -## Acceptance Criteria - -- [x] #1 Hyprland fullscreen mpv overlay uses top-aligned geometry instead of inheriting a downward offset. -- [x] #2 Regression coverage captures the fullscreen Hyprland geometry case. -- [x] #3 Targeted tests pass. - - -## Implementation Notes - - -Added follow-up Hyprland placement handling after the fullscreenClient geometry fix. SubMiner overlay/stats windows now get stable titles and, on Hyprland, are resolved from `hyprctl -j clients` by current PID/title, then set floating and pinned before bounds are applied. The stats overlay reapplies bounds after showing because Hyprland cannot see the hidden window before it is mapped. - - -## Final Summary - - -Summary: -- Treated Hyprland `fullscreenClient` as a fullscreen signal when resolving mpv overlay geometry. -- Added Hyprland window placement handling so SubMiner overlay/stats windows are set floating and pinned before bounds are applied. -- Gave overlay/stats windows stable titles for Hyprland client matching, and reapplied stats bounds after show. -- Added regression coverage for the 28px fullscreen geometry shape and Hyprland placement dispatches. -- Added a changelog fragment for the overlay fix. - -Verification: -- `bun test src/core/services/hyprland-window-placement.test.ts src/core/services/overlay-window-config.test.ts src/core/services/stats-window.test.ts src/core/services/overlay-window-bounds.test.ts src/window-trackers/hyprland-tracker.test.ts` -- `bun run typecheck` -- `bun run changelog:lint` -- `bun run test:fast` - diff --git a/changes/336-hyprland-fullscreen-overlay.md b/changes/336-hyprland-fullscreen-overlay.md index 668ff5b9..6bb3ed24 100644 --- a/changes/336-hyprland-fullscreen-overlay.md +++ b/changes/336-hyprland-fullscreen-overlay.md @@ -1,4 +1,4 @@ type: fixed area: overlay -- Overlay: Aligned the Hyprland fullscreen overlay with mpv when mpv reports client-requested fullscreen. +- Overlay: Aligned the Hyprland fullscreen overlay with mpv when mpv reports client-requested fullscreen, force-applied exact Hyprland overlay window bounds after floating, disabled Hyprland floating-window decoration on exact overlay placement, compensated stats overlay placement for Electron/Wayland content insets, and made the stats overlay page/window opaque so mpv cannot show through transparent top insets. diff --git a/changes/339-hyprland-overlay-pin.md b/changes/339-hyprland-overlay-pin.md new file mode 100644 index 00000000..29316947 --- /dev/null +++ b/changes/339-hyprland-overlay-pin.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Overlay: Stopped Hyprland from pinning SubMiner overlay windows across workspaces while keeping floating placement for fullscreen alignment. diff --git a/package.json b/package.json index 4d531daf..ed8decf8 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", + "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts", "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", diff --git a/src/core/services/hyprland-window-placement.test.ts b/src/core/services/hyprland-window-placement.test.ts index 8df261d6..7904c52b 100644 --- a/src/core/services/hyprland-window-placement.test.ts +++ b/src/core/services/hyprland-window-placement.test.ts @@ -53,32 +53,67 @@ test('findHyprlandWindowForPlacement matches current process by title', () => { assert.equal(client?.address, '0xmatch'); }); -test('buildHyprlandPlacementDispatches floats and pins tiled overlay windows', () => { +test('buildHyprlandPlacementDispatches floats tiled overlay windows without pinning them', () => { assert.deepEqual( buildHyprlandPlacementDispatches({ address: '0xabc', floating: false, pinned: false, }), + [['dispatch', 'setfloating', 'address:0xabc']], + ); +}); + +test('buildHyprlandPlacementDispatches force-aligns floating overlay windows to target bounds', () => { + assert.deepEqual( + buildHyprlandPlacementDispatches( + { + address: '0xabc', + floating: true, + pinned: false, + }, + { + x: 0, + y: 0, + width: 1920, + height: 1080, + }, + ), [ - ['dispatch', 'setfloating', 'address:0xabc'], - ['dispatch', 'pin', 'address:0xabc'], + ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'], + ['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xabc'], + ['dispatch', 'setprop', 'address:0xabc rounding 0'], + ['dispatch', 'setprop', 'address:0xabc border_size 0'], + ['dispatch', 'setprop', 'address:0xabc no_shadow 1'], + ['dispatch', 'setprop', 'address:0xabc no_blur 1'], + ['dispatch', 'setprop', 'address:0xabc decorate 0'], ], ); }); -test('buildHyprlandPlacementDispatches skips already floating and pinned windows', () => { +test('buildHyprlandPlacementDispatches does not pin already floating overlay windows', () => { + assert.deepEqual( + buildHyprlandPlacementDispatches({ + address: '0xabc', + floating: true, + pinned: false, + }), + [], + ); +}); + +test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows', () => { assert.deepEqual( buildHyprlandPlacementDispatches({ address: '0xabc', floating: true, pinned: true, }), - [], + [['dispatch', 'pin', 'address:0xabc']], ); }); -test('ensureHyprlandWindowFloatingByTitle dispatches placement for matching tiled window', () => { +test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => { const calls: unknown[][] = []; const placed = ensureHyprlandWindowFloatingByTitle({ title: 'SubMiner Stats', @@ -111,7 +146,55 @@ test('ensureHyprlandWindowFloatingByTitle dispatches placement for matching tile [ ['-j', 'clients'], ['dispatch', 'setfloating', 'address:0xmatch'], - ['dispatch', 'pin', 'address:0xmatch'], + ], + ); +}); + +test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry when bounds are provided', () => { + const calls: unknown[][] = []; + const placed = ensureHyprlandWindowFloatingByTitle({ + title: 'SubMiner Stats', + platform: 'linux', + env: { + HYPRLAND_INSTANCE_SIGNATURE: 'abc', + }, + pid: 456, + bounds: { + x: 0, + y: 0, + width: 1920, + height: 1080, + }, + execFileSync: ((command: string, args: string[], options: unknown) => { + calls.push([command, args, options]); + if (args.join(' ') === '-j clients') { + return JSON.stringify([ + { + address: '0xmatch', + pid: 456, + title: 'SubMiner Stats', + mapped: true, + floating: true, + pinned: false, + }, + ]); + } + return ''; + }) as never, + }); + + assert.equal(placed, true); + assert.deepEqual( + calls.map(([, args]) => args), + [ + ['-j', 'clients'], + ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'], + ['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xmatch'], + ['dispatch', 'setprop', 'address:0xmatch rounding 0'], + ['dispatch', 'setprop', 'address:0xmatch border_size 0'], + ['dispatch', 'setprop', 'address:0xmatch no_shadow 1'], + ['dispatch', 'setprop', 'address:0xmatch no_blur 1'], + ['dispatch', 'setprop', 'address:0xmatch decorate 0'], ], ); }); diff --git a/src/core/services/hyprland-window-placement.ts b/src/core/services/hyprland-window-placement.ts index d4a2c1fa..5782933c 100644 --- a/src/core/services/hyprland-window-placement.ts +++ b/src/core/services/hyprland-window-placement.ts @@ -11,6 +11,13 @@ export interface HyprlandPlacementClient { title?: string; } +export interface HyprlandPlacementBounds { + x: number; + y: number; + width: number; + height: number; +} + type ExecFileSync = typeof execFileSync; export function shouldAttemptHyprlandWindowPlacement( @@ -56,6 +63,7 @@ export function findHyprlandWindowForPlacement( export function buildHyprlandPlacementDispatches( client: HyprlandPlacementClient, + bounds?: HyprlandPlacementBounds | null, ): string[][] { if (!client.address) { return []; @@ -66,14 +74,55 @@ export function buildHyprlandPlacementDispatches( if (client.floating !== true) { dispatches.push(['dispatch', 'setfloating', windowAddress]); } - if (client.pinned !== true) { + if (client.pinned === true) { dispatches.push(['dispatch', 'pin', windowAddress]); } + const roundedBounds = roundPlacementBounds(bounds); + if (roundedBounds) { + dispatches.push([ + 'dispatch', + 'movewindowpixel', + `exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`, + ]); + dispatches.push([ + 'dispatch', + 'resizewindowpixel', + `exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`, + ]); + dispatches.push(['dispatch', 'setprop', `${windowAddress} rounding 0`]); + dispatches.push(['dispatch', 'setprop', `${windowAddress} border_size 0`]); + dispatches.push(['dispatch', 'setprop', `${windowAddress} no_shadow 1`]); + dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]); + dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]); + } return dispatches; } +function roundPlacementBounds( + bounds?: HyprlandPlacementBounds | null, +): HyprlandPlacementBounds | null { + if (!bounds) { + return null; + } + const rounded = { + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.round(bounds.width), + height: Math.round(bounds.height), + }; + return Number.isFinite(rounded.x) && + Number.isFinite(rounded.y) && + Number.isFinite(rounded.width) && + Number.isFinite(rounded.height) && + rounded.width > 0 && + rounded.height > 0 + ? rounded + : null; +} + export function ensureHyprlandWindowFloatingByTitle(options: { title: string; + bounds?: HyprlandPlacementBounds | null; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; pid?: number; @@ -96,7 +145,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: { return false; } - const dispatches = buildHyprlandPlacementDispatches(client); + const dispatches = buildHyprlandPlacementDispatches(client, options.bounds); for (const args of dispatches) { run('hyprctl', args, { stdio: 'ignore' }); } diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 0e2ac4fd..bb30f98f 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -10,7 +10,7 @@ import { type OverlayWindowKind, } from './overlay-window-input'; import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement'; -import { buildOverlayWindowOptions } from './overlay-window-options'; +import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options'; import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds'; import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; @@ -53,8 +53,9 @@ export function updateOverlayWindowBounds( window: BrowserWindow | null, ): void { if (!geometry || !window || window.isDestroyed()) return; - ensureHyprlandWindowFloatingByTitle({ title: window.getTitle() }); - window.setBounds(normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen)); + const bounds = normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen); + window.setBounds(bounds); + ensureHyprlandWindowFloatingByTitle({ title: window.getTitle(), bounds }); } export function ensureOverlayWindowLevel(window: BrowserWindow): void { @@ -119,9 +120,15 @@ export function createOverlayWindow( }); window.webContents.on('did-finish-load', () => { + window.setTitle(OVERLAY_WINDOW_TITLES[kind]); options.onRuntimeOptionsChanged(); }); + window.webContents.on('page-title-updated', (event) => { + event.preventDefault(); + window.setTitle(OVERLAY_WINDOW_TITLES[kind]); + }); + window.once('ready-to-show', () => { overlayWindowContentReady.add(window); (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index aca4a492..2ffbce95 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -8,6 +8,8 @@ export const STATS_WINDOW_TITLE = 'SubMiner Stats'; type StatsWindowLevelController = Pick & Partial>; +type StatsWindowBoundsController = Pick; + function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean { return ( input.type === 'keyDown' && @@ -37,7 +39,7 @@ export function buildStatsWindowOptions(options: { width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH, height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT, frame: false, - transparent: true, + transparent: false, alwaysOnTop: true, resizable: false, skipTaskbar: true, @@ -45,7 +47,7 @@ export function buildStatsWindowOptions(options: { focusable: true, acceptFirstMouse: true, fullscreenable: false, - backgroundColor: '#1e1e2e', + backgroundColor: '#24273a', show: false, webPreferences: { nodeIntegration: false, @@ -56,6 +58,30 @@ export function buildStatsWindowOptions(options: { }; } +export function resolveStatsWindowOuterBoundsForContent( + window: StatsWindowBoundsController, + target: WindowGeometry, +): WindowGeometry { + const outer = window.getBounds(); + const content = window.getContentBounds(); + const leftInset = content.x - outer.x; + const topInset = content.y - outer.y; + const rightInset = outer.x + outer.width - (content.x + content.width); + const bottomInset = outer.y + outer.height - (content.y + content.height); + const insets = [leftInset, topInset, rightInset, bottomInset]; + + if (insets.some((inset) => !Number.isFinite(inset) || inset < 0)) { + return target; + } + + return { + x: target.x - leftInset, + y: target.y - topInset, + width: target.width + leftInset + rightInset, + height: target.height + topInset + bottomInset, + }; +} + export function promoteStatsWindowLevel( window: StatsWindowLevelController, platform: NodeJS.Platform = process.platform, diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index 87b95160..cc599afa 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -4,6 +4,7 @@ import { buildStatsWindowLoadFileOptions, buildStatsWindowOptions, promoteStatsWindowLevel, + resolveStatsWindowOuterBoundsForContent, shouldHideStatsWindowForInput, } from './stats-window-runtime'; @@ -24,7 +25,8 @@ test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly w assert.equal(options.width, 1440); assert.equal(options.height, 900); assert.equal(options.frame, false); - assert.equal(options.transparent, true); + assert.equal(options.transparent, false); + assert.equal(options.backgroundColor, '#24273a'); assert.equal(options.resizable, false); assert.equal(options.webPreferences?.preload, '/tmp/preload-stats.js'); assert.equal(options.webPreferences?.contextIsolation, true); @@ -152,6 +154,33 @@ test('buildStatsWindowLoadFileOptions includes provided stats API base URL', () }); }); +test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => { + assert.deepEqual( + resolveStatsWindowOuterBoundsForContent( + { + getBounds: () => ({ x: 0, y: 0, width: 3440, height: 1440 }), + getContentBounds: () => ({ x: 0, y: 14, width: 3440, height: 1426 }), + }, + { x: 0, y: 0, width: 3440, height: 1440 }, + ), + { x: 0, y: -14, width: 3440, height: 1454 }, + ); +}); + +test('resolveStatsWindowOuterBoundsForContent ignores invalid inset geometry', () => { + const target = { x: 0, y: 0, width: 3440, height: 1440 }; + assert.deepEqual( + resolveStatsWindowOuterBoundsForContent( + { + getBounds: () => ({ x: 0, y: 0, width: 3440, height: 1440 }), + getContentBounds: () => ({ x: -1, y: 0, width: 3440, height: 1440 }), + }, + target, + ), + target, + ); +}); + test('promoteStatsWindowLevel raises stats above overlay level on macOS', () => { const calls: string[] = []; promoteStatsWindowLevel( diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index fcf36724..85904129 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -6,6 +6,7 @@ import { buildStatsWindowLoadFileOptions, buildStatsWindowOptions, promoteStatsWindowLevel, + resolveStatsWindowOuterBoundsForContent, shouldHideStatsWindowForInput, STATS_WINDOW_TITLE, } from './stats-window-runtime.js'; @@ -29,22 +30,29 @@ export interface StatsWindowOptions { onVisibilityChanged?: (visible: boolean) => void; } -function syncStatsWindowBounds(window: BrowserWindow, bounds: WindowGeometry | null): void { - if (!bounds || window.isDestroyed()) return; +function syncStatsWindowBounds( + window: BrowserWindow, + bounds: WindowGeometry | null, +): WindowGeometry | null { + if (!bounds || window.isDestroyed()) return null; + const outerBounds = resolveStatsWindowOuterBoundsForContent(window, bounds); window.setBounds({ - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, + x: outerBounds.x, + y: outerBounds.y, + width: outerBounds.width, + height: outerBounds.height, }); + return outerBounds; } function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): void { - syncStatsWindowBounds(window, options.resolveBounds()); + const bounds = options.resolveBounds(); + let placementBounds = syncStatsWindowBounds(window, bounds); promoteStatsWindowLevel(window); window.show(); - if (ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE })) { - syncStatsWindowBounds(window, options.resolveBounds()); + placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; + if (!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })) { + placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; } window.focus(); options.onVisibilityChanged?.(true); @@ -64,6 +72,12 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void { }), ); + statsWindow.setTitle(STATS_WINDOW_TITLE); + statsWindow.webContents.on('page-title-updated', (event) => { + event.preventDefault(); + statsWindow?.setTitle(STATS_WINDOW_TITLE); + }); + const indexPath = path.join(options.staticDir, 'index.html'); statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(options.getApiBaseUrl?.())); @@ -79,7 +93,6 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void { options.onVisibilityChanged?.(false); } }); - statsWindow.once('ready-to-show', () => { if (!statsWindow) return; showStatsWindow(statsWindow, options); diff --git a/stats/src/App.tsx b/stats/src/App.tsx index aa5a96b2..e70e0968 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -127,7 +127,7 @@ export function App() { ); return ( -
+
-
+
{mediaDetail ? ( }> { + assert.match(css, /html,\s*body,\s*#root\s*\{[^}]*height:\s*100%;/s); + assert.match(css, /body\.overlay-mode\s*\{[^}]*background-color:\s*var\(--color-ctp-base\);/s); + assert.doesNotMatch(css, /body\.overlay-mode\s*\{[^}]*rgba\(/s); + assert.match( + css, + /body\.overlay-mode #root\s*\{[^}]*background-color:\s*var\(--color-ctp-base\);/s, + ); +});