diff --git a/src/editor/DocumentTab.ts b/src/editor/DocumentTab.ts index d32aaa3c..817f6146 100644 --- a/src/editor/DocumentTab.ts +++ b/src/editor/DocumentTab.ts @@ -11,7 +11,8 @@ const MAX_NAME_WIDTH = 160; /** * A tab that stands for an open document (#171): the icon of its kind before the name, so three - * assets called "Player" can be told apart, and an × that closes it – as does a middle click. + * assets called "Player" can be told apart, and an × that closes it – as does a middle click. A + * right-click opens what else can be done with it (#371). */ export class DocumentTab extends TabButton { private readonly closeText: Text; @@ -22,8 +23,13 @@ export class DocumentTab extends TabButton { icon: string, onPick: () => void, private readonly onClose: () => void, + onMenu?: (globalX: number, globalY: number) => void, ) { super(label, { align: 'left', icon, onClick: onPick }); + // On the press, as a row of the asset panel opens its menu. + this.on('pointerdown', (e: FederatedPointerEvent) => { + if (e.button === 2) onMenu?.(e.global.x, e.global.y); + }); this.closeText = crispText({ text: CLOSE, style: textStyle('muted') }); this.closeText.eventMode = 'static'; this.closeText.cursor = Theme.hoverCursor; diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index eceb358c..a6c05d03 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -31,7 +31,9 @@ import { } from '../ui'; import { TabStrip, type TabPlace } from './TabStrip'; import type { AssetTabEditor } from './AssetTabEditor'; -import { closeDocument, type DocumentRow, openDocument, replaceDocument } from './documents'; +import { closeDocument, type DocumentRow, neighbourOf, openDocument, replaceDocument } from './documents'; +import { openingBox, type WindowBox } from './floating'; +import { FloatWindow } from './FloatWindow'; import { ConsolePanel } from './console/ConsolePanel'; import { GamePane } from './viewport/GamePane'; import { ScenePane } from './viewport/ScenePane'; @@ -299,6 +301,13 @@ export class Editor implements PlayHost { private readonly panes: Record; private readonly assetEditors: Record>; private documents: DocumentRow = { docs: [], active: null }; + /** + * The kinds whose pane is in a window of its own (#371), and the document each window shows. A + * kind has one editor, so it has one window: another document of it opens there, not in a tab. + */ + private readonly floats = new Map(); + /** The window the last press went into; null when it went into the editor. Keys follow it. */ + private pressedWindow: CentreTab | null = null; private readonly pluginPanes = new Map(); /** The plugins of this editor and their switches; null until `startPlugins` has run. */ plugins: PluginManager | null = null; @@ -482,6 +491,11 @@ export class Editor implements PlayHost { }; // Following a call goes on in the same tab: the tab changes its document, not its place. this.graphEditor.onRetarget = (from, to) => { + const float = this.floats.get('graph'); + if (float?.doc === `asset:${from}`) { + this.showInWindow('graph', `asset:${to}`); + return; + } const next = replaceDocument(this.documents, `asset:${from}`, `asset:${to}`); if (next === this.documents) return; this.documents = next; @@ -527,6 +541,8 @@ export class Editor implements PlayHost { this.workspace.add(this.inspector); root.add(this.workspace, 1); root.resize(app.screen.width, app.screen.height); + // A press in the editor takes the keys back from a window (#371). + this.workspace.on('pointerdowncapture', () => (this.pressedWindow = null)); this.autosave = attachAutosave(state, store); this.cleanups.push( @@ -535,9 +551,9 @@ export class Editor implements PlayHost { stop: () => this.play.stop(), togglePause: () => this.play.togglePause(), copy: () => - (this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) || - (this.tab === 'pattern' && this.patternEditor.canvas.copy()) || - (this.tab === 'song' && this.songEditor.canvas.copy()) || + (this.keyTab() === 'animation' && this.animationEditor.canvas.copyKeys()) || + (this.keyTab() === 'pattern' && this.patternEditor.canvas.copy()) || + (this.keyTab() === 'song' && this.songEditor.canvas.copy()) || copySelection(state), paste: () => this.paste(), removePoint: () => this.removePoint(), @@ -597,6 +613,7 @@ export class Editor implements PlayHost { state.on('load', () => { syncMix(); this.editorRun.invalidate(); + for (const kind of [...this.floats.keys()]) this.closeWindow(kind); for (const doc of this.documents.docs) this.tabs.removeTab(doc); this.documents = { docs: [], active: null }; this.valueTargets.clear(); @@ -635,13 +652,25 @@ export class Editor implements PlayHost { private showsPick(): boolean { const pick = this.state.objectPick; if (!pick) return false; - if (this.tab === 'container') return this.containerEditor.currentAssetId === pick.holderId; + if (this.shows('container') && this.containerEditor.currentAssetId === pick.holderId) return true; return this.tab === 'scene' && pickView(this.state, pick.holderId) === 'scene'; } + /** + * Where Delete, Copy and Paste go: the window pressed last (#371), else the tab showing. + */ + private keyTab(): TabId { + return this.pressedWindow && this.floats.has(this.pressedWindow) ? this.pressedWindow : this.tab; + } + + /** Whether a kind's pane is on screen, in the tab showing or in a window of its own. */ + private shows(kind: CentreTab): boolean { + return this.tab === kind || this.floats.has(kind); + } + /** Delete takes the point the visible tab has picked (#288), wherever the keyboard focus is. */ private removePoint(): boolean { - switch (this.tab) { + switch (this.keyTab()) { case 'scene': return this.viewport.removePickedCorner(); case 'container': @@ -667,7 +696,7 @@ export class Editor implements PlayHost { /** Ctrl+V lands in whatever the visible tab edits (#240). */ private paste(): boolean { - switch (this.tab) { + switch (this.keyTab()) { case 'animation': return this.animationEditor.canvas.pasteKeys(); case 'scene': @@ -810,7 +839,12 @@ export class Editor implements PlayHost { /** Brings a document up, opening a tab for it if it has none. Unknown assets are ignored. */ openDocument(doc: DocumentId): void { - if (!this.kindOf(doc)) return; + const kind = this.kindOf(doc); + if (!kind) return; + if (this.floats.has(kind)) { + this.showInWindow(kind, doc); + return; + } if (this.documents.active === doc) { // Still worth a look at the name: a Variables tab's owner may have been renamed. this.syncStrip(); @@ -823,21 +857,174 @@ export class Editor implements PlayHost { /** Closes a document's tab; nothing is lost, every edit is in the model already. */ closeDocument(doc: DocumentId): void { + const floating = [...this.floats].find(([, float]) => float.doc === doc)?.[0]; + if (floating) { + this.closeWindow(floating); + return; + } if (!this.documents.docs.includes(doc)) return; const wasShowing = this.documents.active === doc; + const at = this.documents.docs.indexOf(doc); + this.leaveRow(doc); + this.forget(doc); + if (wasShowing) this.showBeside(at); + } + + /** Takes a document out of the tab row, without anything else that closing it does. */ + private leaveRow(doc: DocumentId): void { this.documents = closeDocument(this.documents, doc); this.tabs.removeTab(doc); + } + + /** Lets go of what a document kept besides its tab: its editor's view, what its tab was for. */ + private forget(doc: DocumentId): void { const kind = this.kindOf(doc); if (kind && kind in this.assetEditors) this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length)); this.valueTargets.delete(doc); if (this.eqTargets.delete(doc) && kind === 'eq') this.eqEditor.close(); if (doc === 'routing') this.routingEditor.close(); - if (!wasShowing) return; - if (this.documents.active) this.showDocument(this.documents.active); + } + + /** + * Shows what comes in place of the document that stood at `at` in the row: its left neighbour, + * else its right one, else the scene – passing over documents whose kind is in a window. + */ + private showBeside(at: number): void { + const next = neighbourOf(this.documents.docs, at, (doc) => { + const kind = this.kindOf(doc); + return kind !== null && !this.floats.has(kind); + }); + if (next) this.showDocument(next); else this.showTab('scene'); } + /** + * Moves a document out of the tab row into a window of its own (#371), its kind's pane with it. + * With a window for the kind open already, the document goes there instead. + */ + floatDocument(doc: DocumentId): void { + const kind = this.kindOf(doc); + if (!kind) return; + if (this.floats.has(kind)) { + this.showInWindow(kind, doc); + return; + } + const at = this.documents.docs.indexOf(doc); + const showing = this.documents.active; + const centreHadIt = this.tab === kind; + if (at >= 0) this.leaveRow(doc); + const window = new FloatWindow({ + onDock: () => this.dockWindow(kind), + onClose: () => this.closeWindow(kind), + onPress: () => (this.pressedWindow = kind), + }); + const pane = this.panes[kind]; + this.centre.removeChild(pane); + window.setContent(pane); + const screen = ui().app.screen; + window.open(openingBox(screen.width, screen.height, this.floats.size)); + this.floats.set(kind, { doc, window }); + this.showInWindow(kind, doc); + // The tab row showed this kind – the document itself, or another one of it: the pane is gone. + if (!centreHadIt) return; + const from = showing && showing !== doc ? this.documents.docs.indexOf(showing) : at; + this.showBeside(from >= 0 ? from : this.documents.docs.length); + } + + /** Puts a window's document back into the tab row, showing, and its pane back below the row. */ + dockWindow(kind: CentreTab): void { + const float = this.floats.get(kind); + if (!float) return; + this.takeBack(kind); + this.documents = openDocument(this.documents, float.doc); + this.syncStrip(); + this.showDocument(float.doc); + // Back at the size it had below the row, the pane would not lay itself out for the move. + this.panes[kind].layout(); + this.panes[kind].redraw(); + } + + /** Closes a window and the document in it, as the × of its tab would. */ + closeWindow(kind: CentreTab): void { + const float = this.floats.get(kind); + if (!float) return; + this.takeBack(kind); + this.forget(float.doc); + } + + /** The pane comes home below the tab row, hidden until its kind shows there; the window goes. */ + private takeBack(kind: CentreTab): void { + const float = this.floats.get(kind)!; + this.floats.delete(kind); + if (this.pressedWindow === kind) this.pressedWindow = null; + const pane = this.panes[kind]; + pane.visible = false; + this.centre.addChildAt(pane, this.centre.getChildIndex(this.consoleSplitter)); + pane.flex = 1; + float.window.close(); + float.window.destroy({ children: true }); + this.centre.relayout(); + } + + /** Points a window at `doc`: another document of its kind takes the place of the one it had. */ + private showInWindow(kind: CentreTab, doc: DocumentId): void { + const float = this.floats.get(kind)!; + if (float.doc !== doc) { + const old = float.doc; + float.doc = doc; + // A document has one place: its tab goes when it comes into the window. + if (this.documents.docs.includes(doc)) this.leaveRow(doc); + this.forget(old); + } + this.pointPane(kind, doc); + float.window.setTitle(this.labelOf(doc), this.iconOf(doc)); + float.window.toFront(); + this.pressedWindow = kind; + } + + /** The windows open, for the check scripts: what each shows, where, and where its controls are. */ + floatWindows(): { + kind: CentreTab; + doc: DocumentId; + title: string; + box: WindowBox; + bar: { x: number; y: number }; + grip: { x: number; y: number }; + dock: { x: number; y: number }; + close: { x: number; y: number }; + front: boolean; + }[] { + const centre = (w: Widget) => { + const at = w.getGlobalPosition(); + return { x: at.x + w.w / 2, y: at.y + w.h / 2 }; + }; + return [...this.floats].map(([kind, { doc, window }]) => ({ + kind, + doc, + title: window.titleText, + box: window.box, + bar: window.barPoint(), + grip: window.gripPoint(), + dock: centre(window.dockButton), + close: centre(window.closeButton), + front: window.parent?.children.at(-1) === window, + })); + } + + /** Where Delete, Copy and Paste go now, for the check scripts (#371). */ + keyTarget(): TabId { + return this.keyTab(); + } + + /** A right-click on a document's tab (#371). */ + private openTabMenu(doc: DocumentId, x: number, y: number): void { + new Menu([ + { label: 'Float', onSelect: () => this.floatDocument(doc) }, + { label: 'Close', onSelect: () => this.closeDocument(doc) }, + ]).open(x, y); + } + /** * Shows a tab by what it is. A fixed or plugin tab is shown as such; a kind of document shows the * one the selection is about, else the one its editor last had – the way the check scripts reach @@ -879,6 +1066,7 @@ export class Editor implements PlayHost { if (on === this.gameView.isFullscreen) return; this.toolbar.visible = !on; this.workspace.visible = !on; + for (const { window } of this.floats.values()) window.visible = !on; if (on) this.root.add(this.gameView, 1); else this.gamePane.add(this.gameView, 1); this.gameView.setFullscreen(on); @@ -911,6 +1099,16 @@ export class Editor implements PlayHost { private showDocument(doc: DocumentId): void { const kind = this.kindOf(doc); if (!kind) return; + if (this.floats.has(kind)) { + this.showInWindow(kind, doc); + return; + } + this.pointPane(kind, doc); + this.display(kind, doc); + } + + /** Points the editor of a kind at a document, wherever its pane is. */ + private pointPane(kind: CentreTab, doc: DocumentId): void { if (kind in this.assetEditors) this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length)); const target = this.valueTargets.get(doc); if (kind === 'variables' && target) this.variablesEditor.show(target); @@ -919,7 +1117,6 @@ export class Editor implements PlayHost { if (kind === 'routing') this.routingEditor.open(); const eq = this.eqTargets.get(doc); if (kind === 'eq' && eq) this.eqEditor.show(eq.assetId, eq.nodeId); - this.display(kind, doc); } /** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */ @@ -957,6 +1154,7 @@ export class Editor implements PlayHost { kind: doc === 'management' || doc === 'routing' ? this.labelOf(doc) : TAB_LABELS[kind], icon: this.iconOf(doc), onClose: () => this.closeDocument(doc), + onMenu: (x, y) => this.openTabMenu(doc, x, y), }); else this.tabs.setLabel(doc, this.labelOf(doc)); }); @@ -965,6 +1163,9 @@ export class Editor implements PlayHost { /** After an edit: a deleted asset takes its tab along, a renamed one renames it. */ private syncDocuments(): void { for (const doc of this.documents.docs) if (!this.kindOf(doc)) this.closeDocument(doc); + for (const [kind, { doc, window }] of [...this.floats]) + if (!this.kindOf(doc)) this.closeWindow(kind); + else window.setTitle(this.labelOf(doc), this.iconOf(doc)); this.syncStrip(); } @@ -980,7 +1181,8 @@ export class Editor implements PlayHost { ? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId) : () => this.state.activeScene, ); - for (const key of PANE_ORDER) this.panes[key].visible = key === tab; + // A pane in a window shows there whatever the row shows. + for (const key of PANE_ORDER) if (!this.floats.has(key)) this.panes[key].visible = key === tab; for (const [key, pane] of this.pluginPanes) pane.visible = key === tab; // The view checks its own flag before it redraws, and the row around it is what switches now. this.viewport.visible = tab === 'scene'; @@ -1047,6 +1249,8 @@ export class Editor implements PlayHost { this.editorRun.shutDown(); await this.plugins?.stopAll(); for (const cleanup of this.cleanups.splice(0).reverse()) cleanup(); + // Before the overlay is emptied: a window listens to the renderer, and its pane goes home with the rest. + for (const kind of [...this.floats.keys()]) this.closeWindow(kind); // Closed rather than just thrown away with the overlay below, so the slot lets go of it too. this.passwordDialog.current()?.close(); this.generalDialog.current()?.close(); @@ -1087,20 +1291,20 @@ export class Editor implements PlayHost { this.viewport.animate(sceneDraws); this.gameView.animate(gameDraws); this.console.refresh(); - if (this.tab === 'animation') this.animationEditor.tick(step); - if (this.tab === 'tile') this.tileEditor.tick(step); + if (this.shows('animation')) this.animationEditor.tick(step); + if (this.shows('tile')) this.tileEditor.tick(step); // The preview is the editor's own, so it runs on while the game is paused. - if (this.tab === 'curve') this.curveEditor.tick(dt); - if (this.tab === 'pattern') this.patternEditor.tick(); - if (this.tab === 'song') this.songEditor.tick(); + if (this.shows('curve')) this.curveEditor.tick(dt); + if (this.shows('pattern')) this.patternEditor.tick(); + if (this.shows('song')) this.songEditor.tick(); else this.songEditor.hideMeters(); - if (this.tab === 'sound') this.soundEditor.tick(); + if (this.shows('sound')) this.soundEditor.tick(); // Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session // to do it, and its states move just the same. - if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw(); - if (this.tab === 'particles') this.particlesEditor.tick(); - if (this.tab === 'shader') this.shaderEditor.tick(); - if (this.tab === 'pixelart') this.pixelArtEditor.tick(); + if (this.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw(); + if (this.shows('particles')) this.particlesEditor.tick(); + if (this.shows('shader')) this.shaderEditor.tick(); + if (this.shows('pixelart')) this.pixelArtEditor.tick(); if (this.movesByItself()) requestRender(); const drawn = this.gameView.drawTime - drawnBefore; if (this.play.measureFrame(dt, performance.now() - started, drawn, lastRenderTime())) @@ -1122,15 +1326,15 @@ export class Editor implements PlayHost { this.editorRun.isRunning || (this.viewport.visible && this.viewport.animates) || (this.gameView.visible && this.gameView.animates) || - this.tab === 'particles' || - this.tab === 'pixelart' || - (this.tab === 'shader' && this.shaderEditor.animates) || - (this.tab === 'animation' && this.animationEditor.isPlaying) || - (this.tab === 'tile' && this.tileEditor.isPlaying) || - (this.tab === 'curve' && this.curveEditor.isPlaying) || - (this.tab === 'pattern' && this.patternEditor.isPlaying) || - (this.tab === 'song' && this.songEditor.isPlaying) || - (this.tab === 'sound' && this.soundEditor.isPlaying) + this.shows('particles') || + this.shows('pixelart') || + (this.shows('shader') && this.shaderEditor.animates) || + (this.shows('animation') && this.animationEditor.isPlaying) || + (this.shows('tile') && this.tileEditor.isPlaying) || + (this.shows('curve') && this.curveEditor.isPlaying) || + (this.shows('pattern') && this.patternEditor.isPlaying) || + (this.shows('song') && this.songEditor.isPlaying) || + (this.shows('sound') && this.soundEditor.isPlaying) ); } diff --git a/src/editor/FloatWindow.ts b/src/editor/FloatWindow.ts new file mode 100644 index 00000000..413b7d56 --- /dev/null +++ b/src/editor/FloatWindow.ts @@ -0,0 +1,187 @@ +import { type Container, type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js'; +import { Button, crisp, drawControlBox, Icon, Label, Spacer, Theme, ui, Widget } from '../ui'; +import { 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 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(); + return { x: at.x + this.dockButton.x - Theme.padding * 2, y: at.y + this.bar.h / 2 }; + } + + /** 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(); + } + + /** 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)); + else this.place({ ...g.box, x: g.box.x + dx, y: g.box.y + dy }); + } + + private readonly keepOnScreen = () => this.place(this.box); + + private place(box: WindowBox): void { + const screen = ui().app.screen; + const fit = onScreen(box, screen.width, screen.height); + 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; + 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.panel, border: c.border }); + // 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; +} diff --git a/src/editor/TabStrip.ts b/src/editor/TabStrip.ts index 2c912e65..7eb614ae 100644 --- a/src/editor/TabStrip.ts +++ b/src/editor/TabStrip.ts @@ -31,6 +31,8 @@ export interface DocumentSpec { kind: string; icon: string; onClose: () => void; + /** A right-click on the tab (#371). */ + onMenu?: (globalX: number, globalY: number) => void; } /** @@ -103,7 +105,14 @@ export class TabStrip extends HStack { 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) + ? 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); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index c0a2e046..0a94ba71 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -136,6 +136,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E documents: () => editor.openDocuments(), openDocument: (doc: DocumentId) => editor.openDocument(doc), closeDocument: (doc: DocumentId) => editor.closeDocument(doc), + /** The tabs in windows of their own (#371): kind, document, box, and where to press. */ + floatWindows: () => editor.floatWindows(), + floatDocument: (doc: DocumentId) => editor.floatDocument(doc), + /** The kind Delete, Copy and Paste go to: the window pressed last, else the tab showing. */ + keyTarget: () => editor.keyTarget(), plugins: { /** Every file in `data/plugins/` and what became of it. */ records: () => [...(editor.plugins?.records ?? [])], diff --git a/src/editor/documents.ts b/src/editor/documents.ts index 88d1f141..4f45f8f4 100644 --- a/src/editor/documents.ts +++ b/src/editor/documents.ts @@ -28,6 +28,17 @@ export function closeDocument(row: DocumentRow, doc: T): DocumentRow { return { docs, active: docs[Math.max(0, at - 1)] ?? null }; } +/** + * What shows when the document that stood at `at` in `docs` has left it (#371): the nearest one to + * its left that `fits`, else the nearest to its right – as `closeDocument` picks, but skipping the + * documents that cannot show in the tabs now. Null means the scene. + */ +export function neighbourOf(docs: readonly T[], at: number, fits: (doc: T) => boolean): T | null { + for (let i = Math.min(at, docs.length) - 1; i >= 0; i--) if (fits(docs[i]!)) return docs[i]!; + for (let i = Math.max(0, at); i < docs.length; i++) if (fits(docs[i]!)) return docs[i]!; + return null; +} + /** * Puts `to` where `from` is – a tab that goes on to another document, like a behavior followed * into a call. Where `to` was open already, that tab goes: a document has one tab. diff --git a/src/editor/floating.ts b/src/editor/floating.ts new file mode 100644 index 00000000..921360ec --- /dev/null +++ b/src/editor/floating.ts @@ -0,0 +1,44 @@ +/** + * Where a tab's window sits (#371). Nothing of it is kept: a window opens in the middle of the + * screen every time, and is held on the screen while it is moved, sized, or the page shrinks. + */ +export interface WindowBox { + x: number; + y: number; + w: number; + h: number; +} + +/** Below this a tab's controls no longer fit in a row, and the title bar loses its buttons. */ +export const MIN_WINDOW_WIDTH = 320; +export const MIN_WINDOW_HEIGHT = 200; +/** How far each further window opens down and to the right, so it does not hide the one before. */ +export const WINDOW_STEP = 24; + +/** Half the screen's width and 60 % of its height, in the middle; the `count`-th one a step further. */ +export function openingBox(screenW: number, screenH: number, count: number): WindowBox { + const w = Math.min(screenW, Math.max(MIN_WINDOW_WIDTH, Math.round(screenW * 0.5))); + const h = Math.min(screenH, Math.max(MIN_WINDOW_HEIGHT, Math.round(screenH * 0.6))); + const step = count * WINDOW_STEP; + return onScreen( + { x: Math.round((screenW - w) / 2) + step, y: Math.round((screenH - h) / 2) + step, w, h }, + screenW, + screenH, + ); +} + +/** `box` whole on the screen: no larger than it, and moved in where it sticks out. */ +export function onScreen(box: WindowBox, screenW: number, screenH: number): WindowBox { + const w = Math.max(Math.min(box.w, screenW), Math.min(MIN_WINDOW_WIDTH, screenW)); + const h = Math.max(Math.min(box.h, screenH), Math.min(MIN_WINDOW_HEIGHT, screenH)); + const x = Math.max(0, Math.min(box.x, screenW - w)); + const y = Math.max(0, Math.min(box.y, screenH - h)); + return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) }; +} + +/** `box` with its lower right corner pulled by `dx`, `dy`: at least the minimum, at most to the screen's edge. */ +export function sized(box: WindowBox, dx: number, dy: number, screenW: number, screenH: number): WindowBox { + const w = Math.max(MIN_WINDOW_WIDTH, Math.min(box.w + dx, screenW - box.x)); + const h = Math.max(MIN_WINDOW_HEIGHT, Math.min(box.h + dy, screenH - box.y)); + return { ...box, w: Math.round(w), h: Math.round(h) }; +} diff --git a/tests/editor/documents.test.ts b/tests/editor/documents.test.ts index b0e853ef..c303aa13 100644 --- a/tests/editor/documents.test.ts +++ b/tests/editor/documents.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { closeDocument, openDocument, replaceDocument } from '../../src/editor/documents'; +import { closeDocument, neighbourOf, openDocument, replaceDocument } from '../../src/editor/documents'; +import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, onScreen, openingBox, sized } from '../../src/editor/floating'; import { tabRowLayout } from '../../src/editor/tabRow'; const row = (docs: string[], active: string | null) => ({ docs, active }); @@ -52,6 +53,42 @@ describe('open documents', () => { }); }); +describe('what shows when a document goes to a window (#371)', () => { + const all = () => true; + + it('picks the left neighbour, else the right one, else none', () => { + expect(neighbourOf(['a', 'c'], 1, all)).toBe('a'); + expect(neighbourOf(['b', 'c'], 0, all)).toBe('b'); + expect(neighbourOf([], 0, all)).toBeNull(); + }); + + it('passes over documents whose kind is in a window', () => { + const tabs = (doc: string) => !doc.startsWith('fx'); + expect(neighbourOf(['a', 'fx1', 'fx2', 'b'], 3, tabs)).toBe('a'); + expect(neighbourOf(['fx1', 'fx2', 'b'], 1, tabs)).toBe('b'); + expect(neighbourOf(['fx1', 'fx2'], 1, tabs)).toBeNull(); + }); +}); + +describe('a tab in a window (#371)', () => { + it('opens in the middle of the screen, every further window a step lower right', () => { + expect(openingBox(1280, 800, 0)).toEqual({ x: 320, y: 160, w: 640, h: 480 }); + expect(openingBox(1280, 800, 2)).toEqual({ x: 368, y: 208, w: 640, h: 480 }); + }); + + it('is held whole on the screen when it is moved past an edge', () => { + expect(onScreen({ x: 900, y: -40, w: 640, h: 480 }, 1280, 800)).toEqual({ x: 640, y: 0, w: 640, h: 480 }); + expect(onScreen({ x: 100, y: 100, w: 640, h: 480 }, 500, 400)).toEqual({ x: 0, y: 0, w: 500, h: 400 }); + }); + + it('is sized from its lower right corner, down to a floor and out to the screen’s edge', () => { + const box = { x: 600, y: 300, w: 400, h: 300 }; + expect(sized(box, 100, 50, 1280, 800)).toEqual({ ...box, w: 500, h: 350 }); + expect(sized(box, -500, -500, 1280, 800)).toEqual({ ...box, w: MIN_WINDOW_WIDTH, h: MIN_WINDOW_HEIGHT }); + expect(sized(box, 900, 900, 1280, 800)).toEqual({ ...box, w: 680, h: 500 }); + }); +}); + describe('tab row without an active tab', () => { it('fills the row in order when a kind with no document is showing', () => { const tabs = [ diff --git a/tools/e2e/44-documents.mjs b/tools/e2e/44-documents.mjs index 26bd11a8..cb15d72f 100644 --- a/tools/e2e/44-documents.mjs +++ b/tools/e2e/44-documents.mjs @@ -157,6 +157,111 @@ export async function documents(t) { await mutate( `__gm.state.project.assets.push(window.__deletedSheet); delete window.__deletedSheet; __gm.state.preview();`, ); + + // 44i. A right-click on a tab floats it: the document leaves the row for a window of its own. + await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${a.id}`)})`); + const tabOfA = await place(`asset:${a.id}`); + await click(tabOfA.x + 20, tabOfA.y + tabOfA.h / 2, 'right'); + const floatEntry = (await json(`__gm.locateAll('Float')`)).find( + (p) => p.y > tabOfA.y + tabOfA.h && Math.abs(p.x - tabOfA.x) < 200, + ); + if (floatEntry) await click(floatEntry.x, floatEntry.y); + const windows = () => json('__gm.floatWindows()'); + const floated = await windows(); + const inWindow = await json('__gm.texture.rect()'); + const box = floated[0]?.box; + check( + 'Float in a tab’s menu moves the document into a window, the texture in it', + !(await docs()).docs.includes(`asset:${a.id}`) && + floated.length === 1 && + floated[0].kind === 'texture' && + floated[0].doc === `asset:${a.id}` && + floated[0].title === a.name && + (await json('__gm.tab()')) === 'scene' && + (await current()) === a.id && + inWindow.x >= box.x && + inWindow.x + inWindow.w <= box.x + box.w && + inWindow.y > box.y && + inWindow.y + inWindow.h <= box.y + box.h, + JSON.stringify({ floatEntry, floated, inWindow, docs: await docs() }), + ); + + // 44j. The title bar moves the window, its corner sizes it, and the texture grows along. + await t.drag(floated[0].bar.x, floated[0].bar.y, floated[0].bar.x - 100, floated[0].bar.y - 60); + const moved = (await windows())[0]; + await t.drag(moved.grip.x, moved.grip.y, moved.grip.x + 80, moved.grip.y + 40); + const grown = (await windows())[0]; + const grownRect = await json('__gm.texture.rect()'); + check( + 'dragging the title bar moves the window, dragging its corner sizes it', + moved.box.x === box.x - 100 && + moved.box.y === box.y - 60 && + grown.box.w === box.w + 80 && + grown.box.h === box.h + 40 && + grown.box.x === moved.box.x && + grownRect.w === inWindow.w + 80, + JSON.stringify({ box, moved: moved.box, grown: grown.box, inWindow, grownRect }), + ); + await shot('44-float'); + + // 44k. Another texture opened goes into the window: the kind has one editor, so one window. + const rowB = await json(`__gm.assetRow(${JSON.stringify(b.id)})`); + await doubleClick(rowB.x, rowB.y); + const other = await windows(); + check( + 'opening another texture shows it in the window, not in a tab', + other.length === 1 && + other[0].doc === `asset:${b.id}` && + other[0].title === b.name && + !(await docs()).docs.includes(`asset:${b.id}`) && + (await current()) === b.id, + JSON.stringify({ other, docs: await docs() }), + ); + + // 44l. Delete, Copy and Paste go where the last press went. + await click(other[0].bar.x, other[0].bar.y); + const toWindow = await json('__gm.keyTarget()'); + const scene = (await json('__gm.tabRow()')).shown.find((p) => p.tab === 'scene'); + await click((scene.x + scene.right) / 2, scene.y + scene.h / 2); + const toTabs = await json('__gm.keyTarget()'); + check( + 'keys go to the window after a press in it, and to the tab after a press in the editor', + toWindow === 'texture' && toTabs === 'scene', + JSON.stringify({ toWindow, toTabs }), + ); + + // 44m. Dock puts the document back into the row, showing, with its pane below the row. + await click(other[0].dock.x, other[0].dock.y); + const docked = await docs(); + const dockedRect = await json('__gm.texture.rect()'); + check( + 'Dock brings the document back as a tab, showing', + (await windows()).length === 0 && + docked.active === `asset:${b.id}` && + (await json('__gm.tab()')) === 'texture' && + (await current()) === b.id && + dockedRect.w > 0 && + dockedRect.h > 0, + JSON.stringify({ docked, dockedRect }), + ); + + // 44n. The window's × closes the document, and the kind shows in the tabs again. + await evaluate(`__gm.floatDocument(${JSON.stringify(`asset:${b.id}`)})`); + const again = await windows(); + await click(again[0].close.x, again[0].close.y); + const closed = { windows: await windows(), docs: await docs() }; + await showTab('texture'); + check( + 'the × closes the window and its document; the kind opens in a tab again', + closed.windows.length === 0 && + !closed.docs.docs.includes(`asset:${b.id}`) && + (await json('__gm.tab()')) === 'texture' && + (await json('__gm.texture.rect()')).w > 0, + JSON.stringify({ again, closed, tab: await json('__gm.tab()') }), + ); + for (const doc of (await docs()).docs) await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`); + await showTab('scene'); + await mutate(` for (const { id, was } of ${JSON.stringify([a, b])}) __gm.state.project.assets.find((x) => x.id === id).name = was; __gm.state.preview();