Files
iptvnator/libs/ui/components/src/lib/window-controls/window-controls.component.ts
T
4grayandClaude Fable 5 24c2da48ae feat(ui): add custom title bar window controls for Windows and Linux
Hide the native title bar on win32/linux (titleBarStyle: 'hidden', frame
untouched so native resize borders and snapping keep working) and render
minimize / maximize-restore / close buttons in the renderer, mirroring the
existing macOS traffic-light setup.

- New WINDOW:* IPC contract (minimize, toggle-maximize, close, get-state)
  handled in window.events.ts, resolved from the sender WebContents;
  close goes through win.close() so window-bounds persistence still runs.
- WINDOW:STATE_CHANGED pushed on maximize/unmaximize/fullscreen so the
  maximize/restore glyph stays correct for OS-triggered changes; controls
  hide while fullscreen.
- WindowControlsComponent mounts once in app-root as a manual popover so
  it stays in the browser top layer above CDK overlays (dialogs,
  multi-EPG) - same behavior as macOS traffic lights.
- Theme-aware via CSS vars (--app-on-surface, --app-hover-overlay);
  Windows-red close hover. Drag regions get right padding through a
  body-level frameless-platform class.
- Gated by RuntimeCapabilitiesService.usesCustomWindowControls; PWA and
  macOS never mount the controls.

Includes unit specs for the component and IPC handlers, an Electron E2E
suite (window-controls.e2e.ts), and a window-chrome section in
docs/architecture/workspace-shell.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:46:21 +02:00

127 lines
3.8 KiB
TypeScript

import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
inject,
signal,
} from '@angular/core';
import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
/**
* Renderer-drawn window-management buttons (minimize / maximize-restore /
* close) for the frameless title bar on Windows and Linux. macOS keeps the
* native traffic lights instead.
*
* Rendered once in the app root so the buttons stay reachable above
* full-window content such as the multi-EPG overlay and dialog backdrops —
* mirroring how the macOS traffic lights float above everything. CDK
* overlays render as popovers in the browser top layer (above any
* z-index), so the host is itself a manual popover and re-enters the top
* layer whenever another popover opens, keeping the controls topmost.
* Hidden while the window is in fullscreen, matching native title-bar
* behavior.
*/
@Component({
selector: 'app-window-controls',
templateUrl: './window-controls.component.html',
styleUrl: './window-controls.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
popover: 'manual',
'[class.is-hidden]': 'isFullScreen()',
},
})
export class WindowControlsComponent implements AfterViewInit {
readonly isMaximized = signal(false);
readonly isFullScreen = signal(false);
private readonly host =
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
constructor() {
const bridge = window.electron;
void bridge
?.getWindowState?.()
.then((state) => this.applyState(state))
.catch(() => undefined);
const unsubscribe = bridge?.onWindowStateChange?.((state) =>
this.applyState(state)
);
document.addEventListener('toggle', this.onDocumentToggle, true);
inject(DestroyRef).onDestroy(() => {
unsubscribe?.();
document.removeEventListener('toggle', this.onDocumentToggle, true);
});
}
ngAfterViewInit(): void {
this.enterTopLayer();
}
onMinimize(): void {
void window.electron?.minimizeWindow?.();
}
onToggleMaximize(): void {
void window.electron
?.toggleMaximizeWindow?.()
.then((state) => this.applyState(state))
.catch(() => undefined);
}
onClose(): void {
void window.electron?.closeWindow?.();
}
private applyState(state: ElectronBridgeWindowState | undefined): void {
if (!state) {
return;
}
this.isMaximized.set(state.isMaximized);
this.isFullScreen.set(state.isFullScreen);
}
/**
* Re-enter the top layer after any other popover opens: top-layer
* elements paint in insertion order, so hiding and re-showing puts the
* controls back above freshly opened CDK overlays (dialogs, multi-EPG).
*/
private readonly onDocumentToggle = (event: Event): void => {
const newState = (event as { newState?: string }).newState;
if (event.target === this.host || newState !== 'open') {
return;
}
requestAnimationFrame(() => this.enterTopLayer(true));
};
private enterTopLayer(reassert = false): void {
if (
typeof this.host.showPopover !== 'function' ||
!this.host.isConnected
) {
return;
}
try {
if (this.host.matches(':popover-open')) {
if (!reassert) {
return;
}
this.host.hidePopover();
}
this.host.showPopover();
} catch {
// Stays a regular fixed element when the popover API is
// unavailable — still above non-top-layer content via z-index.
}
}
}