Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/__fixtures__/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,20 @@ export const fakeTimers = (): Disposable => {
};
};

/**
Read the mock 2D context of a canvas.
*/
export const canvasContext = (canvas: HTMLCanvasElement): MockContext =>
(canvas.getContext as unknown as () => MockContext)();

/**
Read the mock 2D context of the first canvas under `parent`.
*/
export const queryCanvasContext = (parent: HTMLElement): MockContext =>
(
parent.querySelector('canvas')?.getContext as unknown as () => MockContext
)();
export const queryCanvasContext = (parent: HTMLElement): MockContext => {
const canvas = parent.querySelector('canvas');
if (canvas === null) {
throw new Error('No canvas was rendered under the parent.');
}

return canvasContext(canvas);
};
19 changes: 18 additions & 1 deletion src/engine/layout-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ export type LayoutView<M extends AnyModelNode> = {
readonly lowerStroke: readonly Point[] | null;
};

/**
The segment novice mode draws on top of the open menu: from the menu's
center to the pointer, never the path the pointer took to get there.
Startup and expert keep their whole traced stroke instead, which is the
mark being drawn. The machine stores only `lastPosition` and builds the
segment here, so the two can never disagree.
*/
export function noviceUpperStroke({
menuCenter,
lastPosition,
}: {
readonly menuCenter: Point;
readonly lastPosition: Point;
}): readonly [Point, Point] {
return [menuCenter, lastPosition];
}

export function projectLayout<M extends AnyModelNode>(
state: NavigationState<M>,
): LayoutView<M> {
Expand Down Expand Up @@ -52,7 +69,7 @@ export function projectLayout<M extends AnyModelNode>(
activeKey:
(state.active as { readonly key: string } | null)?.key ?? null,
},
upperStroke: state.upperStroke,
upperStroke: noviceUpperStroke(state),
lowerStroke: state.lowerStroke,
};
}
Expand Down
117 changes: 113 additions & 4 deletions src/engine/machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { fakeTimers } from '../__fixtures__/canvas.js';
import { createModel } from '../model.js';
import { recognizeMarkingMenuStroke } from '../recognizer/recognize-mm-stroke.js';
import type * as RecognizeModule from '../recognizer/recognize-mm-stroke.js';
import { navigationMachine } from './machine.js';
import {
navigationMachine,
type NavigationFeedbackAnnouncement,
type NavigationLayoutAnnouncement,
} from './machine.js';

// Wraps the real recognizer rather than replacing it: every existing test
// keeps exercising genuine recognition geometry, and only the tests that
Expand Down Expand Up @@ -91,6 +95,32 @@ const recordEmitted = (host: ReturnType<typeof startHost>): string[] => {
return emitted;
};

/**
Record every layout announcement a host makes, in order.
*/
const recordLayouts = (
host: ReturnType<typeof startHost>,
): NavigationLayoutAnnouncement[] => {
const layouts: NavigationLayoutAnnouncement[] = [];
host.on('layout', ({ data }) => {
layouts.push(data);
});
return layouts;
};

/**
Record every feedback announcement a host makes, in order.
*/
const recordFeedback = (
host: ReturnType<typeof startHost>,
): NavigationFeedbackAnnouncement[] => {
const feedback: NavigationFeedbackAnnouncement[] = [];
host.on('feedback', ({ data }) => {
feedback.push(data);
});
return feedback;
};

