Skip to content

Commit 96a1edc

Browse files
authored
Merge pull request #128 from switchifyapp/codex/surface-layout-editor-127
feat: add reusable surface layout editor
2 parents 558a5ae + 0854203 commit 96a1edc

14 files changed

Lines changed: 1375 additions & 13 deletions

docs/accessibility.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and Android Switch Access.
44

5+
## Custom button layouts
6+
7+
Mouse, Typing, and Window offer Edit layout when movement repeat, dragging, held modifiers, and live Enter delivery are inactive. The editor supports long-press dragging and cell actions for moving, swapping, adding, and removing buttons without sending PC commands. Select a cell to insert or remove a row or column at that position. Occupied row/column removal asks for confirmation.
8+
9+
Layouts retain explicit positions across rotation and text scaling. Labels wrap, rows grow vertically, and targets remain at least 48 points. Empty cells and row wrappers add no scan stops during normal use; buttons scan in row-major order. Unavailable capabilities keep their disabled positions. The typing field, mode selector, status and recovery actions, and Stop movement remain outside the editable grid.
10+
11+
The editor is the only accessibility context while open. Cell actions restore focus to the edited cell, moves announce their destination once, and dismissing the editor returns focus to Edit layout. All operations must be possible with TalkBack, VoiceOver, Switch Access, and Switch Control without dragging. Save persists locally; Cancel discards only after confirmation when changes exist. Reset to default takes effect on Save.
12+
513
- Every interactive target is at least 48 by 48 logical points and has a concise accessible name.
614
- First-run setup explains the Remote before asking for Bluetooth. Its two steps expose headings and "Step 1 of 2"/"Step 2 of 2" announcements in logical reading order, remain scrollable at large text sizes, and never move focus to a permission prompt until Allow Bluetooth is selected.
715
- Surface selection is one button that announces its current value and opens a modal option list. Options expose selected state, scanning stays inside the modal, and focus returns to the selector after selection or dismissal. Toggles expose selected or disabled state.

docs/physical-smoke-test.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
Record the app commit, Switchify PC release, phone model/OS, and desktop platform for each run. Never paste pairing credentials or typed personal content into the record.
44

5+
## Button layout editor checks
6+
7+
On Android and iOS, customize Mouse, Typing, and Window at 100%, 150%, and 200% text in both themes and orientations. Move into empty cells, swap occupied cells, drag near scroll edges, and rotate during a drag. Confirm cancelled drags do not change the grid. Insert and remove rows and columns, cancel an occupied deletion, restore a removed button, Save, restart, and verify positions. Confirm Reset returns to the original arrangement only after Save and Cancel preserves the saved layout.
8+
9+
Repeat editing without gestures using TalkBack, VoiceOver, Switch Access, and Switch Control. Confirm modal containment, cell labels, row-major scanning, destination announcements, focus return, complete labels, and 48-point targets. Check the last row clears system navigation. Confirm editing sends no PC input, retains live typing text, and is unavailable during active repeat, drag, modifiers, or Enter delivery. Verify Stop movement and typing recovery actions remain available after customization. Reconnect to a PC with fewer capabilities and verify unavailable buttons stay disabled in their saved positions.
10+
511
Run the matrix on a physical Android phone and iPhone against current Switchify PC on both Windows and macOS:
612

713
1. Install a native development build; confirm Expo Go is not offered as a supported path.

docs/protocol-compatibility.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
Switchify Remote is a protocol v1 client. It does not change the Bluetooth service, characteristic UUIDs, framed transport, desktop pairing records, or command schema.
44

5+
Custom button layouts are local presentation data in the separate `switchify.remote.layouts.v1` storage key. The versioned record contains only surface names, control IDs, columns, and cells. Existing preference and pairing keys remain readable without migration. Missing, malformed, oversized, unknown-version, or unrecognized-control layouts use the default presentation. Capability changes disable saved controls in place; layouts never store command payloads, text, or authentication material. Older app builds ignore the new key.
6+
57
The TypeScript compatibility suite carries the canonical authentication and pairing-code vectors from Switchify Android. Android requests the established 517-byte MTU, while both platforms adapt the inner frame payload so the encoded GATT value fits the negotiated ATT limit. Transport tests enforce the 160-byte maximum inner payload, 16 KiB message limit, 10-second partial timeout, duplicate and out-of-order handling, UTF-8 reassembly, response correlation, and sanitized failure behavior.
68

79
The active preview surface supports:

