Say what a button without words does when the pointer rests (#209)
The top bar, the magnet, the eye and many marks of the inspector are
pictures without a caption. Whoever did not know one could only try
it. The toolkit had no hint on hover, which also forced the point
menu of #195 to be words. The answers in the issue settle the open
points: a cut tab name shows in full, shortcuts are part of the
text, the point menu stays as it is.
- Button takes a tooltip, a text or a function asked each time. It
shows on a small sign once the pointer has rested for half a
second.
- There is one sign for the page, a TooltipLayer on the overlay
above the menus. A control reports each pointer move and the
pointer leaving, the layer does the waiting.
- A press, a key, the wheel or a blur of the window takes the sign
away. initUI listens for them on the window.
- placeTooltip in ui/tooltip.ts is the rule for the place as a pure
function: under the control and flush left, flush right at the
right border, above the control at the bottom.
- toolbarTip says what the five buttons of the top bar do right now.
Undo and redo name their step from CommandStack.undoLabel and
redoLabel, as in "Undo Paint tiles (Ctrl+Z)".
- A SortableList reports its own marks with a key per mark: the eye,
the duplicate mark, the cross and the plus.
- The magnet, both fullscreen buttons, the eye of the scene tab, the
overflow of the tab strip, the plus of the assets panel, the marks
of the inspector rows and the nine anchors of the resize dialog
carry a text.
Decided here:
- Every button whose text is cut short shows the whole text, not
only a tab.
- A row action of the inspector names the one thing a click does. A
button that opens a menu says only Reset, Apply or Size from the
picture.
- A button just clicked stays quiet until the pointer has left it.
- A greyed out button shows no sign.
- The switches of the eye popup, the cross of a document tab and the
tools of pixel art have none. The tools carry words, other than
the issue assumes.
- The marks of a list say Show or Hide, Duplicate, Remove and Add,
whatever the list holds.
Found on the way:
- Signing out empties the overlay and would have destroyed a sign on
screen with it. The editor takes the tooltip away first. This has
no test of its own.
- The text hooks locate and has skip the sign, or it would answer
for the button under it.
Tests:
- Gate on melektaus-atx: 210 files, 3209 tests (3176 + 33), exit 0
- e2e 46 on melektaus-atx: 20 checks, all pass
- e2e 1-14 on melektaus-atx: 508 checks, all pass
- e2e 1-9,14,39,40,44-48: 685 pass, 2 fail, both in module 14. It
measures tabs that modules 10 to 13 open, and the chain 1-14 is
green. The cut chain was not run on the state before.
- No full run and no smokes. The sum of 1667 (1664 + 3) is reckoned,
1664 is what CI counted for 3452899.
- 33 unit tests in tests/ui/tooltip.test.ts
- Three e2e checks in block 46h of module 46: the redo button after
the wait, taking the sign away, turning at the right border
- 50 unit counter-probes and four browser counter-probes all fail
their tests. Three of the unit ones did not at first and got
sharper tests.
- Not checked in the browser: the sign of a cut tab, the marks of
the lists, turning upwards at the bottom, each other button alone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
345289982b
commit
ec2fd9084f
27 files changed
+831
-15
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -44,4 +44,13 @@ export class CommandStack {
|
|||||||
get canRedo(): boolean {
|
get canRedo(): boolean {
|
||||||
return this.redoStack.length > 0;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
requestRender,
|
requestRender,
|
||||||
Splitter,
|
Splitter,
|
||||||
Theme,
|
Theme,
|
||||||
|
tooltips,
|
||||||
ui,
|
ui,
|
||||||
VStack,
|
VStack,
|
||||||
type Widget,
|
type Widget,
|
||||||
@@ -911,6 +912,8 @@ export class Editor implements PlayHost {
|
|||||||
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
|
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.
|
// Closed rather than just thrown away with the overlay below, so the slot lets go of it too.
|
||||||
this.passwordDialog.current()?.close();
|
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.
|
// 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 });
|
for (const popup of ui().overlay.removeChildren()) popup.destroy({ children: true });
|
||||||
ui().focus.blur();
|
ui().focus.blur();
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ export class ProjectSettingsDialog extends FormDialog {
|
|||||||
new Button('×', {
|
new Button('×', {
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
width: Theme.controlHeight,
|
width: Theme.controlHeight,
|
||||||
|
tooltip: 'Remove layer',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
this.layers.pop();
|
this.layers.pop();
|
||||||
this.buildLayers();
|
this.buildLayers();
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export class TabStrip<T extends string> extends HStack {
|
|||||||
this.overflow = new Button('»', {
|
this.overflow = new Button('»', {
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
width: OVERFLOW_WIDTH,
|
width: OVERFLOW_WIDTH,
|
||||||
|
tooltip: 'More tabs',
|
||||||
onClick: () => this.openMenu(),
|
onClick: () => this.openMenu(),
|
||||||
});
|
});
|
||||||
this.addChild(this.bar);
|
this.addChild(this.bar);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Widget,
|
Widget,
|
||||||
activeDrag,
|
activeDrag,
|
||||||
currentMenu,
|
currentMenu,
|
||||||
|
tooltips,
|
||||||
ui,
|
ui,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import { Theme } from '../../ui/Theme';
|
import { Theme } from '../../ui/Theme';
|
||||||
@@ -243,6 +244,8 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
|||||||
textColor,
|
textColor,
|
||||||
overlayTexts,
|
overlayTexts,
|
||||||
overlayRect,
|
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). */
|
/** Entries of the open menu in drawn order, `null` for a separator line (#43). */
|
||||||
menuRows: () => currentMenu()?.rowsDrawn() ?? null,
|
menuRows: () => currentMenu()?.rowsDrawn() ?? null,
|
||||||
/** Per entry of `menuRows`, whether an icon stands in front of it. */
|
/** Per entry of `menuRows`, whether an icon stands in front of it. */
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export class SnapToggle {
|
|||||||
width: Theme.controlHeight,
|
width: Theme.controlHeight,
|
||||||
icon: HEADER_ICON_SVG.snap,
|
icon: HEADER_ICON_SVG.snap,
|
||||||
toggle: true,
|
toggle: true,
|
||||||
|
tooltip: 'Snap to the grid',
|
||||||
onClick: () => this.set(!this.on),
|
onClick: () => this.set(!this.on),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function aspectActions(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
icon: AXIS_ICON[axis],
|
icon: AXIS_ICON[axis],
|
||||||
|
tooltip: 'Size from the picture',
|
||||||
available: () => {
|
available: () => {
|
||||||
const bag = props();
|
const bag = props();
|
||||||
const size = bag && texturePixelSize(host.project(), bag);
|
const size = bag && texturePixelSize(host.project(), bag);
|
||||||
|
|||||||
@@ -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. */
|
/** One trailing button of a row: its picture (an SVG file), and what it currently offers. */
|
||||||
export interface FieldActionGroup {
|
export interface FieldActionGroup {
|
||||||
icon: string;
|
icon: string;
|
||||||
|
/** What the button says while it offers several actions; with one, it says that action. */
|
||||||
|
tooltip: string;
|
||||||
available: FieldActions;
|
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. */
|
/** 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 {
|
export function attachRowActions(row: HStack, rowActions: RowActions): () => void {
|
||||||
const actions: { button: RowActionButton; available: FieldActions }[] = [];
|
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);
|
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
|
// 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).
|
// last refresh (an undo between refreshes, a value edited in the viewport).
|
||||||
button.onClick = () => {
|
button.onClick = () => {
|
||||||
@@ -180,9 +183,9 @@ export function attachRowActions(row: HStack, rowActions: RowActions): () => voi
|
|||||||
actions.push({ button, available });
|
actions.push({ button, available });
|
||||||
row.add(button);
|
row.add(button);
|
||||||
};
|
};
|
||||||
for (const group of rowActions.extra ?? []) addActions(group.icon, group.available);
|
for (const group of rowActions.extra ?? []) addActions(group.icon, group.tooltip, group.available);
|
||||||
if (rowActions.apply) addActions(ROW_ACTION_ICON_SVG.apply, rowActions.apply);
|
if (rowActions.apply) addActions(ROW_ACTION_ICON_SVG.apply, 'Apply', rowActions.apply);
|
||||||
if (rowActions.reset) addActions(ROW_ACTION_ICON_SVG.reset, rowActions.reset);
|
if (rowActions.reset) addActions(ROW_ACTION_ICON_SVG.reset, 'Reset', rowActions.reset);
|
||||||
|
|
||||||
const refreshActions = (): void => {
|
const refreshActions = (): void => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -201,6 +204,11 @@ export function attachRowActions(row: HStack, rowActions: RowActions): () => voi
|
|||||||
return refreshActions;
|
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. */
|
/** A row action: its picture alone, or with a chevron once it offers more than one thing. */
|
||||||
class RowActionButton extends Button {
|
class RowActionButton extends Button {
|
||||||
private readonly chevron = new Graphics();
|
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 {
|
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: () => {} };
|
export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
|
||||||
@@ -496,6 +504,7 @@ function objectField(ctx: FieldContext, name: string, value: PropertyValue, hand
|
|||||||
minWidth: Theme.controlHeight,
|
minWidth: Theme.controlHeight,
|
||||||
icon: ROW_ACTION_ICON_SVG.eyedropper,
|
icon: ROW_ACTION_ICON_SVG.eyedropper,
|
||||||
toggle: true,
|
toggle: true,
|
||||||
|
tooltip: 'Pick an object in the scene',
|
||||||
});
|
});
|
||||||
eyedropper.onClick = () => {
|
eyedropper.onClick = () => {
|
||||||
picker.toggle(eyedropper, {
|
picker.toggle(eyedropper, {
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ function gridEdgesSection(host: InspectorHost, holderId: string, object: SceneOb
|
|||||||
new Button(count < 0 ? '−' : '+', {
|
new Button(count < 0 ? '−' : '+', {
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
minWidth: Theme.controlHeight,
|
minWidth: Theme.controlHeight,
|
||||||
|
tooltip: new ResizeTilemapEdgeCommand(holderId, object.id, edge, count).label,
|
||||||
onClick: () => host.state.execute(new ResizeTilemapEdgeCommand(holderId, object.id, edge, count)),
|
onClick: () => host.state.execute(new ResizeTilemapEdgeCommand(holderId, object.id, edge, count)),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ export function buildVariable(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SWITCH_TIPS: Record<keyof typeof VARIABLE_SWITCH_ICON_SVG, string> = {
|
||||||
|
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. */
|
/** 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 {
|
function switchButton(icon: keyof typeof VARIABLE_SWITCH_ICON_SVG, of: VariableSwitch): Button {
|
||||||
const button = new Button('', {
|
const button = new Button('', {
|
||||||
@@ -153,6 +158,7 @@ function switchButton(icon: keyof typeof VARIABLE_SWITCH_ICON_SVG, of: VariableS
|
|||||||
toggle: true,
|
toggle: true,
|
||||||
icon: VARIABLE_SWITCH_ICON_SVG[icon],
|
icon: VARIABLE_SWITCH_ICON_SVG[icon],
|
||||||
padding: 4,
|
padding: 4,
|
||||||
|
tooltip: SWITCH_TIPS[icon],
|
||||||
onClick: () => of.set(!of.on),
|
onClick: () => of.set(!of.on),
|
||||||
});
|
});
|
||||||
button.setToggled(of.on);
|
button.setToggled(of.on);
|
||||||
@@ -300,6 +306,7 @@ export function markPicker(kind: string, items: MenuItem[]): Button {
|
|||||||
width: MARK_WIDTH,
|
width: MARK_WIDTH,
|
||||||
style: 'small',
|
style: 'small',
|
||||||
icon,
|
icon,
|
||||||
|
tooltip: 'Change the kind',
|
||||||
});
|
});
|
||||||
picker.onClick = () => new Menu(items).openBelow(picker);
|
picker.onClick = () => new Menu(items).openBelow(picker);
|
||||||
return picker;
|
return picker;
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export class AssetPanel extends Panel {
|
|||||||
super({ title: 'Assets', padding: 0, gap: 0 });
|
super({ title: 'Assets', padding: 0, gap: 0 });
|
||||||
this.minWidth = 160;
|
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);
|
add.onClick = () => new Menu(this.addMenuEntries()).openBelow(add);
|
||||||
this.header.add(add);
|
this.header.add(add);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Graphics } from 'pixi.js';
|
|||||||
import { Button, drawHeaderBar, Dropdown, HStack, Label, Spacer, Theme, type Widget } from '../../ui';
|
import { Button, drawHeaderBar, Dropdown, HStack, Label, Spacer, Theme, type Widget } from '../../ui';
|
||||||
import type { EditorState, SaveStatus } from '../state';
|
import type { EditorState, SaveStatus } from '../state';
|
||||||
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
||||||
|
import { type ToolbarControl, toolbarTip } from './toolbarTips';
|
||||||
|
|
||||||
/** What the toolbar buttons trigger; the editor supplies the implementations. */
|
/** What the toolbar buttons trigger; the editor supplies the implementations. */
|
||||||
export interface ToolbarActions {
|
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
|
* 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.
|
* than beside the gap a label would need.
|
||||||
*/
|
*/
|
||||||
function iconButton(name: IconName, onClick: () => void): Button {
|
function iconButton(name: IconName, onClick: () => void, tooltip: () => string): Button {
|
||||||
return new Button('', { width: ICON_BUTTON, icon: TOOLBAR_ICON_SVG[name], onClick });
|
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.minHeight = Theme.controlHeight + Theme.headerPadding * 2;
|
||||||
this.addChildAt(this.bg, 0);
|
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(this.menuButton);
|
||||||
this.add(new Spacer(Theme.padding));
|
this.add(new Spacer(Theme.padding));
|
||||||
this.undoButton = iconButton('undo', () => state.undo());
|
this.undoButton = iconButton('undo', () => state.undo(), tip('undo'));
|
||||||
this.redoButton = iconButton('redo', () => state.redo());
|
this.redoButton = iconButton('redo', () => state.redo(), tip('redo'));
|
||||||
this.add(this.undoButton);
|
this.add(this.undoButton);
|
||||||
this.add(this.redoButton);
|
this.add(this.redoButton);
|
||||||
|
|
||||||
this.add(new Spacer(), 1);
|
this.add(new Spacer(), 1);
|
||||||
this.playButton = iconButton('play', () => actions.togglePlay());
|
this.playButton = iconButton('play', () => actions.togglePlay(), tip('play'));
|
||||||
this.playButton.variant = 'primary';
|
this.playButton.variant = 'primary';
|
||||||
this.add(this.playButton);
|
this.add(this.playButton);
|
||||||
this.pauseButton = iconButton('pause', () => actions.togglePause());
|
this.pauseButton = iconButton('pause', () => actions.togglePause(), tip('pause'));
|
||||||
this.add(this.pauseButton);
|
this.add(this.pauseButton);
|
||||||
this.add(new Spacer(), 1);
|
this.add(new Spacer(), 1);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,14 @@ import { dialogSlot, FormDialog } from '../FormDialog';
|
|||||||
const FIELD_WIDTH = 76;
|
const FIELD_WIDTH = 76;
|
||||||
const ANCHOR_SIZE = 24;
|
const ANCHOR_SIZE = 24;
|
||||||
const STEPS = [0, 0.5, 1] as const;
|
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<ResizeCanvasDialog>();
|
const slot = dialogSlot<ResizeCanvasDialog>();
|
||||||
|
|
||||||
@@ -61,6 +69,7 @@ export class ResizeCanvasDialog extends FormDialog {
|
|||||||
const button = new Button('', {
|
const button = new Button('', {
|
||||||
width: ANCHOR_SIZE,
|
width: ANCHOR_SIZE,
|
||||||
toggle: true,
|
toggle: true,
|
||||||
|
tooltip: anchorName(anchor),
|
||||||
onClick: () => this.setAnchor(anchor),
|
onClick: () => this.setAnchor(anchor),
|
||||||
});
|
});
|
||||||
this.anchors.push({ anchor, button });
|
this.anchors.push({ anchor, button });
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export function textProbe(app: Application): {
|
|||||||
visit: (node: Container, clip: Rectangle | null, owner: Widget | null) => boolean,
|
visit: (node: Container, clip: Rectangle | null, owner: Widget | null) => boolean,
|
||||||
): void => {
|
): void => {
|
||||||
const walk = (node: Container, clip: Rectangle | null, owner: Widget | null): 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;
|
const holder = node instanceof Widget ? node : owner;
|
||||||
let inner = clip;
|
let inner = clip;
|
||||||
if (node.mask && holder) {
|
if (node.mask && holder) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class GamePane extends VStack {
|
|||||||
readonly fullscreenButton = new Button('', {
|
readonly fullscreenButton = new Button('', {
|
||||||
width: Theme.controlHeight,
|
width: Theme.controlHeight,
|
||||||
icon: UI_ICON_SVG.fullscreen,
|
icon: UI_ICON_SVG.fullscreen,
|
||||||
|
tooltip: 'Fullscreen',
|
||||||
onClick: () => void toggleFullscreen(),
|
onClick: () => void toggleFullscreen(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export class ScenePane extends VStack {
|
|||||||
this.fitButton = new Button('Fit', { onClick: () => view.fitScene() });
|
this.fitButton = new Button('Fit', { onClick: () => view.fitScene() });
|
||||||
this.visibilityButton = new Dropdown('', {
|
this.visibilityButton = new Dropdown('', {
|
||||||
icon: TOOLBAR_ICON_SVG.eye,
|
icon: TOOLBAR_ICON_SVG.eye,
|
||||||
|
tooltip: 'What the scene view shows',
|
||||||
onClick: () => new SceneVisibilityPopup(state).openBelow(this.visibilityButton),
|
onClick: () => new SceneVisibilityPopup(state).openBelow(this.visibilityButton),
|
||||||
});
|
});
|
||||||
this.header.add(this.fitButton);
|
this.header.add(this.fitButton);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Application, Container } from 'pixi.js';
|
|||||||
import { FocusManager } from './FocusManager';
|
import { FocusManager } from './FocusManager';
|
||||||
import { KeyboardBridge } from './KeyboardBridge';
|
import { KeyboardBridge } from './KeyboardBridge';
|
||||||
import { requestRender } from './render';
|
import { requestRender } from './render';
|
||||||
|
import { tooltips } from './widgets/Tooltip';
|
||||||
|
|
||||||
/** Services shared by all widgets, created once by `initUI()`. */
|
/** Services shared by all widgets, created once by `initUI()`. */
|
||||||
export interface UIContext {
|
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 });
|
window.addEventListener(type, wake, { capture: true, passive: true });
|
||||||
for (const type of ['keydown', 'keyup', 'focus', 'blur'] as const)
|
for (const type of ['keydown', 'keyup', 'focus', 'blur'] as const)
|
||||||
window.addEventListener(type, wake, true);
|
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;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep,
|
|||||||
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
|
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
|
||||||
export { Label, truncate } from './widgets/Label';
|
export { Label, truncate } from './widgets/Label';
|
||||||
export { Button, type ButtonOptions } from './widgets/Button';
|
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 { CHEVRON_SLOT, Dropdown, drawChevron } from './widgets/Dropdown';
|
||||||
export { Icon } from './widgets/Icon';
|
export { Icon } from './widgets/Icon';
|
||||||
export { UI_ICON_SVG } from './icons';
|
export { UI_ICON_SVG } from './icons';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { Theme, textStyle, type TextStyleToken } from '../Theme';
|
|||||||
import { type Size, Widget } from '../Widget';
|
import { type Size, Widget } from '../Widget';
|
||||||
import { truncate } from './Label';
|
import { truncate } from './Label';
|
||||||
import { crisp, crispText, hairline } from '../pixelGrid';
|
import { crisp, crispText, hairline } from '../pixelGrid';
|
||||||
|
import { type TooltipTarget, tooltips } from './Tooltip';
|
||||||
|
|
||||||
export interface ButtonOptions {
|
export interface ButtonOptions {
|
||||||
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
||||||
@@ -29,6 +30,8 @@ export interface ButtonOptions {
|
|||||||
toggle?: boolean;
|
toggle?: boolean;
|
||||||
/** SVG file drawn in front of the label, 16 units around its origin (see `drawSvg`). */
|
/** SVG file drawn in front of the label, 16 units around its origin (see `drawSvg`). */
|
||||||
icon?: string;
|
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;
|
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. */
|
/** Colour the icon was last drawn in; it follows the label from muted to the accent text. */
|
||||||
private iconColor = -1;
|
private iconColor = -1;
|
||||||
private drawnFill: number | null = null;
|
private drawnFill: number | null = null;
|
||||||
|
tooltip: string | (() => string) | null;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
|
||||||
constructor(text: string, options: ButtonOptions = {}) {
|
constructor(text: string, options: ButtonOptions = {}) {
|
||||||
@@ -76,6 +80,7 @@ export class Button extends Widget {
|
|||||||
style: textStyle(this.variant === 'primary' ? 'accent' : this.style),
|
style: textStyle(this.variant === 'primary' ? 'accent' : this.style),
|
||||||
});
|
});
|
||||||
this.icon = options.icon ?? null;
|
this.icon = options.icon ?? null;
|
||||||
|
this.tooltip = options.tooltip ?? null;
|
||||||
this.addChild(this.bg, this.labelText);
|
this.addChild(this.bg, this.labelText);
|
||||||
if (this.icon) this.addChild(this.iconGfx);
|
if (this.icon) this.addChild(this.iconGfx);
|
||||||
|
|
||||||
@@ -98,8 +103,31 @@ export class Button extends Widget {
|
|||||||
this.pressed = false;
|
this.pressed = false;
|
||||||
this.invalidate();
|
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 {
|
get text(): string {
|
||||||
return this.fullText;
|
return this.fullText;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { truncate } from './Label';
|
|||||||
import { NumberField } from './NumberField';
|
import { NumberField } from './NumberField';
|
||||||
import { TextField } from './TextField';
|
import { TextField } from './TextField';
|
||||||
import { crisp, crispText } from '../pixelGrid';
|
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. */
|
/** Height of one row: the picture plus the air a hovered row needs to look like a row. */
|
||||||
export const SORTABLE_ROW_HEIGHT = 30;
|
export const SORTABLE_ROW_HEIGHT = 30;
|
||||||
@@ -202,11 +203,13 @@ export class SortableList extends Widget implements SectionStart {
|
|||||||
|
|
||||||
this.on('pointermove', (e: FederatedPointerEvent) => {
|
this.on('pointermove', (e: FederatedPointerEvent) => {
|
||||||
const hovered = this.hitAt(this.toLocal(e.global));
|
const hovered = this.hitAt(this.toLocal(e.global));
|
||||||
|
this.pointTooltip(hovered, e.global);
|
||||||
if (sameHover(hovered, this.over)) return;
|
if (sameHover(hovered, this.over)) return;
|
||||||
this.over = hovered;
|
this.over = hovered;
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
});
|
});
|
||||||
this.on('pointerout', () => {
|
this.on('pointerout', () => {
|
||||||
|
tooltips.leave(this);
|
||||||
if (!this.over) return;
|
if (!this.over) return;
|
||||||
this.over = null;
|
this.over = null;
|
||||||
this.invalidate();
|
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 };
|
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 {
|
private get headerHeight(): number {
|
||||||
return this.options.title ? SECTION_BAND_HEIGHT : this.options.onAdd ? HEADER_HEIGHT : 0;
|
return this.options.title ? SECTION_BAND_HEIGHT : this.options.onAdd ? HEADER_HEIGHT : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof setTimeout> | 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();
|
||||||
@@ -24,6 +24,7 @@ export class FullscreenCorner extends Button {
|
|||||||
super('', {
|
super('', {
|
||||||
width: Theme.controlHeight,
|
width: Theme.controlHeight,
|
||||||
icon: isFullscreen() ? UI_ICON_SVG.fullscreenExit : UI_ICON_SVG.fullscreen,
|
icon: isFullscreen() ? UI_ICON_SVG.fullscreenExit : UI_ICON_SVG.fullscreen,
|
||||||
|
tooltip: () => (isFullscreen() ? 'Leave fullscreen' : 'Fullscreen'),
|
||||||
onClick: () => void toggleFullscreen(),
|
onClick: () => void toggleFullscreen(),
|
||||||
});
|
});
|
||||||
this.focusable = false;
|
this.focusable = false;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -136,6 +136,97 @@ export async function topbar(t) {
|
|||||||
await json('__gm.state.project.name'),
|
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 <step> (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
|
// 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.
|
// scene drawn at half its side (18 → 9 world units), undone in one step.
|
||||||
const iconSize = () =>
|
const iconSize = () =>
|
||||||
|
|||||||
Reference in new issue
Block a user