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
max_column_width: 20
default_editor: "nvim"
auto_open_browser: true
auto_open_browser: false
default_port: 6420
remote_operations: true
auto_commit: false

View File

@@ -1,10 +1,10 @@
---
id: TASK-20.1
title: Refactor overlay runtime to use per-layer window bounds ownership
status: To Do
status: Done
assignee: []
created_date: '2026-02-12 08:47'
updated_date: '2026-02-12 09:42'
updated_date: '2026-02-12 10:12'
labels: []
dependencies: []
parent_task_id: TASK-20
@@ -19,8 +19,24 @@ Refactor overlay runtime so each overlay layer owns and applies its bounds indep
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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.
- [ ] #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] #1 `updateOverlayBoundsService` no longer applies the same bounds to every overlay window by default.
- [x] #2 Main runtime/manager exposes per-layer bounds update paths for visible and invisible overlays.
- [x] #3 Window tracker updates feed shared origin data; each layer applies its own computed bounds.
- [x] #4 Single-layer behavior (visible-only or invisible-only) remains unchanged from user perspective.
<!-- 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,
enforceOverlayLayerOrderService,
ensureOverlayWindowLevelService,
updateOverlayBoundsService,
updateOverlayWindowBoundsService,
} from "./overlay-window-service";
export { initializeOverlayRuntimeService } from "./overlay-runtime-init-service";
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.getInvisibleWindow(), invisibleWindow);
assert.equal(manager.getOverlayWindow("visible"), visibleWindow);
assert.equal(manager.getOverlayWindow("invisible"), 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"]]);
});
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", () => {
const broadcasts: unknown[][] = [];
broadcastRuntimeOptionsChangedRuntimeService(

View File

@@ -1,11 +1,16 @@
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 {
getMainWindow: () => BrowserWindow | null;
setMainWindow: (window: BrowserWindow | null) => void;
getInvisibleWindow: () => BrowserWindow | null;
setInvisibleWindow: (window: BrowserWindow | null) => void;
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void;
getVisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
getInvisibleOverlayVisible: () => boolean;
@@ -29,6 +34,14 @@ export function createOverlayManagerService(): OverlayManagerService {
setInvisibleWindow: (window) => {
invisibleWindow = window;
},
getOverlayWindow: (layer) =>
layer === "visible" ? mainWindow : invisibleWindow,
setOverlayWindowBounds: (layer, geometry) => {
updateOverlayWindowBoundsService(
geometry,
layer === "visible" ? mainWindow : invisibleWindow,
);
},
getVisibleOverlayVisible: () => visibleOverlayVisible,
setVisibleOverlayVisible: (visible) => {
visibleOverlayVisible = visible;

View File

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

View File

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

View File

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

View File

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