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>
389 lines
15 KiB
TypeScript
389 lines
15 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|