The Parametric EQ has a tab of its own since #370, but Play is in the sound FX's tab. A tab that can float keeps both in view. There is one editor per kind, so the kind's pane is lent to the window and given back on Dock, the way fullscreen moves the game view. - A right-click on a document's tab opens a menu with Float and Close. `DocumentTab` passes the press to `TabStrip`'s new `onMenu`. - `FloatWindow` sits on the overlay. Its title bar moves it, its lower right corner sizes it, Dock and the x close it. A press brings it to the front. - `floating.ts` is the pure part: where a window opens, held whole on the screen, sized down to 320 x 200. Nothing of it is remembered. - One window per kind. Another document of that kind opens there and closes the one it had. Its tab, if it had one, leaves the row. - When the row showed the kind, it shows the nearest document whose kind is not in a window, else the scene (`neighbourOf`). - Delete, Copy and Paste go to the window pressed last, and back to the tab showing after a press in the editor (`keyTab`). - Previews tick and redraw for a kind in a window as for the tab showing. Fullscreen hides the windows. Loading a project or signing out closes them. - Only documents float, Scene and Game stay in the row. - Hooks: `floatWindows`, `floatDocument`, `keyTarget`. Tests: - Unit documents: 16 green, 5 new. Counter-check: `neighbourOf` without the window check turns its test red. - e2e 44 (melektaus-atx, after steps 1-12 and 37): ALL PASSED, 505 checks in the chain, 6 new for float, move, size, another texture, keys, Dock and x. - Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4236 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
6.8 KiB
TypeScript
204 lines
6.8 KiB
TypeScript
import { Graphics } from 'pixi.js';
|
||
import { Button, crisp, hairline, HStack, Menu, Theme } from '../ui';
|
||
import { DocumentTab } from './DocumentTab';
|
||
import { TabButton } from './TabButton';
|
||
import { tabRowLayout } from './tabRow';
|
||
|
||
/** Fixed, so that the count of hidden tabs cannot feed back into how much room the row has. */
|
||
const OVERFLOW_WIDTH = 28;
|
||
/** The bar Graphics is the first child, so a tab's index among the children is two past its place. */
|
||
const TABS_AT = 2;
|
||
|
||
export interface TabPlace<T extends string> {
|
||
tab: T;
|
||
x: number;
|
||
right: number;
|
||
/** Top edge and height, so a check can tell the tab from a label of the same text elsewhere. */
|
||
y: number;
|
||
h: number;
|
||
/** Whether the name is drawn bold – the tab showing must not be (#176). */
|
||
bold: boolean;
|
||
/** Whether an icon stands in front of the name; the fixed tabs have one too, bar Plugins (#176). */
|
||
icon: boolean;
|
||
/** A document's kind as shown before its name, and where its × is. */
|
||
kind?: string;
|
||
close?: { x: number; y: number };
|
||
}
|
||
|
||
/** A document's tab: the icon of its kind before the name, and an × that calls `onClose`. */
|
||
export interface DocumentSpec {
|
||
/** The kind as a word, for the check scripts; the tab shows `icon`. */
|
||
kind: string;
|
||
icon: string;
|
||
onClose: () => void;
|
||
/** A right-click on the tab (#371). */
|
||
onMenu?: (globalX: number, globalY: number) => void;
|
||
}
|
||
|
||
/**
|
||
* The row of tab buttons. What does not fit goes behind a `»` button that opens the rest as a
|
||
* menu, so the row survives any window width and any number of tabs.
|
||
*/
|
||
export class TabStrip<T extends string> extends HStack {
|
||
private readonly order: T[] = [];
|
||
private readonly buttons = new Map<T, Button>();
|
||
/** A document's icon, which the `»` menu shows in front of its name too. */
|
||
private readonly icons = new Map<T, string>();
|
||
private readonly overflow: Button;
|
||
/** The bar the cards sit on, drawn behind every tab. */
|
||
private readonly bar = crisp(new Graphics());
|
||
private hiddenTabs: T[] = [];
|
||
private active: T | null;
|
||
|
||
constructor(
|
||
tabs: readonly { tab: T; label: string; icon?: string }[],
|
||
private readonly onPick: (tab: T) => void,
|
||
) {
|
||
super({ gap: 2, padding: 4, align: 'center' });
|
||
this.active = tabs[0]!.tab;
|
||
this.overflow = new Button('»', {
|
||
variant: 'ghost',
|
||
width: OVERFLOW_WIDTH,
|
||
tooltip: 'More tabs',
|
||
onClick: () => this.openMenu(),
|
||
});
|
||
this.addChild(this.bar);
|
||
this.add(this.overflow);
|
||
for (const { tab, label, icon } of tabs) this.place(tab, label, this.order.length, undefined, icon);
|
||
this.buttons.get(this.active)!.setToggled(true);
|
||
}
|
||
|
||
/** Puts a tab at `at` in the row (default: last, in front of the `»` button). */
|
||
addTab(tab: T, label: string, at = this.order.length, document?: DocumentSpec): void {
|
||
this.place(tab, label, at, document);
|
||
this.relayout();
|
||
}
|
||
|
||
/** Takes a tab out of the row again. Unknown tabs are ignored. */
|
||
removeTab(tab: T): void {
|
||
const button = this.buttons.get(tab);
|
||
if (!button) return;
|
||
this.order.splice(this.order.indexOf(tab), 1);
|
||
this.buttons.delete(tab);
|
||
this.icons.delete(tab);
|
||
this.hiddenTabs = this.hiddenTabs.filter((hidden) => hidden !== tab);
|
||
button.destroy({ children: true });
|
||
this.relayout();
|
||
}
|
||
|
||
/** A renamed document; the row measures again, since the name decides the width. */
|
||
setLabel(tab: T, label: string): void {
|
||
const button = this.buttons.get(tab);
|
||
if (!button || button.text === label) return;
|
||
button.setText(label);
|
||
this.relayout();
|
||
}
|
||
|
||
has(tab: T): boolean {
|
||
return this.buttons.has(tab);
|
||
}
|
||
|
||
/** Every tab in row order, hidden ones included. */
|
||
get tabs(): readonly T[] {
|
||
return this.order;
|
||
}
|
||
|
||
private place(tab: T, label: string, at: number, document?: DocumentSpec, icon?: string): void {
|
||
const button = document
|
||
? new DocumentTab(
|
||
label,
|
||
document.kind,
|
||
document.icon,
|
||
() => this.onPick(tab),
|
||
document.onClose,
|
||
document.onMenu,
|
||
)
|
||
: new TabButton(label, { icon, onClick: () => this.onPick(tab) });
|
||
this.order.splice(at, 0, tab);
|
||
this.buttons.set(tab, button);
|
||
const shown = document?.icon ?? icon;
|
||
if (shown) this.icons.set(tab, shown);
|
||
this.addChildAt(button, at + TABS_AT);
|
||
this.setChildIndex(this.overflow, this.children.length - 1);
|
||
button.setToggled(tab === this.active);
|
||
}
|
||
|
||
setActive(tab: T | null): void {
|
||
this.active = tab;
|
||
for (const [key, button] of this.buttons) button.setToggled(key === tab);
|
||
// Not `resize`: the strip keeps its size while a different tab has to come into view.
|
||
this.relayout();
|
||
}
|
||
|
||
/** Global x-extent of the tab buttons currently in the row, in order. */
|
||
places(): TabPlace<T>[] {
|
||
const out: TabPlace<T>[] = [];
|
||
for (const tab of this.order) {
|
||
const button = this.buttons.get(tab)!;
|
||
if (!button.visible) continue;
|
||
const at = button.toGlobal({ x: 0, y: 0 });
|
||
const place: TabPlace<T> = {
|
||
tab,
|
||
x: at.x,
|
||
right: at.x + button.w,
|
||
y: at.y,
|
||
h: button.h,
|
||
bold: button.labelBold,
|
||
icon: button.hasIcon,
|
||
};
|
||
if (button instanceof DocumentTab) {
|
||
place.kind = button.kindLabel;
|
||
place.close = button.closePoint();
|
||
}
|
||
out.push(place);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
hidden(): T[] {
|
||
return [...this.hiddenTabs];
|
||
}
|
||
|
||
override layout(): void {
|
||
this.applyOverflow();
|
||
super.layout();
|
||
// The cards reach down to the bar: the stack's padding below them is theirs, not free space.
|
||
for (const item of this.items) {
|
||
if (!(item instanceof TabButton)) continue;
|
||
item.position.set(item.x, this.padding);
|
||
item.resize(item.w, Math.max(0, this.h - this.padding));
|
||
}
|
||
}
|
||
|
||
override redraw(): void {
|
||
const c = Theme.colors;
|
||
this.bar.clear();
|
||
this.bar.rect(0, 0, this.w, this.h).fill(c.tabBar);
|
||
// The line the cards stand on; the one showing covers its own stretch of it.
|
||
this.bar.rect(0, this.h - hairline(), this.w, hairline()).fill(c.border);
|
||
}
|
||
|
||
private applyOverflow(): void {
|
||
const tabs = this.order.map((tab) => ({ key: tab, width: this.buttons.get(tab)!.measure().w }));
|
||
const { hidden } = tabRowLayout({
|
||
tabs,
|
||
available: this.w - this.padding * 2,
|
||
gap: this.gap,
|
||
overflowWidth: OVERFLOW_WIDTH,
|
||
active: this.active,
|
||
});
|
||
this.hiddenTabs = hidden;
|
||
const away = new Set(hidden);
|
||
for (const [tab, button] of this.buttons) button.visible = !away.has(tab);
|
||
this.overflow.visible = hidden.length > 0;
|
||
}
|
||
|
||
private openMenu(): void {
|
||
const items = this.hiddenTabs.map((tab) => {
|
||
const button = this.buttons.get(tab)!;
|
||
return { label: button.text, icon: this.icons.get(tab), onSelect: () => this.onPick(tab) };
|
||
});
|
||
new Menu(items).openBelow(this.overflow);
|
||
}
|
||
}
|