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
@@ -44,4 +44,13 @@ export class CommandStack {
|
||||
get canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
/** What the next undo takes back, by the name of its command; null with nothing to undo. */
|
||||
get undoLabel(): string | null {
|
||||
return this.undoStack[this.undoStack.length - 1]?.label ?? null;
|
||||
}
|
||||
|
||||
get redoLabel(): string | null {
|
||||
return this.redoStack[this.redoStack.length - 1]?.label ?? null;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -24,6 +24,7 @@ export class FullscreenCorner extends Button {
|
||||
super('', {
|
||||
width: Theme.controlHeight,
|
||||
icon: isFullscreen() ? UI_ICON_SVG.fullscreenExit : UI_ICON_SVG.fullscreen,
|
||||
tooltip: () => (isFullscreen() ? 'Leave fullscreen' : 'Fullscreen'),
|
||||
onClick: () => void toggleFullscreen(),
|
||||
});
|
||||
this.focusable = false;
|
||||
|
||||
Reference in new issue
Block a user