diff --git a/README.md b/README.md index 307e8c3b..8ea26059 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1664 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1667 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/CommandStack.ts b/src/core/commands/CommandStack.ts index 9bf8c73c..c96ae17d 100644 --- a/src/core/commands/CommandStack.ts +++ b/src/core/commands/CommandStack.ts @@ -44,4 +44,13 @@ export class CommandStack { get canRedo(): boolean { return this.redoStack.length > 0; } + + /** What the next undo takes back, by the name of its command; null with nothing to undo. */ + get undoLabel(): string | null { + return this.undoStack[this.undoStack.length - 1]?.label ?? null; + } + + get redoLabel(): string | null { + return this.redoStack[this.redoStack.length - 1]?.label ?? null; + } } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 2f2f846f..ede4a590 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -22,6 +22,7 @@ import { requestRender, Splitter, Theme, + tooltips, ui, VStack, type Widget, @@ -911,6 +912,8 @@ export class Editor implements PlayHost { for (const cleanup of this.cleanups.splice(0).reverse()) cleanup(); // Closed rather than just thrown away with the overlay below, so the slot lets go of it too. this.passwordDialog.current()?.close(); + // The tooltip is not the editor's to destroy: it leaves the overlay before that is emptied. + tooltips.dismiss(); // A menu or a picker left open belongs to widgets that are about to be destroyed. for (const popup of ui().overlay.removeChildren()) popup.destroy({ children: true }); ui().focus.blur(); diff --git a/src/editor/ProjectSettingsDialog.ts b/src/editor/ProjectSettingsDialog.ts index e270caf5..82e813fe 100644 --- a/src/editor/ProjectSettingsDialog.ts +++ b/src/editor/ProjectSettingsDialog.ts @@ -252,6 +252,7 @@ export class ProjectSettingsDialog extends FormDialog { new Button('×', { variant: 'ghost', width: Theme.controlHeight, + tooltip: 'Remove layer', onClick: () => { this.layers.pop(); this.buildLayers(); diff --git a/src/editor/TabStrip.ts b/src/editor/TabStrip.ts index 5f01e5ad..2c912e65 100644 --- a/src/editor/TabStrip.ts +++ b/src/editor/TabStrip.ts @@ -57,6 +57,7 @@ export class TabStrip extends HStack { this.overflow = new Button('»', { variant: 'ghost', width: OVERFLOW_WIDTH, + tooltip: 'More tabs', onClick: () => this.openMenu(), }); this.addChild(this.bar); diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index abbc926d..317d48f9 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -8,6 +8,7 @@ import { Widget, activeDrag, currentMenu, + tooltips, ui, } from '../../ui'; import { Theme } from '../../ui/Theme'; @@ -243,6 +244,8 @@ export function uiHooks({ app, state, editor }: HookContext) { textColor, overlayTexts, overlayRect, + /** The tooltip on screen (#209): its text, its box and the box of the control it explains; null without one. */ + tooltip: () => tooltips.shown, /** Entries of the open menu in drawn order, `null` for a separator line (#43). */ menuRows: () => currentMenu()?.rowsDrawn() ?? null, /** Per entry of `menuRows`, whether an icon stands in front of it. */ diff --git a/src/editor/graph/SnapToggle.ts b/src/editor/graph/SnapToggle.ts index 09fb272a..7eacbd0b 100644 --- a/src/editor/graph/SnapToggle.ts +++ b/src/editor/graph/SnapToggle.ts @@ -16,6 +16,7 @@ export class SnapToggle { width: Theme.controlHeight, icon: HEADER_ICON_SVG.snap, toggle: true, + tooltip: 'Snap to the grid', onClick: () => this.set(!this.on), }); } diff --git a/src/editor/inspector/aspectAction.ts b/src/editor/inspector/aspectAction.ts index 72e1555e..5911a595 100644 --- a/src/editor/inspector/aspectAction.ts +++ b/src/editor/inspector/aspectAction.ts @@ -66,6 +66,7 @@ export function aspectActions( return [ { icon: AXIS_ICON[axis], + tooltip: 'Size from the picture', available: () => { const bag = props(); const size = bag && texturePixelSize(host.project(), bag); diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 5191c81b..a8a45168 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -143,6 +143,8 @@ export const actionIf = (offered: unknown, label: string, onSelect: () => void): /** One trailing button of a row: its picture (an SVG file), and what it currently offers. */ export interface FieldActionGroup { icon: string; + /** What the button says while it offers several actions; with one, it says that action. */ + tooltip: string; available: FieldActions; } @@ -166,8 +168,9 @@ export function labelledRow(text: string): { row: HStack; label: Label } { /** Adds the trailing reset/apply/extra buttons and returns their refresh; `buildVariable` uses it for its first row too. */ export function attachRowActions(row: HStack, rowActions: RowActions): () => void { const actions: { button: RowActionButton; available: FieldActions }[] = []; - const addActions = (icon: string, available: FieldActions): void => { + const addActions = (icon: string, tooltip: string, available: FieldActions): void => { const button = new RowActionButton(icon); + button.tooltip = () => rowActionTip(tooltip, available()); // Asked again on click: the menu must not act on a state that has moved on since the // last refresh (an undo between refreshes, a value edited in the viewport). button.onClick = () => { @@ -180,9 +183,9 @@ export function attachRowActions(row: HStack, rowActions: RowActions): () => voi actions.push({ button, available }); row.add(button); }; - for (const group of rowActions.extra ?? []) addActions(group.icon, group.available); - if (rowActions.apply) addActions(ROW_ACTION_ICON_SVG.apply, rowActions.apply); - if (rowActions.reset) addActions(ROW_ACTION_ICON_SVG.reset, rowActions.reset); + for (const group of rowActions.extra ?? []) addActions(group.icon, group.tooltip, group.available); + if (rowActions.apply) addActions(ROW_ACTION_ICON_SVG.apply, 'Apply', rowActions.apply); + if (rowActions.reset) addActions(ROW_ACTION_ICON_SVG.reset, 'Reset', rowActions.reset); const refreshActions = (): void => { let changed = false; @@ -201,6 +204,11 @@ export function attachRowActions(row: HStack, rowActions: RowActions): () => voi return refreshActions; } +/** The one thing a click would do, by its own name; the group's name where a click opens a menu. */ +export function rowActionTip(group: string, offered: readonly FieldAction[]): string { + return offered.length === 1 ? offered[0]!.label : group; +} + /** A row action: its picture alone, or with a chevron once it offers more than one thing. */ class RowActionButton extends Button { private readonly chevron = new Graphics(); @@ -342,7 +350,7 @@ export function addValue(row: HStack, field: Field): (value: PropertyValue) => v } export function removeButton(onClick: () => void): Button { - return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick }); + return new Button('×', { variant: 'ghost', width: Theme.controlHeight, tooltip: 'Remove', onClick }); } export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} }; @@ -496,6 +504,7 @@ function objectField(ctx: FieldContext, name: string, value: PropertyValue, hand minWidth: Theme.controlHeight, icon: ROW_ACTION_ICON_SVG.eyedropper, toggle: true, + tooltip: 'Pick an object in the scene', }); eyedropper.onClick = () => { picker.toggle(eyedropper, { diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index d6d75bef..7a533a45 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -286,6 +286,7 @@ function gridEdgesSection(host: InspectorHost, holderId: string, object: SceneOb new Button(count < 0 ? '−' : '+', { variant: 'ghost', minWidth: Theme.controlHeight, + tooltip: new ResizeTilemapEdgeCommand(holderId, object.id, edge, count).label, onClick: () => host.state.execute(new ResizeTilemapEdgeCommand(holderId, object.id, edge, count)), }), ); diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts index 4b77e5ef..c7994a15 100644 --- a/src/editor/inspector/variableRows.ts +++ b/src/editor/inspector/variableRows.ts @@ -146,6 +146,11 @@ export function buildVariable( }; } +const SWITCH_TIPS: Record = { + keep: 'Keep the value between calls', + private: 'Private: hidden outside this graph', +}; + /** Lit while the switch is on – the lock while private, the pin while kept; a click flips it. */ function switchButton(icon: keyof typeof VARIABLE_SWITCH_ICON_SVG, of: VariableSwitch): Button { const button = new Button('', { @@ -153,6 +158,7 @@ function switchButton(icon: keyof typeof VARIABLE_SWITCH_ICON_SVG, of: VariableS toggle: true, icon: VARIABLE_SWITCH_ICON_SVG[icon], padding: 4, + tooltip: SWITCH_TIPS[icon], onClick: () => of.set(!of.on), }); button.setToggled(of.on); @@ -300,6 +306,7 @@ export function markPicker(kind: string, items: MenuItem[]): Button { width: MARK_WIDTH, style: 'small', icon, + tooltip: 'Change the kind', }); picker.onClick = () => new Menu(items).openBelow(picker); return picker; diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 03fd41f3..f1d3633e 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -76,7 +76,7 @@ export class AssetPanel extends Panel { super({ title: 'Assets', padding: 0, gap: 0 }); this.minWidth = 160; - const add = new Button('+', { variant: 'ghost', width: 28 }); + const add = new Button('+', { variant: 'ghost', width: 28, tooltip: 'Add asset' }); add.onClick = () => new Menu(this.addMenuEntries()).openBelow(add); this.header.add(add); diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index aae0d5fd..2baea181 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -2,6 +2,7 @@ import { Graphics } from 'pixi.js'; import { Button, drawHeaderBar, Dropdown, HStack, Label, Spacer, Theme, type Widget } from '../../ui'; import type { EditorState, SaveStatus } from '../state'; import { TOOLBAR_ICON_SVG } from './toolbarIcons'; +import { type ToolbarControl, toolbarTip } from './toolbarTips'; /** What the toolbar buttons trigger; the editor supplies the implementations. */ export interface ToolbarActions { @@ -32,8 +33,8 @@ type IconName = keyof typeof TOOLBAR_ICON_SVG; * An icon button. The caption stays empty, so the icon sits in the middle of the square rather * than beside the gap a label would need. */ -function iconButton(name: IconName, onClick: () => void): Button { - return new Button('', { width: ICON_BUTTON, icon: TOOLBAR_ICON_SVG[name], onClick }); +function iconButton(name: IconName, onClick: () => void, tooltip: () => string): Button { + return new Button('', { width: ICON_BUTTON, icon: TOOLBAR_ICON_SVG[name], onClick, tooltip }); } /** @@ -64,19 +65,26 @@ export class Toolbar extends HStack { this.minHeight = Theme.controlHeight + Theme.headerPadding * 2; this.addChildAt(this.bg, 0); - this.menuButton = iconButton('menu', () => actions.openMainMenu(this.menuButton)); + const tip = (control: ToolbarControl) => () => + toolbarTip(control, { + undo: state.commands.undoLabel, + redo: state.commands.redoLabel, + editing: state.mode === 'edit', + paused: state.paused, + }); + this.menuButton = iconButton('menu', () => actions.openMainMenu(this.menuButton), tip('menu')); this.add(this.menuButton); this.add(new Spacer(Theme.padding)); - this.undoButton = iconButton('undo', () => state.undo()); - this.redoButton = iconButton('redo', () => state.redo()); + this.undoButton = iconButton('undo', () => state.undo(), tip('undo')); + this.redoButton = iconButton('redo', () => state.redo(), tip('redo')); this.add(this.undoButton); this.add(this.redoButton); this.add(new Spacer(), 1); - this.playButton = iconButton('play', () => actions.togglePlay()); + this.playButton = iconButton('play', () => actions.togglePlay(), tip('play')); this.playButton.variant = 'primary'; this.add(this.playButton); - this.pauseButton = iconButton('pause', () => actions.togglePause()); + this.pauseButton = iconButton('pause', () => actions.togglePause(), tip('pause')); this.add(this.pauseButton); this.add(new Spacer(), 1); diff --git a/src/editor/panels/toolbarTips.ts b/src/editor/panels/toolbarTips.ts new file mode 100644 index 00000000..7296e4bd --- /dev/null +++ b/src/editor/panels/toolbarTips.ts @@ -0,0 +1,31 @@ +import { withShortcut } from '../../ui/tooltip'; + +/** What the five buttons of the top bar depend on for what they say. */ +export interface ToolbarSituation { + /** The step the next undo and redo take, by name; null with none. */ + undo: string | null; + redo: string | null; + editing: boolean; + paused: boolean; +} + +export type ToolbarControl = 'menu' | 'undo' | 'redo' | 'play' | 'pause'; + +/** + * The sign of a top bar button (#209): what a click does right now, with the keys that do the same. + * Undo and redo name the step they take – "Undo Paint tiles (Ctrl+Z)". + */ +export function toolbarTip(control: ToolbarControl, now: ToolbarSituation): string { + switch (control) { + case 'menu': + return 'Menu'; + case 'undo': + return withShortcut(now.undo ? `Undo ${now.undo}` : 'Undo', 'Ctrl+Z'); + case 'redo': + return withShortcut(now.redo ? `Redo ${now.redo}` : 'Redo', 'Ctrl+Y'); + case 'play': + return now.editing ? withShortcut('Play', 'Ctrl+Enter') : withShortcut('Stop', 'Esc'); + case 'pause': + return withShortcut(now.paused ? 'Resume' : 'Pause', 'Ctrl+Enter'); + } +} diff --git a/src/editor/pixelart/ResizeCanvasDialog.ts b/src/editor/pixelart/ResizeCanvasDialog.ts index 982796e0..d873f5f6 100644 --- a/src/editor/pixelart/ResizeCanvasDialog.ts +++ b/src/editor/pixelart/ResizeCanvasDialog.ts @@ -6,6 +6,14 @@ import { dialogSlot, FormDialog } from '../FormDialog'; const FIELD_WIDTH = 76; const ANCHOR_SIZE = 24; const STEPS = [0, 0.5, 1] as const; +const ROW_NAMES = ['Top', 'Middle', 'Bottom'] as const; +const COLUMN_NAMES = ['left', 'centre', 'right'] as const; + +/** An anchor square by name, for its tooltip: "Top left", and "Centre" for the one in the middle. */ +export function anchorName(anchor: ResizeAnchor): string { + if (anchor.x === 0.5 && anchor.y === 0.5) return 'Centre'; + return `${ROW_NAMES[STEPS.indexOf(anchor.y)]} ${COLUMN_NAMES[STEPS.indexOf(anchor.x)]}`; +} const slot = dialogSlot(); @@ -61,6 +69,7 @@ export class ResizeCanvasDialog extends FormDialog { const button = new Button('', { width: ANCHOR_SIZE, toggle: true, + tooltip: anchorName(anchor), onClick: () => this.setAnchor(anchor), }); this.anchors.push({ anchor, button }); diff --git a/src/editor/textProbe.ts b/src/editor/textProbe.ts index ad518b19..cbb97d41 100644 --- a/src/editor/textProbe.ts +++ b/src/editor/textProbe.ts @@ -64,7 +64,8 @@ export function textProbe(app: Application): { visit: (node: Container, clip: Rectangle | null, owner: Widget | null) => boolean, ): void => { const walk = (node: Container, clip: Rectangle | null, owner: Widget | null): void => { - if (!node.visible) return; + // A tooltip is no part of what a check looks for: it would answer for the button under it. + if (!node.visible || node.label === 'tooltip') return; const holder = node instanceof Widget ? node : owner; let inner = clip; if (node.mask && holder) { diff --git a/src/editor/viewport/GamePane.ts b/src/editor/viewport/GamePane.ts index e19797d2..b2dea0cb 100644 --- a/src/editor/viewport/GamePane.ts +++ b/src/editor/viewport/GamePane.ts @@ -18,6 +18,7 @@ export class GamePane extends VStack { readonly fullscreenButton = new Button('', { width: Theme.controlHeight, icon: UI_ICON_SVG.fullscreen, + tooltip: 'Fullscreen', onClick: () => void toggleFullscreen(), }); diff --git a/src/editor/viewport/ScenePane.ts b/src/editor/viewport/ScenePane.ts index b7bbf52a..56fd4fce 100644 --- a/src/editor/viewport/ScenePane.ts +++ b/src/editor/viewport/ScenePane.ts @@ -23,6 +23,7 @@ export class ScenePane extends VStack { this.fitButton = new Button('Fit', { onClick: () => view.fitScene() }); this.visibilityButton = new Dropdown('', { icon: TOOLBAR_ICON_SVG.eye, + tooltip: 'What the scene view shows', onClick: () => new SceneVisibilityPopup(state).openBelow(this.visibilityButton), }); this.header.add(this.fitButton); diff --git a/src/ui/UIContext.ts b/src/ui/UIContext.ts index 17bd8ad9..92efb88c 100644 --- a/src/ui/UIContext.ts +++ b/src/ui/UIContext.ts @@ -2,6 +2,7 @@ import type { Application, Container } from 'pixi.js'; import { FocusManager } from './FocusManager'; import { KeyboardBridge } from './KeyboardBridge'; import { requestRender } from './render'; +import { tooltips } from './widgets/Tooltip'; /** Services shared by all widgets, created once by `initUI()`. */ export interface UIContext { @@ -32,6 +33,9 @@ export function initUI(app: Application, root: Container, overlay: Container): U window.addEventListener(type, wake, { capture: true, passive: true }); for (const type of ['keydown', 'keyup', 'focus', 'blur'] as const) window.addEventListener(type, wake, true); + // Whatever the user does besides moving the pointer takes the tooltip away. + for (const type of ['pointerdown', 'wheel', 'keydown', 'blur'] as const) + window.addEventListener(type, () => tooltips.dismiss(), { capture: true, passive: true }); return context; } diff --git a/src/ui/index.ts b/src/ui/index.ts index dff7aa47..bc6834f4 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -46,6 +46,8 @@ export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep, export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag'; export { Label, truncate } from './widgets/Label'; export { Button, type ButtonOptions } from './widgets/Button'; +export { TooltipLayer, tooltips, type TooltipHost, type TooltipTarget } from './widgets/Tooltip'; +export { placeTooltip, TOOLTIP_DELAY_MS, TOOLTIP_GAP, type TooltipBox, withShortcut } from './tooltip'; export { CHEVRON_SLOT, Dropdown, drawChevron } from './widgets/Dropdown'; export { Icon } from './widgets/Icon'; export { UI_ICON_SVG } from './icons'; diff --git a/src/ui/tooltip.ts b/src/ui/tooltip.ts new file mode 100644 index 00000000..afdaeee0 --- /dev/null +++ b/src/ui/tooltip.ts @@ -0,0 +1,43 @@ +/** + * The rules of the hint a control shows when the pointer rests on it (#209), apart from the drawing: + * how long the rest has to last, where the sign goes, and how a shortcut is written into it. + */ + +/** How long the pointer has to stay put on a control before its sign shows. */ +export const TOOLTIP_DELAY_MS = 500; +/** Room between the control and its sign. */ +export const TOOLTIP_GAP = 6; + +export interface TooltipBox { + x: number; + y: number; + w: number; + h: number; +} + +/** + * Where a sign of `size` goes for the control at `anchor`, all in screen pixels: under the control + * and flush with its left edge. Where that would leave the screen it turns around – flush with the + * control's right edge at the right border, above the control at the bottom – and a sign that fits + * neither way is pushed back inside rather than cut off. + */ +export function placeTooltip( + anchor: TooltipBox, + size: { w: number; h: number }, + screen: { w: number; h: number }, + gap = TOOLTIP_GAP, +): { x: number; y: number } { + let x = anchor.x; + if (x + size.w > screen.w) x = anchor.x + anchor.w - size.w; + let y = anchor.y + anchor.h + gap; + if (y + size.h > screen.h) y = anchor.y - gap - size.h; + return { + x: Math.round(Math.max(0, Math.min(x, screen.w - size.w))), + y: Math.round(Math.max(0, Math.min(y, screen.h - size.h))), + }; +} + +/** A name with the keys that do the same, as a sign shows it: "Undo (Ctrl+Z)". */ +export function withShortcut(name: string, keys?: string): string { + return keys ? `${name} (${keys})` : name; +} diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index d183f09d..a026ab3e 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -13,6 +13,7 @@ import { Theme, textStyle, type TextStyleToken } from '../Theme'; import { type Size, Widget } from '../Widget'; import { truncate } from './Label'; import { crisp, crispText, hairline } from '../pixelGrid'; +import { type TooltipTarget, tooltips } from './Tooltip'; export interface ButtonOptions { variant?: 'default' | 'primary' | 'ghost' | 'danger'; @@ -29,6 +30,8 @@ export interface ButtonOptions { toggle?: boolean; /** SVG file drawn in front of the label, 16 units around its origin (see `drawSvg`). */ icon?: string; + /** What the button does, shown when the pointer rests on it (#209); a function is asked each time. */ + tooltip?: string | (() => string); onClick?: () => void; } @@ -55,6 +58,7 @@ export class Button extends Widget { /** Colour the icon was last drawn in; it follows the label from muted to the accent text. */ private iconColor = -1; private drawnFill: number | null = null; + tooltip: string | (() => string) | null; onClick?: () => void; constructor(text: string, options: ButtonOptions = {}) { @@ -76,6 +80,7 @@ export class Button extends Widget { style: textStyle(this.variant === 'primary' ? 'accent' : this.style), }); this.icon = options.icon ?? null; + this.tooltip = options.tooltip ?? null; this.addChild(this.bg, this.labelText); if (this.icon) this.addChild(this.iconGfx); @@ -98,8 +103,31 @@ export class Button extends Widget { this.pressed = false; this.invalidate(); }); + const rest = (e: FederatedPointerEvent): void => { + if (this.tooltipText()) tooltips.point(this, '', this.tooltipTarget, e.global); + else tooltips.leave(this); + }; + this.on('pointerover', rest); + this.on('pointermove', rest); + this.on('pointerout', () => tooltips.leave(this)); } + /** + * What the sign says: the tooltip the button was given, and for a button without one its own + * label where that is cut short. Empty for no sign. + */ + tooltipText(): string { + const given = typeof this.tooltip === 'function' ? this.tooltip() : this.tooltip; + if (given) return given; + return this.labelText.text !== this.fullText ? this.fullText : ''; + } + + private readonly tooltipTarget = (): TooltipTarget | null => { + if (this.destroyed) return null; + const at = this.getGlobalPosition(); + return { text: this.tooltipText(), anchor: { x: at.x, y: at.y, w: this.w, h: this.h } }; + }; + get text(): string { return this.fullText; } diff --git a/src/ui/widgets/SortableList.ts b/src/ui/widgets/SortableList.ts index 0c12cac8..76e7f27d 100644 --- a/src/ui/widgets/SortableList.ts +++ b/src/ui/widgets/SortableList.ts @@ -30,6 +30,7 @@ import { truncate } from './Label'; import { NumberField } from './NumberField'; import { TextField } from './TextField'; import { crisp, crispText } from '../pixelGrid'; +import { type TooltipTarget, tooltips } from './Tooltip'; /** Height of one row: the picture plus the air a hovered row needs to look like a row. */ export const SORTABLE_ROW_HEIGHT = 30; @@ -202,11 +203,13 @@ export class SortableList extends Widget implements SectionStart { this.on('pointermove', (e: FederatedPointerEvent) => { const hovered = this.hitAt(this.toLocal(e.global)); + this.pointTooltip(hovered, e.global); if (sameHover(hovered, this.over)) return; this.over = hovered; this.invalidate(); }); this.on('pointerout', () => { + tooltips.leave(this); if (!this.over) return; this.over = null; this.invalidate(); @@ -334,6 +337,42 @@ export class SortableList extends Widget implements SectionStart { return { x: origin.x + this.w - REMOVE_SLOT / 2, y: origin.y + this.headerHeight / 2 }; } + /** The marks of a row and the plus carry no words (#209): a sign says what a press on them does. */ + private pointTooltip(over: Hover, pointer: { x: number; y: number }): void { + const mark = over === 'add' ? 'add' : over && over !== 'head' && over.part !== 'row' ? over.part : null; + if (!mark || mark === 'name' || this.drag?.active) return tooltips.leave(this); + const row = over === 'add' || !over || over === 'head' ? -1 : over.row; + tooltips.point(this, `${mark}:${row}`, () => this.markTooltip(mark, row), pointer); + } + + private markTooltip(mark: 'add' | 'eye' | 'duplicate' | 'cross', row: number): TooltipTarget | null { + if (this.destroyed) return null; + const origin = this.getGlobalPosition(); + if (mark === 'add') + return { + text: 'Add', + anchor: { x: origin.x + this.w - REMOVE_SLOT, y: origin.y, w: REMOVE_SLOT, h: this.headerHeight }, + }; + const item = this.shown[row]; + if (!item) return null; + const slots = this.slots; + const [right, width, text] = + mark === 'cross' + ? [slots.cross, REMOVE_SLOT, 'Remove'] + : mark === 'duplicate' + ? [slots.duplicate, DUPLICATE_SLOT, 'Duplicate'] + : [slots.eye, EYE_SLOT, item.hidden ? 'Show' : 'Hide']; + return { + text, + anchor: { + x: origin.x + right - width, + y: origin.y + this.headerHeight + row * SORTABLE_ROW_HEIGHT, + w: width, + h: SORTABLE_ROW_HEIGHT, + }, + }; + } + private get headerHeight(): number { return this.options.title ? SECTION_BAND_HEIGHT : this.options.onAdd ? HEADER_HEIGHT : 0; } diff --git a/src/ui/widgets/Tooltip.ts b/src/ui/widgets/Tooltip.ts new file mode 100644 index 00000000..69fb0d2d --- /dev/null +++ b/src/ui/widgets/Tooltip.ts @@ -0,0 +1,133 @@ +import { CanvasTextMetrics, Container, Graphics } from 'pixi.js'; +import { drawControlBox } from '../draw'; +import { crisp, crispText } from '../pixelGrid'; +import { requestRender } from '../render'; +import { Theme, textStyle } from '../Theme'; +import { placeTooltip, TOOLTIP_DELAY_MS, type TooltipBox } from '../tooltip'; +import { ui } from '../UIContext'; + +/** What a control says about itself, and the box on screen the sign belongs to. */ +export interface TooltipTarget { + text: string; + anchor: TooltipBox; +} + +/** Where the sign is drawn: the layer above everything, and the screen it has to stay on. */ +export interface TooltipHost { + overlay: Container; + screen: { width: number; height: number }; +} + +const PAD_X = 6; +const PAD_Y = 3; + +/** + * The one sign for the whole interface (#209): whichever control the pointer rests on borrows it. + * A control reports every pointer move over itself (`point`) and the pointer leaving (`leave`); the + * layer does the waiting. A press, a key or the wheel takes the sign away (`dismiss`), and it stays + * away until the pointer has left that control – a button just clicked does not explain itself. + */ +export class TooltipLayer { + private readonly box = new Container(); + private readonly bg = crisp(new Graphics()); + private readonly label = crispText({ text: '', style: textStyle('small') }); + private owner: object | null = null; + private key = ''; + private target: (() => TooltipTarget | null) | null = null; + private timer: ReturnType | null = null; + private restX = NaN; + private restY = NaN; + private silenced = false; + private showing: (TooltipTarget & { box: TooltipBox }) | null = null; + + constructor( + private readonly host: () => TooltipHost = () => ({ overlay: ui().overlay, screen: ui().app.screen }), + ) { + this.box.eventMode = 'none'; + this.box.label = 'tooltip'; + this.box.addChild(this.bg, this.label); + } + + /** + * The pointer is over `owner` at `pointer`. `key` tells the parts of one control apart (the eye + * and the cross of a row); `target` is asked only once the wait is over, so the sign says what + * holds then. + */ + point( + owner: object, + key: string, + target: () => TooltipTarget | null, + pointer: { x: number; y: number }, + ): void { + const same = owner === this.owner && key === this.key; + if (!same) { + this.clear(); + this.owner = owner; + this.key = key; + this.silenced = false; + } + this.target = target; + if (this.silenced || this.showing) return; + // Pixi repeats the last move from its ticker: only a real one starts the wait over. + if (same && this.timer !== null && pointer.x === this.restX && pointer.y === this.restY) return; + this.restX = pointer.x; + this.restY = pointer.y; + this.stopTimer(); + this.timer = setTimeout(() => this.show(), TOOLTIP_DELAY_MS); + } + + /** The pointer left `owner`; a call for a control that is not the current one changes nothing. */ + leave(owner: object): void { + if (owner !== this.owner) return; + this.clear(); + this.owner = null; + this.target = null; + } + + /** A press, a key, the wheel: the sign goes, and the control under the pointer stays quiet. */ + dismiss(): void { + this.clear(); + if (this.owner) this.silenced = true; + } + + /** The sign on screen – its text and its box – or null while there is none (test hook). */ + get shown(): { text: string; box: TooltipBox; anchor: TooltipBox } | null { + return this.showing && { text: this.showing.text, box: this.showing.box, anchor: this.showing.anchor }; + } + + private stopTimer(): void { + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + } + + private clear(): void { + this.stopTimer(); + if (!this.showing) return; + this.showing = null; + this.box.parent?.removeChild(this.box); + requestRender(); + } + + private show(): void { + this.timer = null; + const target = this.target?.(); + if (!target || !target.text) return; + const { overlay, screen } = this.host(); + this.label.text = target.text; + const metrics = CanvasTextMetrics.measureText(target.text, this.label.style); + const w = Math.ceil(metrics.width) + PAD_X * 2; + const h = Math.ceil(metrics.height) + PAD_Y * 2; + this.label.position.set(PAD_X, PAD_Y); + this.bg.clear(); + drawControlBox(this.bg, w, h, { fill: Theme.colors.control, border: Theme.colors.textMuted }); + const at = placeTooltip(target.anchor, { w, h }, { w: screen.width, h: screen.height }); + this.box.position.set(at.x, at.y); + // Last on the overlay, so above a menu that stands open. + overlay.addChild(this.box); + this.showing = { ...target, box: { x: at.x, y: at.y, w, h } }; + requestRender(); + } +} + +/** The layer every control of the page shares. */ +export const tooltips = new TooltipLayer(); diff --git a/src/viewport/FullscreenCorner.ts b/src/viewport/FullscreenCorner.ts index 2c4ad05d..01c1c1bb 100644 --- a/src/viewport/FullscreenCorner.ts +++ b/src/viewport/FullscreenCorner.ts @@ -24,6 +24,7 @@ export class FullscreenCorner extends Button { super('', { width: Theme.controlHeight, icon: isFullscreen() ? UI_ICON_SVG.fullscreenExit : UI_ICON_SVG.fullscreen, + tooltip: () => (isFullscreen() ? 'Leave fullscreen' : 'Fullscreen'), onClick: () => void toggleFullscreen(), }); this.focusable = false; diff --git a/tests/ui/tooltip.test.ts b/tests/ui/tooltip.test.ts new file mode 100644 index 00000000..8364fcea --- /dev/null +++ b/tests/ui/tooltip.test.ts @@ -0,0 +1,388 @@ +/** + * Tooltips (#209): where the sign goes, how long the pointer has to rest, what takes it away again, + * and what the buttons without a caption say. + */ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CanvasTextMetrics, Container } from 'pixi.js'; +import { CommandStack } from '../../src/core/commands/CommandStack'; +import { rowActionTip } from '../../src/editor/inspector/fieldFactory'; +import { toolbarTip } from '../../src/editor/panels/toolbarTips'; +import { anchorName } from '../../src/editor/pixelart/ResizeCanvasDialog'; +import { TabButton } from '../../src/editor/TabButton'; +import { + Button, + placeTooltip, + SortableList, + TOOLTIP_DELAY_MS, + TOOLTIP_GAP, + TooltipLayer, + tooltips, + withShortcut, +} from '../../src/ui'; +import { makeSpriteScene } from '../helpers'; + +vi.mock('../../src/ui/svg', () => ({ drawSvg: () => undefined, tintSvg: (svg: string) => svg })); + +/** Seven pixels a letter, so a text has a width a test can reason about. */ +beforeAll(() => { + CanvasTextMetrics.measureText = ((text: string) => ({ + width: text.length * 7, + height: 14, + lines: [text], + lineWidths: [text.length * 7], + })) as never; +}); + +const SCREEN = { w: 800, h: 600 }; + +describe('where the sign goes', () => { + it('is under the control, flush with its left edge', () => { + expect(placeTooltip({ x: 100, y: 10, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN)).toEqual({ + x: 100, + y: 34 + TOOLTIP_GAP, + }); + }); + + it('turns to the left at the right border: flush with the right edge of the control', () => { + const at = placeTooltip({ x: 770, y: 10, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN); + expect(at.x).toBe(770 + 24 - 80); + expect(at.y).toBe(34 + TOOLTIP_GAP); + }); + + it('turns upwards at the bottom border', () => { + const at = placeTooltip({ x: 100, y: 570, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN); + expect(at).toEqual({ x: 100, y: 570 - TOOLTIP_GAP - 20 }); + }); + + it('still fits exactly against the border without turning', () => { + expect(placeTooltip({ x: 720, y: 10, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN).x).toBe(720); + expect(placeTooltip({ x: 100, y: 550, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN).y).toBe(580); + }); + + it('is pushed back inside where it fits neither way', () => { + const wide = placeTooltip({ x: 10, y: 10, w: 24, h: 24 }, { w: 900, h: 20 }, SCREEN); + expect(wide.x).toBe(0); + const narrowScreen = placeTooltip({ x: 60, y: 10, w: 24, h: 24 }, { w: 80, h: 20 }, { w: 100, h: 600 }); + expect(narrowScreen.x).toBe(4); + const low = placeTooltip({ x: 10, y: 5, w: 24, h: 590 }, { w: 80, h: 20 }, SCREEN); + expect(low.y).toBe(0); + }); + + it('lands on whole pixels', () => { + const at = placeTooltip({ x: 10.5, y: 10.25, w: 24, h: 24 }, { w: 80, h: 20 }, SCREEN); + expect(Number.isInteger(at.x) && Number.isInteger(at.y)).toBe(true); + }); +}); + +describe('the wait and what ends it', () => { + let overlay: Container; + let layer: TooltipLayer; + const owner = {}; + const anchor = { x: 100, y: 10, w: 24, h: 24 }; + const says = (text: string) => () => ({ text, anchor }); + + beforeEach(() => { + vi.useFakeTimers(); + overlay = new Container(); + layer = new TooltipLayer(() => ({ overlay, screen: { width: 800, height: 600 } })); + }); + afterEach(() => vi.useRealTimers()); + + it('shows nothing before the pointer has rested, and the sign after', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS - 1); + expect(layer.shown).toBeNull(); + expect(overlay.children.length).toBe(0); + vi.advanceTimersByTime(1); + expect(layer.shown?.text).toBe('Menu'); + expect(overlay.children.length).toBe(1); + }); + + it('sizes the sign from its text and puts it under the control', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + expect(layer.shown?.box).toEqual({ x: 100, y: 34 + TOOLTIP_GAP, w: 4 * 7 + 12, h: 14 + 6 }); + expect(layer.shown?.anchor).toEqual(anchor); + }); + + it('starts the wait over with every real move', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(400); + layer.point(owner, '', says('Menu'), { x: 106, y: 15 }); + vi.advanceTimersByTime(400); + expect(layer.shown).toBeNull(); + vi.advanceTimersByTime(100); + expect(layer.shown?.text).toBe('Menu'); + }); + + it('does not start over for a move that went nowhere', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(400); + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(100); + expect(layer.shown?.text).toBe('Menu'); + }); + + it('keeps the sign while the pointer moves on the same control', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + layer.point(owner, '', says('Menu'), { x: 110, y: 20 }); + expect(layer.shown?.text).toBe('Menu'); + // And without a second wait running behind it. + expect(vi.getTimerCount()).toBe(0); + }); + + it('asks for the text when the wait is over, not when it began', () => { + let text = 'Undo Move object'; + layer.point(owner, '', () => ({ text, anchor }), { x: 105, y: 15 }); + text = 'Undo Rename object'; + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + expect(layer.shown?.text).toBe('Undo Rename object'); + }); + + it('shows nothing for a control that has nothing to say by then', () => { + layer.point(owner, '', () => null, { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + expect(layer.shown).toBeNull(); + layer.point({}, '', says(''), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + expect(layer.shown).toBeNull(); + }); + + it('takes the sign away when the pointer leaves', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + layer.leave(owner); + expect(layer.shown).toBeNull(); + expect(overlay.children.length).toBe(0); + }); + + it('drops a wait when the pointer leaves before it is over', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + layer.leave(owner); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS * 2); + expect(layer.shown).toBeNull(); + }); + + it('ignores a leave from a control that is not the one under the pointer', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + layer.leave({}); + expect(layer.shown?.text).toBe('Menu'); + }); + + it('takes the sign away on a press and stays quiet on that control', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + layer.dismiss(); + expect(layer.shown).toBeNull(); + layer.point(owner, '', says('Menu'), { x: 107, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS * 2); + expect(layer.shown).toBeNull(); + }); + + it('a press during the wait cancels it too', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(200); + layer.dismiss(); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + expect(layer.shown).toBeNull(); + }); + + it('speaks again once the pointer has left and come back', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + layer.dismiss(); + layer.leave(owner); + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + expect(layer.shown?.text).toBe('Menu'); + }); + + it('moves on to the next control, with a wait of its own', () => { + const other = {}; + layer.point(owner, '', says('Undo'), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + layer.point(other, '', says('Redo'), { x: 130, y: 15 }); + expect(layer.shown).toBeNull(); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + expect(layer.shown?.text).toBe('Redo'); + }); + + it('tells the parts of one control apart by their key', () => { + layer.point(owner, 'eye:0', says('Hide'), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + layer.point(owner, 'cross:0', says('Remove'), { x: 130, y: 15 }); + expect(layer.shown).toBeNull(); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + expect(layer.shown?.text).toBe('Remove'); + }); + + it('lies above whatever was put on the overlay since', () => { + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + layer.leave(owner); + const menu = overlay.addChild(new Container()); + layer.point(owner, '', says('Menu'), { x: 105, y: 15 }); + vi.advanceTimersByTime(TOOLTIP_DELAY_MS); + expect(overlay.children.indexOf(menu)).toBe(0); + expect(overlay.children.length).toBe(2); + }); +}); + +describe('what a button says', () => { + it('is the tooltip it was given, asked anew each time', () => { + let step = 'Move object'; + const button = new Button('', { width: 24, tooltip: () => `Undo ${step}` }); + expect(button.tooltipText()).toBe('Undo Move object'); + step = 'Paint tiles'; + expect(button.tooltipText()).toBe('Undo Paint tiles'); + expect(new Button('', { width: 24, tooltip: 'Menu' }).tooltipText()).toBe('Menu'); + }); + + it('is nothing for a button whose caption says it all', () => { + const button = new Button('Fit'); + button.resize(button.measure().w, 24); + expect(button.tooltipText()).toBe(''); + }); + + it('is the whole name where a tab had to cut it short', () => { + const tab = new TabButton('A very long name of an asset'); + tab.resize(80, 24); + expect(tab.shownText).not.toBe(tab.text); + expect(tab.tooltipText()).toBe('A very long name of an asset'); + tab.resize(400, 24); + expect(tab.tooltipText()).toBe(''); + }); + + it('reports a rest to the shared layer and a leave when the pointer goes', () => { + const point = vi.spyOn(tooltips, 'point').mockImplementation(() => undefined); + const leave = vi.spyOn(tooltips, 'leave').mockImplementation(() => undefined); + const button = new Button('', { width: 24, tooltip: 'Menu' }); + button.resize(24, 24); + button.position.set(40, 8); + button.emit('pointerover', { global: { x: 45, y: 12 } } as never); + expect(point).toHaveBeenCalledTimes(1); + const [owner, key, target] = point.mock.calls[0]!; + expect(owner).toBe(button); + expect(key).toBe(''); + expect(target()).toEqual({ text: 'Menu', anchor: { x: 40, y: 8, w: 24, h: 24 } }); + button.emit('pointerout', {} as never); + expect(leave).toHaveBeenCalledWith(button); + + // A button with nothing to say asks for no sign at all. + point.mockClear(); + const plain = new Button('Fit'); + plain.resize(60, 24); + plain.emit('pointermove', { global: { x: 5, y: 5 } } as never); + expect(point).not.toHaveBeenCalled(); + point.mockRestore(); + leave.mockRestore(); + }); +}); + +describe('the marks of a list row', () => { + it('say what a press does, each with the box of its own mark', () => { + const point = vi.spyOn(tooltips, 'point').mockImplementation(() => undefined); + const leave = vi.spyOn(tooltips, 'leave').mockImplementation(() => undefined); + const list = new SortableList({ + onPick: () => undefined, + onMove: () => undefined, + onToggleHidden: () => undefined, + onDuplicate: () => undefined, + onRemove: () => undefined, + onAdd: () => undefined, + onRename: () => undefined, + title: 'Layers', + }); + list.setItems( + [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B', hidden: true }, + ], + [], + ); + list.resize(200, list.measure().h); + const head = list.measure().h - 60; + const over = (x: number, y: number) => { + point.mockClear(); + leave.mockClear(); + list.emit('pointermove', { global: { x, y } } as never); + const call = point.mock.calls[0]; + return call ? { key: call[1], ...call[2]()! } : null; + }; + // Rows are 30 high; from the right: cross 24, duplicate 22, eye 26. + expect(over(190, head + 15)).toEqual({ + key: 'cross:0', + text: 'Remove', + anchor: { x: 176, y: head, w: 24, h: 30 }, + }); + expect(over(165, head + 15)).toMatchObject({ key: 'duplicate:0', text: 'Duplicate' }); + expect(over(140, head + 15)).toMatchObject({ key: 'eye:0', text: 'Hide' }); + expect(over(140, head + 45)).toMatchObject({ + key: 'eye:1', + text: 'Show', + anchor: { x: 128, y: head + 30, w: 26, h: 30 }, + }); + expect(over(190, head / 2)).toMatchObject({ key: 'add:-1', text: 'Add' }); + // The name and the band have words of their own. + expect(over(40, head + 15)).toBeNull(); + expect(leave).toHaveBeenCalledWith(list); + expect(over(40, head / 2)).toBeNull(); + point.mockRestore(); + leave.mockRestore(); + }); +}); + +describe('what the buttons without a caption say', () => { + const idle = { undo: null, redo: null, editing: true, paused: false }; + + it('writes a shortcut behind the name, and nothing where there is none', () => { + expect(withShortcut('Undo', 'Ctrl+Z')).toBe('Undo (Ctrl+Z)'); + expect(withShortcut('Menu')).toBe('Menu'); + }); + + it('names the step undo and redo would take', () => { + expect(toolbarTip('undo', { ...idle, undo: 'Paint tiles' })).toBe('Undo Paint tiles (Ctrl+Z)'); + expect(toolbarTip('redo', { ...idle, redo: 'Move object' })).toBe('Redo Move object (Ctrl+Y)'); + expect(toolbarTip('undo', idle)).toBe('Undo (Ctrl+Z)'); + expect(toolbarTip('redo', idle)).toBe('Redo (Ctrl+Y)'); + }); + + it('follows the run: play becomes stop, pause becomes resume', () => { + expect(toolbarTip('menu', idle)).toBe('Menu'); + expect(toolbarTip('play', idle)).toBe('Play (Ctrl+Enter)'); + expect(toolbarTip('play', { ...idle, editing: false })).toBe('Stop (Esc)'); + expect(toolbarTip('pause', { ...idle, editing: false })).toBe('Pause (Ctrl+Enter)'); + expect(toolbarTip('pause', { ...idle, editing: false, paused: true })).toBe('Resume (Ctrl+Enter)'); + }); + + it('takes the names from the command stack, the last step first', () => { + const { project } = makeSpriteScene(); + const stack = new CommandStack(project); + const step = (label: string) => ({ label, execute: () => undefined, undo: () => undefined }); + expect(stack.undoLabel).toBeNull(); + expect(stack.redoLabel).toBeNull(); + stack.execute(step('First')); + stack.execute(step('Second')); + expect(stack.undoLabel).toBe('Second'); + stack.undo(); + expect(stack.undoLabel).toBe('First'); + expect(stack.redoLabel).toBe('Second'); + }); + + it('gives a row action the name of the one thing it does, else that of its group', () => { + const action = (label: string) => ({ label, onSelect: () => undefined }); + expect(rowActionTip('Reset', [action('Reset to the asset')])).toBe('Reset to the asset'); + expect(rowActionTip('Reset', [action('Reset to the asset'), action('Reset all')])).toBe('Reset'); + expect(rowActionTip('Reset', [])).toBe('Reset'); + }); + + it('names the nine anchor squares', () => { + expect(anchorName({ x: 0, y: 0 })).toBe('Top left'); + expect(anchorName({ x: 0.5, y: 0 })).toBe('Top centre'); + expect(anchorName({ x: 1, y: 0.5 })).toBe('Middle right'); + expect(anchorName({ x: 0.5, y: 0.5 })).toBe('Centre'); + expect(anchorName({ x: 0, y: 1 })).toBe('Bottom left'); + }); +}); diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index 0ef5e893..9cc50f41 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -136,6 +136,97 @@ export async function topbar(t) { await json('__gm.state.project.name'), ); + // 46h. The icon buttons say what they do (#209). The pointer has to rest for the sign to show; it + // names the step redo would take and the keys, and sits under its button, inside the window. + const tooltip = () => json('__gm.tooltip()'); + const redoAt = await json('__gm.toolbarAt("redo")'); + const redoLabel = await json('__gm.state.commands.redoLabel'); + await t.mouse('mouseMoved', redoAt.x - 1, redoAt.y); + await t.mouse('mouseMoved', redoAt.x, redoAt.y); + const atOnce = await tooltip(); + await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the redo button' }); + const redoTip = await tooltip(); + const windowSize = await json('({ w: window.innerWidth, h: window.innerHeight })'); + await shot('46h-tooltip'); + check( + 'resting on the redo button shows "Redo (Ctrl+Y)" under it, after a wait and not at once', + atOnce === null && + typeof redoLabel === 'string' && + redoLabel.length > 0 && + redoTip.text === `Redo ${redoLabel} (Ctrl+Y)` && + redoTip.box.y >= redoTip.anchor.y + redoTip.anchor.h && + redoTip.box.x === Math.round(redoTip.anchor.x) && + redoTip.box.x + redoTip.box.w <= windowSize.w && + (await has(redoTip.text)) === false, + JSON.stringify({ atOnce, redoLabel, redoTip }), + ); + // Off the button it goes. The wheel takes it away as well, and the button stays quiet until the + // pointer has left it; a click does the same, here with the menu opening under it. + await t.mouse('mouseMoved', redoAt.x + 90, redoAt.y); + await sleep(100); + const afterLeaving = await tooltip(); + const menuAt = await json('__gm.toolbarAt("menu")'); + const restOnMenu = async () => { + await t.mouse('mouseMoved', menuAt.x - 1, menuAt.y); + await t.mouse('mouseMoved', menuAt.x, menuAt.y); + await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the menu button' }); + return tooltip(); + }; + const menuTip = await restOnMenu(); + await t.wheel(menuAt.x, menuAt.y, 40); + const afterWheel = await tooltip(); + await t.mouse('mouseMoved', menuAt.x + 1, menuAt.y); + await sleep(800); + const afterWaiting = await tooltip(); + await t.mouse('mouseMoved', menuAt.x + 200, menuAt.y); + const again = await restOnMenu(); + await clickToolbar('menu'); + const afterClick = await tooltip(); + const menuOpen = (await json('__gm.menuRows()')) !== null; + await press('Escape'); + check( + 'the tooltip goes when the pointer leaves, and the wheel or a click takes it away until the pointer comes back', + afterLeaving === null && + menuTip.text === 'Menu' && + afterWheel === null && + afterWaiting === null && + again.text === 'Menu' && + afterClick === null && + menuOpen === true, + JSON.stringify({ afterLeaving, menuTip, afterWheel, afterWaiting, again, afterClick, menuOpen }), + ); + // At the right border the sign turns left: flush with the right edge of its button. The button + // nearest that border is the reset of an inspector row, which an override brings up. + const edgeObject = await json(`(() => { + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const o = scene.objects.find((o) => p.assets.find((a) => a.id === o.assetId)?.type === 'sprite'); + return o ? { scene: scene.id, id: o.id, overrides: o.overrides } : null; + })()`); + const setOverrides = (overrides) => + t.mutate( + `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(edgeObject.id)}).overrides = ${JSON.stringify(overrides)}`, + ); + await setOverrides({ ...edgeObject.overrides, width: 77 }); + await t.select({ kind: 'object', holderId: edgeObject.scene, objectId: edgeObject.id }); + const resetAt = await t.findIcon('reset', false); + await t.mouse('mouseMoved', resetAt.x - 1, resetAt.y); + await t.mouse('mouseMoved', resetAt.x, resetAt.y); + await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the reset button' }); + const edgeTip = await tooltip(); + await shot('46h-tooltip-edge'); + check( + 'a tooltip that would leave the window on the right turns left, and names the one thing its button does', + edgeTip.text === 'Reset to asset' && + edgeTip.anchor.x + edgeTip.box.w > windowSize.w && + edgeTip.box.x + edgeTip.box.w === Math.round(edgeTip.anchor.x + edgeTip.anchor.w) && + edgeTip.box.x + edgeTip.box.w <= windowSize.w, + JSON.stringify({ edgeTip, windowSize }), + ); + await t.mouse('mouseMoved', windowSize.w / 2, windowSize.h / 2); + await setOverrides(edgeObject.overrides); + await t.select(null); + // 46f. The symbol scale in the same dialog: 1 by default, typed down to 0.5, and every icon of the // scene drawn at half its side (18 → 9 world units), undone in one step. const iconSize = () =>