refactor(overlay): split bounds ownership by layer for TASK-20.1

This commit is contained in:
2026-02-12 02:17:30 -08:00
parent 402788b1e2
commit dfb54630df
10 changed files with 110 additions and 36 deletions

View File

@@ -6,7 +6,7 @@ milestones: []
date_format: yyyy-mm-dd date_format: yyyy-mm-dd
max_column_width: 20 max_column_width: 20
default_editor: "nvim" default_editor: "nvim"
auto_open_browser: true auto_open_browser: false
default_port: 6420 default_port: 6420
remote_operations: true remote_operations: true
auto_commit: false auto_commit: false

View File

@@ -1,10 +1,10 @@
--- ---
id: TASK-20.1 id: TASK-20.1
title: Refactor overlay runtime to use per-layer window bounds ownership title: Refactor overlay runtime to use per-layer window bounds ownership
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-02-12 08:47' created_date: '2026-02-12 08:47'
updated_date: '2026-02-12 09:42' updated_date: '2026-02-12 10:12'
labels: [] labels: []
dependencies: [] dependencies: []
parent_task_id: TASK-20 parent_task_id: TASK-20
@@ -19,8 +19,24 @@ Refactor overlay runtime so each overlay layer owns and applies its bounds indep
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 `updateOverlayBoundsService` no longer applies the same bounds to every overlay window by default. - [x] #1 `updateOverlayBoundsService` no longer applies the same bounds to every overlay window by default.
- [ ] #2 Main runtime/manager exposes per-layer bounds update paths for visible and invisible overlays. - [x] #2 Main runtime/manager exposes per-layer bounds update paths for visible and invisible overlays.
- [ ] #3 Window tracker updates feed shared origin data; each layer applies its own computed bounds. - [x] #3 Window tracker updates feed shared origin data; each layer applies its own computed bounds.
- [ ] #4 Single-layer behavior (visible-only or invisible-only) remains unchanged from user perspective. - [x] #4 Single-layer behavior (visible-only or invisible-only) remains unchanged from user perspective.
<!-- AC:END --> <!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation for per-layer overlay bounds ownership refactor.
Implemented per-layer bounds ownership path: visible and invisible layers now update bounds independently through overlay manager/runtime plumbing, while preserving existing geometry source behavior.
Replaced shared all-window bounds application with per-window bound application service and layer-specific runtime calls from visibility/tracker flows.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Refactored overlay bounds ownership to per-layer update paths. Tracker geometry remains shared input, but visible/invisible windows apply bounds independently via explicit layer routes. Existing single-layer UX behavior is preserved.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -53,7 +53,7 @@ export {
createOverlayWindowService, createOverlayWindowService,
enforceOverlayLayerOrderService, enforceOverlayLayerOrderService,
ensureOverlayWindowLevelService, ensureOverlayWindowLevelService,
updateOverlayBoundsService, updateOverlayWindowBoundsService,
} from "./overlay-window-service"; } from "./overlay-window-service";
export { initializeOverlayRuntimeService } from "./overlay-runtime-init-service"; export { initializeOverlayRuntimeService } from "./overlay-runtime-init-service";
export { export {

View File

@@ -25,6 +25,8 @@ test("overlay manager stores window references and returns stable window order",
assert.equal(manager.getMainWindow(), visibleWindow); assert.equal(manager.getMainWindow(), visibleWindow);
assert.equal(manager.getInvisibleWindow(), invisibleWindow); assert.equal(manager.getInvisibleWindow(), invisibleWindow);
assert.equal(manager.getOverlayWindow("visible"), visibleWindow);
assert.equal(manager.getOverlayWindow("invisible"), invisibleWindow);
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow]); assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow]);
}); });
@@ -70,6 +72,42 @@ test("overlay manager broadcasts to non-destroyed windows", () => {
assert.deepEqual(calls, [["x", 1, "a"]]); assert.deepEqual(calls, [["x", 1, "a"]]);
}); });
test("overlay manager applies bounds by layer", () => {
const manager = createOverlayManagerService();
const visibleCalls: Electron.Rectangle[] = [];
const invisibleCalls: Electron.Rectangle[] = [];
const visibleWindow = {
isDestroyed: () => false,
setBounds: (bounds: Electron.Rectangle) => {
visibleCalls.push(bounds);
},
} as unknown as Electron.BrowserWindow;
const invisibleWindow = {
isDestroyed: () => false,
setBounds: (bounds: Electron.Rectangle) => {
invisibleCalls.push(bounds);
},
} as unknown as Electron.BrowserWindow;
manager.setMainWindow(visibleWindow);
manager.setInvisibleWindow(invisibleWindow);
manager.setOverlayWindowBounds("visible", {
x: 10,
y: 20,
width: 30,
height: 40,
});
manager.setOverlayWindowBounds("invisible", {
x: 1,
y: 2,
width: 3,
height: 4,
});
assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]);
assert.deepEqual(invisibleCalls, [{ x: 1, y: 2, width: 3, height: 4 }]);
});
test("runtime-option and debug broadcasts use expected channels", () => { test("runtime-option and debug broadcasts use expected channels", () => {
const broadcasts: unknown[][] = []; const broadcasts: unknown[][] = [];
broadcastRuntimeOptionsChangedRuntimeService( broadcastRuntimeOptionsChangedRuntimeService(

View File

@@ -1,11 +1,16 @@
import { BrowserWindow } from "electron"; import { BrowserWindow } from "electron";
import { RuntimeOptionState } from "../../types"; import { RuntimeOptionState, WindowGeometry } from "../../types";
import { updateOverlayWindowBoundsService } from "./overlay-window-service";
type OverlayLayer = "visible" | "invisible";
export interface OverlayManagerService { export interface OverlayManagerService {
getMainWindow: () => BrowserWindow | null; getMainWindow: () => BrowserWindow | null;
setMainWindow: (window: BrowserWindow | null) => void; setMainWindow: (window: BrowserWindow | null) => void;
getInvisibleWindow: () => BrowserWindow | null; getInvisibleWindow: () => BrowserWindow | null;
setInvisibleWindow: (window: BrowserWindow | null) => void; setInvisibleWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void;
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
getInvisibleOverlayVisible: () => boolean; getInvisibleOverlayVisible: () => boolean;
@@ -29,6 +34,14 @@ export function createOverlayManagerService(): OverlayManagerService {
setInvisibleWindow: (window) => { setInvisibleWindow: (window) => {
invisibleWindow = window; invisibleWindow = window;
}, },
getOverlayWindow: (layer) =>
layer === "visible" ? mainWindow : invisibleWindow,
setOverlayWindowBounds: (layer, geometry) => {
updateOverlayWindowBoundsService(
geometry,
layer === "visible" ? mainWindow : invisibleWindow,
);
},
getVisibleOverlayVisible: () => visibleOverlayVisible, getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => { setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible; visibleOverlayVisible = visible;

View File

@@ -14,7 +14,8 @@ export function initializeOverlayRuntimeService(options: {
createMainWindow: () => void; createMainWindow: () => void;
createInvisibleWindow: () => void; createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void; registerGlobalShortcuts: () => void;
updateOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
isInvisibleOverlayVisible: () => boolean; isInvisibleOverlayVisible: () => boolean;
updateVisibleOverlayVisibility: () => void; updateVisibleOverlayVisibility: () => void;
@@ -45,10 +46,12 @@ export function initializeOverlayRuntimeService(options: {
options.setWindowTracker(windowTracker); options.setWindowTracker(windowTracker);
if (windowTracker) { if (windowTracker) {
windowTracker.onGeometryChange = (geometry: WindowGeometry) => { windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
options.updateOverlayBounds(geometry); options.updateVisibleOverlayBounds(geometry);
options.updateInvisibleOverlayBounds(geometry);
}; };
windowTracker.onWindowFound = (geometry: WindowGeometry) => { windowTracker.onWindowFound = (geometry: WindowGeometry) => {
options.updateOverlayBounds(geometry); options.updateVisibleOverlayBounds(geometry);
options.updateInvisibleOverlayBounds(geometry);
if (options.isVisibleOverlayVisible()) { if (options.isVisibleOverlayVisible()) {
options.updateVisibleOverlayVisibility(); options.updateVisibleOverlayVisibility();
} }

View File

@@ -19,7 +19,7 @@ export function updateVisibleOverlayVisibilityService(args: {
mpvConnected: boolean; mpvConnected: boolean;
mpvSend: (payload: MpvCommandSender) => void; mpvSend: (payload: MpvCommandSender) => void;
secondarySubVisibilityRequestId: number; secondarySubVisibilityRequestId: number;
updateOverlayBounds: (geometry: WindowGeometry) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void;
enforceOverlayLayerOrder: () => void; enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
@@ -62,7 +62,7 @@ export function updateVisibleOverlayVisibilityService(args: {
args.setTrackerNotReadyWarningShown(false); args.setTrackerNotReadyWarningShown(false);
const geometry = args.windowTracker.getGeometry(); const geometry = args.windowTracker.getGeometry();
if (geometry) { if (geometry) {
args.updateOverlayBounds(geometry); args.updateVisibleOverlayBounds(geometry);
} }
args.ensureOverlayWindowLevel(args.mainWindow); args.ensureOverlayWindowLevel(args.mainWindow);
args.mainWindow.show(); args.mainWindow.show();
@@ -88,7 +88,7 @@ export function updateVisibleOverlayVisibilityService(args: {
const cursorPoint = screen.getCursorScreenPoint(); const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint); const display = screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea; const fallbackBounds = display.workArea;
args.updateOverlayBounds({ args.updateVisibleOverlayBounds({
x: fallbackBounds.x, x: fallbackBounds.x,
y: fallbackBounds.y, y: fallbackBounds.y,
width: fallbackBounds.width, width: fallbackBounds.width,
@@ -106,7 +106,7 @@ export function updateInvisibleOverlayVisibilityService(args: {
visibleOverlayVisible: boolean; visibleOverlayVisible: boolean;
invisibleOverlayVisible: boolean; invisibleOverlayVisible: boolean;
windowTracker: BaseWindowTracker | null; windowTracker: BaseWindowTracker | null;
updateOverlayBounds: (geometry: WindowGeometry) => void; updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void;
enforceOverlayLayerOrder: () => void; enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void; syncOverlayShortcuts: () => void;
@@ -140,7 +140,7 @@ export function updateInvisibleOverlayVisibilityService(args: {
if (args.windowTracker && args.windowTracker.isTracking()) { if (args.windowTracker && args.windowTracker.isTracking()) {
const geometry = args.windowTracker.getGeometry(); const geometry = args.windowTracker.getGeometry();
if (geometry) { if (geometry) {
args.updateOverlayBounds(geometry); args.updateInvisibleOverlayBounds(geometry);
} }
showInvisibleWithoutFocus(); showInvisibleWithoutFocus();
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
@@ -156,7 +156,7 @@ export function updateInvisibleOverlayVisibilityService(args: {
const cursorPoint = screen.getCursorScreenPoint(); const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint); const display = screen.getDisplayNearestPoint(cursorPoint);
const fallbackBounds = display.workArea; const fallbackBounds = display.workArea;
args.updateOverlayBounds({ args.updateInvisibleOverlayBounds({
x: fallbackBounds.x, x: fallbackBounds.x,
y: fallbackBounds.y, y: fallbackBounds.y,
width: fallbackBounds.width, width: fallbackBounds.width,

View File

@@ -4,19 +4,17 @@ import { WindowGeometry } from "../../types";
export type OverlayWindowKind = "visible" | "invisible"; export type OverlayWindowKind = "visible" | "invisible";
export function updateOverlayBoundsService( export function updateOverlayWindowBoundsService(
geometry: WindowGeometry, geometry: WindowGeometry,
getOverlayWindows: () => BrowserWindow[], window: BrowserWindow | null,
): void { ): void {
if (!geometry) return; if (!geometry || !window || window.isDestroyed()) return;
for (const window of getOverlayWindows()) {
window.setBounds({ window.setBounds({
x: geometry.x, x: geometry.x,
y: geometry.y, y: geometry.y,
width: geometry.width, width: geometry.width,
height: geometry.height, height: geometry.height,
}); });
}
} }
export function ensureOverlayWindowLevelService(window: BrowserWindow): void { export function ensureOverlayWindowLevelService(window: BrowserWindow): void {

View File

@@ -154,7 +154,6 @@ import {
updateCurrentMediaPathService, updateCurrentMediaPathService,
updateInvisibleOverlayVisibilityService, updateInvisibleOverlayVisibilityService,
updateLastCardFromClipboardService, updateLastCardFromClipboardService,
updateOverlayBoundsService,
updateVisibleOverlayVisibilityService, updateVisibleOverlayVisibilityService,
} from "./core/services"; } from "./core/services";
import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-handler"; import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-handler";
@@ -763,8 +762,12 @@ async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
); );
} }
function updateOverlayBounds(geometry: WindowGeometry): void { function updateVisibleOverlayBounds(geometry: WindowGeometry): void {
updateOverlayBoundsService(geometry, () => getOverlayWindows()); overlayManager.setOverlayWindowBounds("visible", geometry);
}
function updateInvisibleOverlayBounds(geometry: WindowGeometry): void {
overlayManager.setOverlayWindowBounds("invisible", geometry);
} }
function ensureOverlayWindowLevel(window: BrowserWindow): void { function ensureOverlayWindowLevel(window: BrowserWindow): void {
@@ -856,8 +859,11 @@ function initializeOverlayRuntime(): void {
registerGlobalShortcuts: () => { registerGlobalShortcuts: () => {
registerGlobalShortcuts(); registerGlobalShortcuts();
}, },
updateOverlayBounds: (geometry) => { updateVisibleOverlayBounds: (geometry) => {
updateOverlayBounds(geometry); updateVisibleOverlayBounds(geometry);
},
updateInvisibleOverlayBounds: (geometry) => {
updateInvisibleOverlayBounds(geometry);
}, },
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
isInvisibleOverlayVisible: () => isInvisibleOverlayVisible: () =>
@@ -1181,7 +1187,7 @@ function updateVisibleOverlayVisibility(): void {
mpvClient.send(payload); mpvClient.send(payload);
}, },
secondarySubVisibilityRequestId: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, secondarySubVisibilityRequestId: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
updateOverlayBounds: (geometry) => updateOverlayBounds(geometry), updateVisibleOverlayBounds: (geometry) => updateVisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
enforceOverlayLayerOrder: () => enforceOverlayLayerOrder(), enforceOverlayLayerOrder: () => enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => syncOverlayShortcuts(), syncOverlayShortcuts: () => syncOverlayShortcuts(),
@@ -1196,7 +1202,7 @@ function updateInvisibleOverlayVisibility(): void {
visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(), visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(),
invisibleOverlayVisible: overlayManager.getInvisibleOverlayVisible(), invisibleOverlayVisible: overlayManager.getInvisibleOverlayVisible(),
windowTracker, windowTracker,
updateOverlayBounds: (geometry) => updateOverlayBounds(geometry), updateInvisibleOverlayBounds: (geometry) => updateInvisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
enforceOverlayLayerOrder: () => enforceOverlayLayerOrder(), enforceOverlayLayerOrder: () => enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => syncOverlayShortcuts(), syncOverlayShortcuts: () => syncOverlayShortcuts(),