import { type Container, type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js'; import { Button, crisp, drawFloatingBox, Icon, Label, Spacer, Theme, ui, Widget } from '../ui'; import { keptSize, leastBox, onScreen, sized, type WindowBox } from './floating'; import { TabHeader } from './TabHeader'; /** The corner taken hold of to size the window. */ const GRIP = 14; export interface FloatWindowHost { onDock: () => void; onClose: () => void; /** Any press inside the window, before what is under the pointer sees it. */ onPress: () => void; } /** A pane that is one size only (#379); null while it takes any. */ export interface SizedPane { readonly fixedSize: { w: number; h: number } | null; } /** * A tab's pane in a window of its own (#371), above the editor. The pane is lent, not owned: the * editor puts it in with `setContent` and takes it back before the window goes. The title bar * moves the window, the lower right corner sizes it, and neither is remembered. */ export class FloatWindow extends Widget { private readonly bg = new Graphics(); private readonly bar = new TabHeader(); private icon: { svg: string; widget: Icon } | null = null; private readonly title = new Label('', { style: 'bodyBold' }); readonly dockButton: Button; readonly closeButton: Button; private readonly grip = crisp(new Graphics()); private content: Widget | null = null; private gesture: { kind: 'move' | 'size'; x: number; y: number; box: WindowBox } | null = null; constructor(host: FloatWindowHost) { super(); this.dockButton = new Button('Dock', { variant: 'ghost', tooltip: 'Back into the tab row', onClick: () => host.onDock(), }); this.closeButton = new Button('×', { variant: 'ghost', tooltip: 'Close', onClick: () => host.onClose() }); this.bar.add(this.title); this.bar.add(new Spacer(), 1); this.bar.add(this.dockButton); this.bar.add(this.closeButton); this.bar.cursor = 'move'; this.bar.on('pointerdown', (e: FederatedPointerEvent) => { if (e.button === 0 && !onButton(e.target, this.bar)) this.grab('move', e); }); this.grip.eventMode = 'static'; this.grip.cursor = 'nwse-resize'; this.grip.hitArea = new Rectangle(0, 0, GRIP, GRIP); this.grip.on('pointerdown', (e: FederatedPointerEvent) => { if (e.button === 0) this.grab('size', e); }); this.on('globalpointermove', (e: FederatedPointerEvent) => this.drag(e)); this.onPointerRelease(() => (this.gesture = null)); // Before anything inside: the window comes up, and Delete or Ctrl+V go to it from now on. this.on('pointerdowncapture', () => { this.toFront(); host.onPress(); }); this.addChild(this.bg, this.bar, this.grip); } get isOpen(): boolean { return this.parent !== null; } /** Where the window is and how large, for the check scripts. */ get box(): WindowBox { return { x: this.x, y: this.y, w: this.w, h: this.h }; } /** Where the title bar can be taken hold of: left of the buttons, clear of the name. */ barPoint(): { x: number; y: number } { const at = this.bar.getGlobalPosition(); const first = this.dockButton.visible ? this.dockButton : this.closeButton; return { x: at.x + first.x - Theme.padding * 2, y: at.y + this.bar.h / 2 }; } /** The size of a window around a pane of one size, or null where it can be sized. */ get fixedSize(): { w: number; h: number } | null { const size = (this.content as Partial | null)?.fixedSize; return size ? { w: size.w + 2, h: size.h + this.bar.measure().h + 2 } : null; } /** * After the pane has changed what it is: a pane of one size has the window at that size, with no * corner to size it and no Dock (#379) – it cannot go into the tab row. */ refit(): void { const fixed = this.fixedSize !== null; this.grip.visible = !fixed; if (this.dockButton.visible === fixed) { this.dockButton.visible = !fixed; this.bar.relayout(); } if (this.isOpen) this.place(this.box); } /** The middle of the sizing corner, for the check scripts. */ gripPoint(): { x: number; y: number } { const at = this.grip.getGlobalPosition(); return { x: at.x + GRIP / 2, y: at.y + GRIP / 2 }; } get titleText(): string { return this.title.text; } /** The pane to show; the one before is left without a parent for the caller to put back. */ setContent(content: Widget): void { if (this.content?.parent === this) this.removeChild(this.content); this.content = content; content.visible = true; this.addChildAt(content, this.getChildIndex(this.bar)); this.layout(); // A pane that needs more than the window has grows the window. this.refit(); } /** The name and the icon of the document shown, as its tab had them. */ setTitle(text: string, svg: string): void { if (this.icon?.svg !== svg) { if (this.icon) this.icon.widget.destroy(); this.icon = { svg, widget: new Icon(svg) }; this.bar.addChildAt(this.icon.widget, this.bar.getChildIndex(this.title)); } if (this.title.text === text) return; this.title.setText(text); this.bar.relayout(); } open(box: WindowBox): void { ui().overlay.addChild(this); ui().app.renderer.on('resize', this.keepOnScreen); this.place(box); } /** Takes the window off the screen; the pane in it stays where it is until the caller moves it. */ close(): void { if (!this.isOpen) return; ui().app.renderer.off('resize', this.keepOnScreen); this.parent!.removeChild(this); // A field in the window would keep the keyboard after the window is gone, as in `Popup.close`. if (ui().focus.isWithin(this)) ui().focus.blur(); } toFront(): void { const overlay = this.parent; if (overlay && overlay.getChildIndex(this) < overlay.children.length - 1) overlay.setChildIndex(this, overlay.children.length - 1); } private grab(kind: 'move' | 'size', e: FederatedPointerEvent): void { e.stopPropagation(); this.gesture = { kind, x: e.global.x, y: e.global.y, box: this.box }; } private drag(e: FederatedPointerEvent): void { const g = this.gesture; if (!g) return; const screen = ui().app.screen; const dx = e.global.x - g.x; const dy = e.global.y - g.y; if (g.kind === 'size') this.place(sized(g.box, dx, dy, screen.width, screen.height, this.least())); else this.place({ ...g.box, x: g.box.x + dx, y: g.box.y + dy }); } private readonly keepOnScreen = () => this.place(this.box); /** The window's least size: the usual one, or more where the pane in it says it needs more. */ least(): { w: number; h: number } { const content = this.content; if (!content) return leastBox(0, 0); return leastBox(content.minWidth + 2, content.minHeight + this.bar.measure().h + 2); } private place(box: WindowBox): void { const screen = ui().app.screen; const fixed = this.fixedSize; const fit = fixed ? onScreen(keptSize(box, fixed), screen.width, screen.height, fixed) : onScreen(box, screen.width, screen.height, this.least()); this.position.set(fit.x, fit.y); this.resize(fit.w, fit.h); } override layout(): void { const line = 1; const barH = this.bar.measure().h; this.bar.position.set(line, line); this.bar.resize(Math.max(0, this.w - line * 2), barH); if (this.content) { this.content.position.set(line, line + barH); // Laid out even at the same size: a pane that comes from the tab row may not have been. const w = Math.max(0, this.w - line * 2); const h = Math.max(0, this.h - line * 2 - barH); if (this.content.w === w && this.content.h === h) this.content.layout(); this.content.resize(w, h); } this.grip.position.set(this.w - GRIP - line, this.h - GRIP - line); } override redraw(): void { const c = Theme.colors; drawFloatingBox(this.bg, this.w, this.h, c.panel); // Three short diagonals in the corner, the usual sign of a handle that sizes a window. this.grip.clear(); for (const at of [4, 8, 12]) this.grip.moveTo(GRIP - at, GRIP).lineTo(GRIP, GRIP - at); this.grip.stroke({ width: 1, color: c.textMuted }); } } /** Whether a press landed on one of the title bar's buttons rather than on the bar itself. */ function onButton(target: Container | null, bar: Container): boolean { for (let node = target; node && node !== bar; node = node.parent) if (node instanceof Button) return true; return false; }