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
@@ -2,6 +2,7 @@ import type { Application, Container } from 'pixi.js';
|
||||
import { FocusManager } from './FocusManager';
|
||||
import { KeyboardBridge } from './KeyboardBridge';
|
||||
import { requestRender } from './render';
|
||||
import { tooltips } from './widgets/Tooltip';
|
||||
|
||||
/** Services shared by all widgets, created once by `initUI()`. */
|
||||
export interface UIContext {
|
||||
@@ -32,6 +33,9 @@ export function initUI(app: Application, root: Container, overlay: Container): U
|
||||
window.addEventListener(type, wake, { capture: true, passive: true });
|
||||
for (const type of ['keydown', 'keyup', 'focus', 'blur'] as const)
|
||||
window.addEventListener(type, wake, true);
|
||||
// Whatever the user does besides moving the pointer takes the tooltip away.
|
||||
for (const type of ['pointerdown', 'wheel', 'keydown', 'blur'] as const)
|
||||
window.addEventListener(type, () => tooltips.dismiss(), { capture: true, passive: true });
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ export { clamp, centreY, formatBytes, formatNumber, NUMBER_DECIMALS, snapToStep,
|
||||
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
|
||||
export { Label, truncate } from './widgets/Label';
|
||||
export { Button, type ButtonOptions } from './widgets/Button';
|
||||
export { TooltipLayer, tooltips, type TooltipHost, type TooltipTarget } from './widgets/Tooltip';
|
||||
export { placeTooltip, TOOLTIP_DELAY_MS, TOOLTIP_GAP, type TooltipBox, withShortcut } from './tooltip';
|
||||
export { CHEVRON_SLOT, Dropdown, drawChevron } from './widgets/Dropdown';
|
||||
export { Icon } from './widgets/Icon';
|
||||
export { UI_ICON_SVG } from './icons';
|
||||
|
||||
@@ -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 { truncate } from './Label';
|
||||
import { crisp, crispText, hairline } from '../pixelGrid';
|
||||
import { type TooltipTarget, tooltips } from './Tooltip';
|
||||
|
||||
export interface ButtonOptions {
|
||||
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
||||
@@ -29,6 +30,8 @@ export interface ButtonOptions {
|
||||
toggle?: boolean;
|
||||
/** SVG file drawn in front of the label, 16 units around its origin (see `drawSvg`). */
|
||||
icon?: string;
|
||||
/** What the button does, shown when the pointer rests on it (#209); a function is asked each time. */
|
||||
tooltip?: string | (() => string);
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -55,6 +58,7 @@ export class Button extends Widget {
|
||||
/** Colour the icon was last drawn in; it follows the label from muted to the accent text. */
|
||||
private iconColor = -1;
|
||||
private drawnFill: number | null = null;
|
||||
tooltip: string | (() => string) | null;
|
||||
onClick?: () => void;
|
||||
|
||||
constructor(text: string, options: ButtonOptions = {}) {
|
||||
@@ -76,6 +80,7 @@ export class Button extends Widget {
|
||||
style: textStyle(this.variant === 'primary' ? 'accent' : this.style),
|
||||
});
|
||||
this.icon = options.icon ?? null;
|
||||
this.tooltip = options.tooltip ?? null;
|
||||
this.addChild(this.bg, this.labelText);
|
||||
if (this.icon) this.addChild(this.iconGfx);
|
||||
|
||||
@@ -98,8 +103,31 @@ export class Button extends Widget {
|
||||
this.pressed = false;
|
||||
this.invalidate();
|
||||
});
|
||||
const rest = (e: FederatedPointerEvent): void => {
|
||||
if (this.tooltipText()) tooltips.point(this, '', this.tooltipTarget, e.global);
|
||||
else tooltips.leave(this);
|
||||
};
|
||||
this.on('pointerover', rest);
|
||||
this.on('pointermove', rest);
|
||||
this.on('pointerout', () => tooltips.leave(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sign says: the tooltip the button was given, and for a button without one its own
|
||||
* label where that is cut short. Empty for no sign.
|
||||
*/
|
||||
tooltipText(): string {
|
||||
const given = typeof this.tooltip === 'function' ? this.tooltip() : this.tooltip;
|
||||
if (given) return given;
|
||||
return this.labelText.text !== this.fullText ? this.fullText : '';
|
||||
}
|
||||
|
||||
private readonly tooltipTarget = (): TooltipTarget | null => {
|
||||
if (this.destroyed) return null;
|
||||
const at = this.getGlobalPosition();
|
||||
return { text: this.tooltipText(), anchor: { x: at.x, y: at.y, w: this.w, h: this.h } };
|
||||
};
|
||||
|
||||
get text(): string {
|
||||
return this.fullText;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { truncate } from './Label';
|
||||
import { NumberField } from './NumberField';
|
||||
import { TextField } from './TextField';
|
||||
import { crisp, crispText } from '../pixelGrid';
|
||||
import { type TooltipTarget, tooltips } from './Tooltip';
|
||||
|
||||
/** Height of one row: the picture plus the air a hovered row needs to look like a row. */
|
||||
export const SORTABLE_ROW_HEIGHT = 30;
|
||||
@@ -202,11 +203,13 @@ export class SortableList extends Widget implements SectionStart {
|
||||
|
||||
this.on('pointermove', (e: FederatedPointerEvent) => {
|
||||
const hovered = this.hitAt(this.toLocal(e.global));
|
||||
this.pointTooltip(hovered, e.global);
|
||||
if (sameHover(hovered, this.over)) return;
|
||||
this.over = hovered;
|
||||
this.invalidate();
|
||||
});
|
||||
this.on('pointerout', () => {
|
||||
tooltips.leave(this);
|
||||
if (!this.over) return;
|
||||
this.over = null;
|
||||
this.invalidate();
|
||||
@@ -334,6 +337,42 @@ export class SortableList extends Widget implements SectionStart {
|
||||
return { x: origin.x + this.w - REMOVE_SLOT / 2, y: origin.y + this.headerHeight / 2 };
|
||||
}
|
||||
|
||||
/** The marks of a row and the plus carry no words (#209): a sign says what a press on them does. */
|
||||
private pointTooltip(over: Hover, pointer: { x: number; y: number }): void {
|
||||
const mark = over === 'add' ? 'add' : over && over !== 'head' && over.part !== 'row' ? over.part : null;
|
||||
if (!mark || mark === 'name' || this.drag?.active) return tooltips.leave(this);
|
||||
const row = over === 'add' || !over || over === 'head' ? -1 : over.row;
|
||||
tooltips.point(this, `${mark}:${row}`, () => this.markTooltip(mark, row), pointer);
|
||||
}
|
||||
|
||||
private markTooltip(mark: 'add' | 'eye' | 'duplicate' | 'cross', row: number): TooltipTarget | null {
|
||||
if (this.destroyed) return null;
|
||||
const origin = this.getGlobalPosition();
|
||||
if (mark === 'add')
|
||||
return {
|
||||
text: 'Add',
|
||||
anchor: { x: origin.x + this.w - REMOVE_SLOT, y: origin.y, w: REMOVE_SLOT, h: this.headerHeight },
|
||||
};
|
||||
const item = this.shown[row];
|
||||
if (!item) return null;
|
||||
const slots = this.slots;
|
||||
const [right, width, text] =
|
||||
mark === 'cross'
|
||||
? [slots.cross, REMOVE_SLOT, 'Remove']
|
||||
: mark === 'duplicate'
|
||||
? [slots.duplicate, DUPLICATE_SLOT, 'Duplicate']
|
||||
: [slots.eye, EYE_SLOT, item.hidden ? 'Show' : 'Hide'];
|
||||
return {
|
||||
text,
|
||||
anchor: {
|
||||
x: origin.x + right - width,
|
||||
y: origin.y + this.headerHeight + row * SORTABLE_ROW_HEIGHT,
|
||||
w: width,
|
||||
h: SORTABLE_ROW_HEIGHT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private get headerHeight(): number {
|
||||
return this.options.title ? SECTION_BAND_HEIGHT : this.options.onAdd ? HEADER_HEIGHT : 0;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user