Files
feuerwerk/src/ui/widgets/Popup.ts
T
adminandClaude Opus 5 d3563897d8
game-maker / suite (push) Failing after 2m22s
game-maker / gate (push) Successful in 2m53s
Two compilers that walk a graph the same way walk it through one emitter (#85)
Refactoring round two: the particle and shader compilers share emitGraph.ts (cycle detection,
duplicate names, emit order) and keep only their rules. FontLibrary and TextureLibrary sit on
FileLibrary (base URL, loading/failed bookkeeping, listeners); SoundLibrary keeps its own
contract. The three Create*Command share ImportFileCommand, the three renames share
RenameCommand - class names stay. bandBounds() in framing.ts and portNameField() in
portSection.ts replace in-file clones, and three comments that told history now state the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:28:28 +02:00

174 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js';
import { drawControlBox } from '../draw';
import { VStack } from '../layout/Stack';
import { clamp } from '../math';
import { Theme } from '../Theme';
import { ui } from '../UIContext';
import { type Size, Widget } from '../Widget';
export interface PopupOptions {
padding?: number;
gap?: number;
/** Modal popups dim the screen and ignore click-away; only Escape or an action closes them. */
modal?: boolean;
}
/**
* Left free around a popup, so it never touches the edge of the canvas. A share of the canvas
* rather than a constant: the same gap that frames a popup on a laptop looks like a hairline on a
* large screen, and the height cap is measured against the canvas anyway.
*/
const SCREEN_MARGIN = 0.1;
/** The gap the layout reserves, in pixels on the current canvas. */
export function screenMargin(screenHeight: number): number {
return Math.round(screenHeight * SCREEN_MARGIN);
}
/**
* Where a popup of `height` ends up when it was asked to open at `wanted`. The margin has to be
* enforced here, not only in `roomFor`: a popup anchored low is *shifted* up to fit, and shifting
* it against the raw canvas height would park it on the bottom edge, with the gap above it and none
* below.
*
* Downwards only, though. Above the popup the margin is what the height cap spends, not a distance
* to keep: a menu hanging off a button near the top of the screen belongs under its button, and
* pushing it down to the tenth would tear it away from what opened it. So the top edge of the
* canvas is the only limit upwards, and a popup taller than the canvas starts there instead of
* being centred out of view.
*/
export function clampPopupY(wanted: number, height: number, screenHeight: number): number {
const margin = screenMargin(screenHeight);
return clamp(wanted, 0, Math.max(0, screenHeight - margin - height));
}
/** A floating box on the overlay layer with a click-away backdrop. */
export class Popup extends Widget {
private readonly backdrop = new Graphics();
private readonly bg = new Graphics();
readonly content: VStack;
onClose?: () => void;
private readonly modal: boolean;
/** Where `open()` was asked to put the top-left corner, before clamping to the screen. */
private openX = 0;
private openY = 0;
constructor(options: PopupOptions = {}) {
super();
this.modal = options.modal ?? false;
this.content = new VStack({ padding: options.padding ?? Theme.padding, gap: options.gap ?? Theme.gap });
this.addChild(this.bg, this.content);
this.backdrop.eventMode = 'static';
this.backdrop.on('pointerdown', (e: FederatedPointerEvent) => {
e.stopPropagation();
if (!this.modal) this.close();
});
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
}
private get isOpen(): boolean {
return this.parent !== null;
}
/** Mounts at the given global position, shifted to stay on screen. Blurs the focused widget. */
open(globalX: number, globalY: number): void {
const { overlay, app, focus } = ui();
const screen = app.screen;
this.backdrop
.clear()
.rect(0, 0, screen.width, screen.height)
.fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 });
this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height);
overlay.addChild(this.backdrop, this);
this.openX = globalX;
this.openY = globalY;
this.place();
focus.blur();
window.addEventListener('keydown', this.onEscape, { capture: true });
}
/** Re-measures an open popup after its content changed size. No-op while closed. */
refreshLayout(): void {
if (this.isOpen) this.place();
}
private place(): void {
const screen = ui().app.screen;
const size = this.measure();
this.resize(size.w, size.h);
const x = clamp(this.openX, 0, Math.max(0, screen.width - size.w));
const y = clampPopupY(this.openY, size.h, screen.height);
this.position.set(Math.round(x), Math.round(y));
}
/** Opens left-aligned under `anchor`, and never narrower than it. */
openBelow(anchor: Widget, gap = 2): void {
const g = anchor.getGlobalPosition();
this.minWidth = Math.max(this.minWidth, anchor.w);
this.open(g.x, g.y + anchor.h + gap);
}
openCentered(): void {
const screen = ui().app.screen;
const size = this.measure();
this.open((screen.width - size.w) / 2, (screen.height - size.h) / 2);
}
/** Safe to call on a popup that is not open. */
close(): void {
if (!this.isOpen) return;
window.removeEventListener('keydown', this.onEscape, { capture: true });
ui().overlay.removeChild(this.backdrop, this);
// A field inside the popup would keep the keyboard bridge's DOM focus after the popup is gone,
// swallowing every later Delete or Ctrl+Z. Blurring here mirrors the blur in `open()`.
ui().focus.blur();
this.onClose?.();
}
/**
* How tall the scrolling part of this popup may become: the canvas, minus a margin, minus the
* chrome around it. The chrome is *measured* – everything the content holds besides `scroll` –
* rather than repeated as a constant, so a search field or a row of buttons cannot be forgotten.
*
* A fixed cap would either waste a tall screen or overflow a short one; the canvas is the only
* number that knows which of the two we are on.
*/
protected roomFor(scroll: Widget, minimum = Theme.rowHeight * 3): number {
const chrome = this.content.measure().h - scroll.measure().h;
const screenHeight = ui().app.screen.height;
const room = screenHeight - screenMargin(screenHeight) * 2 - chrome;
return Math.max(minimum, room);
}
/** Closes first, so `action` runs with the overlay already cleared. */
protected closeThen(action?: () => void): () => void {
return () => {
this.close();
action?.();
};
}
private readonly onEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
this.close();
}
};
override measure(): Size {
const c = this.content.measure();
return { w: Math.max(this.minWidth, c.w), h: Math.max(this.minHeight, c.h) };
}
override layout(): void {
this.content.resize(this.w, this.h);
}
override redraw(): void {
const c = Theme.colors;
this.bg.clear();
this.bg.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); // drop shadow
drawControlBox(this.bg, this.w, this.h, { fill: c.panelHeader, border: c.border });
}
}