diff --git a/src/App.svelte b/src/App.svelte
index 7dac95e..631276c 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -199,6 +199,7 @@
bind:all8Directions={settings.all8Directions}
bind:waveDistortion={settings.waveDistortion}
bind:mouseInteractionMode={settings.mouseInteractionMode}
+ showMobileControls={false}
/>
{/if}
diff --git a/src/lib/components/MatrixCanvas.svelte b/src/lib/components/MatrixCanvas.svelte
index 0a9af49..51d7975 100644
--- a/src/lib/components/MatrixCanvas.svelte
+++ b/src/lib/components/MatrixCanvas.svelte
@@ -15,6 +15,7 @@
all8Directions = $bindable(false),
waveDistortion = $bindable(false),
mouseInteractionMode = $bindable('off' as MouseInteractionMode),
+ showMobileControls = true,
} = $props<{
engine: CoreEngine;
mode: 'normal' | 'square';
@@ -25,10 +26,16 @@
all8Directions: boolean;
waveDistortion: boolean;
mouseInteractionMode: MouseInteractionMode;
+ showMobileControls?: boolean;
}>();
/* eslint-enable prefer-const, no-useless-assignment */
+ const coarsePointerQuery = '(hover: none) and (pointer: coarse)';
+
let canvas: HTMLCanvasElement;
+ let isCoarsePointer = $state(false);
+
+ const shouldShowMobileControls = $derived(showMobileControls && isCoarsePointer);
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
@@ -54,7 +61,7 @@
switch (event.key) {
case 'Escape':
- onReturn();
+ returnToMenu();
break;
case 'ArrowLeft':
handleDirectionInput('west');
@@ -69,25 +76,19 @@
handleDirectionInput('south');
break;
case ' ':
- if (engine.ctx != null) {
- engine.pause();
- }
+ togglePause();
break;
case 'c':
- engine.clearScreen();
+ clearCanvas();
break;
case 'd':
- discoOn = !discoOn;
+ toggleDisco();
break;
case 'PageUp':
- if (engine.ctx != null || !engine.squareAnimationOn) {
- engine.speedController(true);
- }
+ increaseSpeed();
break;
case 'PageDown':
- if (engine.ctx != null || !engine.squareAnimationOn) {
- engine.speedController(false);
- }
+ decreaseSpeed();
break;
case '1':
chosenColor = 'green';
@@ -115,7 +116,7 @@
chosenColor = 'random';
break;
case 'w':
- engine.controlFontSize(true);
+ increaseFontSize();
break;
case 'x':
waveDistortion = !waveDistortion;
@@ -124,7 +125,7 @@
mouseInteractionMode = getNextMouseInteractionMode(mouseInteractionMode);
break;
case 's':
- engine.controlFontSize(false);
+ decreaseFontSize();
break;
case 'q':
engine.controlStringSize(true);
@@ -136,9 +137,7 @@
engine.rapidWordChangeControl();
break;
case 'm':
- if (engine.ctx) {
- engine.switchMode();
- }
+ switchMode();
break;
case 't':
all4Directions = !all4Directions;
@@ -169,6 +168,71 @@
}
}
+ function returnToMenu(): void {
+ onReturn();
+ }
+
+ function togglePause(): void {
+ if (engine.ctx != null) {
+ engine.pause();
+ }
+ }
+
+ function clearCanvas(): void {
+ engine.clearScreen();
+ }
+
+ function toggleDisco(): void {
+ discoOn = !discoOn;
+ }
+
+ function increaseSpeed(): void {
+ if (engine.ctx != null || !engine.squareAnimationOn) {
+ engine.speedController(true);
+ }
+ }
+
+ function decreaseSpeed(): void {
+ if (engine.ctx != null || !engine.squareAnimationOn) {
+ engine.speedController(false);
+ }
+ }
+
+ function increaseFontSize(): void {
+ engine.controlFontSize(true);
+ }
+
+ function decreaseFontSize(): void {
+ engine.controlFontSize(false);
+ }
+
+ function switchMode(): void {
+ if (engine.ctx) {
+ engine.switchMode();
+ }
+ }
+
+ function handleCanvasClick(): void {
+ if (!isCoarsePointer) {
+ returnToMenu();
+ }
+ }
+
+ function handleCanvasKeyDown(event: KeyboardEvent): void {
+ if (isCoarsePointer) {
+ return;
+ }
+
+ if (event.key === 'Enter' || event.key === ' ') {
+ returnToMenu();
+ }
+ }
+
+ function runMobileAction(event: MouseEvent, action: () => void): void {
+ event.stopPropagation();
+ action();
+ }
+
function handleDirectionInput(direction: Direction): void {
if (engine.squareAnimationOn) {
moveSquare(direction);
@@ -299,6 +363,30 @@
return undefined;
});
+ $effect(() => {
+ if (typeof window === 'undefined') {
+ return undefined;
+ }
+
+ const mediaQuery = window.matchMedia?.(coarsePointerQuery);
+
+ if (!mediaQuery) {
+ isCoarsePointer = false;
+ return undefined;
+ }
+
+ const updateCoarsePointer = (event?: MediaQueryListEvent) => {
+ isCoarsePointer = event?.matches ?? mediaQuery.matches;
+ };
+
+ updateCoarsePointer();
+ mediaQuery.addEventListener('change', updateCoarsePointer);
+
+ return () => {
+ mediaQuery.removeEventListener('change', updateCoarsePointer);
+ };
+ });
+
// Secondary effect to handle state changes that might happen from App.svelte
$effect(() => {
// These values are synced in App.svelte already, but we might want to trigger resetWordsArray
@@ -311,22 +399,128 @@
+{#if shouldShowMobileControls}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{/if}
+
diff --git a/src/lib/components/__tests__/HelpModal.test.ts b/src/lib/components/__tests__/HelpModal.test.ts
index 6b58fb5..19bd0e6 100644
--- a/src/lib/components/__tests__/HelpModal.test.ts
+++ b/src/lib/components/__tests__/HelpModal.test.ts
@@ -55,6 +55,15 @@ describe('HelpModal', () => {
expect(screen.getByText(/All 4 Directions/i)).toBeInTheDocument();
});
+ it('lists mobile controls separately from keyboard controls', () => {
+ render(HelpModal, { props: { isOpen: true, onClose: vi.fn() } });
+
+ expect(screen.getByText('MOBILE CONTROLS')).toBeInTheDocument();
+ expect(screen.getByText('Swipe')).toBeInTheDocument();
+ expect(screen.getByText('Return to settings')).toBeInTheDocument();
+ expect(screen.getByText('Direction controls')).toBeInTheDocument();
+ });
+
it('traps focus when open', async () => {
const onClose = vi.fn();
render(HelpModal, { props: { isOpen: true, onClose } });
diff --git a/src/lib/components/__tests__/MatrixCanvas.test.ts b/src/lib/components/__tests__/MatrixCanvas.test.ts
index ff9e79d..0e1a794 100644
--- a/src/lib/components/__tests__/MatrixCanvas.test.ts
+++ b/src/lib/components/__tests__/MatrixCanvas.test.ts
@@ -26,6 +26,8 @@ describe('MatrixCanvas', () => {
configurable: true,
value: 600,
});
+
+ mockCoarsePointer(false);
});
afterEach(() => {
@@ -61,7 +63,38 @@ describe('MatrixCanvas', () => {
} as unknown as CoreEngine;
}
- function renderCanvas(engine = createEngine(), mode: 'normal' | 'square' = 'normal') {
+ function mockCoarsePointer(matches: boolean) {
+ Object.defineProperty(window, 'matchMedia', {
+ configurable: true,
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: query === '(hover: none) and (pointer: coarse)' ? matches : false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+ }
+
+ function getCanvas(container: HTMLElement): HTMLElement {
+ const canvas = container.querySelector('canvas');
+
+ if (!(canvas instanceof HTMLElement)) {
+ throw new Error('Expected MatrixCanvas to render a canvas element');
+ }
+
+ return canvas;
+ }
+
+ function renderCanvas(
+ engine = createEngine(),
+ mode: 'normal' | 'square' = 'normal',
+ showMobileControls = true,
+ ) {
const onReturn = vi.fn();
const rendered = render(MatrixCanvas, {
props: {
@@ -74,6 +107,7 @@ describe('MatrixCanvas', () => {
all8Directions: false,
waveDistortion: false,
mouseInteractionMode: 'off',
+ showMobileControls,
},
});
@@ -201,8 +235,8 @@ describe('MatrixCanvas', () => {
});
it('changes direction from canvas swipes', async () => {
- const { engine } = renderCanvas();
- const canvas = screen.getByRole('button', { name: 'Return to settings' });
+ const { container, engine } = renderCanvas();
+ const canvas = getCanvas(container);
await swipeCanvas(canvas, { clientX: 100, clientY: 100 }, { clientX: 20, clientY: 100 });
expect(engine.direction).toBe('west');
@@ -224,8 +258,8 @@ describe('MatrixCanvas', () => {
it('moves the square from canvas swipes in square animation mode', async () => {
const engine = createEngine();
engine.squareAnimationOn = true;
- renderCanvas(engine);
- const canvas = screen.getByRole('button', { name: 'Return to settings' });
+ const { container } = renderCanvas(engine);
+ const canvas = getCanvas(container);
await swipeCanvas(canvas, { clientX: 100, clientY: 100 }, { clientX: 20, clientY: 100 });
await swipeCanvas(canvas, { clientX: 100, clientY: 100 }, { clientX: 180, clientY: 100 });
@@ -239,8 +273,8 @@ describe('MatrixCanvas', () => {
});
it('ignores tiny canvas swipes', async () => {
- const { engine } = renderCanvas();
- const canvas = screen.getByRole('button', { name: 'Return to settings' });
+ const { container, engine } = renderCanvas();
+ const canvas = getCanvas(container);
await swipeCanvas(canvas, { clientX: 100, clientY: 100 }, { clientX: 130, clientY: 100 });
@@ -249,8 +283,8 @@ describe('MatrixCanvas', () => {
});
it('tracks mirrored pointer coordinates and clears pointer state on leave', async () => {
- const { engine } = renderCanvas();
- const canvas = screen.getByRole('button', { name: 'Return to settings' });
+ const { container, engine } = renderCanvas();
+ const canvas = getCanvas(container);
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
x: 0,
@@ -299,4 +333,83 @@ describe('MatrixCanvas', () => {
expect(onReturn).toHaveBeenCalledTimes(2);
});
+
+ it('does not return to settings on coarse-pointer canvas taps', async () => {
+ mockCoarsePointer(true);
+ const { container, onReturn } = renderCanvas();
+ const canvas = getCanvas(container);
+
+ await screen.findByRole('button', { name: 'Return to settings' });
+
+ await fireEvent.click(canvas);
+ await fireEvent.keyDown(canvas, { key: 'Enter' });
+
+ expect(onReturn).not.toHaveBeenCalled();
+ expect(canvas).not.toHaveAttribute('role');
+ expect(canvas).not.toHaveAttribute('aria-label');
+ });
+
+ it('returns to settings from the coarse-pointer mobile menu button', async () => {
+ mockCoarsePointer(true);
+ const { onReturn } = renderCanvas();
+ const menuButton = await screen.findByRole('button', { name: 'Return to settings' });
+
+ await fireEvent.click(menuButton);
+
+ expect(onReturn).toHaveBeenCalledTimes(1);
+ });
+
+ it('hides coarse-pointer mobile controls when disabled', async () => {
+ mockCoarsePointer(true);
+ renderCanvas(createEngine(), 'normal', false);
+
+ expect(screen.queryByRole('button', { name: 'Return to settings' })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Pause or play animation' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('runs shared actions from coarse-pointer mobile controls', async () => {
+ mockCoarsePointer(true);
+ const engine = createEngine();
+ render(MatrixCanvasBindingWrapper, { props: { engine } });
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'Pause or play animation' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Clear screen' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Toggle disco mode' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Switch animation mode' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Decrease speed' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Increase speed' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Decrease font size' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Increase font size' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Move west' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Move north' }));
+
+ expect(engine.pause).toHaveBeenCalled();
+ expect(engine.clearScreen).toHaveBeenCalled();
+ expect(screen.getByTestId('disco-on')).toHaveTextContent('true');
+ expect(engine.switchMode).toHaveBeenCalled();
+ expect(engine.speedController).toHaveBeenCalledWith(false);
+ expect(engine.speedController).toHaveBeenCalledWith(true);
+ expect(engine.controlFontSize).toHaveBeenCalledWith(false);
+ expect(engine.controlFontSize).toHaveBeenCalledWith(true);
+ expect(engine.direction).toBe('north');
+ });
+
+ it('moves the square from coarse-pointer direction controls in square animation mode', async () => {
+ mockCoarsePointer(true);
+ const engine = createEngine();
+ engine.squareAnimationOn = true;
+ renderCanvas(engine);
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'Move west' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Move east' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Move north' }));
+ await fireEvent.click(screen.getByRole('button', { name: 'Move south' }));
+
+ expect(engine.moveSquareLeft).toHaveBeenCalledWith(false);
+ expect(engine.moveSquareRight).toHaveBeenCalledWith(false);
+ expect(engine.moveSquareUp).toHaveBeenCalledWith(false);
+ expect(engine.moveSquareDown).toHaveBeenCalledWith(false);
+ });
});
diff --git a/src/lib/components/__tests__/MatrixCanvasBindingWrapper.svelte b/src/lib/components/__tests__/MatrixCanvasBindingWrapper.svelte
index 0f79dc9..dcb2664 100644
--- a/src/lib/components/__tests__/MatrixCanvasBindingWrapper.svelte
+++ b/src/lib/components/__tests__/MatrixCanvasBindingWrapper.svelte
@@ -7,9 +7,15 @@
engine: CoreEngine;
mode?: 'normal' | 'square';
onReturn?: () => void;
+ showMobileControls?: boolean;
}
- const { engine, mode = 'normal', onReturn = () => {} }: Props = $props();
+ const {
+ engine,
+ mode = 'normal',
+ onReturn = () => {},
+ showMobileControls = true,
+ }: Props = $props();
let discoOn = $state(false);
let chosenColor = $state('green');
@@ -29,6 +35,8 @@
bind:all8Directions
bind:waveDistortion
bind:mouseInteractionMode
+ {showMobileControls}
/>
+
diff --git a/src/lib/components/__tests__/MockMatrixCanvas.svelte b/src/lib/components/__tests__/MockMatrixCanvas.svelte
index 2ddb8fc..b850c0c 100644
--- a/src/lib/components/__tests__/MockMatrixCanvas.svelte
+++ b/src/lib/components/__tests__/MockMatrixCanvas.svelte
@@ -6,6 +6,7 @@
onReturn: () => void;
engine: CoreEngine;
mode: 'normal' | 'square';
+ showMobileControls?: boolean;
}>();
onMount(() => {
diff --git a/src/lib/constants/HelpData.ts b/src/lib/constants/HelpData.ts
index 2bd0abb..c181f3c 100644
--- a/src/lib/constants/HelpData.ts
+++ b/src/lib/constants/HelpData.ts
@@ -31,4 +31,18 @@ export const helpGroups: HelpGroup[] = [
{ key: 'Esc', description: 'Quit to menu' },
],
},
+ {
+ title: 'MOBILE CONTROLS',
+ items: [
+ { key: 'Swipe', description: 'Change rain direction / Move box' },
+ { key: 'Menu', description: 'Return to settings' },
+ { key: 'Pause', description: 'Pause or play animation' },
+ { key: 'Clear', description: 'Clear screen' },
+ { key: 'Disco', description: 'Toggle disco' },
+ { key: 'Mode', description: 'Switch mode' },
+ { key: 'Speed', description: 'Adjust speed' },
+ { key: 'Font', description: 'Adjust font size' },
+ { key: 'D-pad', description: 'Direction controls' },
+ ],
+ },
];