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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -57,6 +57,7 @@ export class TabStrip<T extends string> extends HStack {
|
||||
this.overflow = new Button('»', {
|
||||
variant: 'ghost',
|
||||
width: OVERFLOW_WIDTH,
|
||||
tooltip: 'More tabs',
|
||||
onClick: () => this.openMenu(),
|
||||
});
|
||||
this.addChild(this.bar);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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. */
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 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<ResizeCanvasDialog>();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user