Say what a button without words does when the pointer rests (#209)
game-maker / gate (push) Successful in 2m50s
game-maker / suite (push) Successful in 15m34s

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:
adminandClaude Opus 5.5 committed 2026-10-02 12:02:57 +02:00
1 parent 345289982b
commit ec2fd9084f
27 files changed
+831 -15

No files matched your search

+3
View File
@@ -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();
+1
View File
@@ -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();
+1
View File
@@ -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);
+3
View File
@@ -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. */
+1
View File
@@ -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),
});
}
+1
View File
@@ -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);
+14 -5
View File
@@ -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, {
+1
View File
@@ -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)),
}),
);
+7
View File
@@ -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;
+1 -1
View File
@@ -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);
+15 -7
View File
@@ -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);
+31
View File
@@ -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 });
+2 -1
View File
@@ -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) {
+1
View File
@@ -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(),
});
+1
View File
@@ -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);