describe('navigationMachine', () => {
it('recognizes directly from startup on pointer up, without an intermediate move', () => {
const host = startHost();
Expand Down Expand Up @@ -325,7 +355,7 @@ describe('navigationMachine', () => {
host.send('dwell');

const data = host.current.name === 'novice' ? host.current.data : null;
expect(data?.upperStroke).toEqual([[100, 0]]);
expect(data?.lastPosition).toEqual([100, 0]);
expect(data?.lowerStroke).toEqual([
[0, 0],
[100, 0],
Expand Down Expand Up @@ -612,15 +642,15 @@ describe('navigationMachine', () => {
expect(event.position).toEqual([100, 0]);
});

it('accumulates the prior stroke into the lower stroke and restarts the upper stroke from the new centre', () => {
it('accumulates the prior stroke into the lower stroke and restarts from the new center', () => {
const host = navigationMachine.start({ model: submenuModel, options });

openNovice(host);
host.send('move', { position: [100, 0] });
host.send('dwell');

const data = host.current.name === 'novice' ? host.current.data : null;
expect(data?.upperStroke).toEqual([[100, 0]]);
expect(data?.lastPosition).toEqual([100, 0]);
expect(data?.lowerStroke).toEqual([
[0, 0],
[0, 0],
Expand Down Expand Up @@ -737,4 +767,83 @@ describe('navigationMachine', () => {
expect(vi.getTimerCount()).toBe(1);
});
});

describe('the strokes it announces to the layout', () => {
it('reduces the novice upper stroke to the menu center and the last position, whatever path the pointer took between them', () => {
const host = startHost();
const layouts = recordLayouts(host);

openNovice(host);
host.send('move', { position: [30, 20] });
host.send('move', { position: [100, 0] });

expect(layouts.at(-1)?.upperStroke).toEqual([
[0, 0],
[100, 0],
]);
});

it('keeps accumulating the startup and expert stroke point by point', () => {
const host = startHost();
const layouts = recordLayouts(host);

host.send('down', { position: [0, 0] });
host.send('move', { position: [1, 0] }); // Below the threshold: still startup.
host.send('move', { position: [100, 0] }); // Crosses it: expert.
host.send('move', { position: [100, 40] });

expect(layouts.at(-1)?.upperStroke).toEqual([
[0, 0],
[1, 0],
[100, 0],
[100, 40],
]);
});

it("folds the parent menu's straight segment, not the pointer's path, into the lower stroke when a submenu opens", () => {
const host = navigationMachine.start({ model: submenuModel, options });
const layouts = recordLayouts(host);

openNovice(host);
host.send('move', { position: [30, 20] });
host.send('move', { position: [100, 0] });
host.send('dwell');

expect(layouts.at(-1)?.lowerStroke).toEqual([
[0, 0],
[0, 0],
[100, 0],
]);
// The fresh submenu has had no move yet, so its segment is still a
// single point drawn twice.
expect(layouts.at(-1)?.upperStroke).toEqual([
[100, 0],
[100, 0],
]);
});

it('announces a completed novice gesture as one straight segment per menu level', () => {
const host = navigationMachine.start({ model: submenuModel, options });
const feedback = recordFeedback(host);

openNovice(host);
host.send('move', { position: [30, 20] });
host.send('move', { position: [100, 0] });
host.send('dwell'); // Opens the "right" submenu, centered on [100, 0].
host.send('move', { position: [70, -60] });
host.send('move', { position: [100, -100] });
host.send('up', { position: [100, -100] });

// Each menu center repeats where one level's segment ends and the
// next begins, and the release position repeats the last move.
expect(feedback.at(-1)?.stroke).toEqual([
[0, 0],
[0, 0],
[100, 0],
[100, 0],
[100, -100],
[100, -100],
]);
});
});
});
55 changes: 26 additions & 29 deletions src/engine/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
ModelMenus,
} from '../types.js';
import { dist, toPolar, type Point } from '../utils.js';
import { projectLayout } from './layout-view.js';
import { noviceUpperStroke, projectLayout } from './layout-view.js';

/*
The navigation machine, declared as a totorobot definition rather than a
Expand Down Expand Up @@ -89,7 +89,11 @@ type NavigationPhaseFields<Menu, Active> = {
readonly menu: Menu;
readonly menuCenter: Point;
readonly active: Active | null;
readonly upperStroke: readonly Point[];
// Where the pointer is now. The upper stroke novice mode draws is the
// straight segment from `menuCenter` to here, so the machine keeps the
// endpoint rather than the segment: `noviceUpperStroke` builds it for
// the three places that need it.
readonly lastPosition: Point;
readonly lowerStroke: readonly Point[];
// The last position significant movement was measured from: distinct
// from `menuCenter`, which stays fixed for the life of this menu. Only a
Expand Down Expand Up @@ -299,9 +303,9 @@ function terminationContext(
readonly active: AnyModelNode | null;
} {
if ('lowerStroke' in fromData) {
const { lowerStroke, upperStroke, menu, active } = fromData;
const { lowerStroke, menu, active } = fromData;
return {
stroke: [...lowerStroke, ...upperStroke, position],
stroke: [...lowerStroke, ...noviceUpperStroke(fromData), position],
menu,
active,
};
Expand Down Expand Up @@ -390,7 +394,7 @@ export const navigationMachine = machine({
menu: model,
menuCenter: origin,
active: null,
upperStroke: [origin],
lastPosition: origin,
lowerStroke: stroke,
dwellAnchor: origin,
}),
Expand Down Expand Up @@ -430,7 +434,7 @@ export const navigationMachine = machine({
menu,
menuCenter: position,
active: null,
upperStroke: [position],
lastPosition: position,
lowerStroke: stroke,
dwellAnchor: [...position],
};
Expand All @@ -451,7 +455,7 @@ export const navigationMachine = machine({
return {
...fromData,
active,
upperStroke: [...fromData.upperStroke, position],
lastPosition: position,
// A fresh reference only when movement is significant: the
// submenu-dwell residency's `restart` predicate below compares this
// by reference, so an unchanged anchor must stay the same object.
Expand All @@ -466,19 +470,10 @@ export const navigationMachine = machine({
// that submenu: a genuine phase change, even though the destination is
// named `novice` too. Anything else declines, and since no other row is
// declared for (novice, dwell), the dwell is silently dropped.
'novice -dwell> novice'({
fromData: {
active,
menuCenter,
upperStroke,
lowerStroke,
options,
model,
},
skip,
}) {
const position = upperStroke.at(-1) as Point;
const { radius } = toPolar(position, menuCenter);
'novice -dwell> novice'({ fromData, skip }) {
const { active, menuCenter, lastPosition, lowerStroke, options, model } =
fromData;
const { radius } = toPolar(lastPosition, menuCenter);
if (
active === null ||
active.isLeaf ||
Expand All @@ -491,16 +486,18 @@ export const navigationMachine = machine({
model,
options,
menu: active,
menuCenter: position,
menuCenter: lastPosition,
active: null,
upperStroke: [position],
lowerStroke: [...lowerStroke, ...upperStroke],
// A fresh reference, deliberately never `position` itself: opening a
// submenu must always restart the residency for the new menu, and
// `position` is `upperStroke.at(-1)`. With no wobble between the
// move that armed this dwell and the dwell itself, that is the very
// same reference `fromData.dwellAnchor` already holds.
dwellAnchor: [...position],
lastPosition,
// The parent menu's own segment becomes part of the trail left
// behind the new one.
lowerStroke: [...lowerStroke, ...noviceUpperStroke(fromData)],
// A fresh reference, deliberately never `lastPosition` itself:
// opening a submenu must always restart the residency for the new
// menu, and with no wobble between the move that armed this dwell
// and the dwell itself, `lastPosition` is the very same reference
// `fromData.dwellAnchor` already holds.
dwellAnchor: [...lastPosition],
};
},

Expand Down
Loading