src/layouts/LayoutEditor.test.tsx

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { fireEvent, render, act } from "@testing-library/react-native";
2+
import { Alert } from "react-native";
3+
import { LayoutEditor } from "./LayoutEditor";
4+
import { initialLayout } from "./model";
5+
const mockDimensions = { width: 320, height: 640, scale: 1, fontScale: 1 };
6+
const mockScrollTo = jest.fn();
7+
8+
jest.mock('react-native', () => {
9+
const actual = jest.requireActual('react-native');
10+
const React = jest.requireActual('react');
11+
const mocked = Object.create(actual);
12+
Object.defineProperty(mocked, 'useWindowDimensions', { value: () => mockDimensions });
13+
Object.defineProperty(mocked, 'ScrollView', { value: React.forwardRef(function MockScrollView(props: Record<string, unknown>, ref: unknown) {
14+
React.useImperativeHandle(ref, () => ({ scrollTo: mockScrollTo }), []);
15+
return React.createElement(actual.ScrollView, props);
16+
}) });
17+
Object.defineProperty(mocked, 'View', { value: React.forwardRef(function MockView(props: Record<string, unknown>, ref: unknown) {
18+
React.useImperativeHandle(ref, () => ({ measureInWindow: (done: (...values: number[]) => void) => done(0, 0, 300, 500) }), []);
19+
return React.createElement(actual.View, props);
20+
}) });
21+
return mocked;
22+
});
23+
24+
jest.mock("react-native-gesture-handler", () => {
25+
const React = jest.requireActual('react');
26+
const { View } = jest.requireActual("react-native");
27+
const gesture = () => {
28+
const g: Record<string, unknown> = {};
29+
for (const name of [
30+
"enabled",
31+
"activateAfterLongPress",
32+
"runOnJS",
33+
"onStart",
34+
"onUpdate",
35+
"onEnd",
36+
"onFinalize",
37+
])
38+
g[name] = (value: unknown) => { g[`_${name}`] = value; return g; };
39+
return g;
40+
};
41+
return {
42+
GestureHandlerRootView: View,
43+
GestureDetector: ({ gesture, children }: { gesture: unknown; children: unknown }) => React.createElement(View, { testID: 'layout-drag-cell', gesture }, children),
44+
Gesture: { Pan: gesture },
45+
};
46+
});
47+
48+
jest.mock('@/components/ControlButton', () => {
49+
const React = jest.requireActual('react');
50+
const actual = jest.requireActual('@/components/ControlButton');
51+
return { ControlButton: (props: Record<string, unknown>) => {
52+
React.useImperativeHandle(props.controlRef, () => ({ measureInWindow: (done: (...values: number[]) => void) => {
53+
const match = String(props.accessibilityLabel).match(/Row (\d+), column (\d+)/);
54+
done(match ? (Number(match[2]) - 1) * 100 : 0, match ? Number(match[1]) * 100 : 0, 90, 60);
55+
} }), [props.accessibilityLabel]);
56+
return React.createElement(actual.ControlButton, { ...props, controlRef: undefined });
57+
} };
58+
});
59+
60+
const command = jest.fn();
61+
const controls = [
62+
{ id: "a", label: "Click", onPress: command },
63+
{ id: "b", label: "Enter", onPress: command },
64+
];
65+
const setup = async (onSave = jest.fn().mockResolvedValue(undefined)) => {
66+
const onClose = jest.fn();
67+
const view = await render(
68+
<LayoutEditor
69+
visible
70+
controls={controls}
71+
initial={initialLayout(["a", "b"])}
72+
onSave={onSave}
73+
onClose={onClose}
74+
onDismiss={jest.fn()}
75+
/>,
76+
);
77+
return { ...view, onSave, onClose };
78+
};
79+
beforeEach(() => { jest.clearAllMocks(); mockDimensions.width = 320; mockDimensions.height = 640; });
80+
it('keeps measured drop targets through drag-start rendering and swaps on drop', async () => {
81+
const view = await setup();
82+
const gesture = () => view.getAllByTestId('layout-drag-cell')[0]!.props.gesture;
83+
await act(async () => { gesture()._onStart({ absoluteX: 45, absoluteY: 130 }); });
84+
await act(async () => { gesture()._onEnd({ absoluteX: 145, absoluteY: 130 }); });
85+
await fireEvent.press(view.getByText('Save layout'));
86+
expect(view.onSave).toHaveBeenCalledWith({ columns: 3, cells: ['b', 'a', null] });
87+
expect(command).not.toHaveBeenCalled();
88+
});
89+
it.each(['outside', 'rotation', 'cancel'] as const)('does not save a move after %s interrupts dragging', async (reason) => {
90+
const view = await setup();
91+
const gesture = () => view.getAllByTestId('layout-drag-cell')[0]!.props.gesture;
92+
await act(async () => { gesture()._onStart({ absoluteX: 45, absoluteY: 130 }); });
93+
if (reason === 'rotation') {
94+
mockDimensions.width = 640; mockDimensions.height = 320;
95+
await view.rerender(<LayoutEditor visible controls={controls} initial={initialLayout(['a', 'b'])} onSave={view.onSave} onClose={view.onClose} onDismiss={jest.fn()} />);
96+
}
97+
if (reason === 'cancel') await act(async () => { gesture()._onFinalize(); });
98+
await act(async () => { gesture()._onEnd({ absoluteX: reason === 'outside' ? 900 : 145, absoluteY: 130 }); });
99+
await fireEvent.press(view.getByText('Save layout'));
100+
expect(view.onSave).toHaveBeenCalledWith(initialLayout(['a', 'b']));
101+
});
102+
it('auto-scrolls near an edge and cancels its animation frame on drag cancellation', async () => {
103+
const frames: FrameRequestCallback[] = [];
104+
const frame = jest.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => { frames.push(callback); return frames.length; });
105+
const cancel = jest.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => undefined);
106+
const view = await setup();
107+
const gesture = () => view.getAllByTestId('layout-drag-cell')[0]!.props.gesture;
108+
await act(async () => { gesture()._onStart({ absoluteX: 45, absoluteY: 130 }); });
109+
await fireEvent(view.getByTestId('layout-editor-scroll'), 'contentSizeChange', 300, 2000);
110+
await act(async () => { gesture()._onUpdate({ absoluteX: 45, absoluteY: 490 }); frames.shift()!(16); });
111+
expect(mockScrollTo).toHaveBeenCalledWith({ y: 6, animated: false });
112+
await act(async () => { gesture()._onFinalize(); });
113+
expect(cancel).toHaveBeenCalled();
114+
await view.unmount(); frame.mockRestore(); cancel.mockRestore();
115+
});
116+
it("moves and swaps using accessible cells without dispatching a command", async () => {
117+
const view = await setup();
118+
await fireEvent.press(view.getByLabelText("Row 1, column 1: Click"));
119+
await fireEvent.press(view.getByText("Move button"));
120+
await fireEvent.press(view.getByLabelText("Row 1, column 2: Enter"));
121+
await fireEvent.press(view.getByText("Save layout"));
122+
expect(view.onSave).toHaveBeenCalledWith({
123+
columns: 3,
124+
cells: ["b", "a", null],
125+
});
126+
expect(command).not.toHaveBeenCalled();
127+
});
128+
it("removes a control and restores it from an empty cell", async () => {
129+
const view = await setup();
130+
await fireEvent.press(view.getByLabelText("Row 1, column 1: Click"));
131+
await fireEvent.press(view.getByText("Remove button"));
132+
await fireEvent.press(view.getByLabelText("Row 1, column 3: Empty"));
133+
await fireEvent.press(view.getByText("Add Click"));
134+
await fireEvent.press(view.getByText("Save layout"));
135+
expect(view.onSave).toHaveBeenCalledWith({
136+
columns: 3,
137+
cells: [null, "b", "a"],
138+
});
139+
});
140+
it("keeps the draft after a sanitized save failure and retries", async () => {
141+
const save = jest
142+
.fn()
143+
.mockRejectedValueOnce(new Error("private"))
144+
.mockResolvedValueOnce(undefined);
145+
const view = await setup(save);
146+
await fireEvent.press(view.getByText("Add row at end"));
147+
await fireEvent.press(view.getByText("Save layout"));
148+
expect(view.getByText("Layout could not be saved. Try again.")).toBeTruthy();
149+
expect(view.queryByText("private")).toBeNull();
150+
expect(view.onClose).not.toHaveBeenCalled();
151+
await fireEvent.press(view.getByText("Save layout"));
152+
expect(save.mock.calls[0]).toEqual(save.mock.calls[1]);
153+
expect(view.onClose).toHaveBeenCalledTimes(1);
154+
});
155+
it("confirms discarding edits and removing occupied rows", async () => {
156+
const alert = jest.spyOn(Alert, "alert").mockImplementation(() => undefined);
157+
const view = await setup();
158+
await fireEvent.press(view.getByText("Add row at end"));
159+
await fireEvent.press(view.getByLabelText("Row 1, column 1: Click"));
160+
await fireEvent.press(view.getByText("Remove row"));
161+
expect(alert).toHaveBeenLastCalledWith(
162+
"Remove row?",
163+
expect.any(String),
164+
expect.any(Array),
165+
);
166+
await fireEvent.press(view.getByText("Cancel"));
167+
expect(alert).toHaveBeenLastCalledWith(
168+
"Discard layout changes?",
169+
expect.any(String),
170+
expect.any(Array),
171+
);
172+
expect(view.onSave).not.toHaveBeenCalled();
173+
alert.mockRestore();
174+
});
175+
it("blocks duplicate saves and handles unmount during saving", async () => {
176+
let resolve!: () => void;
177+
const view = await setup(
178+
jest.fn(
179+
() =>
180+
new Promise<void>((done) => {
181+
resolve = done;
182+
}),
183+
),
184+
);
185+
await fireEvent.press(view.getByText("Save layout"));
186+
await fireEvent.press(view.getByText("Saving layout"));
187+
expect(view.onSave).toHaveBeenCalledTimes(1);
188+
await view.unmount();
189+
await act(async () => resolve());
190+
expect(view.onClose).not.toHaveBeenCalled();
191+
});

0 commit comments

Comments
 (0)