From 6f132b042c2d8b8540698b5fd3caedecd3c59d03 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 12 May 2026 21:31:36 -0700 Subject: [PATCH 01/29] Add tether gesture radial menu --- docs/specs/mobile-ui.md | 116 +++++- .../components/MobileGestureRadialMenu.tsx | 207 +++++++++++ lib/src/components/MobileTerminalUi.tsx | 161 +++++++-- lib/src/lib/mobile-gesture-menu.test.ts | 89 +++++ lib/src/lib/mobile-gesture-menu.ts | 332 ++++++++++++++++++ lib/src/stories/MobileTerminalUi.stories.tsx | 106 ++++++ website/src/pages/Tether.tsx | 4 + 7 files changed, 977 insertions(+), 38 deletions(-) create mode 100644 lib/src/components/MobileGestureRadialMenu.tsx create mode 100644 lib/src/lib/mobile-gesture-menu.test.ts create mode 100644 lib/src/lib/mobile-gesture-menu.ts diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 5b92e0e..259f88b 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -41,7 +41,7 @@ Non-goals: * Session persistence. * Command history storage. * A real draft/scratchpad workflow. -* Advanced gestures. +* Multi-touch gestures. * Production security hardening. * Full accessibility implementation. @@ -87,7 +87,7 @@ Touch modes: | Mode | Button label | Icon | Availability | Behavior | | --- | --- | --- | --- | --- | -| Gestures | `Gestures` | `HandPointingIcon` | Always available | Touch drags generate arrow keys. Drag left sends left, drag right sends right, drag up sends up, and drag down sends down. | +| Gestures | `Gestures` | `HandPointingIcon` | Always available | Pane-content touches open the Gesture mode radial menu. | | Text selection | `Select` | `CursorTextIcon` | Always available | Touches are reserved for terminal text selection and copy/paste. If the TUI is capturing mouse events, MouseTerm activates mouse override for the active pane. | | Cursor | `Cursor` | `CursorClickIcon` | Only when the active TUI is capturing mouse events | Touches are passed through as terminal mouse/cursor input. | @@ -96,7 +96,88 @@ Default touch mode is **Gestures**. If Cursor mode is active and the active pane stops capturing mouse events, the selector must fall back to Gestures. -## 5. Keyboard Mode Selector +## 5. Gesture Mode + +Gesture mode is the default pane-content touch behavior. Tapping the pane content +opens a radial menu at the touch origin. The center `o` is only the origin +marker; it is not an action. + +The radial menu is a two-stage gesture: + +1. Touch down to open the menu. +2. Drag far enough toward one compass point to choose a group. +3. Drag in a different direction to choose one of that group's three options. +4. Release to send the selected terminal input. + +After the first breakout, the final option is selected this way: + +| Final movement | Selected option | +| --- | --- | +| Back to center | First option | +| Visually counter-clockwise from the breakout direction | Second option | +| Visually clockwise from the breakout direction | Third option | +| Still in the original breakout direction | Cancel | + +Examples: + +* Right arrow: tap, drag right, drag back to center, release. +* End: tap, drag right, drag up, release. +* `l`: tap, drag right, drag down, release. + +Root gesture menu: + +```text +Esc|Ctrl+C*|Quit** Up|PgUp|k Backspace|Paste*|n + +Left|Home|h o Right|End|l + +Tab|Shift+Tab|Space Down|PgDown|j Enter|Shift+Enter|y +``` + +`Ctrl+C` and `Paste` require an in-pane confirmation modal before they run. + +`Quit` enters a second breakout menu instead of sending input immediately: + +```text +q | Ctrl+X | :q↵ +``` + +The quit submenu uses the same final movement rule. Returning to center selects +`q`, visually counter-clockwise selects `Ctrl+X`, and visually clockwise selects +`:q↵`. + +Gesture action mappings: + +| Action | Sequence | +| --- | --- | +| Esc | `\x1B` | +| Ctrl+C | `\x03` | +| q | `q` | +| Ctrl+X | `\x18` | +| `:q↵` | `:q\r` | +| Up | `\x1B[A` | +| PgUp | `\x1B[5~` | +| k | `k` | +| Backspace | `\x7F` | +| Paste | Existing MouseTerm paste flow for the active pane | +| n | `n` | +| Left | `\x1B[D` | +| Home | `\x1B[H` | +| h | `h` | +| Right | `\x1B[C` | +| End | `\x1B[F` | +| l | `l` | +| Tab | `\x09` | +| Shift+Tab | `\x1B[Z` | +| Space | ` ` | +| Down | `\x1B[B` | +| PgDown | `\x1B[6~` | +| j | `j` | +| Enter | `\r` | +| Shift+Enter | `\x1B[13;2u` | +| y | `y` | + +## 6. Keyboard Mode Selector The keyboard mode selector controls what appears in the keyboard reserve area. It is always visible and has four items: @@ -129,7 +210,7 @@ the tap/click handler. Do not defer this focus to `requestAnimationFrame` or a timer, because mobile browsers may then treat it as no longer user-initiated and refuse to open the native keyboard. -## 6. Keys Mode +## 7. Keys Mode Keys mode displays exactly these buttons: @@ -153,7 +234,7 @@ Mappings: Tapping a key sends exactly one action. Long-press repeat is not required for v0. -## 7. Type Mode Input +## 8. Type Mode Input Use a hidden or visually minimal input configured for terminal-style typing: @@ -178,7 +259,7 @@ Required behavior: * Input supports mobile keyboard behavior and IME composition. * The app does not depend only on `keydown` for text input. -## 8. Terminal Playground Behavior +## 9. Terminal Playground Behavior A fake shell is acceptable for v0. @@ -209,7 +290,7 @@ tut The shell only needs enough behavior to test the mobile controls. -## 9. Keyboard Reserve +## 10. Keyboard Reserve The keyboard reserve area has a stable height. It should not be recomputed from `visualViewport` while the native keyboard animates. @@ -220,7 +301,7 @@ UI (`Recent - WIP`, Type focus target, `Draft - WIP`, or Keys buttons). When the OS keyboard is visible, the OS keyboard may cover or occupy that same physical area. This is preferred over resizing the whole app around the keyboard. -## 10. Touch Interactions +## 11. Touch Interactions Required interactions: @@ -229,7 +310,8 @@ Required interactions: * Tap Type reserve area to focus typing. * Type through the native keyboard. * Tap key buttons in Keys mode. -* Drag in Gestures mode to send arrow keys. +* Use Gesture mode to open the radial menu and send terminal inputs. +* Confirm sensitive Gesture mode actions before sending `Ctrl+C` or reading the clipboard for Paste. * Use Text selection mode for terminal selection and copy/paste. * Use Cursor mode for terminal mouse/cursor input when a TUI requests mouse reporting. @@ -250,7 +332,7 @@ Not required for v0: * A full command history UI. * A real draft editor. -## 11. Copy And Paste +## 12. Copy And Paste Keep copy and paste minimal. @@ -261,7 +343,7 @@ Prototype behavior: * No custom mobile clipboard manager is required. * No multi-line paste review is required. -## 12. Recommended v0 Scope +## 13. Recommended v0 Scope Build exactly this: @@ -283,6 +365,7 @@ Input Recent | Type | Draft | Keys * Recent reserve content: `Recent - WIP`. * Draft reserve content: `Draft - WIP`. * Type mode native mobile keyboard input. +* Gesture mode radial menu for arrows, navigation keys, Esc, Tab, Enter, simple vim-like keys, confirmed Ctrl+C, confirmed Paste, and Quit breakout. * Keys buttons: ```text @@ -292,20 +375,20 @@ Esc Tab Space Enter * Simple local playground terminal behavior. -## 13. Prototype Success Criteria +## 14. Prototype Success Criteria The prototype should answer these questions: 1. Does the terminal viewport feel stable when the mobile keyboard opens and closes? 2. Is the touch mode selector understandable and reachable? -3. Are gesture arrows usable enough for command history and cursor movement? +3. Is Gesture mode fast and understandable enough for arrows, navigation keys, and common TUI exits? 4. Is text selection discoverable and reliable on mobile? 5. Is Cursor mode useful when a TUI captures mouse events? 6. Does native keyboard Type mode feel acceptable for terminal text entry? 7. Does the stable keyboard reserve feel better than resizing the whole UI? 8. Is the UI too cramped in portrait orientation? -## 14. Future Work +## 15. Future Work Potential later additions: @@ -313,9 +396,8 @@ Potential later additions: * Draft scratchpad. * Dual-pane copy/paste. * Pinned snippets. -* Ctrl+C, Ctrl+D, and Ctrl+Z app-key buttons. +* Ctrl+D and Ctrl+Z app-key buttons. * Alt and modifier behavior. -* Home, End, PgUp, PgDn. * Long-press key repeat. * Remote backend PTY. * SSH sessions. @@ -324,7 +406,7 @@ Potential later additions: * Multi-session support. * Production security model. -## 15. Product Principle +## 16. Product Principle The v0 prototype should stay focused: diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx new file mode 100644 index 0000000..38d71d4 --- /dev/null +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -0,0 +1,207 @@ +import type { CSSProperties } from 'react'; +import { clsx } from 'clsx'; +import { + MOBILE_GESTURE_DIRECTION_VECTORS, + MOBILE_GESTURE_GROUP_ORDER, + MOBILE_GESTURE_GROUPS, + MOBILE_GESTURE_QUIT_GROUP, + type MobileGestureCandidate, + type MobileGestureDirection, + type MobileGestureTrackingState, +} from '../lib/mobile-gesture-menu'; + +const ROOT_RADIUS = 92; +const QUIT_RADIUS = 78; + +function translatedStyle(x: number, y: number): CSSProperties { + return { + left: x, + top: y, + transform: 'translate(-50%, -50%)', + }; +} + +function directionPoint( + direction: MobileGestureDirection, + center: { x: number; y: number }, + radius: number, +): { x: number; y: number } { + const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; + return { + x: center.x + vector.x * radius, + y: center.y + vector.y * radius, + }; +} + +function displayCurrentPoint(state: Exclude) { + return { + x: state.displayOrigin.x + state.currentPoint.x - state.origin.x, + y: state.displayOrigin.y + state.currentPoint.y - state.origin.y, + }; +} + +function OptionPill({ + labels, + candidate, + active, +}: { + labels: [string, string, string]; + candidate?: MobileGestureCandidate; + active: boolean; +}) { + return ( +
+ {labels.map((label, index) => ( + 0 && 'border-l border-border', + candidate?.optionIndex === index + ? 'bg-header-active-bg text-header-active-fg' + : active && 'text-foreground', + )} + > + {label} + + ))} +
+ ); +} + +export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackingState }) { + if (state.phase === 'idle') return null; + + const currentPoint = displayCurrentPoint(state); + + return ( + + ); +} + +export function MobileGestureConfirmDialog({ + confirmation, + onCancel, + onConfirm, +}: { + confirmation: 'ctrlC' | 'paste'; + onCancel: () => void; + onConfirm: () => void; +}) { + const copy = confirmation === 'ctrlC' + ? { + title: 'Send Ctrl+C?', + body: 'Interrupt the running terminal app.', + action: 'Send Ctrl+C', + } + : { + title: 'Paste?', + body: 'Read the clipboard and paste into this pane.', + action: 'Paste', + }; + + return ( +
+
event.stopPropagation()} + > +
{copy.title}
+
{copy.body}
+
+ + +
+
+
+ ); +} diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 407999b..9a0900b 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -15,6 +15,19 @@ import { HandPointingIcon, } from '@phosphor-icons/react'; import { clsx } from 'clsx'; +import { + MobileGestureConfirmDialog, + MobileGestureRadialMenu, +} from './MobileGestureRadialMenu'; +import { + beginMobileGesture, + finishMobileGesture, + MOBILE_GESTURE_IDLE_STATE, + updateMobileGesture, + type MobileGestureAction, + type MobileGesturePoint, + type MobileGestureTrackingState, +} from '../lib/mobile-gesture-menu'; export type MobileTerminalKeyboardMode = 'recent' | 'type' | 'draft' | 'keys'; export type MobileTerminalSection = MobileTerminalKeyboardMode; @@ -22,15 +35,22 @@ export type MobileTerminalTouchMode = 'gestures' | 'selection' | 'cursor'; export const MOBILE_TERMINAL_KEY_SEQUENCES = { ctrlC: '\x03', + ctrlX: '\x18', esc: '\x1b', tab: '\x09', + shiftTab: '\x1b[Z', space: ' ', enter: '\r', + shiftEnter: '\x1b[13;2u', backspace: '\x7f', up: '\x1b[A', + pageUp: '\x1b[5~', down: '\x1b[B', + pageDown: '\x1b[6~', right: '\x1b[C', + end: '\x1b[F', left: '\x1b[D', + home: '\x1b[H', } as const; interface TerminalKey { @@ -82,6 +102,7 @@ export interface MobileTerminalUiProps { onTouchModeChange?: (mode: MobileTerminalTouchMode) => void; cursorTouchAvailable?: boolean; onSendInput?: (data: string) => void; + onPaste?: () => void | Promise; onFocusInput?: () => void; interactive?: boolean; fillViewport?: boolean; @@ -97,13 +118,23 @@ function keyDownSequence(event: KeyboardEvent): string | nu switch (event.key) { case 'Enter': + if (event.shiftKey) return MOBILE_TERMINAL_KEY_SEQUENCES.shiftEnter; return MOBILE_TERMINAL_KEY_SEQUENCES.enter; case 'Backspace': return MOBILE_TERMINAL_KEY_SEQUENCES.backspace; case 'Escape': return MOBILE_TERMINAL_KEY_SEQUENCES.esc; case 'Tab': + if (event.shiftKey) return MOBILE_TERMINAL_KEY_SEQUENCES.shiftTab; return MOBILE_TERMINAL_KEY_SEQUENCES.tab; + case 'PageUp': + return MOBILE_TERMINAL_KEY_SEQUENCES.pageUp; + case 'PageDown': + return MOBILE_TERMINAL_KEY_SEQUENCES.pageDown; + case 'Home': + return MOBILE_TERMINAL_KEY_SEQUENCES.home; + case 'End': + return MOBILE_TERMINAL_KEY_SEQUENCES.end; case 'ArrowUp': return MOBILE_TERMINAL_KEY_SEQUENCES.up; case 'ArrowDown': @@ -276,6 +307,32 @@ function WorkInProgressPane({ label }: { label: 'Recent' | 'Draft' }) { ); } +type MobileGestureConfirmationAction = Extract; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function localPointerPoint(event: PointerEvent): MobileGesturePoint { + const rect = event.currentTarget.getBoundingClientRect(); + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; +} + +function gestureDisplayOrigin(origin: MobileGesturePoint, rect: DOMRect): MobileGesturePoint { + const margin = 104; + return { + x: rect.width > margin * 2 ? clamp(origin.x, margin, rect.width - margin) : rect.width / 2, + y: rect.height > margin * 2 ? clamp(origin.y, margin, rect.height - margin) : rect.height / 2, + }; +} + +function isGestureDialogTarget(target: EventTarget | null): boolean { + return target instanceof Element && target.closest('[data-mobile-gesture-dialog]') !== null; +} + export function MobileTerminalUi({ terminal, activeSection, @@ -289,6 +346,7 @@ export function MobileTerminalUi({ onTouchModeChange, cursorTouchAvailable = false, onSendInput, + onPaste, onFocusInput, interactive = true, fillViewport = false, @@ -304,7 +362,9 @@ export function MobileTerminalUi({ const terminalHostRef = useRef(null); const inputRef = useRef(null); const composingRef = useRef(false); - const gestureStartRef = useRef<{ pointerId: number; x: number; y: number } | null>(null); + const gestureStateRef = useRef(MOBILE_GESTURE_IDLE_STATE); + const [gestureState, setGestureState] = useState(MOBILE_GESTURE_IDLE_STATE); + const [pendingGestureConfirmation, setPendingGestureConfirmation] = useState(null); const [inputValue, setInputValue] = useState(''); const sendInput = useCallback((data: string) => { @@ -312,6 +372,11 @@ export function MobileTerminalUi({ onSendInput?.(data); }, [interactive, onSendInput]); + const commitGestureState = useCallback((nextState: MobileGestureTrackingState) => { + gestureStateRef.current = nextState; + setGestureState(nextState); + }, []); + const focusInput = useCallback(() => { if (!interactive) return; onFocusInput?.(); @@ -380,6 +445,32 @@ export function MobileTerminalUi({ setInputValue(''); }, [sendInput]); + const executeGestureAction = useCallback((action: MobileGestureAction | undefined) => { + if (!action) return; + if (action.kind === 'input') { + sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[action.input]); + return; + } + if (action.kind === 'text') { + sendInput(action.text); + return; + } + if (action.kind === 'paste') { + void onPaste?.(); + return; + } + if (action.kind === 'confirm') { + setPendingGestureConfirmation(action); + } + }, [onPaste, sendInput]); + + const confirmPendingGestureAction = useCallback(() => { + if (!pendingGestureConfirmation) return; + const confirmedAction = pendingGestureConfirmation.action; + setPendingGestureConfirmation(null); + executeGestureAction(confirmedAction); + }, [executeGestureAction, pendingGestureConfirmation]); + useEffect(() => { if (keyboardMode !== 'type' || !interactive) return; const frame = window.requestAnimationFrame(focusInput); @@ -410,42 +501,61 @@ export function MobileTerminalUi({ return () => observer.disconnect(); }, [configurePaneTextInputs, terminal]); + useEffect(() => { + if (touchMode === 'gestures' && interactive) return; + commitGestureState(MOBILE_GESTURE_IDLE_STATE); + setPendingGestureConfirmation(null); + }, [commitGestureState, interactive, touchMode]); + const handlePanePointerDownCapture = useCallback((event: PointerEvent) => { + if (isGestureDialogTarget(event.target)) return; blurPaneTextInputs(); if (!interactive || touchMode !== 'gestures') return; - if (event.pointerType === 'mouse') return; + if (event.pointerType === 'mouse' && event.button !== 0) return; event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); - gestureStartRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; - }, [interactive, touchMode]); + setPendingGestureConfirmation(null); + + const origin = localPointerPoint(event); + commitGestureState(beginMobileGesture( + event.pointerId, + origin, + gestureDisplayOrigin(origin, event.currentTarget.getBoundingClientRect()), + )); + }, [blurPaneTextInputs, commitGestureState, interactive, touchMode]); + + const handlePanePointerMoveCapture = useCallback((event: PointerEvent) => { + const state = gestureStateRef.current; + if (state.phase === 'idle' || state.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + commitGestureState(updateMobileGesture(state, localPointerPoint(event))); + }, [commitGestureState]); const handlePanePointerUpCapture = useCallback((event: PointerEvent) => { - const start = gestureStartRef.current; - if (!start || start.pointerId !== event.pointerId) return; - gestureStartRef.current = null; + const state = gestureStateRef.current; + if (state.phase === 'idle' || state.pointerId !== event.pointerId) return; event.preventDefault(); event.stopPropagation(); - - const dx = event.clientX - start.x; - const dy = event.clientY - start.y; - if (Math.max(Math.abs(dx), Math.abs(dy)) < 24) return; - if (Math.abs(dx) > Math.abs(dy)) { - sendInput(dx < 0 ? MOBILE_TERMINAL_KEY_SEQUENCES.left : MOBILE_TERMINAL_KEY_SEQUENCES.right); - } else { - sendInput(dy < 0 ? MOBILE_TERMINAL_KEY_SEQUENCES.up : MOBILE_TERMINAL_KEY_SEQUENCES.down); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); } - }, [sendInput]); + + const result = finishMobileGesture(updateMobileGesture(state, localPointerPoint(event))); + commitGestureState(result.state); + executeGestureAction(result.action); + }, [commitGestureState, executeGestureAction]); const handlePaneFocusStartCapture = useCallback(() => { blurPaneTextInputs(); }, [blurPaneTextInputs]); const handlePanePointerCancelCapture = useCallback((event: PointerEvent) => { - if (gestureStartRef.current?.pointerId === event.pointerId) { - gestureStartRef.current = null; - } - }, []); + const state = gestureStateRef.current; + if (state.phase === 'idle' || state.pointerId !== event.pointerId) return; + commitGestureState(MOBILE_GESTURE_IDLE_STATE); + }, [commitGestureState]); return (
{terminal}
+ + {pendingGestureConfirmation ? ( + setPendingGestureConfirmation(null)} + onConfirm={confirmPendingGestureAction} + /> + ) : null}
{ + it('cancels a tap that never breaks out', () => { + expect(runGesture([])).toBeUndefined(); + }); + + it('selects Right by breaking east and returning to center', () => { + expect(runGesture([point(70, 0), point(0, 0)])).toEqual({ kind: 'input', input: 'right' }); + }); + + it('selects End by breaking east and turning up', () => { + expect(runGesture([point(70, 0), point(0, -70)])).toEqual({ kind: 'input', input: 'end' }); + }); + + it('selects l by breaking east and turning down', () => { + expect(runGesture([point(70, 0), point(0, 70)])).toEqual({ kind: 'text', text: 'l' }); + }); + + it('cancels when released in the original breakout direction', () => { + expect(runGesture([point(70, 0)])).toBeUndefined(); + }); + + it('opens Ctrl+C confirmation from the northwest group', () => { + expect(runGesture([point(-70, -70), point(-70, 0)])).toEqual({ + kind: 'confirm', + confirmation: 'ctrlC', + action: { kind: 'input', input: 'ctrlC' }, + }); + }); + + it('opens paste confirmation from the northeast group', () => { + expect(runGesture([point(70, -70), point(0, -70)])).toEqual({ + kind: 'confirm', + confirmation: 'paste', + action: { kind: 'paste' }, + }); + }); + + it('uses a second breakout for quit as q', () => { + expect(runGesture([point(-70, -70), point(0, -70), point(0, 0)])).toEqual({ + kind: 'text', + text: 'q', + }); + }); + + it('uses a second breakout for quit as Ctrl+X', () => { + expect(runGesture([point(-70, -70), point(0, -70), point(-70, 0)])).toEqual({ + kind: 'input', + input: 'ctrlX', + }); + }); + + it('uses a second breakout for quit as :q enter', () => { + expect(runGesture([point(-70, -70), point(0, -70), point(70, 0)])).toEqual({ + kind: 'text', + text: ':q\r', + }); + }); + + it('selects Shift+Enter with the southeast counter-clockwise turn', () => { + expect(runGesture([point(70, 70), point(70, 0)])).toEqual({ + kind: 'input', + input: 'shiftEnter', + }); + }); +}); diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts new file mode 100644 index 0000000..128955f --- /dev/null +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -0,0 +1,332 @@ +export type MobileGestureDirection = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw'; +export type MobileGestureOptionIndex = 0 | 1 | 2; +export type MobileGestureInputId = + | 'ctrlC' + | 'esc' + | 'tab' + | 'shiftTab' + | 'space' + | 'enter' + | 'shiftEnter' + | 'backspace' + | 'up' + | 'pageUp' + | 'down' + | 'pageDown' + | 'right' + | 'end' + | 'left' + | 'home' + | 'ctrlX'; + +export interface MobileGesturePoint { + x: number; + y: number; +} + +export type MobileGestureDirectAction = + | { kind: 'input'; input: MobileGestureInputId } + | { kind: 'text'; text: string } + | { kind: 'paste' } + | { kind: 'quitMenu' }; + +export type MobileGestureConfirmableAction = Extract; + +export type MobileGestureAction = + | MobileGestureDirectAction + | { + kind: 'confirm'; + confirmation: 'ctrlC' | 'paste'; + action: MobileGestureConfirmableAction; + }; + +export interface MobileGestureOption { + label: string; + action: MobileGestureAction; +} + +export interface MobileGestureGroup { + direction: MobileGestureDirection; + options: [MobileGestureOption, MobileGestureOption, MobileGestureOption]; +} + +export interface MobileGestureCandidate { + phase: 'root' | 'quit'; + groupDirection: MobileGestureDirection; + optionIndex: MobileGestureOptionIndex; + turn: 'center' | 'counterClockwise' | 'clockwise'; + option: MobileGestureOption; +} + +export type MobileGestureTrackingState = + | { phase: 'idle' } + | { + phase: 'root'; + pointerId: number; + origin: MobileGesturePoint; + displayOrigin: MobileGesturePoint; + currentPoint: MobileGesturePoint; + primaryDirection?: MobileGestureDirection; + candidate?: MobileGestureCandidate; + } + | { + phase: 'quit'; + pointerId: number; + origin: MobileGesturePoint; + displayOrigin: MobileGesturePoint; + currentPoint: MobileGesturePoint; + parentDirection: MobileGestureDirection; + baseDirection: MobileGestureDirection; + candidate?: MobileGestureCandidate; + }; + +export interface MobileGestureFinishResult { + state: MobileGestureTrackingState; + action?: MobileGestureAction; +} + +const DIAGONAL = Math.SQRT1_2; + +export const MOBILE_GESTURE_IDLE_STATE: MobileGestureTrackingState = { phase: 'idle' }; +export const MOBILE_GESTURE_BREAKOUT_RADIUS = 44; +export const MOBILE_GESTURE_RETURN_RADIUS = 26; +export const MOBILE_GESTURE_TURN_THRESHOLD = 0.55; + +export const MOBILE_GESTURE_DIRECTION_VECTORS: Record = { + n: { x: 0, y: -1 }, + ne: { x: DIAGONAL, y: -DIAGONAL }, + e: { x: 1, y: 0 }, + se: { x: DIAGONAL, y: DIAGONAL }, + s: { x: 0, y: 1 }, + sw: { x: -DIAGONAL, y: DIAGONAL }, + w: { x: -1, y: 0 }, + nw: { x: -DIAGONAL, y: -DIAGONAL }, +}; + +const ANGLE_DIRECTIONS: MobileGestureDirection[] = ['e', 'se', 's', 'sw', 'w', 'nw', 'n', 'ne']; + +export const MOBILE_GESTURE_GROUPS: Record = { + nw: { + direction: 'nw', + options: [ + { label: 'Esc', action: { kind: 'input', input: 'esc' } }, + { + label: 'Ctrl+C', + action: { kind: 'confirm', confirmation: 'ctrlC', action: { kind: 'input', input: 'ctrlC' } }, + }, + { label: 'Quit', action: { kind: 'quitMenu' } }, + ], + }, + n: { + direction: 'n', + options: [ + { label: 'Up', action: { kind: 'input', input: 'up' } }, + { label: 'PgUp', action: { kind: 'input', input: 'pageUp' } }, + { label: 'k', action: { kind: 'text', text: 'k' } }, + ], + }, + ne: { + direction: 'ne', + options: [ + { label: 'Backspace', action: { kind: 'input', input: 'backspace' } }, + { label: 'Paste', action: { kind: 'confirm', confirmation: 'paste', action: { kind: 'paste' } } }, + { label: 'n', action: { kind: 'text', text: 'n' } }, + ], + }, + w: { + direction: 'w', + options: [ + { label: 'Left', action: { kind: 'input', input: 'left' } }, + { label: 'Home', action: { kind: 'input', input: 'home' } }, + { label: 'h', action: { kind: 'text', text: 'h' } }, + ], + }, + e: { + direction: 'e', + options: [ + { label: 'Right', action: { kind: 'input', input: 'right' } }, + { label: 'End', action: { kind: 'input', input: 'end' } }, + { label: 'l', action: { kind: 'text', text: 'l' } }, + ], + }, + sw: { + direction: 'sw', + options: [ + { label: 'Tab', action: { kind: 'input', input: 'tab' } }, + { label: 'Shift+Tab', action: { kind: 'input', input: 'shiftTab' } }, + { label: 'Space', action: { kind: 'input', input: 'space' } }, + ], + }, + s: { + direction: 's', + options: [ + { label: 'Down', action: { kind: 'input', input: 'down' } }, + { label: 'PgDown', action: { kind: 'input', input: 'pageDown' } }, + { label: 'j', action: { kind: 'text', text: 'j' } }, + ], + }, + se: { + direction: 'se', + options: [ + { label: 'Enter', action: { kind: 'input', input: 'enter' } }, + { label: 'Shift+Enter', action: { kind: 'input', input: 'shiftEnter' } }, + { label: 'y', action: { kind: 'text', text: 'y' } }, + ], + }, +}; + +export const MOBILE_GESTURE_GROUP_ORDER: MobileGestureDirection[] = [ + 'nw', + 'n', + 'ne', + 'w', + 'e', + 'sw', + 's', + 'se', +]; + +export const MOBILE_GESTURE_QUIT_GROUP: MobileGestureGroup = { + direction: 'n', + options: [ + { label: 'q', action: { kind: 'text', text: 'q' } }, + { label: 'Ctrl+X', action: { kind: 'input', input: 'ctrlX' } }, + { label: ':q\u21b5', action: { kind: 'text', text: ':q\r' } }, + ], +}; + +function distance(a: MobileGesturePoint, b: MobileGesturePoint): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +export function directionFromVector(dx: number, dy: number): MobileGestureDirection | null { + if (Math.hypot(dx, dy) === 0) return null; + const angle = Math.atan2(dy, dx) * 180 / Math.PI; + const index = ((Math.round(angle / 45) % 8) + 8) % 8; + return ANGLE_DIRECTIONS[index]; +} + +function candidateForBase( + phase: 'root' | 'quit', + groupDirection: MobileGestureDirection, + options: [MobileGestureOption, MobileGestureOption, MobileGestureOption], + origin: MobileGesturePoint, + point: MobileGesturePoint, +): MobileGestureCandidate | undefined { + const dist = distance(origin, point); + if (dist <= MOBILE_GESTURE_RETURN_RADIUS) { + return { + phase, + groupDirection, + optionIndex: 0, + turn: 'center', + option: options[0], + }; + } + + const vector = MOBILE_GESTURE_DIRECTION_VECTORS[groupDirection]; + const dx = point.x - origin.x; + const dy = point.y - origin.y; + const normalizedCross = (vector.x * dy - vector.y * dx) / dist; + if (normalizedCross <= -MOBILE_GESTURE_TURN_THRESHOLD) { + return { + phase, + groupDirection, + optionIndex: 1, + turn: 'counterClockwise', + option: options[1], + }; + } + if (normalizedCross >= MOBILE_GESTURE_TURN_THRESHOLD) { + return { + phase, + groupDirection, + optionIndex: 2, + turn: 'clockwise', + option: options[2], + }; + } + return undefined; +} + +export function beginMobileGesture( + pointerId: number, + origin: MobileGesturePoint, + displayOrigin: MobileGesturePoint = origin, +): MobileGestureTrackingState { + return { + phase: 'root', + pointerId, + origin, + displayOrigin, + currentPoint: origin, + }; +} + +export function updateMobileGesture( + state: MobileGestureTrackingState, + point: MobileGesturePoint, +): MobileGestureTrackingState { + if (state.phase === 'idle') return state; + + if (state.phase === 'root') { + const primaryDirection = state.primaryDirection + ?? ( + distance(state.origin, point) >= MOBILE_GESTURE_BREAKOUT_RADIUS + ? directionFromVector(point.x - state.origin.x, point.y - state.origin.y) ?? undefined + : undefined + ); + const candidate = primaryDirection + ? candidateForBase('root', primaryDirection, MOBILE_GESTURE_GROUPS[primaryDirection].options, state.origin, point) + : undefined; + if (primaryDirection && candidate?.option.action.kind === 'quitMenu') { + const baseDirection = directionFromVector(point.x - state.origin.x, point.y - state.origin.y) ?? primaryDirection; + return { + phase: 'quit', + pointerId: state.pointerId, + origin: state.origin, + displayOrigin: state.displayOrigin, + currentPoint: point, + parentDirection: primaryDirection, + baseDirection, + }; + } + return { + ...state, + currentPoint: point, + primaryDirection, + candidate, + }; + } + + return { + ...state, + currentPoint: point, + candidate: candidateForBase( + 'quit', + state.baseDirection, + MOBILE_GESTURE_QUIT_GROUP.options, + state.origin, + point, + ), + }; +} + +export function finishMobileGesture(state: MobileGestureTrackingState): MobileGestureFinishResult { + const action = state.phase === 'idle' ? undefined : state.candidate?.option.action; + return { + state: MOBILE_GESTURE_IDLE_STATE, + action, + }; +} + +export function mobileGestureStateFromPoints( + points: MobileGesturePoint[], + origin: MobileGesturePoint = { x: 195, y: 220 }, +): MobileGestureTrackingState { + let state = beginMobileGesture(1, origin); + for (const point of points) { + state = updateMobileGesture(state, point); + } + return state; +} diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index e0f459a..ba17f41 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -5,6 +5,16 @@ import { MobileTerminalUi, type MobileTerminalUiProps, } from '../components/MobileTerminalUi'; +import { + MobileGestureConfirmDialog, + MobileGestureRadialMenu, +} from '../components/MobileGestureRadialMenu'; +import { + MOBILE_GESTURE_IDLE_STATE, + mobileGestureStateFromPoints, + type MobileGesturePoint, + type MobileGestureTrackingState, +} from '../lib/mobile-gesture-menu'; const meta: Meta = { title: 'App/MobileTerminalUi', @@ -19,15 +29,22 @@ type Story = StoryObj; const SEQUENCE_LABELS = new Map([ [MOBILE_TERMINAL_KEY_SEQUENCES.ctrlC, 'CTRL_C'], + [MOBILE_TERMINAL_KEY_SEQUENCES.ctrlX, 'CTRL_X'], [MOBILE_TERMINAL_KEY_SEQUENCES.esc, 'ESC'], [MOBILE_TERMINAL_KEY_SEQUENCES.tab, 'TAB'], + [MOBILE_TERMINAL_KEY_SEQUENCES.shiftTab, 'SHIFT_TAB'], [MOBILE_TERMINAL_KEY_SEQUENCES.space, 'SPACE'], [MOBILE_TERMINAL_KEY_SEQUENCES.enter, 'ENTER'], + [MOBILE_TERMINAL_KEY_SEQUENCES.shiftEnter, 'SHIFT_ENTER'], [MOBILE_TERMINAL_KEY_SEQUENCES.backspace, 'BACKSPACE'], [MOBILE_TERMINAL_KEY_SEQUENCES.up, 'ARROW_UP'], + [MOBILE_TERMINAL_KEY_SEQUENCES.pageUp, 'PAGE_UP'], [MOBILE_TERMINAL_KEY_SEQUENCES.down, 'ARROW_DOWN'], + [MOBILE_TERMINAL_KEY_SEQUENCES.pageDown, 'PAGE_DOWN'], [MOBILE_TERMINAL_KEY_SEQUENCES.right, 'ARROW_RIGHT'], + [MOBILE_TERMINAL_KEY_SEQUENCES.end, 'END'], [MOBILE_TERMINAL_KEY_SEQUENCES.left, 'ARROW_LEFT'], + [MOBILE_TERMINAL_KEY_SEQUENCES.home, 'HOME'], ]); function describeInput(data: string): string { @@ -70,11 +87,50 @@ function StoryFrame(args: MobileTerminalUiProps) { args.onSendInput?.(data); setInputLog((entries) => [...entries, describeInput(data)]); }} + onPaste={() => { + void args.onPaste?.(); + setInputLog((entries) => [...entries, 'PASTE']); + }} /> ); } +const GESTURE_ORIGIN: MobileGesturePoint = { x: 195, y: 220 }; + +function gesturePoint(dx: number, dy: number): MobileGesturePoint { + return { + x: GESTURE_ORIGIN.x + dx, + y: GESTURE_ORIGIN.y + dy, + }; +} + +function gestureState(points: MobileGesturePoint[]): MobileGestureTrackingState { + return mobileGestureStateFromPoints(points, GESTURE_ORIGIN); +} + +function GestureSnapshotFrame({ + state, + confirmation, +}: { + state: MobileGestureTrackingState; + confirmation?: 'ctrlC' | 'paste'; +}) { + return ( +
+ + + {confirmation ? ( + {}} + onConfirm={() => {}} + /> + ) : null} +
+ ); +} + export const TypePane: Story = { args: { defaultSection: 'type', @@ -118,3 +174,53 @@ export const CursorTouchAvailable: Story = { }, render: (args) => , }; + +export const GestureMenuOpened: Story = { + render: () => , +}; + +export const GesturePrimaryEast: Story = { + render: () => , +}; + +export const GestureEastReturnRight: Story = { + render: () => , +}; + +export const GestureEastTurnUpEnd: Story = { + render: () => , +}; + +export const GestureEastTurnDownL: Story = { + render: () => , +}; + +export const GestureCtrlCConfirmation: Story = { + render: () => ( + + ), +}; + +export const GesturePasteConfirmation: Story = { + render: () => ( + + ), +}; + +export const GestureQuitSubmenu: Story = { + render: () => , +}; + +export const GestureQuitCtrlXCandidate: Story = { + render: () => ( + + ), +}; diff --git a/website/src/pages/Tether.tsx b/website/src/pages/Tether.tsx index 2669010..a27e6d9 100644 --- a/website/src/pages/Tether.tsx +++ b/website/src/pages/Tether.tsx @@ -225,6 +225,10 @@ function TetherTerminalExperience({ onTouchModeChange={setTouchMode} cursorTouchAvailable={cursorTouchAvailable} onSendInput={(data) => adapterRef.current?.writePty(activePaneId, data)} + onPaste={async () => { + const { doPaste } = await import("mouseterm-lib/lib/clipboard"); + await doPaste(activePaneId); + }} /> ); } From 409d946ae48bc452fb711b0f35ffd4f2463dd4b6 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 12 May 2026 21:45:19 -0700 Subject: [PATCH 02/29] Offset tether gesture menu from thumb --- docs/specs/mobile-ui.md | 12 +++++- .../components/MobileGestureRadialMenu.tsx | 37 +++++++++++++++--- lib/src/components/MobileTerminalUi.tsx | 15 +------- lib/src/lib/mobile-gesture-menu.test.ts | 15 ++++++++ lib/src/lib/mobile-gesture-menu.ts | 38 ++++++++++++++++++- lib/src/stories/MobileTerminalUi.stories.tsx | 10 ++++- 6 files changed, 104 insertions(+), 23 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 259f88b..8f9d413 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -99,8 +99,16 @@ selector must fall back to Gestures. ## 5. Gesture Mode Gesture mode is the default pane-content touch behavior. Tapping the pane content -opens a radial menu at the touch origin. The center `o` is only the origin -marker; it is not an action. +opens a radial menu offset from the touch origin. The menu should appear in the +opposite diagonal from the user's thumb so the compass rose fills the visible +area away from the touch point. For example, a lower-right thumb press opens the +rose up and left; a lower-left thumb press opens it up and right. The center `o` +is only the menu origin marker; it is not an action. + +As the user drags, the UI draws a visible line from the initial thumb press to +the current thumb position. The offset compass rose may also mirror that motion +with a lighter guide line so the selected direction remains readable away from +the thumb. The radial menu is a two-stage gesture: diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 38d71d4..c37f3f5 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -33,7 +33,7 @@ function directionPoint( }; } -function displayCurrentPoint(state: Exclude) { +function translatedCurrentPoint(state: Exclude) { return { x: state.displayOrigin.x + state.currentPoint.x - state.origin.x, y: state.displayOrigin.y + state.currentPoint.y - state.origin.y, @@ -77,7 +77,7 @@ function OptionPill({ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackingState }) { if (state.phase === 'idle') return null; - const currentPoint = displayCurrentPoint(state); + const translatedPoint = translatedCurrentPoint(state); return (
+ + + ; -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - function localPointerPoint(event: PointerEvent): MobileGesturePoint { const rect = event.currentTarget.getBoundingClientRect(); return { @@ -321,14 +318,6 @@ function localPointerPoint(event: PointerEvent): MobileGesturePoint }; } -function gestureDisplayOrigin(origin: MobileGesturePoint, rect: DOMRect): MobileGesturePoint { - const margin = 104; - return { - x: rect.width > margin * 2 ? clamp(origin.x, margin, rect.width - margin) : rect.width / 2, - y: rect.height > margin * 2 ? clamp(origin.y, margin, rect.height - margin) : rect.height / 2, - }; -} - function isGestureDialogTarget(target: EventTarget | null): boolean { return target instanceof Element && target.closest('[data-mobile-gesture-dialog]') !== null; } @@ -521,7 +510,7 @@ export function MobileTerminalUi({ commitGestureState(beginMobileGesture( event.pointerId, origin, - gestureDisplayOrigin(origin, event.currentTarget.getBoundingClientRect()), + displayOriginAwayFromThumb(origin, event.currentTarget.getBoundingClientRect()), )); }, [blurPaneTextInputs, commitGestureState, interactive, touchMode]); diff --git a/lib/src/lib/mobile-gesture-menu.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts index 5d52db0..d0f4190 100644 --- a/lib/src/lib/mobile-gesture-menu.test.ts +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { beginMobileGesture, + displayOriginAwayFromThumb, finishMobileGesture, updateMobileGesture, type MobileGestureAction, @@ -86,4 +87,18 @@ describe('mobile gesture menu state machine', () => { input: 'shiftEnter', }); }); + + it('places the display origin up and left from a lower-right thumb press', () => { + expect(displayOriginAwayFromThumb({ x: 320, y: 300 }, { width: 390, height: 460 })).toEqual({ + x: 188, + y: 168, + }); + }); + + it('places the display origin up and right from a lower-left thumb press', () => { + expect(displayOriginAwayFromThumb({ x: 70, y: 300 }, { width: 390, height: 460 })).toEqual({ + x: 202, + y: 168, + }); + }); }); diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index 128955f..c44392b 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -24,6 +24,11 @@ export interface MobileGesturePoint { y: number; } +export interface MobileGestureBounds { + width: number; + height: number; +} + export type MobileGestureDirectAction = | { kind: 'input'; input: MobileGestureInputId } | { kind: 'text'; text: string } @@ -91,6 +96,8 @@ export const MOBILE_GESTURE_IDLE_STATE: MobileGestureTrackingState = { phase: 'i export const MOBILE_GESTURE_BREAKOUT_RADIUS = 44; export const MOBILE_GESTURE_RETURN_RADIUS = 26; export const MOBILE_GESTURE_TURN_THRESHOLD = 0.55; +export const MOBILE_GESTURE_DISPLAY_MARGIN = 112; +export const MOBILE_GESTURE_THUMB_OFFSET = 132; export const MOBILE_GESTURE_DIRECTION_VECTORS: Record = { n: { x: 0, y: -1 }, @@ -199,6 +206,10 @@ function distance(a: MobileGesturePoint, b: MobileGesturePoint): number { return Math.hypot(a.x - b.x, a.y - b.y); } +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + export function directionFromVector(dx: number, dy: number): MobileGestureDirection | null { if (Math.hypot(dx, dy) === 0) return null; const angle = Math.atan2(dy, dx) * 180 / Math.PI; @@ -263,6 +274,30 @@ export function beginMobileGesture( }; } +export function displayOriginAwayFromThumb( + origin: MobileGesturePoint, + bounds: MobileGestureBounds, +): MobileGesturePoint { + const xDirection = origin.x < bounds.width / 2 ? 1 : -1; + const yDirection = origin.y < bounds.height / 2 ? 1 : -1; + return { + x: bounds.width > MOBILE_GESTURE_DISPLAY_MARGIN * 2 + ? clamp( + origin.x + xDirection * MOBILE_GESTURE_THUMB_OFFSET, + MOBILE_GESTURE_DISPLAY_MARGIN, + bounds.width - MOBILE_GESTURE_DISPLAY_MARGIN, + ) + : bounds.width / 2, + y: bounds.height > MOBILE_GESTURE_DISPLAY_MARGIN * 2 + ? clamp( + origin.y + yDirection * MOBILE_GESTURE_THUMB_OFFSET, + MOBILE_GESTURE_DISPLAY_MARGIN, + bounds.height - MOBILE_GESTURE_DISPLAY_MARGIN, + ) + : bounds.height / 2, + }; +} + export function updateMobileGesture( state: MobileGestureTrackingState, point: MobileGesturePoint, @@ -323,8 +358,9 @@ export function finishMobileGesture(state: MobileGestureTrackingState): MobileGe export function mobileGestureStateFromPoints( points: MobileGesturePoint[], origin: MobileGesturePoint = { x: 195, y: 220 }, + displayOrigin: MobileGesturePoint = origin, ): MobileGestureTrackingState { - let state = beginMobileGesture(1, origin); + let state = beginMobileGesture(1, origin, displayOrigin); for (const point of points) { state = updateMobileGesture(state, point); } diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index ba17f41..8b27d45 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -10,6 +10,7 @@ import { MobileGestureRadialMenu, } from '../components/MobileGestureRadialMenu'; import { + displayOriginAwayFromThumb, MOBILE_GESTURE_IDLE_STATE, mobileGestureStateFromPoints, type MobileGesturePoint, @@ -96,7 +97,8 @@ function StoryFrame(args: MobileTerminalUiProps) { ); } -const GESTURE_ORIGIN: MobileGesturePoint = { x: 195, y: 220 }; +const GESTURE_BOUNDS = { width: 390, height: 460 }; +const GESTURE_ORIGIN: MobileGesturePoint = { x: 300, y: 280 }; function gesturePoint(dx: number, dy: number): MobileGesturePoint { return { @@ -106,7 +108,11 @@ function gesturePoint(dx: number, dy: number): MobileGesturePoint { } function gestureState(points: MobileGesturePoint[]): MobileGestureTrackingState { - return mobileGestureStateFromPoints(points, GESTURE_ORIGIN); + return mobileGestureStateFromPoints( + points, + GESTURE_ORIGIN, + displayOriginAwayFromThumb(GESTURE_ORIGIN, GESTURE_BOUNDS), + ); } function GestureSnapshotFrame({ From c5709c7d53188855c2b001f0848077a4306bf042 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 12 May 2026 22:04:57 -0700 Subject: [PATCH 03/29] Refine tether mobile gesture labels --- docs/specs/mobile-ui.md | 46 ++++++++++--------- .../components/MobileGestureRadialMenu.tsx | 4 +- lib/src/components/MobileTerminalUi.tsx | 8 ++-- lib/src/lib/mobile-gesture-menu.ts | 18 ++++---- 4 files changed, 40 insertions(+), 36 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 8f9d413..87097d0 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -132,26 +132,30 @@ Examples: * End: tap, drag right, drag up, release. * `l`: tap, drag right, drag down, release. +Root gesture menu labels use compact key glyphs: `⌃` for Ctrl, `⬆︎` for +Shift, and `▲`/`▼`/`◀`/`▶` for arrow keys. Enter, Backspace, PgUp, and PgDn +remain spelled out. + Root gesture menu: ```text -Esc|Ctrl+C*|Quit** Up|PgUp|k Backspace|Paste*|n +Esc|⌃C*|Quit** ▲|PgUp|k Backspace|Paste*|n -Left|Home|h o Right|End|l +◀|Home|h o ▶|End|l -Tab|Shift+Tab|Space Down|PgDown|j Enter|Shift+Enter|y +Tab|⬆︎Tab|Space ▼|PgDn|j Enter|⬆︎Enter|y ``` -`Ctrl+C` and `Paste` require an in-pane confirmation modal before they run. +`⌃C` and `Paste` require an in-pane confirmation modal before they run. `Quit` enters a second breakout menu instead of sending input immediately: ```text -q | Ctrl+X | :q↵ +q | ⌃X | :q↵ ``` The quit submenu uses the same final movement rule. Returning to center selects -`q`, visually counter-clockwise selects `Ctrl+X`, and visually clockwise selects +`q`, visually counter-clockwise selects `⌃X`, and visually clockwise selects `:q↵`. Gesture action mappings: @@ -159,30 +163,30 @@ Gesture action mappings: | Action | Sequence | | --- | --- | | Esc | `\x1B` | -| Ctrl+C | `\x03` | +| ⌃C | `\x03` | | q | `q` | -| Ctrl+X | `\x18` | +| ⌃X | `\x18` | | `:q↵` | `:q\r` | -| Up | `\x1B[A` | +| ▲ | `\x1B[A` | | PgUp | `\x1B[5~` | | k | `k` | | Backspace | `\x7F` | | Paste | Existing MouseTerm paste flow for the active pane | | n | `n` | -| Left | `\x1B[D` | +| ◀ | `\x1B[D` | | Home | `\x1B[H` | | h | `h` | -| Right | `\x1B[C` | +| ▶ | `\x1B[C` | | End | `\x1B[F` | | l | `l` | | Tab | `\x09` | -| Shift+Tab | `\x1B[Z` | +| ⬆︎Tab | `\x1B[Z` | | Space | ` ` | -| Down | `\x1B[B` | -| PgDown | `\x1B[6~` | +| ▼ | `\x1B[B` | +| PgDn | `\x1B[6~` | | j | `j` | | Enter | `\r` | -| Shift+Enter | `\x1B[13;2u` | +| ⬆︎Enter | `\x1B[13;2u` | | y | `y` | ## 6. Keyboard Mode Selector @@ -224,7 +228,7 @@ Keys mode displays exactly these buttons: ```text Esc Tab Space Enter -← ↓ ↑ → +◀ ▼ ▲ ▶ ``` Mappings: @@ -235,10 +239,10 @@ Mappings: | Tab | `\x09` | | Space | ` ` | | Enter | `\r` | -| ← | `\x1B[D` | -| ↓ | `\x1B[B` | -| ↑ | `\x1B[A` | -| → | `\x1B[C` | +| ◀ | `\x1B[D` | +| ▼ | `\x1B[B` | +| ▲ | `\x1B[A` | +| ▶ | `\x1B[C` | Tapping a key sends exactly one action. Long-press repeat is not required for v0. @@ -378,7 +382,7 @@ Input Recent | Type | Draft | Keys ```text Esc Tab Space Enter -← ↓ ↑ → +◀ ▼ ▲ ▶ ``` * Simple local playground terminal behavior. diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index c37f3f5..aa2a463 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -190,9 +190,9 @@ export function MobileGestureConfirmDialog({ }) { const copy = confirmation === 'ctrlC' ? { - title: 'Send Ctrl+C?', + title: 'Send ⌃C?', body: 'Interrupt the running terminal app.', - action: 'Send Ctrl+C', + action: 'Send ⌃C', } : { title: 'Paste?', diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 7d61bee..f68bbb2 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -65,10 +65,10 @@ const TERMINAL_KEYS: TerminalKey[] = [ { id: 'tab', label: 'Tab', title: 'Tab' }, { id: 'space', label: 'Space', title: 'Space' }, { id: 'enter', label: 'Enter', title: 'Enter' }, - { id: 'left', label: '\u2190', title: 'Left arrow' }, - { id: 'down', label: '\u2193', title: 'Down arrow' }, - { id: 'up', label: '\u2191', title: 'Up arrow' }, - { id: 'right', label: '\u2192', title: 'Right arrow' }, + { id: 'left', label: '\u25c0', title: 'Left arrow' }, + { id: 'down', label: '\u25bc', title: 'Down arrow' }, + { id: 'up', label: '\u25b2', title: 'Up arrow' }, + { id: 'right', label: '\u25b6', title: 'Right arrow' }, ]; const KEYBOARD_MODES: { id: MobileTerminalKeyboardMode; label: string }[] = [ diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index c44392b..d854657 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -118,7 +118,7 @@ export const MOBILE_GESTURE_GROUPS: Record Date: Tue, 12 May 2026 22:11:04 -0700 Subject: [PATCH 04/29] Add tether gesture radius thresholds --- docs/specs/mobile-ui.md | 15 ++++++-- .../components/MobileGestureRadialMenu.tsx | 16 +++++++-- lib/src/lib/mobile-gesture-menu.test.ts | 34 +++++++++++++++++++ lib/src/lib/mobile-gesture-menu.ts | 17 ++++++---- lib/src/stories/MobileTerminalUi.stories.tsx | 5 +++ 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 87097d0..dbdaabc 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -110,12 +110,21 @@ the current thumb position. The offset compass rose may also mirror that motion with a lighter guide line so the selected direction remains readable away from the thumb. +Gesture mode uses these radii: + +| Variable | Value | Behavior | +| --- | --- | --- | +| `RADIUS_LAYOUT` | `92px` | Distance from the offset compass rose origin to the menu item groups. | +| `RADIUS_SELECT` | `RADIUS_LAYOUT * 0.75` | Visible circle drawn around the offset compass rose origin. When the mirrored drag reaches this distance, the closest compass direction is selected. | +| `RADIUS_HIGHLIGHT` | `RADIUS_SELECT * 0.5` | No circle is drawn. When the drag reaches this distance, the closest compass direction is highlighted, but not selected. | + The radial menu is a two-stage gesture: 1. Touch down to open the menu. -2. Drag far enough toward one compass point to choose a group. -3. Drag in a different direction to choose one of that group's three options. -4. Release to send the selected terminal input. +2. Drag to `RADIUS_HIGHLIGHT` to preview the closest compass point. +3. Drag to `RADIUS_SELECT` to choose that compass point's group. +4. Drag in a different direction to choose one of that group's three options. +5. Release to send the selected terminal input. After the first breakout, the final option is selected this way: diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index aa2a463..580956e 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -5,12 +5,13 @@ import { MOBILE_GESTURE_GROUP_ORDER, MOBILE_GESTURE_GROUPS, MOBILE_GESTURE_QUIT_GROUP, + RADIUS_LAYOUT, + RADIUS_SELECT, type MobileGestureCandidate, type MobileGestureDirection, type MobileGestureTrackingState, } from '../lib/mobile-gesture-menu'; -const ROOT_RADIUS = 92; const QUIT_RADIUS = 78; function translatedStyle(x: number, y: number): CSSProperties { @@ -106,6 +107,15 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin strokeDasharray="4 4" strokeLinecap="round" /> + { if (state.phase === 'quit' && direction === state.baseDirection) return null; const group = MOBILE_GESTURE_GROUPS[direction]; - const point = directionPoint(direction, state.displayOrigin, ROOT_RADIUS); + const point = directionPoint(direction, state.displayOrigin, RADIUS_LAYOUT); const candidate = state.phase === 'root' && state.candidate?.groupDirection === direction ? state.candidate : undefined; const active = state.phase === 'root' - ? state.primaryDirection === direction + ? (state.primaryDirection ?? state.highlightedDirection) === direction : state.parentDirection === direction; return (
diff --git a/lib/src/lib/mobile-gesture-menu.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts index d0f4190..1af6686 100644 --- a/lib/src/lib/mobile-gesture-menu.test.ts +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -3,6 +3,9 @@ import { beginMobileGesture, displayOriginAwayFromThumb, finishMobileGesture, + RADIUS_HIGHLIGHT, + RADIUS_LAYOUT, + RADIUS_SELECT, updateMobileGesture, type MobileGestureAction, type MobileGesturePoint, @@ -24,10 +27,41 @@ function point(x: number, y: number): MobileGesturePoint { } describe('mobile gesture menu state machine', () => { + it('derives highlight and select radii from the layout radius', () => { + expect(RADIUS_LAYOUT).toBe(92); + expect(RADIUS_SELECT).toBe(RADIUS_LAYOUT * 0.75); + expect(RADIUS_HIGHLIGHT).toBe(RADIUS_SELECT * 0.5); + }); + it('cancels a tap that never breaks out', () => { expect(runGesture([])).toBeUndefined(); }); + it('does not highlight a direction before the highlight radius', () => { + const state = updateMobileGesture(beginMobileGesture(1, ORIGIN), point(RADIUS_HIGHLIGHT - 1, 0)); + expect(state.phase).toBe('root'); + if (state.phase !== 'root') return; + expect(state.highlightedDirection).toBeUndefined(); + expect(state.primaryDirection).toBeUndefined(); + }); + + it('highlights the closest direction after the highlight radius without selecting it', () => { + const state = updateMobileGesture(beginMobileGesture(1, ORIGIN), point(RADIUS_HIGHLIGHT + 1, 0)); + expect(state.phase).toBe('root'); + if (state.phase !== 'root') return; + expect(state.highlightedDirection).toBe('e'); + expect(state.primaryDirection).toBeUndefined(); + expect(finishMobileGesture(state).action).toBeUndefined(); + }); + + it('selects the closest direction after the select radius', () => { + const state = updateMobileGesture(beginMobileGesture(1, ORIGIN), point(RADIUS_SELECT + 1, 0)); + expect(state.phase).toBe('root'); + if (state.phase !== 'root') return; + expect(state.highlightedDirection).toBe('e'); + expect(state.primaryDirection).toBe('e'); + }); + it('selects Right by breaking east and returning to center', () => { expect(runGesture([point(70, 0), point(0, 0)])).toEqual({ kind: 'input', input: 'right' }); }); diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index d854657..4557ce6 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -71,6 +71,7 @@ export type MobileGestureTrackingState = origin: MobileGesturePoint; displayOrigin: MobileGesturePoint; currentPoint: MobileGesturePoint; + highlightedDirection?: MobileGestureDirection; primaryDirection?: MobileGestureDirection; candidate?: MobileGestureCandidate; } @@ -93,7 +94,9 @@ export interface MobileGestureFinishResult { const DIAGONAL = Math.SQRT1_2; export const MOBILE_GESTURE_IDLE_STATE: MobileGestureTrackingState = { phase: 'idle' }; -export const MOBILE_GESTURE_BREAKOUT_RADIUS = 44; +export const RADIUS_LAYOUT = 92; +export const RADIUS_SELECT = RADIUS_LAYOUT * 0.75; +export const RADIUS_HIGHLIGHT = RADIUS_SELECT * 0.5; export const MOBILE_GESTURE_RETURN_RADIUS = 26; export const MOBILE_GESTURE_TURN_THRESHOLD = 0.55; export const MOBILE_GESTURE_DISPLAY_MARGIN = 112; @@ -305,12 +308,13 @@ export function updateMobileGesture( if (state.phase === 'idle') return state; if (state.phase === 'root') { + const movementDistance = distance(state.origin, point); + const closestDirection = movementDistance >= RADIUS_HIGHLIGHT + ? directionFromVector(point.x - state.origin.x, point.y - state.origin.y) ?? undefined + : undefined; const primaryDirection = state.primaryDirection - ?? ( - distance(state.origin, point) >= MOBILE_GESTURE_BREAKOUT_RADIUS - ? directionFromVector(point.x - state.origin.x, point.y - state.origin.y) ?? undefined - : undefined - ); + ?? (movementDistance >= RADIUS_SELECT ? closestDirection : undefined); + const highlightedDirection = primaryDirection ?? closestDirection; const candidate = primaryDirection ? candidateForBase('root', primaryDirection, MOBILE_GESTURE_GROUPS[primaryDirection].options, state.origin, point) : undefined; @@ -329,6 +333,7 @@ export function updateMobileGesture( return { ...state, currentPoint: point, + highlightedDirection, primaryDirection, candidate, }; diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index 8b27d45..58fa235 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -13,6 +13,7 @@ import { displayOriginAwayFromThumb, MOBILE_GESTURE_IDLE_STATE, mobileGestureStateFromPoints, + RADIUS_HIGHLIGHT, type MobileGesturePoint, type MobileGestureTrackingState, } from '../lib/mobile-gesture-menu'; @@ -185,6 +186,10 @@ export const GestureMenuOpened: Story = { render: () => , }; +export const GestureEastHighlight: Story = { + render: () => , +}; + export const GesturePrimaryEast: Story = { render: () => , }; From 4683fdfb9fe1c437287eebfc92518f09940b1fc9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 12 May 2026 22:42:30 -0700 Subject: [PATCH 05/29] Add two-phase tether gesture selection --- docs/specs/mobile-ui.md | 42 ++-- .../components/MobileGestureRadialMenu.tsx | 132 ++++++++---- lib/src/lib/mobile-gesture-menu.test.ts | 112 ++++++++-- lib/src/lib/mobile-gesture-menu.ts | 193 ++++++++++++------ lib/src/stories/MobileTerminalUi.stories.tsx | 72 ++++++- 5 files changed, 413 insertions(+), 138 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index dbdaabc..f685b3c 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -123,23 +123,36 @@ The radial menu is a two-stage gesture: 1. Touch down to open the menu. 2. Drag to `RADIUS_HIGHLIGHT` to preview the closest compass point. 3. Drag to `RADIUS_SELECT` to choose that compass point's group. -4. Drag in a different direction to choose one of that group's three options. -5. Release to send the selected terminal input. +4. The other seven compass groups fade out. +5. The compass center resets to the point where the user's drag intersected the + `RADIUS_SELECT` circle. +6. The selected group's three options explode out from the reset center in the + opposite directions. +7. Drag from the reset center to `RADIUS_HIGHLIGHT` to preview an option. +8. Drag from the reset center to `RADIUS_SELECT` to choose that option. +9. Release to send the selected terminal input. -After the first breakout, the final option is selected this way: +If the user releases after the first group selection but before choosing one of +the exploded options, the gesture is cancelled. -| Final movement | Selected option | +Exploded option directions: + +| Selected group | Option directions | | --- | --- | -| Back to center | First option | -| Visually counter-clockwise from the breakout direction | Second option | -| Visually clockwise from the breakout direction | Third option | -| Still in the original breakout direction | Cancel | +| N | S, SW, SE | +| NE | SW, S, W | +| E | W, NW, SW | +| SE | NW, N, W | +| S | N, NE, NW | +| SW | NE, N, E | +| W | E, SE, NE | +| NW | SE, S, E | Examples: -* Right arrow: tap, drag right, drag back to center, release. -* End: tap, drag right, drag up, release. -* `l`: tap, drag right, drag down, release. +* Right arrow: tap, drag right to choose the E group, drag left from the reset center, release. +* End: tap, drag right to choose the E group, drag up-left from the reset center, release. +* `l`: tap, drag right to choose the E group, drag down-left from the reset center, release. Root gesture menu labels use compact key glyphs: `⌃` for Ctrl, `⬆︎` for Shift, and `▲`/`▼`/`◀`/`▶` for arrow keys. Enter, Backspace, PgUp, and PgDn @@ -157,15 +170,14 @@ Tab|⬆︎Tab|Space ▼|PgDn|j Enter|⬆︎Enter|y `⌃C` and `Paste` require an in-pane confirmation modal before they run. -`Quit` enters a second breakout menu instead of sending input immediately: +`Quit` enters a second exploded-option menu instead of sending input immediately: ```text q | ⌃X | :q↵ ``` -The quit submenu uses the same final movement rule. Returning to center selects -`q`, visually counter-clockwise selects `⌃X`, and visually clockwise selects -`:q↵`. +The quit submenu uses the same reset-center, highlight-radius, and select-radius +rules as the main option selection. Gesture action mappings: diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 580956e..dba599a 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -4,10 +4,10 @@ import { MOBILE_GESTURE_DIRECTION_VECTORS, MOBILE_GESTURE_GROUP_ORDER, MOBILE_GESTURE_GROUPS, + MOBILE_GESTURE_OPTION_DIRECTIONS, MOBILE_GESTURE_QUIT_GROUP, RADIUS_LAYOUT, RADIUS_SELECT, - type MobileGestureCandidate, type MobileGestureDirection, type MobileGestureTrackingState, } from '../lib/mobile-gesture-menu'; @@ -34,20 +34,22 @@ function directionPoint( }; } -function translatedCurrentPoint(state: Exclude) { +function translatedCurrentPoint( + state: Exclude, + origin: { x: number; y: number }, + displayOrigin: { x: number; y: number }, +) { return { - x: state.displayOrigin.x + state.currentPoint.x - state.origin.x, - y: state.displayOrigin.y + state.currentPoint.y - state.origin.y, + x: displayOrigin.x + state.currentPoint.x - origin.x, + y: displayOrigin.y + state.currentPoint.y - origin.y, }; } function OptionPill({ labels, - candidate, active, }: { labels: [string, string, string]; - candidate?: MobileGestureCandidate; active: boolean; }) { return ( @@ -63,9 +65,7 @@ function OptionPill({ className={clsx( 'min-w-0 px-1.5 py-1', index > 0 && 'border-l border-border', - candidate?.optionIndex === index - ? 'bg-header-active-bg text-header-active-fg' - : active && 'text-foreground', + active && 'text-foreground', )} > {label} @@ -75,10 +75,72 @@ function OptionPill({ ); } +function OptionChip({ + label, + highlighted, + selected, +}: { + label: string; + highlighted: boolean; + selected: boolean; +}) { + return ( +
+ {label} +
+ ); +} + export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackingState }) { if (state.phase === 'idle') return null; - const translatedPoint = translatedCurrentPoint(state); + const phaseOrigin = state.phase === 'root' ? state.origin : state.optionOrigin; + const phaseDisplayOrigin = state.phase === 'root' ? state.displayOrigin : state.displayOptionOrigin; + const translatedPoint = translatedCurrentPoint(state, phaseOrigin, phaseDisplayOrigin); + const activeRootDirection = state.phase === 'root' + ? state.highlightedDirection + : state.phase === 'options' + ? state.selectedDirection + : state.parentDirection; + const explodedOptions = (() => { + if (state.phase === 'root') return null; + const group = state.phase === 'options' + ? MOBILE_GESTURE_GROUPS[state.selectedDirection] + : MOBILE_GESTURE_QUIT_GROUP; + const directions = state.phase === 'options' + ? MOBILE_GESTURE_OPTION_DIRECTIONS[state.selectedDirection] + : MOBILE_GESTURE_OPTION_DIRECTIONS[state.baseDirection]; + const radius = state.phase === 'quit' ? QUIT_RADIUS : RADIUS_LAYOUT; + const highlightedOptionIndex = state.highlightedOptionIndex; + const candidateOptionIndex = state.candidate?.optionIndex; + + return group.options.map((option, index) => { + const direction = directions[index]; + const point = directionPoint(direction, phaseDisplayOrigin, radius); + return ( +
+ +
+ ); + }); + })(); return (
{MOBILE_GESTURE_GROUP_ORDER.map((direction) => { - if (state.phase === 'quit' && direction === state.baseDirection) return null; const group = MOBILE_GESTURE_GROUPS[direction]; const point = directionPoint(direction, state.displayOrigin, RADIUS_LAYOUT); - const candidate = state.phase === 'root' && state.candidate?.groupDirection === direction - ? state.candidate - : undefined; - const active = state.phase === 'root' - ? (state.primaryDirection ?? state.highlightedDirection) === direction - : state.parentDirection === direction; + const active = activeRootDirection === direction; + const faded = state.phase !== 'root' && !active; return ( -
+
option.label) as [string, string, string]} - candidate={candidate} active={active} />
@@ -164,27 +227,12 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin
o
- {state.phase === 'quit' ? ( -
-
Quit
- option.label) as [string, string, string]} - candidate={state.candidate} - active - /> -
- ) : null} + {explodedOptions}
); } diff --git a/lib/src/lib/mobile-gesture-menu.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts index 1af6686..a1604ed 100644 --- a/lib/src/lib/mobile-gesture-menu.test.ts +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -3,11 +3,15 @@ import { beginMobileGesture, displayOriginAwayFromThumb, finishMobileGesture, + MOBILE_GESTURE_DIRECTION_VECTORS, + MOBILE_GESTURE_OPTION_DIRECTIONS, RADIUS_HIGHLIGHT, RADIUS_LAYOUT, RADIUS_SELECT, updateMobileGesture, type MobileGestureAction, + type MobileGestureDirection, + type MobileGestureOptionIndex, type MobileGesturePoint, type MobileGestureTrackingState, } from './mobile-gesture-menu'; @@ -26,6 +30,61 @@ function point(x: number, y: number): MobileGesturePoint { return { x: ORIGIN.x + x, y: ORIGIN.y + y }; } +function pointInDirection( + origin: MobileGesturePoint, + direction: MobileGestureDirection, + distance: number, +): MobileGesturePoint { + const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; + return { + x: origin.x + vector.x * distance, + y: origin.y + vector.y * distance, + }; +} + +function rootSelectionPoint(direction: MobileGestureDirection): MobileGesturePoint { + return pointInDirection(ORIGIN, direction, RADIUS_SELECT + 1); +} + +function optionOrigin(direction: MobileGestureDirection): MobileGesturePoint { + return pointInDirection(ORIGIN, direction, RADIUS_SELECT); +} + +function optionSelectionPoint( + direction: MobileGestureDirection, + optionIndex: MobileGestureOptionIndex, +): MobileGesturePoint { + return pointInDirection( + optionOrigin(direction), + MOBILE_GESTURE_OPTION_DIRECTIONS[direction][optionIndex], + RADIUS_SELECT + 1, + ); +} + +function quitOrigin( + direction: MobileGestureDirection, + optionIndex: MobileGestureOptionIndex, +): MobileGesturePoint { + return pointInDirection( + optionOrigin(direction), + MOBILE_GESTURE_OPTION_DIRECTIONS[direction][optionIndex], + RADIUS_SELECT, + ); +} + +function quitSelectionPoint( + direction: MobileGestureDirection, + quitMenuIndex: MobileGestureOptionIndex, + optionIndex: MobileGestureOptionIndex, +): MobileGesturePoint { + const quitDirection = MOBILE_GESTURE_OPTION_DIRECTIONS[direction][quitMenuIndex]; + return pointInDirection( + quitOrigin(direction, quitMenuIndex), + MOBILE_GESTURE_OPTION_DIRECTIONS[quitDirection][optionIndex], + RADIUS_SELECT + 1, + ); +} + describe('mobile gesture menu state machine', () => { it('derives highlight and select radii from the layout radius', () => { expect(RADIUS_LAYOUT).toBe(92); @@ -33,6 +92,12 @@ describe('mobile gesture menu state machine', () => { expect(RADIUS_HIGHLIGHT).toBe(RADIUS_SELECT * 0.5); }); + it('places exploded options opposite the selected direction', () => { + expect(MOBILE_GESTURE_OPTION_DIRECTIONS.n).toEqual(['s', 'sw', 'se']); + expect(MOBILE_GESTURE_OPTION_DIRECTIONS.e).toEqual(['w', 'nw', 'sw']); + expect(MOBILE_GESTURE_OPTION_DIRECTIONS.ne).toEqual(['sw', 's', 'w']); + }); + it('cancels a tap that never breaks out', () => { expect(runGesture([])).toBeUndefined(); }); @@ -42,7 +107,6 @@ describe('mobile gesture menu state machine', () => { expect(state.phase).toBe('root'); if (state.phase !== 'root') return; expect(state.highlightedDirection).toBeUndefined(); - expect(state.primaryDirection).toBeUndefined(); }); it('highlights the closest direction after the highlight radius without selecting it', () => { @@ -50,36 +114,50 @@ describe('mobile gesture menu state machine', () => { expect(state.phase).toBe('root'); if (state.phase !== 'root') return; expect(state.highlightedDirection).toBe('e'); - expect(state.primaryDirection).toBeUndefined(); expect(finishMobileGesture(state).action).toBeUndefined(); }); - it('selects the closest direction after the select radius', () => { + it('opens the option phase after the select radius', () => { const state = updateMobileGesture(beginMobileGesture(1, ORIGIN), point(RADIUS_SELECT + 1, 0)); - expect(state.phase).toBe('root'); - if (state.phase !== 'root') return; - expect(state.highlightedDirection).toBe('e'); - expect(state.primaryDirection).toBe('e'); + expect(state.phase).toBe('options'); + if (state.phase !== 'options') return; + expect(state.selectedDirection).toBe('e'); + expect(state.optionOrigin).toEqual(point(RADIUS_SELECT, 0)); + expect(finishMobileGesture(state).action).toBeUndefined(); }); it('selects Right by breaking east and returning to center', () => { - expect(runGesture([point(70, 0), point(0, 0)])).toEqual({ kind: 'input', input: 'right' }); + expect(runGesture([rootSelectionPoint('e'), optionSelectionPoint('e', 0)])).toEqual({ kind: 'input', input: 'right' }); }); it('selects End by breaking east and turning up', () => { - expect(runGesture([point(70, 0), point(0, -70)])).toEqual({ kind: 'input', input: 'end' }); + expect(runGesture([rootSelectionPoint('e'), optionSelectionPoint('e', 1)])).toEqual({ kind: 'input', input: 'end' }); + }); + + it('clears the option highlight when the drag moves back inside the highlight radius', () => { + let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('e')); + state = updateMobileGesture(state, pointInDirection(optionOrigin('e'), 'nw', RADIUS_HIGHLIGHT + 1)); + expect(state.phase).toBe('options'); + if (state.phase !== 'options') return; + expect(state.highlightedOptionIndex).toBe(1); + + state = updateMobileGesture(state, optionOrigin('e')); + expect(state.phase).toBe('options'); + if (state.phase !== 'options') return; + expect(state.highlightedOptionIndex).toBeUndefined(); + expect(state.candidate).toBeUndefined(); }); it('selects l by breaking east and turning down', () => { - expect(runGesture([point(70, 0), point(0, 70)])).toEqual({ kind: 'text', text: 'l' }); + expect(runGesture([rootSelectionPoint('e'), optionSelectionPoint('e', 2)])).toEqual({ kind: 'text', text: 'l' }); }); it('cancels when released in the original breakout direction', () => { - expect(runGesture([point(70, 0)])).toBeUndefined(); + expect(runGesture([rootSelectionPoint('e')])).toBeUndefined(); }); it('opens Ctrl+C confirmation from the northwest group', () => { - expect(runGesture([point(-70, -70), point(-70, 0)])).toEqual({ + expect(runGesture([rootSelectionPoint('nw'), optionSelectionPoint('nw', 1)])).toEqual({ kind: 'confirm', confirmation: 'ctrlC', action: { kind: 'input', input: 'ctrlC' }, @@ -87,7 +165,7 @@ describe('mobile gesture menu state machine', () => { }); it('opens paste confirmation from the northeast group', () => { - expect(runGesture([point(70, -70), point(0, -70)])).toEqual({ + expect(runGesture([rootSelectionPoint('ne'), optionSelectionPoint('ne', 1)])).toEqual({ kind: 'confirm', confirmation: 'paste', action: { kind: 'paste' }, @@ -95,28 +173,28 @@ describe('mobile gesture menu state machine', () => { }); it('uses a second breakout for quit as q', () => { - expect(runGesture([point(-70, -70), point(0, -70), point(0, 0)])).toEqual({ + expect(runGesture([rootSelectionPoint('nw'), optionSelectionPoint('nw', 2), quitSelectionPoint('nw', 2, 0)])).toEqual({ kind: 'text', text: 'q', }); }); it('uses a second breakout for quit as Ctrl+X', () => { - expect(runGesture([point(-70, -70), point(0, -70), point(-70, 0)])).toEqual({ + expect(runGesture([rootSelectionPoint('nw'), optionSelectionPoint('nw', 2), quitSelectionPoint('nw', 2, 1)])).toEqual({ kind: 'input', input: 'ctrlX', }); }); it('uses a second breakout for quit as :q enter', () => { - expect(runGesture([point(-70, -70), point(0, -70), point(70, 0)])).toEqual({ + expect(runGesture([rootSelectionPoint('nw'), optionSelectionPoint('nw', 2), quitSelectionPoint('nw', 2, 2)])).toEqual({ kind: 'text', text: ':q\r', }); }); it('selects Shift+Enter with the southeast counter-clockwise turn', () => { - expect(runGesture([point(70, 70), point(70, 0)])).toEqual({ + expect(runGesture([rootSelectionPoint('se'), optionSelectionPoint('se', 1)])).toEqual({ kind: 'input', input: 'shiftEnter', }); diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index 4557ce6..00ca52f 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -56,10 +56,10 @@ export interface MobileGestureGroup { } export interface MobileGestureCandidate { - phase: 'root' | 'quit'; + phase: 'options' | 'quit'; groupDirection: MobileGestureDirection; + direction: MobileGestureDirection; optionIndex: MobileGestureOptionIndex; - turn: 'center' | 'counterClockwise' | 'clockwise'; option: MobileGestureOption; } @@ -72,7 +72,17 @@ export type MobileGestureTrackingState = displayOrigin: MobileGesturePoint; currentPoint: MobileGesturePoint; highlightedDirection?: MobileGestureDirection; - primaryDirection?: MobileGestureDirection; + } + | { + phase: 'options'; + pointerId: number; + origin: MobileGesturePoint; + displayOrigin: MobileGesturePoint; + currentPoint: MobileGesturePoint; + selectedDirection: MobileGestureDirection; + optionOrigin: MobileGesturePoint; + displayOptionOrigin: MobileGesturePoint; + highlightedOptionIndex?: MobileGestureOptionIndex; candidate?: MobileGestureCandidate; } | { @@ -83,6 +93,9 @@ export type MobileGestureTrackingState = currentPoint: MobileGesturePoint; parentDirection: MobileGestureDirection; baseDirection: MobileGestureDirection; + optionOrigin: MobileGesturePoint; + displayOptionOrigin: MobileGesturePoint; + highlightedOptionIndex?: MobileGestureOptionIndex; candidate?: MobileGestureCandidate; }; @@ -91,14 +104,17 @@ export interface MobileGestureFinishResult { action?: MobileGestureAction; } +interface MobileGestureOptionState { + highlightedOptionIndex?: MobileGestureOptionIndex; + candidate?: MobileGestureCandidate; +} + const DIAGONAL = Math.SQRT1_2; export const MOBILE_GESTURE_IDLE_STATE: MobileGestureTrackingState = { phase: 'idle' }; export const RADIUS_LAYOUT = 92; export const RADIUS_SELECT = RADIUS_LAYOUT * 0.75; export const RADIUS_HIGHLIGHT = RADIUS_SELECT * 0.5; -export const MOBILE_GESTURE_RETURN_RADIUS = 26; -export const MOBILE_GESTURE_TURN_THRESHOLD = 0.55; export const MOBILE_GESTURE_DISPLAY_MARGIN = 112; export const MOBILE_GESTURE_THUMB_OFFSET = 132; @@ -196,6 +212,20 @@ export const MOBILE_GESTURE_GROUP_ORDER: MobileGestureDirection[] = [ 'se', ]; +export const MOBILE_GESTURE_OPTION_DIRECTIONS: Record< + MobileGestureDirection, + [MobileGestureDirection, MobileGestureDirection, MobileGestureDirection] +> = { + n: ['s', 'sw', 'se'], + ne: ['sw', 's', 'w'], + e: ['w', 'nw', 'sw'], + se: ['nw', 'n', 'w'], + s: ['n', 'ne', 'nw'], + sw: ['ne', 'n', 'e'], + w: ['e', 'se', 'ne'], + nw: ['se', 's', 'e'], +}; + export const MOBILE_GESTURE_QUIT_GROUP: MobileGestureGroup = { direction: 'n', options: [ @@ -220,47 +250,67 @@ export function directionFromVector(dx: number, dy: number): MobileGestureDirect return ANGLE_DIRECTIONS[index]; } -function candidateForBase( - phase: 'root' | 'quit', +function pointOnRadius( + origin: MobileGesturePoint, + point: MobileGesturePoint, + radius: number, +): MobileGesturePoint { + const dx = point.x - origin.x; + const dy = point.y - origin.y; + const dist = Math.hypot(dx, dy); + if (dist === 0) return origin; + const scale = radius / dist; + return { + x: origin.x + dx * scale, + y: origin.y + dy * scale, + }; +} + +function translatedPoint( + displayOrigin: MobileGesturePoint, + origin: MobileGesturePoint, + point: MobileGesturePoint, +): MobileGesturePoint { + return { + x: displayOrigin.x + point.x - origin.x, + y: displayOrigin.y + point.y - origin.y, + }; +} + +function optionIndexForDirection( + groupDirection: MobileGestureDirection, + direction: MobileGestureDirection | null, +): MobileGestureOptionIndex | undefined { + if (!direction) return undefined; + const index = MOBILE_GESTURE_OPTION_DIRECTIONS[groupDirection].indexOf(direction); + return index === -1 ? undefined : index as MobileGestureOptionIndex; +} + +function candidateForOptions( + phase: 'options' | 'quit', groupDirection: MobileGestureDirection, options: [MobileGestureOption, MobileGestureOption, MobileGestureOption], origin: MobileGesturePoint, point: MobileGesturePoint, -): MobileGestureCandidate | undefined { +): MobileGestureOptionState { const dist = distance(origin, point); - if (dist <= MOBILE_GESTURE_RETURN_RADIUS) { - return { + const direction = dist >= RADIUS_HIGHLIGHT + ? directionFromVector(point.x - origin.x, point.y - origin.y) + : null; + const highlightedOptionIndex = optionIndexForDirection(groupDirection, direction); + if (highlightedOptionIndex === undefined) return {}; + const option = options[highlightedOptionIndex]; + const result: MobileGestureOptionState = { highlightedOptionIndex }; + if (dist >= RADIUS_SELECT && direction) { + result.candidate = { phase, groupDirection, - optionIndex: 0, - turn: 'center', - option: options[0], + direction, + optionIndex: highlightedOptionIndex, + option, }; } - - const vector = MOBILE_GESTURE_DIRECTION_VECTORS[groupDirection]; - const dx = point.x - origin.x; - const dy = point.y - origin.y; - const normalizedCross = (vector.x * dy - vector.y * dx) / dist; - if (normalizedCross <= -MOBILE_GESTURE_TURN_THRESHOLD) { - return { - phase, - groupDirection, - optionIndex: 1, - turn: 'counterClockwise', - option: options[1], - }; - } - if (normalizedCross >= MOBILE_GESTURE_TURN_THRESHOLD) { - return { - phase, - groupDirection, - optionIndex: 2, - turn: 'clockwise', - option: options[2], - }; - } - return undefined; + return result; } export function beginMobileGesture( @@ -312,48 +362,75 @@ export function updateMobileGesture( const closestDirection = movementDistance >= RADIUS_HIGHLIGHT ? directionFromVector(point.x - state.origin.x, point.y - state.origin.y) ?? undefined : undefined; - const primaryDirection = state.primaryDirection - ?? (movementDistance >= RADIUS_SELECT ? closestDirection : undefined); - const highlightedDirection = primaryDirection ?? closestDirection; - const candidate = primaryDirection - ? candidateForBase('root', primaryDirection, MOBILE_GESTURE_GROUPS[primaryDirection].options, state.origin, point) - : undefined; - if (primaryDirection && candidate?.option.action.kind === 'quitMenu') { - const baseDirection = directionFromVector(point.x - state.origin.x, point.y - state.origin.y) ?? primaryDirection; + if (movementDistance >= RADIUS_SELECT && closestDirection) { + const optionOrigin = pointOnRadius(state.origin, point, RADIUS_SELECT); + return { + phase: 'options', + pointerId: state.pointerId, + origin: state.origin, + displayOrigin: state.displayOrigin, + currentPoint: point, + selectedDirection: closestDirection, + optionOrigin, + displayOptionOrigin: translatedPoint(state.displayOrigin, state.origin, optionOrigin), + }; + } + return { + ...state, + currentPoint: point, + highlightedDirection: closestDirection, + }; + } + + if (state.phase === 'options') { + const optionState = candidateForOptions( + 'options', + state.selectedDirection, + MOBILE_GESTURE_GROUPS[state.selectedDirection].options, + state.optionOrigin, + point, + ); + if (optionState.candidate?.option.action.kind === 'quitMenu') { + const quitOrigin = pointOnRadius(state.optionOrigin, point, RADIUS_SELECT); return { phase: 'quit', pointerId: state.pointerId, origin: state.origin, displayOrigin: state.displayOrigin, currentPoint: point, - parentDirection: primaryDirection, - baseDirection, + parentDirection: state.selectedDirection, + baseDirection: optionState.candidate.direction, + optionOrigin: quitOrigin, + displayOptionOrigin: translatedPoint(state.displayOptionOrigin, state.optionOrigin, quitOrigin), }; } return { ...state, currentPoint: point, - highlightedDirection, - primaryDirection, - candidate, + highlightedOptionIndex: optionState.highlightedOptionIndex, + candidate: optionState.candidate, }; } + const optionState = candidateForOptions( + 'quit', + state.baseDirection, + MOBILE_GESTURE_QUIT_GROUP.options, + state.optionOrigin, + point, + ); return { ...state, currentPoint: point, - candidate: candidateForBase( - 'quit', - state.baseDirection, - MOBILE_GESTURE_QUIT_GROUP.options, - state.origin, - point, - ), + highlightedOptionIndex: optionState.highlightedOptionIndex, + candidate: optionState.candidate, }; } export function finishMobileGesture(state: MobileGestureTrackingState): MobileGestureFinishResult { - const action = state.phase === 'idle' ? undefined : state.candidate?.option.action; + const action = state.phase === 'options' || state.phase === 'quit' + ? state.candidate?.option.action + : undefined; return { state: MOBILE_GESTURE_IDLE_STATE, action, diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index 58fa235..2ffa5d8 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -11,9 +11,14 @@ import { } from '../components/MobileGestureRadialMenu'; import { displayOriginAwayFromThumb, + MOBILE_GESTURE_DIRECTION_VECTORS, MOBILE_GESTURE_IDLE_STATE, + MOBILE_GESTURE_OPTION_DIRECTIONS, mobileGestureStateFromPoints, RADIUS_HIGHLIGHT, + RADIUS_SELECT, + type MobileGestureDirection, + type MobileGestureOptionIndex, type MobileGesturePoint, type MobileGestureTrackingState, } from '../lib/mobile-gesture-menu'; @@ -108,6 +113,61 @@ function gesturePoint(dx: number, dy: number): MobileGesturePoint { }; } +function gesturePointInDirection( + origin: MobileGesturePoint, + direction: MobileGestureDirection, + distance: number, +): MobileGesturePoint { + const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; + return { + x: origin.x + vector.x * distance, + y: origin.y + vector.y * distance, + }; +} + +function gestureRootSelectionPoint(direction: MobileGestureDirection): MobileGesturePoint { + return gesturePointInDirection(GESTURE_ORIGIN, direction, RADIUS_SELECT + 1); +} + +function gestureOptionOrigin(direction: MobileGestureDirection): MobileGesturePoint { + return gesturePointInDirection(GESTURE_ORIGIN, direction, RADIUS_SELECT); +} + +function gestureOptionSelectionPoint( + direction: MobileGestureDirection, + optionIndex: MobileGestureOptionIndex, +): MobileGesturePoint { + return gesturePointInDirection( + gestureOptionOrigin(direction), + MOBILE_GESTURE_OPTION_DIRECTIONS[direction][optionIndex], + RADIUS_SELECT + 1, + ); +} + +function gestureQuitOrigin( + direction: MobileGestureDirection, + optionIndex: MobileGestureOptionIndex, +): MobileGesturePoint { + return gesturePointInDirection( + gestureOptionOrigin(direction), + MOBILE_GESTURE_OPTION_DIRECTIONS[direction][optionIndex], + RADIUS_SELECT, + ); +} + +function gestureQuitSelectionPoint( + direction: MobileGestureDirection, + quitMenuIndex: MobileGestureOptionIndex, + optionIndex: MobileGestureOptionIndex, +): MobileGesturePoint { + const quitDirection = MOBILE_GESTURE_OPTION_DIRECTIONS[direction][quitMenuIndex]; + return gesturePointInDirection( + gestureQuitOrigin(direction, quitMenuIndex), + MOBILE_GESTURE_OPTION_DIRECTIONS[quitDirection][optionIndex], + RADIUS_SELECT + 1, + ); +} + function gestureState(points: MobileGesturePoint[]): MobileGestureTrackingState { return mobileGestureStateFromPoints( points, @@ -191,19 +251,19 @@ export const GestureEastHighlight: Story = { }; export const GesturePrimaryEast: Story = { - render: () => , + render: () => , }; export const GestureEastReturnRight: Story = { - render: () => , + render: () => , }; export const GestureEastTurnUpEnd: Story = { - render: () => , + render: () => , }; export const GestureEastTurnDownL: Story = { - render: () => , + render: () => , }; export const GestureCtrlCConfirmation: Story = { @@ -225,13 +285,13 @@ export const GesturePasteConfirmation: Story = { }; export const GestureQuitSubmenu: Story = { - render: () => , + render: () => , }; export const GestureQuitCtrlXCandidate: Story = { render: () => ( ), }; From a16478503655b914135c5d430f452defbad9e0e4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 01:09:19 -0700 Subject: [PATCH 06/29] Refine tether gesture menu chrome --- docs/specs/mobile-ui.md | 16 +++-- .../components/MobileGestureRadialMenu.tsx | 71 ++++--------------- 2 files changed, 22 insertions(+), 65 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index f685b3c..18a6180 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -102,13 +102,11 @@ Gesture mode is the default pane-content touch behavior. Tapping the pane conten opens a radial menu offset from the touch origin. The menu should appear in the opposite diagonal from the user's thumb so the compass rose fills the visible area away from the touch point. For example, a lower-right thumb press opens the -rose up and left; a lower-left thumb press opens it up and right. The center `o` -is only the menu origin marker; it is not an action. +rose up and left; a lower-left thumb press opens it up and right. -As the user drags, the UI draws a visible line from the initial thumb press to -the current thumb position. The offset compass rose may also mirror that motion -with a lighter guide line so the selected direction remains readable away from -the thumb. +As the user drags, the UI draws only the offset guide line inside the visible +compass rose. It must not draw a line directly under the user's thumb, and it +must not render a center marker that obscures the offset line. Gesture mode uses these radii: @@ -118,6 +116,12 @@ Gesture mode uses these radii: | `RADIUS_SELECT` | `RADIUS_LAYOUT * 0.75` | Visible circle drawn around the offset compass rose origin. When the mirrored drag reaches this distance, the closest compass direction is selected. | | `RADIUS_HIGHLIGHT` | `RADIUS_SELECT * 0.5` | No circle is drawn. When the drag reaches this distance, the closest compass direction is highlighted, but not selected. | +Gesture menu item state uses the same palette as pane headers. Idle groups and +options use inactive header background/foreground. Highlighted or selected +groups and options use active header background/foreground plus an inset +`color-focus-ring` ring. Layout-affecting borders must not be used to indicate +gesture selection state. + The radial menu is a two-stage gesture: 1. Touch down to open the menu. diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index dba599a..f0e003c 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -55,18 +55,16 @@ function OptionPill({ return (
{labels.map((label, index) => ( 0 && 'border-l border-border', - active && 'text-foreground', - )} + className="min-w-0 px-1.5 py-1" > {label} @@ -77,22 +75,18 @@ function OptionPill({ function OptionChip({ label, - highlighted, - selected, + active, }: { label: string; - highlighted: boolean; - selected: boolean; + active: boolean; }) { return (
{label} @@ -134,8 +128,7 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin >
); @@ -148,16 +141,6 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin className="pointer-events-none absolute inset-0 z-20 overflow-hidden" > - - - - {MOBILE_GESTURE_GROUP_ORDER.map((direction) => { @@ -225,13 +185,6 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin ); })} -
- o -
- {explodedOptions}
); From 7ac6a9ee0013a9001751d16ae64a4ecafc3e9eee Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 01:28:03 -0700 Subject: [PATCH 07/29] Send tether gesture options immediately --- docs/specs/mobile-ui.md | 12 +++++----- lib/src/components/MobileTerminalUi.tsx | 29 +++++++++++++++++++++++-- lib/src/lib/mobile-gesture-menu.test.ts | 10 ++++++++- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 18a6180..2243a3c 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -133,8 +133,8 @@ The radial menu is a two-stage gesture: 6. The selected group's three options explode out from the reset center in the opposite directions. 7. Drag from the reset center to `RADIUS_HIGHLIGHT` to preview an option. -8. Drag from the reset center to `RADIUS_SELECT` to choose that option. -9. Release to send the selected terminal input. +8. Drag from the reset center to `RADIUS_SELECT` to choose and immediately send + that option. The app must not wait for touch release. If the user releases after the first group selection but before choosing one of the exploded options, the gesture is cancelled. @@ -154,9 +154,9 @@ Exploded option directions: Examples: -* Right arrow: tap, drag right to choose the E group, drag left from the reset center, release. -* End: tap, drag right to choose the E group, drag up-left from the reset center, release. -* `l`: tap, drag right to choose the E group, drag down-left from the reset center, release. +* Right arrow: tap, drag right to choose the E group, then drag left from the reset center until it sends. +* End: tap, drag right to choose the E group, then drag up-left from the reset center until it sends. +* `l`: tap, drag right to choose the E group, then drag down-left from the reset center until it sends. Root gesture menu labels use compact key glyphs: `⌃` for Ctrl, `⬆︎` for Shift, and `▲`/`▼`/`◀`/`▶` for arrow keys. Enter, Backspace, PgUp, and PgDn @@ -167,7 +167,7 @@ Root gesture menu: ```text Esc|⌃C*|Quit** ▲|PgUp|k Backspace|Paste*|n -◀|Home|h o ▶|End|l +◀|Home|h ▶|End|l Tab|⬆︎Tab|Space ▼|PgDn|j Enter|⬆︎Enter|y ``` diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index f68bbb2..8564ab6 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -352,6 +352,7 @@ export function MobileTerminalUi({ const inputRef = useRef(null); const composingRef = useRef(false); const gestureStateRef = useRef(MOBILE_GESTURE_IDLE_STATE); + const completedGesturePointerIdRef = useRef(null); const [gestureState, setGestureState] = useState(MOBILE_GESTURE_IDLE_STATE); const [pendingGestureConfirmation, setPendingGestureConfirmation] = useState(null); const [inputValue, setInputValue] = useState(''); @@ -505,6 +506,7 @@ export function MobileTerminalUi({ event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); setPendingGestureConfirmation(null); + completedGesturePointerIdRef.current = null; const origin = localPointerPoint(event); commitGestureState(beginMobileGesture( @@ -519,11 +521,28 @@ export function MobileTerminalUi({ if (state.phase === 'idle' || state.pointerId !== event.pointerId) return; event.preventDefault(); event.stopPropagation(); - commitGestureState(updateMobileGesture(state, localPointerPoint(event))); - }, [commitGestureState]); + const nextState = updateMobileGesture(state, localPointerPoint(event)); + const result = finishMobileGesture(nextState); + if (result.action) { + completedGesturePointerIdRef.current = event.pointerId; + commitGestureState(result.state); + executeGestureAction(result.action); + return; + } + commitGestureState(nextState); + }, [commitGestureState, executeGestureAction]); const handlePanePointerUpCapture = useCallback((event: PointerEvent) => { const state = gestureStateRef.current; + if (state.phase === 'idle' && completedGesturePointerIdRef.current === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + completedGesturePointerIdRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + return; + } if (state.phase === 'idle' || state.pointerId !== event.pointerId) return; event.preventDefault(); event.stopPropagation(); @@ -542,6 +561,12 @@ export function MobileTerminalUi({ const handlePanePointerCancelCapture = useCallback((event: PointerEvent) => { const state = gestureStateRef.current; + if (completedGesturePointerIdRef.current === event.pointerId) { + completedGesturePointerIdRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + } if (state.phase === 'idle' || state.pointerId !== event.pointerId) return; commitGestureState(MOBILE_GESTURE_IDLE_STATE); }, [commitGestureState]); diff --git a/lib/src/lib/mobile-gesture-menu.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts index a1604ed..6fe528e 100644 --- a/lib/src/lib/mobile-gesture-menu.test.ts +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -126,10 +126,18 @@ describe('mobile gesture menu state machine', () => { expect(finishMobileGesture(state).action).toBeUndefined(); }); - it('selects Right by breaking east and returning to center', () => { + it('selects Right by breaking east and dragging west from the option origin', () => { expect(runGesture([rootSelectionPoint('e'), optionSelectionPoint('e', 0)])).toEqual({ kind: 'input', input: 'right' }); }); + it('makes the option action available as soon as the second select radius is crossed', () => { + let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('e')); + state = updateMobileGesture(state, optionSelectionPoint('e', 0)); + expect(state.phase).toBe('options'); + if (state.phase !== 'options') return; + expect(state.candidate?.option.action).toEqual({ kind: 'input', input: 'right' }); + }); + it('selects End by breaking east and turning up', () => { expect(runGesture([rootSelectionPoint('e'), optionSelectionPoint('e', 1)])).toEqual({ kind: 'input', input: 'end' }); }); From 76586b25549cb51f173f3bb74123ce93c9da2ae0 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 01:36:11 -0700 Subject: [PATCH 08/29] Tween tether gesture labels into options --- docs/specs/mobile-ui.md | 12 +- .../components/MobileGestureRadialMenu.tsx | 123 +++++++++--------- 2 files changed, 67 insertions(+), 68 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 2243a3c..f2883a6 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -122,6 +122,12 @@ groups and options use active header background/foreground plus an inset `color-focus-ring` ring. Layout-affecting borders must not be used to indicate gesture selection state. +Each root compass group renders as three separate labels placed close together, +not as one combined pill. When a group is selected, those same three labels tween +from their root group positions to their exploded positions in the opposite +directions. They must not fade out and be replaced by newly spawned option +labels. + The radial menu is a two-stage gesture: 1. Touch down to open the menu. @@ -165,11 +171,11 @@ remain spelled out. Root gesture menu: ```text -Esc|⌃C*|Quit** ▲|PgUp|k Backspace|Paste*|n +Esc ⌃C* Quit** ▲ PgUp k Backspace Paste* n -◀|Home|h ▶|End|l +◀ Home h ▶ End l -Tab|⬆︎Tab|Space ▼|PgDn|j Enter|⬆︎Enter|y +Tab ⬆︎Tab Space ▼ PgDn j Enter ⬆︎Enter y ``` `⌃C` and `Paste` require an in-pane confirmation modal before they run. diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index f0e003c..ab8800f 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -9,10 +9,12 @@ import { RADIUS_LAYOUT, RADIUS_SELECT, type MobileGestureDirection, + type MobileGestureOptionIndex, type MobileGestureTrackingState, } from '../lib/mobile-gesture-menu'; const QUIT_RADIUS = 78; +const ROOT_OPTION_SPACING = 48; function translatedStyle(x: number, y: number): CSSProperties { return { @@ -45,34 +47,6 @@ function translatedCurrentPoint( }; } -function OptionPill({ - labels, - active, -}: { - labels: [string, string, string]; - active: boolean; -}) { - return ( -
- {labels.map((label, index) => ( - - {label} - - ))} -
- ); -} - function OptionChip({ label, active, @@ -94,6 +68,18 @@ function OptionChip({ ); } +function rootOptionPoint( + direction: MobileGestureDirection, + index: number, + center: { x: number; y: number }, +): { x: number; y: number } { + const groupCenter = directionPoint(direction, center, RADIUS_LAYOUT); + return { + x: groupCenter.x + (index - 1) * ROOT_OPTION_SPACING, + y: groupCenter.y, + }; +} + export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackingState }) { if (state.phase === 'idle') return null; @@ -105,30 +91,58 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin : state.phase === 'options' ? state.selectedDirection : state.parentDirection; - const explodedOptions = (() => { - if (state.phase === 'root') return null; - const group = state.phase === 'options' - ? MOBILE_GESTURE_GROUPS[state.selectedDirection] - : MOBILE_GESTURE_QUIT_GROUP; - const directions = state.phase === 'options' - ? MOBILE_GESTURE_OPTION_DIRECTIONS[state.selectedDirection] - : MOBILE_GESTURE_OPTION_DIRECTIONS[state.baseDirection]; - const radius = state.phase === 'quit' ? QUIT_RADIUS : RADIUS_LAYOUT; + const rootOptions = MOBILE_GESTURE_GROUP_ORDER.flatMap((direction) => { + const group = MOBILE_GESTURE_GROUPS[direction]; + return group.options.map((option, index) => { + const optionIndex = index as MobileGestureOptionIndex; + const isSelectedGroup = state.phase === 'options' && state.selectedDirection === direction; + const point = isSelectedGroup + ? directionPoint( + MOBILE_GESTURE_OPTION_DIRECTIONS[direction][optionIndex], + phaseDisplayOrigin, + RADIUS_LAYOUT, + ) + : rootOptionPoint(direction, index, state.displayOrigin); + const active = state.phase === 'root' + ? activeRootDirection === direction + : isSelectedGroup && ( + state.highlightedOptionIndex === optionIndex + || state.candidate?.optionIndex === optionIndex + ); + const faded = state.phase === 'quit' || (state.phase === 'options' && !isSelectedGroup); + return ( +
+ +
+ ); + }); + }); + const quitOptions = (() => { + if (state.phase !== 'quit') return null; + const directions = MOBILE_GESTURE_OPTION_DIRECTIONS[state.baseDirection]; const highlightedOptionIndex = state.highlightedOptionIndex; const candidateOptionIndex = state.candidate?.optionIndex; - return group.options.map((option, index) => { - const direction = directions[index]; - const point = directionPoint(direction, phaseDisplayOrigin, radius); + return MOBILE_GESTURE_QUIT_GROUP.options.map((option, index) => { + const optionIndex = index as MobileGestureOptionIndex; + const direction = directions[optionIndex]; + const point = directionPoint(direction, phaseDisplayOrigin, QUIT_RADIUS); return (
); @@ -163,29 +177,8 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin /> - {MOBILE_GESTURE_GROUP_ORDER.map((direction) => { - const group = MOBILE_GESTURE_GROUPS[direction]; - const point = directionPoint(direction, state.displayOrigin, RADIUS_LAYOUT); - const active = activeRootDirection === direction; - const faded = state.phase !== 'root' && !active; - return ( -
- option.label) as [string, string, string]} - active={active} - /> -
- ); - })} - - {explodedOptions} + {rootOptions} + {quitOptions}
); } From 4d85be160212956b37cc48e88e380bd2d1a5280a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 08:51:10 -0700 Subject: [PATCH 09/29] Animate tether gesture completion --- docs/specs/mobile-ui.md | 6 +- .../components/MobileGestureRadialMenu.tsx | 52 ++++++++++++----- lib/src/components/MobileTerminalUi.tsx | 57 ++++++++++++++++--- lib/src/lib/mobile-gesture-menu.test.ts | 16 ++++++ lib/src/lib/mobile-gesture-menu.ts | 30 +++++++++- lib/src/stories/MobileTerminalUi.stories.tsx | 18 ++++++ 6 files changed, 155 insertions(+), 24 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index f2883a6..77f7536 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -141,6 +141,9 @@ The radial menu is a two-stage gesture: 7. Drag from the reset center to `RADIUS_HIGHLIGHT` to preview an option. 8. Drag from the reset center to `RADIUS_SELECT` to choose and immediately send that option. The app must not wait for touch release. +9. After the option sends, the radial menu remains for a short completion + animation: removed labels fade out, and the selected label expands and fades + out for positive confirmation before the overlay clears. If the user releases after the first group selection but before choosing one of the exploded options, the gesture is cancelled. @@ -187,7 +190,8 @@ q | ⌃X | :q↵ ``` The quit submenu uses the same reset-center, highlight-radius, and select-radius -rules as the main option selection. +rules as the main option selection. Its final selected item uses the same +expand-and-fade completion feedback as the root menu options. Gesture action mappings: diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index ab8800f..f5643ae 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -15,12 +15,13 @@ import { const QUIT_RADIUS = 78; const ROOT_OPTION_SPACING = 48; +const COMPLETE_SCALE = 2.4; -function translatedStyle(x: number, y: number): CSSProperties { +function translatedStyle(x: number, y: number, scale = 1): CSSProperties { return { left: x, top: y, - transform: 'translate(-50%, -50%)', + transform: `translate(-50%, -50%) scale(${scale})`, }; } @@ -90,12 +91,21 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin ? state.highlightedDirection : state.phase === 'options' ? state.selectedDirection - : state.parentDirection; + : state.phase === 'complete' + ? state.selectedDirection + : state.parentDirection; const rootOptions = MOBILE_GESTURE_GROUP_ORDER.flatMap((direction) => { const group = MOBILE_GESTURE_GROUPS[direction]; return group.options.map((option, index) => { const optionIndex = index as MobileGestureOptionIndex; - const isSelectedGroup = state.phase === 'options' && state.selectedDirection === direction; + const isCompletingRootOption = state.phase === 'complete' + && state.candidate.phase === 'options' + && state.selectedDirection === direction + && state.candidate.optionIndex === optionIndex; + const isSelectedGroup = ( + state.phase === 'options' + || (state.phase === 'complete' && state.candidate.phase === 'options') + ) && state.selectedDirection === direction; const point = isSelectedGroup ? directionPoint( MOBILE_GESTURE_OPTION_DIRECTIONS[direction][optionIndex], @@ -105,19 +115,22 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin : rootOptionPoint(direction, index, state.displayOrigin); const active = state.phase === 'root' ? activeRootDirection === direction - : isSelectedGroup && ( + : isCompletingRootOption || (isSelectedGroup && state.phase === 'options' && ( state.highlightedOptionIndex === optionIndex || state.candidate?.optionIndex === optionIndex - ); - const faded = state.phase === 'quit' || (state.phase === 'options' && !isSelectedGroup); + )); + const faded = state.phase === 'quit' + || (state.phase === 'options' && !isSelectedGroup) + || state.phase === 'complete'; return (
@@ -125,20 +138,29 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin }); }); const quitOptions = (() => { - if (state.phase !== 'quit') return null; - const directions = MOBILE_GESTURE_OPTION_DIRECTIONS[state.baseDirection]; - const highlightedOptionIndex = state.highlightedOptionIndex; - const candidateOptionIndex = state.candidate?.optionIndex; + if (state.phase !== 'quit' && !(state.phase === 'complete' && state.candidate.phase === 'quit')) return null; + const directions = MOBILE_GESTURE_OPTION_DIRECTIONS[ + state.phase === 'quit' ? state.baseDirection : state.candidate.groupDirection + ]; + const highlightedOptionIndex = state.phase === 'quit' ? state.highlightedOptionIndex : undefined; + const candidateOptionIndex = state.phase === 'quit' ? state.candidate?.optionIndex : state.candidate.optionIndex; return MOBILE_GESTURE_QUIT_GROUP.options.map((option, index) => { const optionIndex = index as MobileGestureOptionIndex; const direction = directions[optionIndex]; const point = directionPoint(direction, phaseDisplayOrigin, QUIT_RADIUS); + const isCompletingQuitOption = state.phase === 'complete' + && state.candidate.phase === 'quit' + && candidateOptionIndex === optionIndex; return (
(MOBILE_GESTURE_IDLE_STATE); const completedGesturePointerIdRef = useRef(null); + const gestureCompletionTimerRef = useRef(null); const [gestureState, setGestureState] = useState(MOBILE_GESTURE_IDLE_STATE); const [pendingGestureConfirmation, setPendingGestureConfirmation] = useState(null); const [inputValue, setInputValue] = useState(''); @@ -367,6 +370,20 @@ export function MobileTerminalUi({ setGestureState(nextState); }, []); + const clearGestureCompletionTimer = useCallback(() => { + if (gestureCompletionTimerRef.current === null) return; + window.clearTimeout(gestureCompletionTimerRef.current); + gestureCompletionTimerRef.current = null; + }, []); + + const scheduleGestureCompletionClear = useCallback(() => { + clearGestureCompletionTimer(); + gestureCompletionTimerRef.current = window.setTimeout(() => { + gestureCompletionTimerRef.current = null; + commitGestureState(MOBILE_GESTURE_IDLE_STATE); + }, MOBILE_GESTURE_COMPLETE_MS); + }, [clearGestureCompletionTimer, commitGestureState]); + const focusInput = useCallback(() => { if (!interactive) return; onFocusInput?.(); @@ -493,9 +510,12 @@ export function MobileTerminalUi({ useEffect(() => { if (touchMode === 'gestures' && interactive) return; + clearGestureCompletionTimer(); commitGestureState(MOBILE_GESTURE_IDLE_STATE); setPendingGestureConfirmation(null); - }, [commitGestureState, interactive, touchMode]); + }, [clearGestureCompletionTimer, commitGestureState, interactive, touchMode]); + + useEffect(() => clearGestureCompletionTimer, [clearGestureCompletionTimer]); const handlePanePointerDownCapture = useCallback((event: PointerEvent) => { if (isGestureDialogTarget(event.target)) return; @@ -505,6 +525,7 @@ export function MobileTerminalUi({ event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); + clearGestureCompletionTimer(); setPendingGestureConfirmation(null); completedGesturePointerIdRef.current = null; @@ -514,7 +535,7 @@ export function MobileTerminalUi({ origin, displayOriginAwayFromThumb(origin, event.currentTarget.getBoundingClientRect()), )); - }, [blurPaneTextInputs, commitGestureState, interactive, touchMode]); + }, [blurPaneTextInputs, clearGestureCompletionTimer, commitGestureState, interactive, touchMode]); const handlePanePointerMoveCapture = useCallback((event: PointerEvent) => { const state = gestureStateRef.current; @@ -524,16 +545,27 @@ export function MobileTerminalUi({ const nextState = updateMobileGesture(state, localPointerPoint(event)); const result = finishMobileGesture(nextState); if (result.action) { + const completionState = completeMobileGesture(nextState); completedGesturePointerIdRef.current = event.pointerId; - commitGestureState(result.state); + commitGestureState(completionState ?? result.state); executeGestureAction(result.action); + if (completionState) scheduleGestureCompletionClear(); return; } commitGestureState(nextState); - }, [commitGestureState, executeGestureAction]); + }, [commitGestureState, executeGestureAction, scheduleGestureCompletionClear]); const handlePanePointerUpCapture = useCallback((event: PointerEvent) => { const state = gestureStateRef.current; + if (state.phase === 'complete' && state.pointerId === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + completedGesturePointerIdRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + return; + } if (state.phase === 'idle' && completedGesturePointerIdRef.current === event.pointerId) { event.preventDefault(); event.stopPropagation(); @@ -550,10 +582,14 @@ export function MobileTerminalUi({ event.currentTarget.releasePointerCapture(event.pointerId); } - const result = finishMobileGesture(updateMobileGesture(state, localPointerPoint(event))); - commitGestureState(result.state); + const nextState = updateMobileGesture(state, localPointerPoint(event)); + const result = finishMobileGesture(nextState); + const completionState = completeMobileGesture(nextState); + completedGesturePointerIdRef.current = result.action ? event.pointerId : null; + commitGestureState(completionState ?? result.state); executeGestureAction(result.action); - }, [commitGestureState, executeGestureAction]); + if (completionState) scheduleGestureCompletionClear(); + }, [commitGestureState, executeGestureAction, scheduleGestureCompletionClear]); const handlePaneFocusStartCapture = useCallback(() => { blurPaneTextInputs(); @@ -561,6 +597,13 @@ export function MobileTerminalUi({ const handlePanePointerCancelCapture = useCallback((event: PointerEvent) => { const state = gestureStateRef.current; + if (state.phase === 'complete' && state.pointerId === event.pointerId) { + completedGesturePointerIdRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + return; + } if (completedGesturePointerIdRef.current === event.pointerId) { completedGesturePointerIdRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) { diff --git a/lib/src/lib/mobile-gesture-menu.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts index 6fe528e..16e61a7 100644 --- a/lib/src/lib/mobile-gesture-menu.test.ts +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { beginMobileGesture, + completeMobileGesture, displayOriginAwayFromThumb, finishMobileGesture, + MOBILE_GESTURE_COMPLETE_MS, MOBILE_GESTURE_DIRECTION_VECTORS, MOBILE_GESTURE_OPTION_DIRECTIONS, RADIUS_HIGHLIGHT, @@ -90,6 +92,7 @@ describe('mobile gesture menu state machine', () => { expect(RADIUS_LAYOUT).toBe(92); expect(RADIUS_SELECT).toBe(RADIUS_LAYOUT * 0.75); expect(RADIUS_HIGHLIGHT).toBe(RADIUS_SELECT * 0.5); + expect(MOBILE_GESTURE_COMPLETE_MS).toBe(220); }); it('places exploded options opposite the selected direction', () => { @@ -138,6 +141,19 @@ describe('mobile gesture menu state machine', () => { expect(state.candidate?.option.action).toEqual({ kind: 'input', input: 'right' }); }); + it('keeps a complete visual state after the second select radius is crossed', () => { + let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('e')); + state = updateMobileGesture(state, optionSelectionPoint('e', 0)); + const complete = completeMobileGesture(state); + + expect(complete?.phase).toBe('complete'); + if (!complete || complete.phase !== 'complete') return; + expect(complete.selectedDirection).toBe('e'); + expect(complete.candidate.optionIndex).toBe(0); + expect(complete.candidate.option.action).toEqual({ kind: 'input', input: 'right' }); + expect(updateMobileGesture(complete, optionSelectionPoint('e', 2))).toBe(complete); + }); + it('selects End by breaking east and turning up', () => { expect(runGesture([rootSelectionPoint('e'), optionSelectionPoint('e', 1)])).toEqual({ kind: 'input', input: 'end' }); }); diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index 00ca52f..68b14b9 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -97,6 +97,17 @@ export type MobileGestureTrackingState = displayOptionOrigin: MobileGesturePoint; highlightedOptionIndex?: MobileGestureOptionIndex; candidate?: MobileGestureCandidate; + } + | { + phase: 'complete'; + pointerId: number; + origin: MobileGesturePoint; + displayOrigin: MobileGesturePoint; + currentPoint: MobileGesturePoint; + selectedDirection: MobileGestureDirection; + optionOrigin: MobileGesturePoint; + displayOptionOrigin: MobileGesturePoint; + candidate: MobileGestureCandidate; }; export interface MobileGestureFinishResult { @@ -115,6 +126,7 @@ export const MOBILE_GESTURE_IDLE_STATE: MobileGestureTrackingState = { phase: 'i export const RADIUS_LAYOUT = 92; export const RADIUS_SELECT = RADIUS_LAYOUT * 0.75; export const RADIUS_HIGHLIGHT = RADIUS_SELECT * 0.5; +export const MOBILE_GESTURE_COMPLETE_MS = 220; export const MOBILE_GESTURE_DISPLAY_MARGIN = 112; export const MOBILE_GESTURE_THUMB_OFFSET = 132; @@ -355,7 +367,7 @@ export function updateMobileGesture( state: MobileGestureTrackingState, point: MobileGesturePoint, ): MobileGestureTrackingState { - if (state.phase === 'idle') return state; + if (state.phase === 'idle' || state.phase === 'complete') return state; if (state.phase === 'root') { const movementDistance = distance(state.origin, point); @@ -437,6 +449,22 @@ export function finishMobileGesture(state: MobileGestureTrackingState): MobileGe }; } +export function completeMobileGesture(state: MobileGestureTrackingState): MobileGestureTrackingState | undefined { + if (state.phase !== 'options' && state.phase !== 'quit') return undefined; + if (!state.candidate) return undefined; + return { + phase: 'complete', + pointerId: state.pointerId, + origin: state.origin, + displayOrigin: state.displayOrigin, + currentPoint: state.currentPoint, + selectedDirection: state.phase === 'options' ? state.selectedDirection : state.parentDirection, + optionOrigin: state.optionOrigin, + displayOptionOrigin: state.displayOptionOrigin, + candidate: state.candidate, + }; +} + export function mobileGestureStateFromPoints( points: MobileGesturePoint[], origin: MobileGesturePoint = { x: 195, y: 220 }, diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index 2ffa5d8..9ebbdb0 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -10,6 +10,7 @@ import { MobileGestureRadialMenu, } from '../components/MobileGestureRadialMenu'; import { + completeMobileGesture, displayOriginAwayFromThumb, MOBILE_GESTURE_DIRECTION_VECTORS, MOBILE_GESTURE_IDLE_STATE, @@ -176,6 +177,11 @@ function gestureState(points: MobileGesturePoint[]): MobileGestureTrackingState ); } +function gestureCompleteState(points: MobileGesturePoint[]): MobileGestureTrackingState { + const state = gestureState(points); + return completeMobileGesture(state) ?? state; +} + function GestureSnapshotFrame({ state, confirmation, @@ -258,6 +264,10 @@ export const GestureEastReturnRight: Story = { render: () => , }; +export const GestureEastReturnRightComplete: Story = { + render: () => , +}; + export const GestureEastTurnUpEnd: Story = { render: () => , }; @@ -295,3 +305,11 @@ export const GestureQuitCtrlXCandidate: Story = { /> ), }; + +export const GestureQuitCtrlXComplete: Story = { + render: () => ( + + ), +}; From c16d3e10712ee87271096713432e696d3552dc55 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 10:08:04 -0700 Subject: [PATCH 10/29] Refine tether gesture label layout --- docs/specs/mobile-ui.md | 25 +++- .../components/MobileGestureRadialMenu.tsx | 118 +++++++++++++++--- lib/src/lib/mobile-gesture-menu.ts | 6 +- 3 files changed, 123 insertions(+), 26 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 77f7536..b373ac3 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -112,7 +112,7 @@ Gesture mode uses these radii: | Variable | Value | Behavior | | --- | --- | --- | -| `RADIUS_LAYOUT` | `92px` | Distance from the offset compass rose origin to the menu item groups. | +| `RADIUS_LAYOUT` | `92px` | Base half-side for square direction anchors around the offset compass rose origin. Exploded option labels land on these anchors; root labels are packed around the same square so long labels do not overlap. | | `RADIUS_SELECT` | `RADIUS_LAYOUT * 0.75` | Visible circle drawn around the offset compass rose origin. When the mirrored drag reaches this distance, the closest compass direction is selected. | | `RADIUS_HIGHLIGHT` | `RADIUS_SELECT * 0.5` | No circle is drawn. When the drag reaches this distance, the closest compass direction is highlighted, but not selected. | @@ -128,6 +128,19 @@ from their root group positions to their exploded positions in the opposite directions. They must not fade out and be replaced by newly spawned option labels. +Root labels are laid out as a square keypad, not on a circle. N and S groups +form horizontal rows, E and W groups form vertical columns, and diagonal groups +form corner L shapes so the diagonal corner still reads as one group without +compressing all three labels into a line. Exploded option labels use the square +direction anchors directly. + +Each root cluster uses `GAP_CLUSTER = 2px`. The first option in each group is +the cluster center and is centered on the group's anchor. Items to the left are +right-aligned to the center chip's left edge plus the cluster gap; items to the +right are left-aligned to the center chip's right edge plus the cluster gap. +Vertical neighbors use the same edge-and-gap rule above or below the center +chip. + The radial menu is a two-stage gesture: 1. Touch down to open the menu. @@ -174,11 +187,15 @@ remain spelled out. Root gesture menu: ```text -Esc ⌃C* Quit** ▲ PgUp k Backspace Paste* n +Esc ⌃C* k ▲ PgUp n Backspace +Quit** Paste* -◀ Home h ▶ End l +Home End +◀ ▶ +h l -Tab ⬆︎Tab Space ▼ PgDn j Enter ⬆︎Enter y +⬆︎Tab j ▼ PgDn y ⬆︎Enter +Tab Space Enter ``` `⌃C` and `Paste` require an in-pane confirmation modal before they run. diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index f5643ae..8c4b7c4 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -1,7 +1,6 @@ import type { CSSProperties } from 'react'; import { clsx } from 'clsx'; import { - MOBILE_GESTURE_DIRECTION_VECTORS, MOBILE_GESTURE_GROUP_ORDER, MOBILE_GESTURE_GROUPS, MOBILE_GESTURE_OPTION_DIRECTIONS, @@ -10,18 +9,84 @@ import { RADIUS_SELECT, type MobileGestureDirection, type MobileGestureOptionIndex, + type MobileGesturePoint, type MobileGestureTrackingState, } from '../lib/mobile-gesture-menu'; const QUIT_RADIUS = 78; -const ROOT_OPTION_SPACING = 48; +const GAP_CLUSTER = 2; +const ROOT_CHIP_HALF_HEIGHT = 9; const COMPLETE_SCALE = 2.4; -function translatedStyle(x: number, y: number, scale = 1): CSSProperties { +const SQUARE_DIRECTION_VECTORS: Record = { + n: { x: 0, y: -1 }, + ne: { x: 1, y: -1 }, + e: { x: 1, y: 0 }, + se: { x: 1, y: 1 }, + s: { x: 0, y: 1 }, + sw: { x: -1, y: 1 }, + w: { x: -1, y: 0 }, + nw: { x: -1, y: -1 }, +}; + +const ROOT_GROUP_ANCHORS: Record = { + n: { x: 18, y: -RADIUS_LAYOUT }, + ne: { x: 132, y: -RADIUS_LAYOUT }, + e: { x: 132, y: 24 }, + se: { x: 132, y: RADIUS_LAYOUT }, + s: { x: 18, y: RADIUS_LAYOUT }, + sw: { x: -106, y: RADIUS_LAYOUT }, + w: { x: -106, y: 24 }, + nw: { x: -106, y: -RADIUS_LAYOUT }, +}; + +const ROOT_CENTER_HALF_WIDTHS: Record = { + n: 11, + ne: 35, + e: 11, + se: 23, + s: 11, + sw: 17, + w: 11, + nw: 17, +}; + +type ChipPlacement = 'center' | 'left' | 'right' | 'above' | 'below'; + +const ROOT_OPTION_PLACEMENTS: Record< + MobileGestureDirection, + [ChipPlacement, ChipPlacement, ChipPlacement] +> = { + n: ['center', 'left', 'right'], + ne: ['center', 'below', 'left'], + e: ['center', 'above', 'below'], + se: ['center', 'above', 'left'], + s: ['center', 'left', 'right'], + sw: ['center', 'above', 'right'], + w: ['center', 'above', 'below'], + nw: ['center', 'right', 'below'], +}; + +function translateForPlacement(placement: ChipPlacement): string { + switch (placement) { + case 'left': + return 'translate(-100%, -50%)'; + case 'right': + return 'translate(0, -50%)'; + case 'above': + return 'translate(-50%, -100%)'; + case 'below': + return 'translate(-50%, 0)'; + case 'center': + return 'translate(-50%, -50%)'; + } +} + +function translatedStyle(x: number, y: number, scale = 1, placement: ChipPlacement = 'center'): CSSProperties { return { left: x, top: y, - transform: `translate(-50%, -50%) scale(${scale})`, + transform: `${translateForPlacement(placement)} scale(${scale})`, }; } @@ -30,7 +95,7 @@ function directionPoint( center: { x: number; y: number }, radius: number, ): { x: number; y: number } { - const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; + const vector = SQUARE_DIRECTION_VECTORS[direction]; return { x: center.x + vector.x * radius, y: center.y + vector.y * radius, @@ -69,16 +134,23 @@ function OptionChip({ ); } -function rootOptionPoint( +function rootOptionLayout( direction: MobileGestureDirection, index: number, center: { x: number; y: number }, -): { x: number; y: number } { - const groupCenter = directionPoint(direction, center, RADIUS_LAYOUT); - return { - x: groupCenter.x + (index - 1) * ROOT_OPTION_SPACING, - y: groupCenter.y, +): { point: MobileGesturePoint; placement: ChipPlacement } { + const optionIndex = index as MobileGestureOptionIndex; + const anchor = ROOT_GROUP_ANCHORS[direction]; + const placement = ROOT_OPTION_PLACEMENTS[direction][optionIndex]; + const point = { + x: center.x + anchor.x, + y: center.y + anchor.y, }; + if (placement === 'left') point.x -= ROOT_CENTER_HALF_WIDTHS[direction] + GAP_CLUSTER; + if (placement === 'right') point.x += ROOT_CENTER_HALF_WIDTHS[direction] + GAP_CLUSTER; + if (placement === 'above') point.y -= ROOT_CHIP_HALF_HEIGHT + GAP_CLUSTER; + if (placement === 'below') point.y += ROOT_CHIP_HALF_HEIGHT + GAP_CLUSTER; + return { point, placement }; } export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackingState }) { @@ -106,13 +178,16 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin state.phase === 'options' || (state.phase === 'complete' && state.candidate.phase === 'options') ) && state.selectedDirection === direction; - const point = isSelectedGroup - ? directionPoint( - MOBILE_GESTURE_OPTION_DIRECTIONS[direction][optionIndex], - phaseDisplayOrigin, - RADIUS_LAYOUT, - ) - : rootOptionPoint(direction, index, state.displayOrigin); + const layout = isSelectedGroup + ? { + point: directionPoint( + MOBILE_GESTURE_OPTION_DIRECTIONS[direction][optionIndex], + phaseDisplayOrigin, + RADIUS_LAYOUT, + ), + placement: 'center' as ChipPlacement, + } + : rootOptionLayout(direction, index, state.displayOrigin); const active = state.phase === 'root' ? activeRootDirection === direction : isCompletingRootOption || (isSelectedGroup && state.phase === 'options' && ( @@ -130,7 +205,12 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin state.phase === 'complete' ? 'duration-200' : 'duration-150', faded ? 'opacity-0' : 'opacity-100', )} - style={translatedStyle(point.x, point.y, isCompletingRootOption ? COMPLETE_SCALE : 1)} + style={translatedStyle( + layout.point.x, + layout.point.y, + isCompletingRootOption ? COMPLETE_SCALE : 1, + layout.placement, + )} >
diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index 68b14b9..cd31054 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -127,7 +127,7 @@ export const RADIUS_LAYOUT = 92; export const RADIUS_SELECT = RADIUS_LAYOUT * 0.75; export const RADIUS_HIGHLIGHT = RADIUS_SELECT * 0.5; export const MOBILE_GESTURE_COMPLETE_MS = 220; -export const MOBILE_GESTURE_DISPLAY_MARGIN = 112; +export const MOBILE_GESTURE_DISPLAY_MARGIN = 168; export const MOBILE_GESTURE_THUMB_OFFSET = 132; export const MOBILE_GESTURE_DIRECTION_VECTORS: Record = { @@ -159,8 +159,8 @@ export const MOBILE_GESTURE_GROUPS: Record Date: Wed, 13 May 2026 10:19:03 -0700 Subject: [PATCH 11/29] Polish tether gesture story --- docs/specs/mobile-ui.md | 10 ++- .../components/MobileGestureRadialMenu.tsx | 69 ++++++++++++++++--- lib/src/stories/MobileTerminalUi.stories.tsx | 1 - 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index b373ac3..4faf75c 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -120,7 +120,12 @@ Gesture menu item state uses the same palette as pane headers. Idle groups and options use inactive header background/foreground. Highlighted or selected groups and options use active header background/foreground plus an inset `color-focus-ring` ring. Layout-affecting borders must not be used to indicate -gesture selection state. +gesture selection state. Inactive chips should have only a quiet shadow; the +heavier elevation is reserved for active chips. + +The select circle renders subtle ticks at the eight compass directions. The +current highlighted or selected direction uses a stronger tick so the circle and +label clusters read as one gesture system. Each root compass group renders as three separate labels placed close together, not as one combined pill. When a group is selected, those same three labels tween @@ -132,7 +137,8 @@ Root labels are laid out as a square keypad, not on a circle. N and S groups form horizontal rows, E and W groups form vertical columns, and diagonal groups form corner L shapes so the diagonal corner still reads as one group without compressing all three labels into a line. Exploded option labels use the square -direction anchors directly. +direction anchors directly. The root label pack stays close to the select +circle, while preserving enough room for long labels like Backspace. Each root cluster uses `GAP_CLUSTER = 2px`. The first option in each group is the cluster center and is centered on the group's anchor. Items to the left are diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 8c4b7c4..868f391 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -1,6 +1,7 @@ import type { CSSProperties } from 'react'; import { clsx } from 'clsx'; import { + MOBILE_GESTURE_DIRECTION_VECTORS, MOBILE_GESTURE_GROUP_ORDER, MOBILE_GESTURE_GROUPS, MOBILE_GESTURE_OPTION_DIRECTIONS, @@ -16,7 +17,15 @@ import { const QUIT_RADIUS = 78; const GAP_CLUSTER = 2; const ROOT_CHIP_HALF_HEIGHT = 9; +const ROOT_LABEL_CENTER_X = 18; +const ROOT_LABEL_LEFT_X = -96; +const ROOT_LABEL_RIGHT_X = 116; +const ROOT_LABEL_TOP_Y = -86; +const ROOT_LABEL_BOTTOM_Y = 86; +const ROOT_LABEL_SIDE_CENTER_Y = 22; const COMPLETE_SCALE = 2.4; +const SELECT_TICK_INSET = 5; +const SELECT_TICK_OUTSET = 6; const SQUARE_DIRECTION_VECTORS: Record = { n: { x: 0, y: -1 }, @@ -30,14 +39,14 @@ const SQUARE_DIRECTION_VECTORS: Record = { - n: { x: 18, y: -RADIUS_LAYOUT }, - ne: { x: 132, y: -RADIUS_LAYOUT }, - e: { x: 132, y: 24 }, - se: { x: 132, y: RADIUS_LAYOUT }, - s: { x: 18, y: RADIUS_LAYOUT }, - sw: { x: -106, y: RADIUS_LAYOUT }, - w: { x: -106, y: 24 }, - nw: { x: -106, y: -RADIUS_LAYOUT }, + n: { x: ROOT_LABEL_CENTER_X, y: ROOT_LABEL_TOP_Y }, + ne: { x: ROOT_LABEL_RIGHT_X, y: ROOT_LABEL_TOP_Y }, + e: { x: ROOT_LABEL_RIGHT_X, y: ROOT_LABEL_SIDE_CENTER_Y }, + se: { x: ROOT_LABEL_RIGHT_X, y: ROOT_LABEL_BOTTOM_Y }, + s: { x: ROOT_LABEL_CENTER_X, y: ROOT_LABEL_BOTTOM_Y }, + sw: { x: ROOT_LABEL_LEFT_X, y: ROOT_LABEL_BOTTOM_Y }, + w: { x: ROOT_LABEL_LEFT_X, y: ROOT_LABEL_SIDE_CENTER_Y }, + nw: { x: ROOT_LABEL_LEFT_X, y: ROOT_LABEL_TOP_Y }, }; const ROOT_CENTER_HALF_WIDTHS: Record = { @@ -67,6 +76,8 @@ const ROOT_OPTION_PLACEMENTS: Record< nw: ['center', 'right', 'below'], }; +const SELECT_TICK_DIRECTIONS: MobileGestureDirection[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; + function translateForPlacement(placement: ChipPlacement): string { switch (placement) { case 'left': @@ -126,7 +137,7 @@ function OptionChip({ 'rounded px-2 py-1 font-mono text-[10px] leading-none transition-colors', active ? 'bg-header-active-bg text-header-active-fg shadow-[inset_0_0_0_1px_var(--color-focus-ring),0_8px_28px_rgba(0,0,0,0.35)]' - : 'bg-header-inactive-bg text-header-inactive-fg shadow-[0_8px_28px_rgba(0,0,0,0.35)]', + : 'bg-header-inactive-bg text-header-inactive-fg shadow-[0_3px_12px_rgba(0,0,0,0.12)]', )} > {label} @@ -166,6 +177,43 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin : state.phase === 'complete' ? state.selectedDirection : state.parentDirection; + const activeTickDirection = (() => { + if (state.phase === 'root') return state.highlightedDirection; + if (state.phase === 'options') { + return state.candidate?.direction + ?? ( + state.highlightedOptionIndex === undefined + ? undefined + : MOBILE_GESTURE_OPTION_DIRECTIONS[state.selectedDirection][state.highlightedOptionIndex] + ); + } + if (state.phase === 'quit') { + return state.candidate?.direction + ?? ( + state.highlightedOptionIndex === undefined + ? undefined + : MOBILE_GESTURE_OPTION_DIRECTIONS[state.baseDirection][state.highlightedOptionIndex] + ); + } + return state.candidate.direction; + })(); + const selectTicks = SELECT_TICK_DIRECTIONS.map((direction) => { + const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; + const active = activeTickDirection === direction; + return ( + + ); + }); const rootOptions = MOBILE_GESTURE_GROUP_ORDER.flatMap((direction) => { const group = MOBILE_GESTURE_GROUPS[direction]; return group.options.map((option, index) => { @@ -274,9 +322,10 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin r={RADIUS_SELECT} fill="none" stroke="var(--color-focus-ring)" - strokeOpacity="0.28" + strokeOpacity="0.22" strokeWidth="1.5" /> + {selectTicks} {rootOptions} diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index 9ebbdb0..66d411b 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -191,7 +191,6 @@ function GestureSnapshotFrame({ }) { return (
- {confirmation ? ( Date: Wed, 13 May 2026 10:47:18 -0700 Subject: [PATCH 12/29] Align tether gesture cardinal labels --- docs/specs/mobile-ui.md | 19 ++--- .../components/MobileGestureRadialMenu.tsx | 73 +++++++++++++++++-- 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 4faf75c..2c16c3f 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -133,9 +133,11 @@ from their root group positions to their exploded positions in the opposite directions. They must not fade out and be replaced by newly spawned option labels. -Root labels are laid out as a square keypad, not on a circle. N and S groups -form horizontal rows, E and W groups form vertical columns, and diagonal groups -form corner L shapes so the diagonal corner still reads as one group without +Root labels are laid out as a square keypad, not on a circle. N, S, E, and W +use a hierarchical layout: the arrow chip sits closest to the select circle, and +the four arrow chips use one shared `GAP_CARDINAL_RING` from the select circle +edge. The two secondary chips sit just outside each arrow. Diagonal groups keep +corner L shapes so the diagonal corner still reads as one group without compressing all three labels into a line. Exploded option labels use the square direction anchors directly. The root label pack stays close to the select circle, while preserving enough room for long labels like Backspace. @@ -193,15 +195,14 @@ remain spelled out. Root gesture menu: ```text -Esc ⌃C* k ▲ PgUp n Backspace -Quit** Paste* +Esc ⌃C* k PgUp n Backspace +Quit** ▲ Paste* -Home End -◀ ▶ +Home ◀ ▶ End h l -⬆︎Tab j ▼ PgDn y ⬆︎Enter -Tab Space Enter +⬆︎Tab ▼ y ⬆︎Enter +Tab Space j PgDn Enter ``` `⌃C` and `Paste` require an in-pane confirmation modal before they run. diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 868f391..14a71ee 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -17,12 +17,17 @@ import { const QUIT_RADIUS = 78; const GAP_CLUSTER = 2; const ROOT_CHIP_HALF_HEIGHT = 9; -const ROOT_LABEL_CENTER_X = 18; +const ROOT_CHIP_STACK_OFFSET = ROOT_CHIP_HALF_HEIGHT * 2 + GAP_CLUSTER; +const ROOT_CHIP_HALF_WIDTH_ARROW = 11; +const GAP_CARDINAL_RING = 12; +const ROOT_CARDINAL_X = RADIUS_SELECT + ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CARDINAL_RING; +const ROOT_CARDINAL_Y = RADIUS_SELECT + ROOT_CHIP_HALF_HEIGHT + GAP_CARDINAL_RING; +const ROOT_LABEL_CENTER_X = 0; const ROOT_LABEL_LEFT_X = -96; const ROOT_LABEL_RIGHT_X = 116; const ROOT_LABEL_TOP_Y = -86; const ROOT_LABEL_BOTTOM_Y = 86; -const ROOT_LABEL_SIDE_CENTER_Y = 22; +const ROOT_LABEL_SIDE_CENTER_Y = 0; const COMPLETE_SCALE = 2.4; const SELECT_TICK_INSET = 5; const SELECT_TICK_OUTSET = 6; @@ -39,13 +44,13 @@ const SQUARE_DIRECTION_VECTORS: Record = { - n: { x: ROOT_LABEL_CENTER_X, y: ROOT_LABEL_TOP_Y }, + n: { x: ROOT_LABEL_CENTER_X, y: -ROOT_CARDINAL_Y }, ne: { x: ROOT_LABEL_RIGHT_X, y: ROOT_LABEL_TOP_Y }, - e: { x: ROOT_LABEL_RIGHT_X, y: ROOT_LABEL_SIDE_CENTER_Y }, + e: { x: ROOT_CARDINAL_X, y: ROOT_LABEL_SIDE_CENTER_Y }, se: { x: ROOT_LABEL_RIGHT_X, y: ROOT_LABEL_BOTTOM_Y }, - s: { x: ROOT_LABEL_CENTER_X, y: ROOT_LABEL_BOTTOM_Y }, + s: { x: ROOT_LABEL_CENTER_X, y: ROOT_CARDINAL_Y }, sw: { x: ROOT_LABEL_LEFT_X, y: ROOT_LABEL_BOTTOM_Y }, - w: { x: ROOT_LABEL_LEFT_X, y: ROOT_LABEL_SIDE_CENTER_Y }, + w: { x: -ROOT_CARDINAL_X, y: ROOT_LABEL_SIDE_CENTER_Y }, nw: { x: ROOT_LABEL_LEFT_X, y: ROOT_LABEL_TOP_Y }, }; @@ -77,6 +82,7 @@ const ROOT_OPTION_PLACEMENTS: Record< }; const SELECT_TICK_DIRECTIONS: MobileGestureDirection[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; +const CARDINAL_DIRECTIONS = new Set(['n', 'e', 's', 'w']); function translateForPlacement(placement: ChipPlacement): string { switch (placement) { @@ -152,6 +158,61 @@ function rootOptionLayout( ): { point: MobileGesturePoint; placement: ChipPlacement } { const optionIndex = index as MobileGestureOptionIndex; const anchor = ROOT_GROUP_ANCHORS[direction]; + if (CARDINAL_DIRECTIONS.has(direction)) { + const point = { + x: center.x + anchor.x, + y: center.y + anchor.y, + }; + if (direction === 'n') { + if (optionIndex === 1) { + point.x -= ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.y -= ROOT_CHIP_STACK_OFFSET; + return { point, placement: 'left' }; + } + if (optionIndex === 2) { + point.x += ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.y -= ROOT_CHIP_STACK_OFFSET; + return { point, placement: 'right' }; + } + } + if (direction === 's') { + if (optionIndex === 1) { + point.x -= ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.y += ROOT_CHIP_STACK_OFFSET; + return { point, placement: 'left' }; + } + if (optionIndex === 2) { + point.x += ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.y += ROOT_CHIP_STACK_OFFSET; + return { point, placement: 'right' }; + } + } + if (direction === 'e') { + if (optionIndex === 1) { + point.x += ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.y -= ROOT_CHIP_STACK_OFFSET; + return { point, placement: 'right' }; + } + if (optionIndex === 2) { + point.x += ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.y += ROOT_CHIP_STACK_OFFSET; + return { point, placement: 'right' }; + } + } + if (direction === 'w') { + if (optionIndex === 1) { + point.x -= ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.y -= ROOT_CHIP_STACK_OFFSET; + return { point, placement: 'left' }; + } + if (optionIndex === 2) { + point.x -= ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.y += ROOT_CHIP_STACK_OFFSET; + return { point, placement: 'left' }; + } + } + return { point, placement: 'center' }; + } const placement = ROOT_OPTION_PLACEMENTS[direction][optionIndex]; const point = { x: center.x + anchor.x, From 2549b86ea1a8e5ddc32dbebbe740abea07b13931 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 11:03:40 -0700 Subject: [PATCH 13/29] Tighten tether gesture cardinal gaps --- docs/specs/mobile-ui.md | 16 +++++++++------- lib/src/components/MobileGestureRadialMenu.tsx | 18 ++++++++++-------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 2c16c3f..5d60f59 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -136,11 +136,13 @@ labels. Root labels are laid out as a square keypad, not on a circle. N, S, E, and W use a hierarchical layout: the arrow chip sits closest to the select circle, and the four arrow chips use one shared `GAP_CARDINAL_RING` from the select circle -edge. The two secondary chips sit just outside each arrow. Diagonal groups keep -corner L shapes so the diagonal corner still reads as one group without -compressing all three labels into a line. Exploded option labels use the square -direction anchors directly. The root label pack stays close to the select -circle, while preserving enough room for long labels like Backspace. +edge. The two secondary chips sit just outside each arrow. N/S secondary pairs +use `GAP_CLUSTER` as the horizontal edge-to-edge gap across the axis; E/W +secondary pairs use the same `GAP_CLUSTER` as the vertical edge-to-edge gap. +Diagonal groups keep corner L shapes so the diagonal corner still reads as one +group without compressing all three labels into a line. Exploded option labels +use the square direction anchors directly. The root label pack stays close to +the select circle, while preserving enough room for long labels like Backspace. Each root cluster uses `GAP_CLUSTER = 2px`. The first option in each group is the cluster center and is centered on the group's anchor. Items to the left are @@ -195,14 +197,14 @@ remain spelled out. Root gesture menu: ```text -Esc ⌃C* k PgUp n Backspace +Esc ⌃C* k PgUp n Backspace Quit** ▲ Paste* Home ◀ ▶ End h l ⬆︎Tab ▼ y ⬆︎Enter -Tab Space j PgDn Enter +Tab Space j PgDn Enter ``` `⌃C` and `Paste` require an in-pane confirmation modal before they run. diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 14a71ee..a7a3553 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -19,6 +19,8 @@ const GAP_CLUSTER = 2; const ROOT_CHIP_HALF_HEIGHT = 9; const ROOT_CHIP_STACK_OFFSET = ROOT_CHIP_HALF_HEIGHT * 2 + GAP_CLUSTER; const ROOT_CHIP_HALF_WIDTH_ARROW = 11; +const ROOT_CLUSTER_AXIS_GAP = GAP_CLUSTER / 2; +const ROOT_SIDE_STACK_OFFSET = ROOT_CHIP_HALF_HEIGHT + ROOT_CLUSTER_AXIS_GAP; const GAP_CARDINAL_RING = 12; const ROOT_CARDINAL_X = RADIUS_SELECT + ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CARDINAL_RING; const ROOT_CARDINAL_Y = RADIUS_SELECT + ROOT_CHIP_HALF_HEIGHT + GAP_CARDINAL_RING; @@ -165,24 +167,24 @@ function rootOptionLayout( }; if (direction === 'n') { if (optionIndex === 1) { - point.x -= ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.x -= ROOT_CLUSTER_AXIS_GAP; point.y -= ROOT_CHIP_STACK_OFFSET; return { point, placement: 'left' }; } if (optionIndex === 2) { - point.x += ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.x += ROOT_CLUSTER_AXIS_GAP; point.y -= ROOT_CHIP_STACK_OFFSET; return { point, placement: 'right' }; } } if (direction === 's') { if (optionIndex === 1) { - point.x -= ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.x -= ROOT_CLUSTER_AXIS_GAP; point.y += ROOT_CHIP_STACK_OFFSET; return { point, placement: 'left' }; } if (optionIndex === 2) { - point.x += ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; + point.x += ROOT_CLUSTER_AXIS_GAP; point.y += ROOT_CHIP_STACK_OFFSET; return { point, placement: 'right' }; } @@ -190,24 +192,24 @@ function rootOptionLayout( if (direction === 'e') { if (optionIndex === 1) { point.x += ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; - point.y -= ROOT_CHIP_STACK_OFFSET; + point.y -= ROOT_SIDE_STACK_OFFSET; return { point, placement: 'right' }; } if (optionIndex === 2) { point.x += ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; - point.y += ROOT_CHIP_STACK_OFFSET; + point.y += ROOT_SIDE_STACK_OFFSET; return { point, placement: 'right' }; } } if (direction === 'w') { if (optionIndex === 1) { point.x -= ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; - point.y -= ROOT_CHIP_STACK_OFFSET; + point.y -= ROOT_SIDE_STACK_OFFSET; return { point, placement: 'left' }; } if (optionIndex === 2) { point.x -= ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; - point.y += ROOT_CHIP_STACK_OFFSET; + point.y += ROOT_SIDE_STACK_OFFSET; return { point, placement: 'left' }; } } From bc4c78f910fa31cd30188212e0009a189dcb2d66 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 11:08:38 -0700 Subject: [PATCH 14/29] Fix tether gesture explode directions --- docs/specs/mobile-ui.md | 4 ++-- lib/src/lib/mobile-gesture-menu.test.ts | 12 ++++++++++++ lib/src/lib/mobile-gesture-menu.ts | 4 ++-- lib/src/stories/MobileTerminalUi.stories.tsx | 8 ++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 5d60f59..7630cd9 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -179,9 +179,9 @@ Exploded option directions: | NE | SW, S, W | | E | W, NW, SW | | SE | NW, N, W | -| S | N, NE, NW | +| S | N, NW, NE | | SW | NE, N, E | -| W | E, SE, NE | +| W | E, NE, SE | | NW | SE, S, E | Examples: diff --git a/lib/src/lib/mobile-gesture-menu.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts index 16e61a7..7007990 100644 --- a/lib/src/lib/mobile-gesture-menu.test.ts +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -98,6 +98,8 @@ describe('mobile gesture menu state machine', () => { it('places exploded options opposite the selected direction', () => { expect(MOBILE_GESTURE_OPTION_DIRECTIONS.n).toEqual(['s', 'sw', 'se']); expect(MOBILE_GESTURE_OPTION_DIRECTIONS.e).toEqual(['w', 'nw', 'sw']); + expect(MOBILE_GESTURE_OPTION_DIRECTIONS.s).toEqual(['n', 'nw', 'ne']); + expect(MOBILE_GESTURE_OPTION_DIRECTIONS.w).toEqual(['e', 'ne', 'se']); expect(MOBILE_GESTURE_OPTION_DIRECTIONS.ne).toEqual(['sw', 's', 'w']); }); @@ -176,6 +178,16 @@ describe('mobile gesture menu state machine', () => { expect(runGesture([rootSelectionPoint('e'), optionSelectionPoint('e', 2)])).toEqual({ kind: 'text', text: 'l' }); }); + it('keeps south secondary options on their root side when exploded', () => { + expect(runGesture([rootSelectionPoint('s'), optionSelectionPoint('s', 1)])).toEqual({ kind: 'text', text: 'j' }); + expect(runGesture([rootSelectionPoint('s'), optionSelectionPoint('s', 2)])).toEqual({ kind: 'input', input: 'pageDown' }); + }); + + it('keeps west secondary options on their root side when exploded', () => { + expect(runGesture([rootSelectionPoint('w'), optionSelectionPoint('w', 1)])).toEqual({ kind: 'input', input: 'home' }); + expect(runGesture([rootSelectionPoint('w'), optionSelectionPoint('w', 2)])).toEqual({ kind: 'text', text: 'h' }); + }); + it('cancels when released in the original breakout direction', () => { expect(runGesture([rootSelectionPoint('e')])).toBeUndefined(); }); diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index cd31054..ef028cf 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -232,9 +232,9 @@ export const MOBILE_GESTURE_OPTION_DIRECTIONS: Record< ne: ['sw', 's', 'w'], e: ['w', 'nw', 'sw'], se: ['nw', 'n', 'w'], - s: ['n', 'ne', 'nw'], + s: ['n', 'nw', 'ne'], sw: ['ne', 'n', 'e'], - w: ['e', 'se', 'ne'], + w: ['e', 'ne', 'se'], nw: ['se', 's', 'e'], }; diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index 66d411b..d019c1c 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -259,6 +259,14 @@ export const GesturePrimaryEast: Story = { render: () => , }; +export const GesturePrimarySouth: Story = { + render: () => , +}; + +export const GesturePrimaryWest: Story = { + render: () => , +}; + export const GestureEastReturnRight: Story = { render: () => , }; From 36e9a29b1f489bd8f3f9ca5e9470274fd6e9c06a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 12:14:53 -0700 Subject: [PATCH 15/29] Align tether gesture diagonal corners --- docs/specs/mobile-ui.md | 27 ++-- .../components/MobileGestureRadialMenu.tsx | 124 +++++++++++------- lib/src/stories/MobileTerminalUi.stories.tsx | 16 +++ 3 files changed, 112 insertions(+), 55 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 7630cd9..5b16859 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -139,17 +139,26 @@ the four arrow chips use one shared `GAP_CARDINAL_RING` from the select circle edge. The two secondary chips sit just outside each arrow. N/S secondary pairs use `GAP_CLUSTER` as the horizontal edge-to-edge gap across the axis; E/W secondary pairs use the same `GAP_CLUSTER` as the vertical edge-to-edge gap. -Diagonal groups keep corner L shapes so the diagonal corner still reads as one -group without compressing all three labels into a line. Exploded option labels -use the square direction anchors directly. The root label pack stays close to -the select circle, while preserving enough room for long labels like Backspace. +Diagonal groups use a corner-and-tangent layout: the center option's inward +corner is aligned with the diagonal tick mark at the same ring gap used by the +cardinal arrow chips, measured on screen as the same horizontal/vertical visual +gap rather than as a longer diagonal distance. The other two options sit above +and below it along the perpendicular 45 degree tangent. For example, the SE +group puts Enter's top-left corner just outside the SE tick, puts Shift+Enter +above and to the right, and puts `y` below and to the left. The diagonal center +corner contract is: SE aligns Enter's top-left corner, NE aligns Backspace's +bottom-left corner, SW aligns Tab's top-right corner, and NW aligns Esc's +bottom-right corner. Exploded option labels use the square direction anchors +directly. The root label pack stays close to the select circle, while preserving +enough room for long labels like Backspace. Each root cluster uses `GAP_CLUSTER = 2px`. The first option in each group is -the cluster center and is centered on the group's anchor. Items to the left are -right-aligned to the center chip's left edge plus the cluster gap; items to the -right are left-aligned to the center chip's right edge plus the cluster gap. -Vertical neighbors use the same edge-and-gap rule above or below the center -chip. +the cluster center. For N/S/E/W groups, items to the left are right-aligned to +the center chip's left edge plus the cluster gap; items to the right are +left-aligned to the center chip's right edge plus the cluster gap. Vertical +neighbors use the same edge-and-gap rule above or below the center chip. +Diagonal groups use the tick-corner and 45 degree tangent rule above instead of +the cardinal edge-and-gap rule. The radial menu is a two-stage gesture: diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index a7a3553..2e3aa9d 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -25,14 +25,12 @@ const GAP_CARDINAL_RING = 12; const ROOT_CARDINAL_X = RADIUS_SELECT + ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CARDINAL_RING; const ROOT_CARDINAL_Y = RADIUS_SELECT + ROOT_CHIP_HALF_HEIGHT + GAP_CARDINAL_RING; const ROOT_LABEL_CENTER_X = 0; -const ROOT_LABEL_LEFT_X = -96; -const ROOT_LABEL_RIGHT_X = 116; -const ROOT_LABEL_TOP_Y = -86; -const ROOT_LABEL_BOTTOM_Y = 86; const ROOT_LABEL_SIDE_CENTER_Y = 0; const COMPLETE_SCALE = 2.4; const SELECT_TICK_INSET = 5; const SELECT_TICK_OUTSET = 6; +const ROOT_DIAGONAL_CORNER_RADIUS = RADIUS_SELECT + SELECT_TICK_OUTSET + GAP_CARDINAL_RING * Math.SQRT1_2; +const ROOT_DIAGONAL_TANGENT_OFFSET = 18; const SQUARE_DIRECTION_VECTORS: Record = { n: { x: 0, y: -1 }, @@ -45,46 +43,65 @@ const SQUARE_DIRECTION_VECTORS: Record = { +const ROOT_CARDINAL_ANCHORS: Partial> = { n: { x: ROOT_LABEL_CENTER_X, y: -ROOT_CARDINAL_Y }, - ne: { x: ROOT_LABEL_RIGHT_X, y: ROOT_LABEL_TOP_Y }, e: { x: ROOT_CARDINAL_X, y: ROOT_LABEL_SIDE_CENTER_Y }, - se: { x: ROOT_LABEL_RIGHT_X, y: ROOT_LABEL_BOTTOM_Y }, s: { x: ROOT_LABEL_CENTER_X, y: ROOT_CARDINAL_Y }, - sw: { x: ROOT_LABEL_LEFT_X, y: ROOT_LABEL_BOTTOM_Y }, w: { x: -ROOT_CARDINAL_X, y: ROOT_LABEL_SIDE_CENTER_Y }, - nw: { x: ROOT_LABEL_LEFT_X, y: ROOT_LABEL_TOP_Y }, }; -const ROOT_CENTER_HALF_WIDTHS: Record = { - n: 11, - ne: 35, - e: 11, - se: 23, - s: 11, - sw: 17, - w: 11, - nw: 17, -}; - -type ChipPlacement = 'center' | 'left' | 'right' | 'above' | 'below'; +type ChipPlacement = + | 'center' + | 'left' + | 'right' + | 'above' + | 'below' + | 'topLeft' + | 'topRight' + | 'bottomLeft' + | 'bottomRight'; -const ROOT_OPTION_PLACEMENTS: Record< +const ROOT_DIAGONAL_LAYOUT: Partial = { - n: ['center', 'left', 'right'], - ne: ['center', 'below', 'left'], - e: ['center', 'above', 'below'], - se: ['center', 'above', 'left'], - s: ['center', 'left', 'right'], - sw: ['center', 'above', 'right'], - w: ['center', 'above', 'below'], - nw: ['center', 'right', 'below'], + { + centerPlacement: ChipPlacement; + options: [ + { offset: MobileGesturePoint; placement: ChipPlacement }, + { offset: MobileGesturePoint; placement: ChipPlacement }, + ]; + } +>> = { + ne: { + centerPlacement: 'bottomLeft', + options: [ + { offset: { x: ROOT_DIAGONAL_TANGENT_OFFSET, y: ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'topLeft' }, + { offset: { x: -ROOT_DIAGONAL_TANGENT_OFFSET, y: -ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'bottomRight' }, + ], + }, + se: { + centerPlacement: 'topLeft', + options: [ + { offset: { x: ROOT_DIAGONAL_TANGENT_OFFSET, y: -ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'bottomLeft' }, + { offset: { x: -ROOT_DIAGONAL_TANGENT_OFFSET, y: ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'topRight' }, + ], + }, + sw: { + centerPlacement: 'topRight', + options: [ + { offset: { x: -ROOT_DIAGONAL_TANGENT_OFFSET, y: -ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'bottomRight' }, + { offset: { x: ROOT_DIAGONAL_TANGENT_OFFSET, y: ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'topLeft' }, + ], + }, + nw: { + centerPlacement: 'bottomRight', + options: [ + { offset: { x: ROOT_DIAGONAL_TANGENT_OFFSET, y: -ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'bottomLeft' }, + { offset: { x: -ROOT_DIAGONAL_TANGENT_OFFSET, y: ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'topRight' }, + ], + }, }; const SELECT_TICK_DIRECTIONS: MobileGestureDirection[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; -const CARDINAL_DIRECTIONS = new Set(['n', 'e', 's', 'w']); function translateForPlacement(placement: ChipPlacement): string { switch (placement) { @@ -96,6 +113,14 @@ function translateForPlacement(placement: ChipPlacement): string { return 'translate(-50%, -100%)'; case 'below': return 'translate(-50%, 0)'; + case 'topLeft': + return 'translate(0, 0)'; + case 'topRight': + return 'translate(-100%, 0)'; + case 'bottomLeft': + return 'translate(0, -100%)'; + case 'bottomRight': + return 'translate(-100%, -100%)'; case 'center': return 'translate(-50%, -50%)'; } @@ -159,11 +184,11 @@ function rootOptionLayout( center: { x: number; y: number }, ): { point: MobileGesturePoint; placement: ChipPlacement } { const optionIndex = index as MobileGestureOptionIndex; - const anchor = ROOT_GROUP_ANCHORS[direction]; - if (CARDINAL_DIRECTIONS.has(direction)) { + const cardinalAnchor = ROOT_CARDINAL_ANCHORS[direction]; + if (cardinalAnchor) { const point = { - x: center.x + anchor.x, - y: center.y + anchor.y, + x: center.x + cardinalAnchor.x, + y: center.y + cardinalAnchor.y, }; if (direction === 'n') { if (optionIndex === 1) { @@ -215,16 +240,23 @@ function rootOptionLayout( } return { point, placement: 'center' }; } - const placement = ROOT_OPTION_PLACEMENTS[direction][optionIndex]; - const point = { - x: center.x + anchor.x, - y: center.y + anchor.y, + const diagonalLayout = ROOT_DIAGONAL_LAYOUT[direction]; + const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; + const corner = { + x: center.x + vector.x * ROOT_DIAGONAL_CORNER_RADIUS, + y: center.y + vector.y * ROOT_DIAGONAL_CORNER_RADIUS, + }; + if (!diagonalLayout || optionIndex === 0) { + return { point: corner, placement: diagonalLayout?.centerPlacement ?? 'center' }; + } + const optionLayout = diagonalLayout.options[optionIndex - 1]; + return { + point: { + x: corner.x + optionLayout.offset.x, + y: corner.y + optionLayout.offset.y, + }, + placement: optionLayout.placement, }; - if (placement === 'left') point.x -= ROOT_CENTER_HALF_WIDTHS[direction] + GAP_CLUSTER; - if (placement === 'right') point.x += ROOT_CENTER_HALF_WIDTHS[direction] + GAP_CLUSTER; - if (placement === 'above') point.y -= ROOT_CHIP_HALF_HEIGHT + GAP_CLUSTER; - if (placement === 'below') point.y += ROOT_CHIP_HALF_HEIGHT + GAP_CLUSTER; - return { point, placement }; } export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackingState }) { diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index d019c1c..0b7f622 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -259,14 +259,30 @@ export const GesturePrimaryEast: Story = { render: () => , }; +export const GesturePrimaryNortheast: Story = { + render: () => , +}; + +export const GesturePrimarySoutheast: Story = { + render: () => , +}; + export const GesturePrimarySouth: Story = { render: () => , }; +export const GesturePrimarySouthwest: Story = { + render: () => , +}; + export const GesturePrimaryWest: Story = { render: () => , }; +export const GesturePrimaryNorthwest: Story = { + render: () => , +}; + export const GestureEastReturnRight: Story = { render: () => , }; From 5fe5753d900b6e4279c8372595d293e9887ec6ed Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 12:29:19 -0700 Subject: [PATCH 16/29] Stack tether gesture diagonal subitems --- docs/specs/mobile-ui.md | 30 ++++----- .../components/MobileGestureRadialMenu.tsx | 63 +++++++++++-------- 2 files changed, 54 insertions(+), 39 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 5b16859..df4335c 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -139,26 +139,28 @@ the four arrow chips use one shared `GAP_CARDINAL_RING` from the select circle edge. The two secondary chips sit just outside each arrow. N/S secondary pairs use `GAP_CLUSTER` as the horizontal edge-to-edge gap across the axis; E/W secondary pairs use the same `GAP_CLUSTER` as the vertical edge-to-edge gap. -Diagonal groups use a corner-and-tangent layout: the center option's inward -corner is aligned with the diagonal tick mark at the same ring gap used by the -cardinal arrow chips, measured on screen as the same horizontal/vertical visual -gap rather than as a longer diagonal distance. The other two options sit above -and below it along the perpendicular 45 degree tangent. For example, the SE -group puts Enter's top-left corner just outside the SE tick, puts Shift+Enter -above and to the right, and puts `y` below and to the left. The diagonal center -corner contract is: SE aligns Enter's top-left corner, NE aligns Backspace's -bottom-left corner, SW aligns Tab's top-right corner, and NW aligns Esc's -bottom-right corner. Exploded option labels use the square direction anchors -directly. The root label pack stays close to the select circle, while preserving -enough room for long labels like Backspace. +Diagonal groups use a corner-and-stack layout: the center option's inward corner +is aligned with the diagonal tick mark at the same ring gap used by the cardinal +arrow chips, measured on screen as the same horizontal/vertical visual gap rather +than as a longer diagonal distance. The diagonal center corner contract is: SE +aligns Enter's top-left corner, NE aligns Backspace's bottom-left corner, SW +aligns Tab's top-right corner, and NW aligns Esc's bottom-right corner. SE and +SW place their secondary options relative to the center option exactly like the +S cluster places `j` and `PgDn` relative to `▼`: both below the center, one +right-aligned to the center axis and one left-aligned to it. NE and NW place +their secondary options exactly like the N cluster places `k` and `PgUp` +relative to `▲`: both above the center with the same left/right alignment. +Exploded option labels use the square direction anchors directly. The root label +pack stays close to the select circle, while preserving enough room for long +labels like Backspace. Each root cluster uses `GAP_CLUSTER = 2px`. The first option in each group is the cluster center. For N/S/E/W groups, items to the left are right-aligned to the center chip's left edge plus the cluster gap; items to the right are left-aligned to the center chip's right edge plus the cluster gap. Vertical neighbors use the same edge-and-gap rule above or below the center chip. -Diagonal groups use the tick-corner and 45 degree tangent rule above instead of -the cardinal edge-and-gap rule. +Diagonal groups combine the tick-corner rule above with the same secondary-stack +rule used by the matching N or S cardinal group. The radial menu is a two-stage gesture: diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 2e3aa9d..c10833f 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -30,7 +30,6 @@ const COMPLETE_SCALE = 2.4; const SELECT_TICK_INSET = 5; const SELECT_TICK_OUTSET = 6; const ROOT_DIAGONAL_CORNER_RADIUS = RADIUS_SELECT + SELECT_TICK_OUTSET + GAP_CARDINAL_RING * Math.SQRT1_2; -const ROOT_DIAGONAL_TANGENT_OFFSET = 18; const SQUARE_DIRECTION_VECTORS: Record = { n: { x: 0, y: -1 }, @@ -65,39 +64,29 @@ const ROOT_DIAGONAL_LAYOUT: Partial> = { ne: { centerPlacement: 'bottomLeft', - options: [ - { offset: { x: ROOT_DIAGONAL_TANGENT_OFFSET, y: ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'topLeft' }, - { offset: { x: -ROOT_DIAGONAL_TANGENT_OFFSET, y: -ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'bottomRight' }, - ], + centerHalfWidth: 35, + secondaryStack: 'above', }, se: { centerPlacement: 'topLeft', - options: [ - { offset: { x: ROOT_DIAGONAL_TANGENT_OFFSET, y: -ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'bottomLeft' }, - { offset: { x: -ROOT_DIAGONAL_TANGENT_OFFSET, y: ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'topRight' }, - ], + centerHalfWidth: 23, + secondaryStack: 'below', }, sw: { centerPlacement: 'topRight', - options: [ - { offset: { x: -ROOT_DIAGONAL_TANGENT_OFFSET, y: -ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'bottomRight' }, - { offset: { x: ROOT_DIAGONAL_TANGENT_OFFSET, y: ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'topLeft' }, - ], + centerHalfWidth: 17, + secondaryStack: 'below', }, nw: { centerPlacement: 'bottomRight', - options: [ - { offset: { x: ROOT_DIAGONAL_TANGENT_OFFSET, y: -ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'bottomLeft' }, - { offset: { x: -ROOT_DIAGONAL_TANGENT_OFFSET, y: ROOT_DIAGONAL_TANGENT_OFFSET }, placement: 'topRight' }, - ], + centerHalfWidth: 17, + secondaryStack: 'above', }, }; @@ -134,6 +123,25 @@ function translatedStyle(x: number, y: number, scale = 1, placement: ChipPlaceme }; } +function centerFromPlacedCorner( + corner: MobileGesturePoint, + placement: ChipPlacement, + halfWidth: number, +): MobileGesturePoint { + switch (placement) { + case 'topLeft': + return { x: corner.x + halfWidth, y: corner.y + ROOT_CHIP_HALF_HEIGHT }; + case 'topRight': + return { x: corner.x - halfWidth, y: corner.y + ROOT_CHIP_HALF_HEIGHT }; + case 'bottomLeft': + return { x: corner.x + halfWidth, y: corner.y - ROOT_CHIP_HALF_HEIGHT }; + case 'bottomRight': + return { x: corner.x - halfWidth, y: corner.y - ROOT_CHIP_HALF_HEIGHT }; + default: + return corner; + } +} + function directionPoint( direction: MobileGestureDirection, center: { x: number; y: number }, @@ -249,13 +257,18 @@ function rootOptionLayout( if (!diagonalLayout || optionIndex === 0) { return { point: corner, placement: diagonalLayout?.centerPlacement ?? 'center' }; } - const optionLayout = diagonalLayout.options[optionIndex - 1]; + const centerPoint = centerFromPlacedCorner( + corner, + diagonalLayout.centerPlacement, + diagonalLayout.centerHalfWidth, + ); + const stackDirection = diagonalLayout.secondaryStack === 'above' ? -1 : 1; return { point: { - x: corner.x + optionLayout.offset.x, - y: corner.y + optionLayout.offset.y, + x: centerPoint.x + (optionIndex === 1 ? -ROOT_CLUSTER_AXIS_GAP : ROOT_CLUSTER_AXIS_GAP), + y: centerPoint.y + stackDirection * ROOT_CHIP_STACK_OFFSET, }, - placement: optionLayout.placement, + placement: optionIndex === 1 ? 'left' : 'right', }; } From e4e309017c19a7093d0da16ba2fada15e7827dab Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 12:35:29 -0700 Subject: [PATCH 17/29] Make tether gesture diagonals EW dominant --- docs/specs/mobile-ui.md | 21 +++++++++---------- .../components/MobileGestureRadialMenu.tsx | 18 ++++++++-------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index df4335c..839539e 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -139,20 +139,19 @@ the four arrow chips use one shared `GAP_CARDINAL_RING` from the select circle edge. The two secondary chips sit just outside each arrow. N/S secondary pairs use `GAP_CLUSTER` as the horizontal edge-to-edge gap across the axis; E/W secondary pairs use the same `GAP_CLUSTER` as the vertical edge-to-edge gap. -Diagonal groups use a corner-and-stack layout: the center option's inward corner +Diagonal groups use an EW-dominant corner-and-stack layout: the center option's inward corner is aligned with the diagonal tick mark at the same ring gap used by the cardinal arrow chips, measured on screen as the same horizontal/vertical visual gap rather than as a longer diagonal distance. The diagonal center corner contract is: SE aligns Enter's top-left corner, NE aligns Backspace's bottom-left corner, SW -aligns Tab's top-right corner, and NW aligns Esc's bottom-right corner. SE and -SW place their secondary options relative to the center option exactly like the -S cluster places `j` and `PgDn` relative to `▼`: both below the center, one -right-aligned to the center axis and one left-aligned to it. NE and NW place -their secondary options exactly like the N cluster places `k` and `PgUp` -relative to `▲`: both above the center with the same left/right alignment. -Exploded option labels use the square direction anchors directly. The root label -pack stays close to the select circle, while preserving enough room for long -labels like Backspace. +aligns Tab's top-right corner, and NW aligns Esc's bottom-right corner. NE and +SE place their secondary options relative to the center option exactly like the +E cluster places `End` and `l` relative to `▶`: both to the right of the center, +one above and one below. NW and SW place their secondary options exactly like the +W cluster places `Home` and `h` relative to `◀`: both to the left of the center, +one above and one below. Exploded option labels use the square direction anchors +directly. The root label pack stays close to the select circle, while preserving +enough room for long labels like Backspace. Each root cluster uses `GAP_CLUSTER = 2px`. The first option in each group is the cluster center. For N/S/E/W groups, items to the left are right-aligned to @@ -160,7 +159,7 @@ the center chip's left edge plus the cluster gap; items to the right are left-aligned to the center chip's right edge plus the cluster gap. Vertical neighbors use the same edge-and-gap rule above or below the center chip. Diagonal groups combine the tick-corner rule above with the same secondary-stack -rule used by the matching N or S cardinal group. +rule used by the matching E or W cardinal group. The radial menu is a two-stage gesture: diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index c10833f..2a048bd 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -65,28 +65,28 @@ const ROOT_DIAGONAL_LAYOUT: Partial> = { ne: { centerPlacement: 'bottomLeft', centerHalfWidth: 35, - secondaryStack: 'above', + secondarySide: 'east', }, se: { centerPlacement: 'topLeft', centerHalfWidth: 23, - secondaryStack: 'below', + secondarySide: 'east', }, sw: { centerPlacement: 'topRight', centerHalfWidth: 17, - secondaryStack: 'below', + secondarySide: 'west', }, nw: { centerPlacement: 'bottomRight', centerHalfWidth: 17, - secondaryStack: 'above', + secondarySide: 'west', }, }; @@ -262,13 +262,13 @@ function rootOptionLayout( diagonalLayout.centerPlacement, diagonalLayout.centerHalfWidth, ); - const stackDirection = diagonalLayout.secondaryStack === 'above' ? -1 : 1; + const sideDirection = diagonalLayout.secondarySide === 'east' ? 1 : -1; return { point: { - x: centerPoint.x + (optionIndex === 1 ? -ROOT_CLUSTER_AXIS_GAP : ROOT_CLUSTER_AXIS_GAP), - y: centerPoint.y + stackDirection * ROOT_CHIP_STACK_OFFSET, + x: centerPoint.x + sideDirection * (diagonalLayout.centerHalfWidth + GAP_CLUSTER), + y: centerPoint.y + (optionIndex === 1 ? -ROOT_SIDE_STACK_OFFSET : ROOT_SIDE_STACK_OFFSET), }, - placement: optionIndex === 1 ? 'left' : 'right', + placement: diagonalLayout.secondarySide === 'east' ? 'right' : 'left', }; } From 536a8aeaddb1912dfccf86f4a0f0ed3dc771316e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 12:40:17 -0700 Subject: [PATCH 18/29] Swap tether gesture north diagonal explode positions --- docs/specs/mobile-ui.md | 4 ++-- lib/src/lib/mobile-gesture-menu.test.ts | 3 ++- lib/src/lib/mobile-gesture-menu.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 839539e..295c191 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -186,13 +186,13 @@ Exploded option directions: | Selected group | Option directions | | --- | --- | | N | S, SW, SE | -| NE | SW, S, W | +| NE | SW, W, S | | E | W, NW, SW | | SE | NW, N, W | | S | N, NW, NE | | SW | NE, N, E | | W | E, NE, SE | -| NW | SE, S, E | +| NW | SE, E, S | Examples: diff --git a/lib/src/lib/mobile-gesture-menu.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts index 7007990..23bbe87 100644 --- a/lib/src/lib/mobile-gesture-menu.test.ts +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -100,7 +100,8 @@ describe('mobile gesture menu state machine', () => { expect(MOBILE_GESTURE_OPTION_DIRECTIONS.e).toEqual(['w', 'nw', 'sw']); expect(MOBILE_GESTURE_OPTION_DIRECTIONS.s).toEqual(['n', 'nw', 'ne']); expect(MOBILE_GESTURE_OPTION_DIRECTIONS.w).toEqual(['e', 'ne', 'se']); - expect(MOBILE_GESTURE_OPTION_DIRECTIONS.ne).toEqual(['sw', 's', 'w']); + expect(MOBILE_GESTURE_OPTION_DIRECTIONS.ne).toEqual(['sw', 'w', 's']); + expect(MOBILE_GESTURE_OPTION_DIRECTIONS.nw).toEqual(['se', 'e', 's']); }); it('cancels a tap that never breaks out', () => { diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index ef028cf..2a40e8a 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -229,13 +229,13 @@ export const MOBILE_GESTURE_OPTION_DIRECTIONS: Record< [MobileGestureDirection, MobileGestureDirection, MobileGestureDirection] > = { n: ['s', 'sw', 'se'], - ne: ['sw', 's', 'w'], + ne: ['sw', 'w', 's'], e: ['w', 'nw', 'sw'], se: ['nw', 'n', 'w'], s: ['n', 'nw', 'ne'], sw: ['ne', 'n', 'e'], w: ['e', 'ne', 'se'], - nw: ['se', 's', 'e'], + nw: ['se', 'e', 's'], }; export const MOBILE_GESTURE_QUIT_GROUP: MobileGestureGroup = { From f9063c745882c78763b7f597744119cac060a426 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 13:01:17 -0700 Subject: [PATCH 19/29] Simplify tether gesture cleanup - Extract MobileGestureConfirmation type alias - Derive squareDirectionVector from existing unit vectors - Replace 'east'|'west' string side with 1|-1 sign - Drop dead hasPointerCapture prechecks - Collapse two grid-cols-4 key rows into single grid Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/MobileGestureRadialMenu.tsx | 34 ++++++------- lib/src/components/MobileTerminalUi.tsx | 50 ++++++------------- lib/src/lib/mobile-gesture-menu.ts | 6 ++- 3 files changed, 32 insertions(+), 58 deletions(-) diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 2a048bd..b534e1d 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -8,6 +8,7 @@ import { MOBILE_GESTURE_QUIT_GROUP, RADIUS_LAYOUT, RADIUS_SELECT, + type MobileGestureConfirmation, type MobileGestureDirection, type MobileGestureOptionIndex, type MobileGesturePoint, @@ -31,16 +32,10 @@ const SELECT_TICK_INSET = 5; const SELECT_TICK_OUTSET = 6; const ROOT_DIAGONAL_CORNER_RADIUS = RADIUS_SELECT + SELECT_TICK_OUTSET + GAP_CARDINAL_RING * Math.SQRT1_2; -const SQUARE_DIRECTION_VECTORS: Record = { - n: { x: 0, y: -1 }, - ne: { x: 1, y: -1 }, - e: { x: 1, y: 0 }, - se: { x: 1, y: 1 }, - s: { x: 0, y: 1 }, - sw: { x: -1, y: 1 }, - w: { x: -1, y: 0 }, - nw: { x: -1, y: -1 }, -}; +function squareDirectionVector(direction: MobileGestureDirection): MobileGesturePoint { + const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; + return { x: Math.sign(vector.x), y: Math.sign(vector.y) }; +} const ROOT_CARDINAL_ANCHORS: Partial> = { n: { x: ROOT_LABEL_CENTER_X, y: -ROOT_CARDINAL_Y }, @@ -65,28 +60,28 @@ const ROOT_DIAGONAL_LAYOUT: Partial> = { ne: { centerPlacement: 'bottomLeft', centerHalfWidth: 35, - secondarySide: 'east', + secondarySideSign: 1, }, se: { centerPlacement: 'topLeft', centerHalfWidth: 23, - secondarySide: 'east', + secondarySideSign: 1, }, sw: { centerPlacement: 'topRight', centerHalfWidth: 17, - secondarySide: 'west', + secondarySideSign: -1, }, nw: { centerPlacement: 'bottomRight', centerHalfWidth: 17, - secondarySide: 'west', + secondarySideSign: -1, }, }; @@ -147,7 +142,7 @@ function directionPoint( center: { x: number; y: number }, radius: number, ): { x: number; y: number } { - const vector = SQUARE_DIRECTION_VECTORS[direction]; + const vector = squareDirectionVector(direction); return { x: center.x + vector.x * radius, y: center.y + vector.y * radius, @@ -262,13 +257,12 @@ function rootOptionLayout( diagonalLayout.centerPlacement, diagonalLayout.centerHalfWidth, ); - const sideDirection = diagonalLayout.secondarySide === 'east' ? 1 : -1; return { point: { - x: centerPoint.x + sideDirection * (diagonalLayout.centerHalfWidth + GAP_CLUSTER), + x: centerPoint.x + diagonalLayout.secondarySideSign * (diagonalLayout.centerHalfWidth + GAP_CLUSTER), y: centerPoint.y + (optionIndex === 1 ? -ROOT_SIDE_STACK_OFFSET : ROOT_SIDE_STACK_OFFSET), }, - placement: diagonalLayout.secondarySide === 'east' ? 'right' : 'left', + placement: diagonalLayout.secondarySideSign === 1 ? 'right' : 'left', }; } @@ -447,7 +441,7 @@ export function MobileGestureConfirmDialog({ onCancel, onConfirm, }: { - confirmation: 'ctrlC' | 'paste'; + confirmation: MobileGestureConfirmation; onCancel: () => void; onConfirm: () => void; }) { diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 16bf503..b3fb91e 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -561,26 +561,20 @@ export function MobileTerminalUi({ event.preventDefault(); event.stopPropagation(); completedGesturePointerIdRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } + event.currentTarget.releasePointerCapture(event.pointerId); return; } if (state.phase === 'idle' && completedGesturePointerIdRef.current === event.pointerId) { event.preventDefault(); event.stopPropagation(); completedGesturePointerIdRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } + event.currentTarget.releasePointerCapture(event.pointerId); return; } if (state.phase === 'idle' || state.pointerId !== event.pointerId) return; event.preventDefault(); event.stopPropagation(); - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } + event.currentTarget.releasePointerCapture(event.pointerId); const nextState = updateMobileGesture(state, localPointerPoint(event)); const result = finishMobileGesture(nextState); @@ -599,16 +593,12 @@ export function MobileTerminalUi({ const state = gestureStateRef.current; if (state.phase === 'complete' && state.pointerId === event.pointerId) { completedGesturePointerIdRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } + event.currentTarget.releasePointerCapture(event.pointerId); return; } if (completedGesturePointerIdRef.current === event.pointerId) { completedGesturePointerIdRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } + event.currentTarget.releasePointerCapture(event.pointerId); } if (state.phase === 'idle' || state.pointerId !== event.pointerId) return; commitGestureState(MOBILE_GESTURE_IDLE_STATE); @@ -681,27 +671,15 @@ export function MobileTerminalUi({ ) : null} {keyboardMode === 'keys' ? ( -
-
- {TERMINAL_KEYS.slice(0, 4).map((item) => ( - sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[id])} - /> - ))} -
-
- {TERMINAL_KEYS.slice(4).map((item) => ( - sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[id])} - /> - ))} -
+
+ {TERMINAL_KEYS.map((item) => ( + sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[id])} + /> + ))}
) : null}
diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index 2a40e8a..c49d180 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -37,11 +37,13 @@ export type MobileGestureDirectAction = export type MobileGestureConfirmableAction = Extract; +export type MobileGestureConfirmation = 'ctrlC' | 'paste'; + export type MobileGestureAction = | MobileGestureDirectAction | { kind: 'confirm'; - confirmation: 'ctrlC' | 'paste'; + confirmation: MobileGestureConfirmation; action: MobileGestureConfirmableAction; }; @@ -256,7 +258,7 @@ function clamp(value: number, min: number, max: number): number { } export function directionFromVector(dx: number, dy: number): MobileGestureDirection | null { - if (Math.hypot(dx, dy) === 0) return null; + if (dx === 0 && dy === 0) return null; const angle = Math.atan2(dy, dx) * 180 / Math.PI; const index = ((Math.round(angle / 45) % 8) + 8) % 8; return ANGLE_DIRECTIONS[index]; From b035043f1948072972b4dd8afeebd6c826498ed1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 13:02:35 -0700 Subject: [PATCH 20/29] Type key sequences map against MobileGestureInputId Catch drift if a new gesture input is added without a matching key sequence. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/MobileTerminalUi.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index b3fb91e..f383052 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -28,6 +28,7 @@ import { MOBILE_GESTURE_IDLE_STATE, updateMobileGesture, type MobileGestureAction, + type MobileGestureInputId, type MobileGesturePoint, type MobileGestureTrackingState, } from '../lib/mobile-gesture-menu'; @@ -36,7 +37,7 @@ export type MobileTerminalKeyboardMode = 'recent' | 'type' | 'draft' | 'keys'; export type MobileTerminalSection = MobileTerminalKeyboardMode; export type MobileTerminalTouchMode = 'gestures' | 'selection' | 'cursor'; -export const MOBILE_TERMINAL_KEY_SEQUENCES = { +export const MOBILE_TERMINAL_KEY_SEQUENCES: Record = { ctrlC: '\x03', ctrlX: '\x18', esc: '\x1b', @@ -54,10 +55,10 @@ export const MOBILE_TERMINAL_KEY_SEQUENCES = { end: '\x1b[F', left: '\x1b[D', home: '\x1b[H', -} as const; +}; interface TerminalKey { - id: keyof typeof MOBILE_TERMINAL_KEY_SEQUENCES; + id: MobileGestureInputId; label: string; title: string; } From 8354f65cc0a3e3ba3402201e333075b120c3b42a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 13:03:20 -0700 Subject: [PATCH 21/29] Rename local translatedPoint to avoid name collision The local variable shadowed the lib helper with the same name; rename to currentDisplayPoint to read clearly at the SVG line. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/MobileGestureRadialMenu.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index b534e1d..d8abf94 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -271,7 +271,7 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin const phaseOrigin = state.phase === 'root' ? state.origin : state.optionOrigin; const phaseDisplayOrigin = state.phase === 'root' ? state.displayOrigin : state.displayOptionOrigin; - const translatedPoint = translatedCurrentPoint(state, phaseOrigin, phaseDisplayOrigin); + const currentDisplayPoint = translatedCurrentPoint(state, phaseOrigin, phaseDisplayOrigin); const activeRootDirection = state.phase === 'root' ? state.highlightedDirection : state.phase === 'options' @@ -410,8 +410,8 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin Date: Wed, 13 May 2026 13:04:57 -0700 Subject: [PATCH 22/29] Flatten gesture direction ternaries into switch helpers Extract activeRootDirection and activeTickDirection as helpers over an ActiveGestureState alias; the call sites now read one identifier instead of a 4-deep ternary chain or IIFE. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/MobileGestureRadialMenu.tsx | 65 ++++++++++--------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index d8abf94..dc9afdf 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -149,8 +149,10 @@ function directionPoint( }; } +type ActiveGestureState = Exclude; + function translatedCurrentPoint( - state: Exclude, + state: ActiveGestureState, origin: { x: number; y: number }, displayOrigin: { x: number; y: number }, ) { @@ -160,6 +162,34 @@ function translatedCurrentPoint( }; } +function activeRootDirection(state: ActiveGestureState): MobileGestureDirection | undefined { + switch (state.phase) { + case 'root': return state.highlightedDirection; + case 'options': + case 'complete': return state.selectedDirection; + case 'quit': return state.parentDirection; + } +} + +function activeTickDirection(state: ActiveGestureState): MobileGestureDirection | undefined { + switch (state.phase) { + case 'root': + return state.highlightedDirection; + case 'options': + return state.candidate?.direction + ?? (state.highlightedOptionIndex !== undefined + ? MOBILE_GESTURE_OPTION_DIRECTIONS[state.selectedDirection][state.highlightedOptionIndex] + : undefined); + case 'quit': + return state.candidate?.direction + ?? (state.highlightedOptionIndex !== undefined + ? MOBILE_GESTURE_OPTION_DIRECTIONS[state.baseDirection][state.highlightedOptionIndex] + : undefined); + case 'complete': + return state.candidate.direction; + } +} + function OptionChip({ label, active, @@ -272,36 +302,11 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin const phaseOrigin = state.phase === 'root' ? state.origin : state.optionOrigin; const phaseDisplayOrigin = state.phase === 'root' ? state.displayOrigin : state.displayOptionOrigin; const currentDisplayPoint = translatedCurrentPoint(state, phaseOrigin, phaseDisplayOrigin); - const activeRootDirection = state.phase === 'root' - ? state.highlightedDirection - : state.phase === 'options' - ? state.selectedDirection - : state.phase === 'complete' - ? state.selectedDirection - : state.parentDirection; - const activeTickDirection = (() => { - if (state.phase === 'root') return state.highlightedDirection; - if (state.phase === 'options') { - return state.candidate?.direction - ?? ( - state.highlightedOptionIndex === undefined - ? undefined - : MOBILE_GESTURE_OPTION_DIRECTIONS[state.selectedDirection][state.highlightedOptionIndex] - ); - } - if (state.phase === 'quit') { - return state.candidate?.direction - ?? ( - state.highlightedOptionIndex === undefined - ? undefined - : MOBILE_GESTURE_OPTION_DIRECTIONS[state.baseDirection][state.highlightedOptionIndex] - ); - } - return state.candidate.direction; - })(); + const rootDirection = activeRootDirection(state); + const tickDirection = activeTickDirection(state); const selectTicks = SELECT_TICK_DIRECTIONS.map((direction) => { const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; - const active = activeTickDirection === direction; + const active = tickDirection === direction; return ( Date: Wed, 13 May 2026 13:06:16 -0700 Subject: [PATCH 23/29] Table-drive cardinal secondary chip layout Replace four nested direction/optionIndex branches in rootOptionLayout with a ROOT_CARDINAL_SECONDARY lookup keyed by direction and option. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/MobileGestureRadialMenu.tsx | 82 ++++++++----------- 1 file changed, 32 insertions(+), 50 deletions(-) diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index dc9afdf..4e84253 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -44,6 +44,29 @@ const ROOT_CARDINAL_ANCHORS: Partial +>> = { + n: { + 1: { dx: -ROOT_CLUSTER_AXIS_GAP, dy: -ROOT_CHIP_STACK_OFFSET, placement: 'left' }, + 2: { dx: ROOT_CLUSTER_AXIS_GAP, dy: -ROOT_CHIP_STACK_OFFSET, placement: 'right' }, + }, + s: { + 1: { dx: -ROOT_CLUSTER_AXIS_GAP, dy: ROOT_CHIP_STACK_OFFSET, placement: 'left' }, + 2: { dx: ROOT_CLUSTER_AXIS_GAP, dy: ROOT_CHIP_STACK_OFFSET, placement: 'right' }, + }, + e: { + 1: { dx: ROOT_SIDE_DX, dy: -ROOT_SIDE_STACK_OFFSET, placement: 'right' }, + 2: { dx: ROOT_SIDE_DX, dy: ROOT_SIDE_STACK_OFFSET, placement: 'right' }, + }, + w: { + 1: { dx: -ROOT_SIDE_DX, dy: -ROOT_SIDE_STACK_OFFSET, placement: 'left' }, + 2: { dx: -ROOT_SIDE_DX, dy: ROOT_SIDE_STACK_OFFSET, placement: 'left' }, + }, +}; + type ChipPlacement = | 'center' | 'left' @@ -219,59 +242,18 @@ function rootOptionLayout( const optionIndex = index as MobileGestureOptionIndex; const cardinalAnchor = ROOT_CARDINAL_ANCHORS[direction]; if (cardinalAnchor) { - const point = { + const anchorPoint = { x: center.x + cardinalAnchor.x, y: center.y + cardinalAnchor.y, }; - if (direction === 'n') { - if (optionIndex === 1) { - point.x -= ROOT_CLUSTER_AXIS_GAP; - point.y -= ROOT_CHIP_STACK_OFFSET; - return { point, placement: 'left' }; - } - if (optionIndex === 2) { - point.x += ROOT_CLUSTER_AXIS_GAP; - point.y -= ROOT_CHIP_STACK_OFFSET; - return { point, placement: 'right' }; - } - } - if (direction === 's') { - if (optionIndex === 1) { - point.x -= ROOT_CLUSTER_AXIS_GAP; - point.y += ROOT_CHIP_STACK_OFFSET; - return { point, placement: 'left' }; - } - if (optionIndex === 2) { - point.x += ROOT_CLUSTER_AXIS_GAP; - point.y += ROOT_CHIP_STACK_OFFSET; - return { point, placement: 'right' }; - } - } - if (direction === 'e') { - if (optionIndex === 1) { - point.x += ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; - point.y -= ROOT_SIDE_STACK_OFFSET; - return { point, placement: 'right' }; - } - if (optionIndex === 2) { - point.x += ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; - point.y += ROOT_SIDE_STACK_OFFSET; - return { point, placement: 'right' }; - } - } - if (direction === 'w') { - if (optionIndex === 1) { - point.x -= ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; - point.y -= ROOT_SIDE_STACK_OFFSET; - return { point, placement: 'left' }; - } - if (optionIndex === 2) { - point.x -= ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; - point.y += ROOT_SIDE_STACK_OFFSET; - return { point, placement: 'left' }; - } - } - return { point, placement: 'center' }; + const secondary = optionIndex === 0 + ? undefined + : ROOT_CARDINAL_SECONDARY[direction]?.[optionIndex]; + if (!secondary) return { point: anchorPoint, placement: 'center' }; + return { + point: { x: anchorPoint.x + secondary.dx, y: anchorPoint.y + secondary.dy }, + placement: secondary.placement, + }; } const diagonalLayout = ROOT_DIAGONAL_LAYOUT[direction]; const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; From 2b02f02f7dd18990f0802c3a260cf8e6f560ee0a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 13:06:57 -0700 Subject: [PATCH 24/29] Drop unused default origin from mobileGestureStateFromPoints The hardcoded { x: 195, y: 220 } was a story-specific value leaking into the lib; the only caller passes its own origin explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/mobile-gesture-menu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index c49d180..6dfc9ab 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -469,7 +469,7 @@ export function completeMobileGesture(state: MobileGestureTrackingState): Mobile export function mobileGestureStateFromPoints( points: MobileGesturePoint[], - origin: MobileGesturePoint = { x: 195, y: 220 }, + origin: MobileGesturePoint, displayOrigin: MobileGesturePoint = origin, ): MobileGestureTrackingState { let state = beginMobileGesture(1, origin, displayOrigin); From 0f97e0a411e37d74a0c29bceed98901fa50d4808 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 13:08:19 -0700 Subject: [PATCH 25/29] Comment why blurPaneTextInputs repeats the blur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-schedule pattern is mandated by mobile-ui.md §11 because Wall defers xterm focus via rAF; without the repeats a single blur can be reverted after we return. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/MobileTerminalUi.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index f383052..2e9e0d7 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -422,6 +422,8 @@ export function MobileTerminalUi({ active.blur(); } }; + // Wall defers xterm focus via rAF, so a single blur can be reverted after we + // return; repeat across rAF and a few staggered ticks. See mobile-ui.md §11. blurActivePaneInput(); window.setTimeout(blurActivePaneInput, 0); window.setTimeout(blurActivePaneInput, 50); From 4607efb3844e56018c44ca87d71b76537a4fa2fc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 14:04:52 -0700 Subject: [PATCH 26/29] Smooth tether gesture root fade --- docs/specs/mobile-ui.md | 17 ++++ .../components/MobileGestureRadialMenu.tsx | 77 +++++++++++++++---- lib/src/lib/mobile-gesture-menu.ts | 1 + lib/src/stories/MobileTerminalUi.stories.tsx | 5 ++ lib/src/theme.css | 26 +++++++ 5 files changed, 112 insertions(+), 14 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 295c191..10cd322 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -114,6 +114,7 @@ Gesture mode uses these radii: | --- | --- | --- | | `RADIUS_LAYOUT` | `92px` | Base half-side for square direction anchors around the offset compass rose origin. Exploded option labels land on these anchors; root labels are packed around the same square so long labels do not overlap. | | `RADIUS_SELECT` | `RADIUS_LAYOUT * 0.75` | Visible circle drawn around the offset compass rose origin. When the mirrored drag reaches this distance, the closest compass direction is selected. | +| `RADIUS_FADE_START` | `RADIUS_SELECT * 0.25` | No directional root-group fading happens before this drag distance. | | `RADIUS_HIGHLIGHT` | `RADIUS_SELECT * 0.5` | No circle is drawn. When the drag reaches this distance, the closest compass direction is highlighted, but not selected. | Gesture menu item state uses the same palette as pane headers. Idle groups and @@ -127,6 +128,22 @@ The select circle renders subtle ticks at the eight compass directions. The current highlighted or selected direction uses a stronger tick so the circle and label clusters read as one gesture system. +When the rose opens on touch-down, root labels fade in and the select circle +grows from zero radius to `RADIUS_SELECT`. This is a short state-reveal motion, +not an ongoing decoration; reduced-motion users get the final state immediately. + +While the user is still choosing a root group, the root groups fade according to +the current drag vector only after the drag exceeds `RADIUS_FADE_START`. Before +that threshold, all root groups render at full opacity. After the threshold, +define `dragHat = (currentPoint - origin) / RADIUS_SELECT` and `unitToGroup` as +the unit vector from the origin to the group's compass direction. The root group +target opacity is `clamp(0.75 + dragHat dot unitToGroup, 0, 1)`. The rendered +opacity blends smoothly from `1` at `RADIUS_FADE_START` to that target at +`RADIUS_SELECT` using +`fadeProgress = clamp((dragDistance - RADIUS_FADE_START) / (RADIUS_SELECT - +RADIUS_FADE_START), 0, 1)` and +`opacity = 1 + (targetOpacity - 1) * fadeProgress`. + Each root compass group renders as three separate labels placed close together, not as one combined pill. When a group is selected, those same three labels tween from their root group positions to their exploded positions in the opposite diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 4e84253..8adce95 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -7,6 +7,7 @@ import { MOBILE_GESTURE_OPTION_DIRECTIONS, MOBILE_GESTURE_QUIT_GROUP, RADIUS_LAYOUT, + RADIUS_FADE_START, RADIUS_SELECT, type MobileGestureConfirmation, type MobileGestureDirection, @@ -141,6 +142,42 @@ function translatedStyle(x: number, y: number, scale = 1, placement: ChipPlaceme }; } +type GestureChipStyle = CSSProperties & { '--mobile-gesture-target-opacity'?: number }; + +function translatedChipStyle( + x: number, + y: number, + scale: number, + placement: ChipPlacement, + opacity: number, +): GestureChipStyle { + return { + ...translatedStyle(x, y, scale, placement), + opacity, + '--mobile-gesture-target-opacity': opacity, + }; +} + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +function rootGroupOpacity(state: ActiveGestureState, direction: MobileGestureDirection): number { + if (state.phase !== 'root') return 1; + const dx = state.currentPoint.x - state.origin.x; + const dy = state.currentPoint.y - state.origin.y; + const dragDistance = Math.hypot(dx, dy); + if (dragDistance <= RADIUS_FADE_START) return 1; + const dragHat = { + x: dx / RADIUS_SELECT, + y: dy / RADIUS_SELECT, + }; + const unitToGroup = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; + const targetOpacity = clamp01(0.75 + dragHat.x * unitToGroup.x + dragHat.y * unitToGroup.y); + const fadeProgress = clamp01((dragDistance - RADIUS_FADE_START) / (RADIUS_SELECT - RADIUS_FADE_START)); + return 1 + (targetOpacity - 1) * fadeProgress; +} + function centerFromPlacedCorner( corner: MobileGesturePoint, placement: ChipPlacement, @@ -334,19 +371,21 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin const faded = state.phase === 'quit' || (state.phase === 'options' && !isSelectedGroup) || state.phase === 'complete'; + const targetOpacity = faded ? 0 : rootGroupOpacity(state, direction); return (
@@ -375,9 +414,14 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin className={clsx( 'absolute transition-[left,top,opacity,transform] ease-out', state.phase === 'complete' ? 'duration-200' : 'duration-150', - state.phase === 'complete' ? 'opacity-0' : 'opacity-100', )} - style={translatedStyle(point.x, point.y, isCompletingQuitOption ? COMPLETE_SCALE : 1)} + style={translatedChipStyle( + point.x, + point.y, + isCompletingQuitOption ? COMPLETE_SCALE : 1, + 'center', + state.phase === 'complete' ? 0 : 1, + )} > - - {selectTicks} + + + {selectTicks} + {rootOptions} diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index 6dfc9ab..3d17d68 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -127,6 +127,7 @@ const DIAGONAL = Math.SQRT1_2; export const MOBILE_GESTURE_IDLE_STATE: MobileGestureTrackingState = { phase: 'idle' }; export const RADIUS_LAYOUT = 92; export const RADIUS_SELECT = RADIUS_LAYOUT * 0.75; +export const RADIUS_FADE_START = RADIUS_SELECT * 0.25; export const RADIUS_HIGHLIGHT = RADIUS_SELECT * 0.5; export const MOBILE_GESTURE_COMPLETE_MS = 220; export const MOBILE_GESTURE_DISPLAY_MARGIN = 168; diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index 0b7f622..6178b09 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -16,6 +16,7 @@ import { MOBILE_GESTURE_IDLE_STATE, MOBILE_GESTURE_OPTION_DIRECTIONS, mobileGestureStateFromPoints, + RADIUS_FADE_START, RADIUS_HIGHLIGHT, RADIUS_SELECT, type MobileGestureDirection, @@ -251,6 +252,10 @@ export const GestureMenuOpened: Story = { render: () => , }; +export const GestureRootFadeStart: Story = { + render: () => , +}; + export const GestureEastHighlight: Story = { render: () => , }; diff --git a/lib/src/theme.css b/lib/src/theme.css index c518918..3185783 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -137,6 +137,32 @@ body { 80% { translate: 2px; } } +/* Mobile gesture radial menu: short reveal on touch-down only. Drag-state + * changes are still driven by React through inline opacity/position targets. */ +@keyframes mobile-gesture-circle-spawn { + from { opacity: 0; transform: scale(0); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes mobile-gesture-chip-spawn { + from { opacity: 0; } + to { opacity: var(--mobile-gesture-target-opacity, 1); } +} + +.mobile-gesture-circle-spawn { + animation: mobile-gesture-circle-spawn 180ms cubic-bezier(0.22, 1, 0.36, 1); + transform-box: view-box; +} + +.mobile-gesture-chip-spawn { + animation: mobile-gesture-chip-spawn 160ms cubic-bezier(0.22, 1, 0.36, 1); +} + +@media (prefers-reduced-motion: reduce) { + .mobile-gesture-circle-spawn, + .mobile-gesture-chip-spawn { animation: none; } +} + /* Pane spawn — directional clip-path reveal applied to the dockview group element. * clip-path (not transform) keeps getBoundingClientRect accurate so the selection * overlay measures the real post-animation bounds during the animation. */ From b317006ad6fc30f3d33b9c1c2efa32d146cf590d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 14:15:41 -0700 Subject: [PATCH 27/29] Polish tether gesture guide visibility --- docs/specs/mobile-ui.md | 18 +++++++------ .../components/MobileGestureRadialMenu.tsx | 25 +++++++++++-------- lib/src/theme.css | 7 +++--- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 10cd322..002f382 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -105,8 +105,9 @@ area away from the touch point. For example, a lower-right thumb press opens the rose up and left; a lower-left thumb press opens it up and right. As the user drags, the UI draws only the offset guide line inside the visible -compass rose. It must not draw a line directly under the user's thumb, and it -must not render a center marker that obscures the offset line. +compass rose. It must not draw a line directly under the user's thumb. The guide +line is solid and fully opaque, and the offset rose center renders a small +fully opaque circle. Gesture mode uses these radii: @@ -124,13 +125,14 @@ groups and options use active header background/foreground plus an inset gesture selection state. Inactive chips should have only a quiet shadow; the heavier elevation is reserved for active chips. -The select circle renders subtle ticks at the eight compass directions. The -current highlighted or selected direction uses a stronger tick so the circle and -label clusters read as one gesture system. +The select circle and its eight compass-direction ticks render at full opacity. +The current highlighted or selected direction uses a stronger tick so the circle +and label clusters read as one gesture system. -When the rose opens on touch-down, root labels fade in and the select circle -grows from zero radius to `RADIUS_SELECT`. This is a short state-reveal motion, -not an ongoing decoration; reduced-motion users get the final state immediately. +When the rose opens on touch-down, root labels fade in with a subtle scale-in +and the select circle grows from zero radius to `RADIUS_SELECT`. This is a short +state-reveal motion, not an ongoing decoration; reduced-motion users get the +final state immediately. While the user is still choosing a root group, the root groups fade according to the current drag vector only after the drag exceeds `RADIUS_FADE_START`. Before diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 8adce95..84f3cf1 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -142,19 +142,16 @@ function translatedStyle(x: number, y: number, scale = 1, placement: ChipPlaceme }; } -type GestureChipStyle = CSSProperties & { '--mobile-gesture-target-opacity'?: number }; - function translatedChipStyle( x: number, y: number, scale: number, placement: ChipPlacement, opacity: number, -): GestureChipStyle { +): CSSProperties { return { ...translatedStyle(x, y, scale, placement), opacity, - '--mobile-gesture-target-opacity': opacity, }; } @@ -334,7 +331,7 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin x2={phaseDisplayOrigin.x + vector.x * (RADIUS_SELECT + SELECT_TICK_OUTSET)} y2={phaseDisplayOrigin.y + vector.y * (RADIUS_SELECT + SELECT_TICK_OUTSET)} stroke="var(--color-focus-ring)" - strokeOpacity={active ? '0.65' : '0.28'} + strokeOpacity="1" strokeWidth={active ? '2' : '1.25'} strokeLinecap="round" /> @@ -378,7 +375,6 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin className={clsx( 'absolute transition-[left,top,opacity,transform] ease-out', state.phase === 'complete' ? 'duration-200' : 'duration-150', - state.phase === 'root' && 'mobile-gesture-chip-spawn', )} style={translatedChipStyle( layout.point.x, @@ -388,7 +384,9 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin targetOpacity, )} > - +
+ +
); }); @@ -444,9 +442,8 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin x2={currentDisplayPoint.x} y2={currentDisplayPoint.y} stroke="var(--color-focus-ring)" - strokeOpacity="0.35" + strokeOpacity="1" strokeWidth="2" - strokeDasharray="4 4" strokeLinecap="round" /> {selectTicks} + diff --git a/lib/src/theme.css b/lib/src/theme.css index 3185783..5b2160c 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -145,8 +145,8 @@ body { } @keyframes mobile-gesture-chip-spawn { - from { opacity: 0; } - to { opacity: var(--mobile-gesture-target-opacity, 1); } + from { opacity: 0; transform: scale(0.88); } + to { opacity: 1; transform: scale(1); } } .mobile-gesture-circle-spawn { @@ -155,7 +155,8 @@ body { } .mobile-gesture-chip-spawn { - animation: mobile-gesture-chip-spawn 160ms cubic-bezier(0.22, 1, 0.36, 1); + animation: mobile-gesture-chip-spawn 320ms cubic-bezier(0.22, 1, 0.36, 1) both; + transform-origin: center; } @media (prefers-reduced-motion: reduce) { From b8b50ac3e9972c80935b20086fb52d9d4e9b80c5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 14:49:24 -0700 Subject: [PATCH 28/29] Simplify tether gesture radial menu Drop dead 'above'/'below' ChipPlacement variants, fold translatedStyle into translatedChipStyle, replace duplicated translatedCurrentPoint with the existing translatedPoint, and switch the chip-spawn className to clsx for consistency. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/MobileGestureRadialMenu.tsx | 34 ++++--------------- lib/src/lib/mobile-gesture-menu.ts | 2 +- 2 files changed, 7 insertions(+), 29 deletions(-) diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 84f3cf1..77ecac9 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -9,6 +9,7 @@ import { RADIUS_LAYOUT, RADIUS_FADE_START, RADIUS_SELECT, + translatedPoint, type MobileGestureConfirmation, type MobileGestureDirection, type MobileGestureOptionIndex, @@ -72,8 +73,6 @@ type ChipPlacement = | 'center' | 'left' | 'right' - | 'above' - | 'below' | 'topLeft' | 'topRight' | 'bottomLeft' @@ -117,10 +116,6 @@ function translateForPlacement(placement: ChipPlacement): string { return 'translate(-100%, -50%)'; case 'right': return 'translate(0, -50%)'; - case 'above': - return 'translate(-50%, -100%)'; - case 'below': - return 'translate(-50%, 0)'; case 'topLeft': return 'translate(0, 0)'; case 'topRight': @@ -134,14 +129,6 @@ function translateForPlacement(placement: ChipPlacement): string { } } -function translatedStyle(x: number, y: number, scale = 1, placement: ChipPlacement = 'center'): CSSProperties { - return { - left: x, - top: y, - transform: `${translateForPlacement(placement)} scale(${scale})`, - }; -} - function translatedChipStyle( x: number, y: number, @@ -150,7 +137,9 @@ function translatedChipStyle( opacity: number, ): CSSProperties { return { - ...translatedStyle(x, y, scale, placement), + left: x, + top: y, + transform: `${translateForPlacement(placement)} scale(${scale})`, opacity, }; } @@ -208,17 +197,6 @@ function directionPoint( type ActiveGestureState = Exclude; -function translatedCurrentPoint( - state: ActiveGestureState, - origin: { x: number; y: number }, - displayOrigin: { x: number; y: number }, -) { - return { - x: displayOrigin.x + state.currentPoint.x - origin.x, - y: displayOrigin.y + state.currentPoint.y - origin.y, - }; -} - function activeRootDirection(state: ActiveGestureState): MobileGestureDirection | undefined { switch (state.phase) { case 'root': return state.highlightedDirection; @@ -317,7 +295,7 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin const phaseOrigin = state.phase === 'root' ? state.origin : state.optionOrigin; const phaseDisplayOrigin = state.phase === 'root' ? state.displayOrigin : state.displayOptionOrigin; - const currentDisplayPoint = translatedCurrentPoint(state, phaseOrigin, phaseDisplayOrigin); + const currentDisplayPoint = translatedPoint(phaseDisplayOrigin, phaseOrigin, state.currentPoint); const rootDirection = activeRootDirection(state); const tickDirection = activeTickDirection(state); const selectTicks = SELECT_TICK_DIRECTIONS.map((direction) => { @@ -384,7 +362,7 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin targetOpacity, )} > -
+
diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index 3d17d68..68768e7 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -281,7 +281,7 @@ function pointOnRadius( }; } -function translatedPoint( +export function translatedPoint( displayOrigin: MobileGesturePoint, origin: MobileGesturePoint, point: MobileGesturePoint, From 13af502365e44a66dfdf434883938a4d7a1a9109 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 13 May 2026 15:07:42 -0700 Subject: [PATCH 29/29] Document mouse/trackpad click handling in Gesture mode Spec that primary mouse/trackpad clicks (alongside touches and pen presses) open the radial menu so /tether is usable on desktop without a touchscreen, and clarify that non-primary buttons remain unhandled. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/specs/mobile-ui.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 002f382..eaf4599 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -87,7 +87,7 @@ Touch modes: | Mode | Button label | Icon | Availability | Behavior | | --- | --- | --- | --- | --- | -| Gestures | `Gestures` | `HandPointingIcon` | Always available | Pane-content touches open the Gesture mode radial menu. | +| Gestures | `Gestures` | `HandPointingIcon` | Always available | Pane-content touches, pen presses, and primary mouse/trackpad clicks open the Gesture mode radial menu. | | Text selection | `Select` | `CursorTextIcon` | Always available | Touches are reserved for terminal text selection and copy/paste. If the TUI is capturing mouse events, MouseTerm activates mouse override for the active pane. | | Cursor | `Cursor` | `CursorClickIcon` | Only when the active TUI is capturing mouse events | Touches are passed through as terminal mouse/cursor input. | @@ -96,6 +96,16 @@ Default touch mode is **Gestures**. If Cursor mode is active and the active pane stops capturing mouse events, the selector must fall back to Gestures. +Gesture mode intentionally consumes primary mouse/trackpad clicks in addition to +touch input. This keeps the `/tether` prototype usable in desktop browsers, +narrow desktop viewports, and Storybook without a touchscreen. A primary +mouse/trackpad click in pane content must start radial gesture handling, call +`preventDefault()`, stop propagation, and capture that pointer; it is not passed +through to the embedded `Wall`, xterm, or dockview for focus, selection, or pane +interaction. Non-primary mouse buttons are ignored by gesture handling so their +browser or host behavior can continue. Users who want terminal selection or TUI +mouse input must choose Select or Cursor mode explicitly. + ## 5. Gesture Mode Gesture mode is the default pane-content touch behavior. Tapping the pane content