diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 5b92e0e..eaf4599 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, 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,7 +96,200 @@ 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 +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 +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. + +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. The guide +line is solid and fully opaque, and the offset rose center renders a small +fully opaque circle. + +Gesture mode uses these radii: + +| Variable | Value | Behavior | +| --- | --- | --- | +| `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 +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. Inactive chips should have only a quiet shadow; the +heavier elevation is reserved for active chips. + +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 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 +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 +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, 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. 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 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. 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 +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 E or W cardinal group. + +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. 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 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. + +Exploded option directions: + +| Selected group | Option directions | +| --- | --- | +| N | S, SW, SE | +| 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, E, S | + +Examples: + +* 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 +remain spelled out. + +Root gesture menu: + +```text +Esc ⌃C* k PgUp n Backspace +Quit** ▲ Paste* + +Home ◀ ▶ End +h l + +⬆︎Tab ▼ y ⬆︎Enter +Tab Space j PgDn Enter +``` + +`⌃C` and `Paste` require an in-pane confirmation modal before they run. + +`Quit` enters a second exploded-option menu instead of sending input immediately: + +```text +q | ⌃X | :q↵ +``` + +The quit submenu uses the same reset-center, highlight-radius, and select-radius +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: + +| Action | Sequence | +| --- | --- | +| Esc | `\x1B` | +| ⌃C | `\x03` | +| q | `q` | +| ⌃X | `\x18` | +| `:q↵` | `:q\r` | +| ▲ | `\x1B[A` | +| PgUp | `\x1B[5~` | +| k | `k` | +| Backspace | `\x7F` | +| Paste | Existing MouseTerm paste flow for the active pane | +| n | `n` | +| ◀ | `\x1B[D` | +| Home | `\x1B[H` | +| h | `h` | +| ▶ | `\x1B[C` | +| End | `\x1B[F` | +| l | `l` | +| Tab | `\x09` | +| ⬆︎Tab | `\x1B[Z` | +| Space | ` ` | +| ▼ | `\x1B[B` | +| PgDn | `\x1B[6~` | +| j | `j` | +| Enter | `\r` | +| ⬆︎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,13 +322,13 @@ 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: ```text Esc Tab Space Enter -← ↓ ↑ → +◀ ▼ ▲ ▶ ``` Mappings: @@ -146,14 +339,14 @@ 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. -## 7. Type Mode Input +## 8. Type Mode Input Use a hidden or visually minimal input configured for terminal-style typing: @@ -178,7 +371,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 +402,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 +413,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 +422,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 +444,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 +455,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,29 +477,30 @@ 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 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 +508,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 +518,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..77ecac9 --- /dev/null +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -0,0 +1,510 @@ +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, + MOBILE_GESTURE_QUIT_GROUP, + RADIUS_LAYOUT, + RADIUS_FADE_START, + RADIUS_SELECT, + translatedPoint, + type MobileGestureConfirmation, + type MobileGestureDirection, + type MobileGestureOptionIndex, + type MobileGesturePoint, + type MobileGestureTrackingState, +} from '../lib/mobile-gesture-menu'; + +const QUIT_RADIUS = 78; +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; +const ROOT_LABEL_CENTER_X = 0; +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; + +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 }, + e: { x: ROOT_CARDINAL_X, y: ROOT_LABEL_SIDE_CENTER_Y }, + s: { x: ROOT_LABEL_CENTER_X, y: ROOT_CARDINAL_Y }, + w: { x: -ROOT_CARDINAL_X, y: ROOT_LABEL_SIDE_CENTER_Y }, +}; + +const ROOT_SIDE_DX = ROOT_CHIP_HALF_WIDTH_ARROW + GAP_CLUSTER; +const ROOT_CARDINAL_SECONDARY: 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' + | 'right' + | 'topLeft' + | 'topRight' + | 'bottomLeft' + | 'bottomRight'; + +const ROOT_DIAGONAL_LAYOUT: Partial> = { + ne: { + centerPlacement: 'bottomLeft', + centerHalfWidth: 35, + secondarySideSign: 1, + }, + se: { + centerPlacement: 'topLeft', + centerHalfWidth: 23, + secondarySideSign: 1, + }, + sw: { + centerPlacement: 'topRight', + centerHalfWidth: 17, + secondarySideSign: -1, + }, + nw: { + centerPlacement: 'bottomRight', + centerHalfWidth: 17, + secondarySideSign: -1, + }, +}; + +const SELECT_TICK_DIRECTIONS: MobileGestureDirection[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; + +function translateForPlacement(placement: ChipPlacement): string { + switch (placement) { + case 'left': + return 'translate(-100%, -50%)'; + case 'right': + return 'translate(0, -50%)'; + 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%)'; + } +} + +function translatedChipStyle( + x: number, + y: number, + scale: number, + placement: ChipPlacement, + opacity: number, +): CSSProperties { + return { + left: x, + top: y, + transform: `${translateForPlacement(placement)} scale(${scale})`, + 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, + 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 }, + radius: number, +): { x: number; y: number } { + const vector = squareDirectionVector(direction); + return { + x: center.x + vector.x * radius, + y: center.y + vector.y * radius, + }; +} + +type ActiveGestureState = Exclude; + +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, +}: { + label: string; + active: boolean; +}) { + return ( +
+ {label} +
+ ); +} + +function rootOptionLayout( + direction: MobileGestureDirection, + index: number, + center: { x: number; y: number }, +): { point: MobileGesturePoint; placement: ChipPlacement } { + const optionIndex = index as MobileGestureOptionIndex; + const cardinalAnchor = ROOT_CARDINAL_ANCHORS[direction]; + if (cardinalAnchor) { + const anchorPoint = { + x: center.x + cardinalAnchor.x, + y: center.y + cardinalAnchor.y, + }; + 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]; + 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 centerPoint = centerFromPlacedCorner( + corner, + diagonalLayout.centerPlacement, + diagonalLayout.centerHalfWidth, + ); + return { + point: { + x: centerPoint.x + diagonalLayout.secondarySideSign * (diagonalLayout.centerHalfWidth + GAP_CLUSTER), + y: centerPoint.y + (optionIndex === 1 ? -ROOT_SIDE_STACK_OFFSET : ROOT_SIDE_STACK_OFFSET), + }, + placement: diagonalLayout.secondarySideSign === 1 ? 'right' : 'left', + }; +} + +export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackingState }) { + if (state.phase === 'idle') return null; + + const phaseOrigin = state.phase === 'root' ? state.origin : state.optionOrigin; + const phaseDisplayOrigin = state.phase === 'root' ? state.displayOrigin : state.displayOptionOrigin; + const currentDisplayPoint = translatedPoint(phaseDisplayOrigin, phaseOrigin, state.currentPoint); + const rootDirection = activeRootDirection(state); + const tickDirection = activeTickDirection(state); + const selectTicks = SELECT_TICK_DIRECTIONS.map((direction) => { + const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; + const active = tickDirection === direction; + return ( + + ); + }); + 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 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 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' + ? rootDirection === direction + : isCompletingRootOption || (isSelectedGroup && state.phase === 'options' && ( + state.highlightedOptionIndex === optionIndex + || state.candidate?.optionIndex === optionIndex + )); + const faded = state.phase === 'quit' + || (state.phase === 'options' && !isSelectedGroup) + || state.phase === 'complete'; + const targetOpacity = faded ? 0 : rootGroupOpacity(state, direction); + return ( +
+
+ +
+
+ ); + }); + }); + const quitOptions = (() => { + 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 ( +
+ +
+ ); + }); + })(); + + return ( + + ); +} + +export function MobileGestureConfirmDialog({ + confirmation, + onCancel, + onConfirm, +}: { + confirmation: MobileGestureConfirmation; + onCancel: () => void; + onConfirm: () => void; +}) { + const copy = confirmation === 'ctrlC' + ? { + title: 'Send ⌃C?', + body: 'Interrupt the running terminal app.', + action: 'Send ⌃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 a04f1c6..2e9e0d7 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -15,26 +15,50 @@ import { HandPointingIcon, } from '@phosphor-icons/react'; import { clsx } from 'clsx'; +import { + MobileGestureConfirmDialog, + MobileGestureRadialMenu, +} from './MobileGestureRadialMenu'; +import { + beginMobileGesture, + completeMobileGesture, + displayOriginAwayFromThumb, + finishMobileGesture, + MOBILE_GESTURE_COMPLETE_MS, + MOBILE_GESTURE_IDLE_STATE, + updateMobileGesture, + type MobileGestureAction, + type MobileGestureInputId, + type MobileGesturePoint, + type MobileGestureTrackingState, +} from '../lib/mobile-gesture-menu'; 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', 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', -} as const; + home: '\x1b[H', +}; interface TerminalKey { - id: keyof typeof MOBILE_TERMINAL_KEY_SEQUENCES; + id: MobileGestureInputId; label: string; title: string; } @@ -44,10 +68,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 }[] = [ @@ -82,6 +106,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 +122,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 +311,20 @@ function WorkInProgressPane({ label }: { label: 'Recent' | 'Draft' }) { ); } +type MobileGestureConfirmationAction = Extract; + +function localPointerPoint(event: PointerEvent): MobileGesturePoint { + const rect = event.currentTarget.getBoundingClientRect(); + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; +} + +function isGestureDialogTarget(target: EventTarget | null): boolean { + return target instanceof Element && target.closest('[data-mobile-gesture-dialog]') !== null; +} + export function MobileTerminalUi({ terminal, activeSection, @@ -289,6 +338,7 @@ export function MobileTerminalUi({ onTouchModeChange, cursorTouchAvailable = false, onSendInput, + onPaste, onFocusInput, interactive = true, fillViewport = false, @@ -304,7 +354,11 @@ 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 completedGesturePointerIdRef = useRef(null); + const gestureCompletionTimerRef = useRef(null); + const [gestureState, setGestureState] = useState(MOBILE_GESTURE_IDLE_STATE); + const [pendingGestureConfirmation, setPendingGestureConfirmation] = useState(null); const [inputValue, setInputValue] = useState(''); const sendInput = useCallback((data: string) => { @@ -312,6 +366,25 @@ export function MobileTerminalUi({ onSendInput?.(data); }, [interactive, onSendInput]); + const commitGestureState = useCallback((nextState: MobileGestureTrackingState) => { + gestureStateRef.current = nextState; + 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?.(); @@ -349,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); @@ -380,6 +455,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 +511,101 @@ export function MobileTerminalUi({ return () => observer.disconnect(); }, [configurePaneTextInputs, terminal]); + useEffect(() => { + if (touchMode === 'gestures' && interactive) return; + clearGestureCompletionTimer(); + commitGestureState(MOBILE_GESTURE_IDLE_STATE); + setPendingGestureConfirmation(null); + }, [clearGestureCompletionTimer, commitGestureState, interactive, touchMode]); + + useEffect(() => clearGestureCompletionTimer, [clearGestureCompletionTimer]); + 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]); + clearGestureCompletionTimer(); + setPendingGestureConfirmation(null); + completedGesturePointerIdRef.current = null; + + const origin = localPointerPoint(event); + commitGestureState(beginMobileGesture( + event.pointerId, + origin, + displayOriginAwayFromThumb(origin, event.currentTarget.getBoundingClientRect()), + )); + }, [blurPaneTextInputs, clearGestureCompletionTimer, 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(); + const nextState = updateMobileGesture(state, localPointerPoint(event)); + const result = finishMobileGesture(nextState); + if (result.action) { + const completionState = completeMobileGesture(nextState); + completedGesturePointerIdRef.current = event.pointerId; + commitGestureState(completionState ?? result.state); + executeGestureAction(result.action); + if (completionState) scheduleGestureCompletionClear(); + return; + } + commitGestureState(nextState); + }, [commitGestureState, executeGestureAction, scheduleGestureCompletionClear]); 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 === 'complete' && state.pointerId === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + completedGesturePointerIdRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + return; + } + if (state.phase === 'idle' && completedGesturePointerIdRef.current === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + completedGesturePointerIdRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + return; + } + if (state.phase === 'idle' || state.pointerId !== event.pointerId) return; event.preventDefault(); event.stopPropagation(); + event.currentTarget.releasePointerCapture(event.pointerId); - 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); - } - }, [sendInput]); + 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); + if (completionState) scheduleGestureCompletionClear(); + }, [commitGestureState, executeGestureAction, scheduleGestureCompletionClear]); 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 === 'complete' && state.pointerId === event.pointerId) { + completedGesturePointerIdRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + return; } - }, []); + if (completedGesturePointerIdRef.current === event.pointerId) { + completedGesturePointerIdRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (state.phase === 'idle' || state.pointerId !== event.pointerId) return; + commitGestureState(MOBILE_GESTURE_IDLE_STATE); + }, [commitGestureState]); return (
{terminal}
+ + {pendingGestureConfirmation ? ( + setPendingGestureConfirmation(null)} + onConfirm={confirmPendingGestureAction} + /> + ) : null}
) : 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.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts new file mode 100644 index 0000000..23bbe87 --- /dev/null +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -0,0 +1,253 @@ +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, + RADIUS_LAYOUT, + RADIUS_SELECT, + updateMobileGesture, + type MobileGestureAction, + type MobileGestureDirection, + type MobileGestureOptionIndex, + type MobileGesturePoint, + type MobileGestureTrackingState, +} from './mobile-gesture-menu'; + +const ORIGIN: MobileGesturePoint = { x: 100, y: 100 }; + +function runGesture(points: MobileGesturePoint[]): MobileGestureAction | undefined { + let state: MobileGestureTrackingState = beginMobileGesture(1, ORIGIN); + for (const point of points) { + state = updateMobileGesture(state, point); + } + return finishMobileGesture(state).action; +} + +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); + 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', () => { + 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', 'w', 's']); + expect(MOBILE_GESTURE_OPTION_DIRECTIONS.nw).toEqual(['se', 'e', 's']); + }); + + 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(); + }); + + 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(finishMobileGesture(state).action).toBeUndefined(); + }); + + it('opens the option phase after the select radius', () => { + const state = updateMobileGesture(beginMobileGesture(1, ORIGIN), point(RADIUS_SELECT + 1, 0)); + 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 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('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' }); + }); + + 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([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(); + }); + + it('opens Ctrl+C confirmation from the northwest group', () => { + expect(runGesture([rootSelectionPoint('nw'), optionSelectionPoint('nw', 1)])).toEqual({ + kind: 'confirm', + confirmation: 'ctrlC', + action: { kind: 'input', input: 'ctrlC' }, + }); + }); + + it('opens paste confirmation from the northeast group', () => { + expect(runGesture([rootSelectionPoint('ne'), optionSelectionPoint('ne', 1)])).toEqual({ + kind: 'confirm', + confirmation: 'paste', + action: { kind: 'paste' }, + }); + }); + + it('uses a second breakout for quit as q', () => { + 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([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([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([rootSelectionPoint('se'), optionSelectionPoint('se', 1)])).toEqual({ + kind: 'input', + 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 new file mode 100644 index 0000000..68768e7 --- /dev/null +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -0,0 +1,481 @@ +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 interface MobileGestureBounds { + width: number; + height: number; +} + +export type MobileGestureDirectAction = + | { kind: 'input'; input: MobileGestureInputId } + | { kind: 'text'; text: string } + | { kind: 'paste' } + | { kind: 'quitMenu' }; + +export type MobileGestureConfirmableAction = Extract; + +export type MobileGestureConfirmation = 'ctrlC' | 'paste'; + +export type MobileGestureAction = + | MobileGestureDirectAction + | { + kind: 'confirm'; + confirmation: MobileGestureConfirmation; + action: MobileGestureConfirmableAction; + }; + +export interface MobileGestureOption { + label: string; + action: MobileGestureAction; +} + +export interface MobileGestureGroup { + direction: MobileGestureDirection; + options: [MobileGestureOption, MobileGestureOption, MobileGestureOption]; +} + +export interface MobileGestureCandidate { + phase: 'options' | 'quit'; + groupDirection: MobileGestureDirection; + direction: MobileGestureDirection; + optionIndex: MobileGestureOptionIndex; + option: MobileGestureOption; +} + +export type MobileGestureTrackingState = + | { phase: 'idle' } + | { + phase: 'root'; + pointerId: number; + origin: MobileGesturePoint; + displayOrigin: MobileGesturePoint; + currentPoint: MobileGesturePoint; + highlightedDirection?: MobileGestureDirection; + } + | { + phase: 'options'; + pointerId: number; + origin: MobileGesturePoint; + displayOrigin: MobileGesturePoint; + currentPoint: MobileGesturePoint; + selectedDirection: MobileGestureDirection; + optionOrigin: MobileGesturePoint; + displayOptionOrigin: MobileGesturePoint; + highlightedOptionIndex?: MobileGestureOptionIndex; + candidate?: MobileGestureCandidate; + } + | { + phase: 'quit'; + pointerId: number; + origin: MobileGesturePoint; + displayOrigin: MobileGesturePoint; + currentPoint: MobileGesturePoint; + parentDirection: MobileGestureDirection; + baseDirection: MobileGestureDirection; + optionOrigin: MobileGesturePoint; + 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 { + state: MobileGestureTrackingState; + 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_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; +export const MOBILE_GESTURE_THUMB_OFFSET = 132; + +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: '⌃C', + action: { kind: 'confirm', confirmation: 'ctrlC', action: { kind: 'input', input: 'ctrlC' } }, + }, + { label: 'Quit', action: { kind: 'quitMenu' } }, + ], + }, + n: { + direction: 'n', + options: [ + { label: '▲', action: { kind: 'input', input: 'up' } }, + { label: 'k', action: { kind: 'text', text: 'k' } }, + { label: 'PgUp', action: { kind: 'input', input: 'pageUp' } }, + ], + }, + 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: '◀', action: { kind: 'input', input: 'left' } }, + { label: 'Home', action: { kind: 'input', input: 'home' } }, + { label: 'h', action: { kind: 'text', text: 'h' } }, + ], + }, + e: { + direction: 'e', + options: [ + { label: '▶', 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: '⬆︎Tab', action: { kind: 'input', input: 'shiftTab' } }, + { label: 'Space', action: { kind: 'input', input: 'space' } }, + ], + }, + s: { + direction: 's', + options: [ + { label: '▼', action: { kind: 'input', input: 'down' } }, + { label: 'j', action: { kind: 'text', text: 'j' } }, + { label: 'PgDn', action: { kind: 'input', input: 'pageDown' } }, + ], + }, + se: { + direction: 'se', + options: [ + { label: 'Enter', action: { kind: 'input', input: 'enter' } }, + { label: '⬆︎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_OPTION_DIRECTIONS: Record< + MobileGestureDirection, + [MobileGestureDirection, MobileGestureDirection, MobileGestureDirection] +> = { + n: ['s', 'sw', 'se'], + 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', 'e', 's'], +}; + +export const MOBILE_GESTURE_QUIT_GROUP: MobileGestureGroup = { + direction: 'n', + options: [ + { label: 'q', action: { kind: 'text', text: 'q' } }, + { label: '⌃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); +} + +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 (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]; +} + +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, + }; +} + +export 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, +): MobileGestureOptionState { + const dist = distance(origin, point); + 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, + direction, + optionIndex: highlightedOptionIndex, + option, + }; + } + return result; +} + +export function beginMobileGesture( + pointerId: number, + origin: MobileGesturePoint, + displayOrigin: MobileGesturePoint = origin, +): MobileGestureTrackingState { + return { + phase: 'root', + pointerId, + origin, + displayOrigin, + currentPoint: origin, + }; +} + +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, +): MobileGestureTrackingState { + if (state.phase === 'idle' || state.phase === 'complete') 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; + 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: state.selectedDirection, + baseDirection: optionState.candidate.direction, + optionOrigin: quitOrigin, + displayOptionOrigin: translatedPoint(state.displayOptionOrigin, state.optionOrigin, quitOrigin), + }; + } + return { + ...state, + currentPoint: point, + highlightedOptionIndex: optionState.highlightedOptionIndex, + candidate: optionState.candidate, + }; + } + + const optionState = candidateForOptions( + 'quit', + state.baseDirection, + MOBILE_GESTURE_QUIT_GROUP.options, + state.optionOrigin, + point, + ); + return { + ...state, + currentPoint: point, + highlightedOptionIndex: optionState.highlightedOptionIndex, + candidate: optionState.candidate, + }; +} + +export function finishMobileGesture(state: MobileGestureTrackingState): MobileGestureFinishResult { + const action = state.phase === 'options' || state.phase === 'quit' + ? state.candidate?.option.action + : undefined; + return { + state: MOBILE_GESTURE_IDLE_STATE, + action, + }; +} + +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, + displayOrigin: MobileGesturePoint = origin, +): MobileGestureTrackingState { + let state = beginMobileGesture(1, origin, displayOrigin); + 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..6178b09 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -5,6 +5,25 @@ import { MobileTerminalUi, type MobileTerminalUiProps, } from '../components/MobileTerminalUi'; +import { + MobileGestureConfirmDialog, + MobileGestureRadialMenu, +} from '../components/MobileGestureRadialMenu'; +import { + completeMobileGesture, + displayOriginAwayFromThumb, + MOBILE_GESTURE_DIRECTION_VECTORS, + MOBILE_GESTURE_IDLE_STATE, + MOBILE_GESTURE_OPTION_DIRECTIONS, + mobileGestureStateFromPoints, + RADIUS_FADE_START, + RADIUS_HIGHLIGHT, + RADIUS_SELECT, + type MobileGestureDirection, + type MobileGestureOptionIndex, + type MobileGesturePoint, + type MobileGestureTrackingState, +} from '../lib/mobile-gesture-menu'; const meta: Meta = { title: 'App/MobileTerminalUi', @@ -19,15 +38,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 +96,114 @@ function StoryFrame(args: MobileTerminalUiProps) { args.onSendInput?.(data); setInputLog((entries) => [...entries, describeInput(data)]); }} + onPaste={() => { + void args.onPaste?.(); + setInputLog((entries) => [...entries, 'PASTE']); + }} /> ); } +const GESTURE_BOUNDS = { width: 390, height: 460 }; +const GESTURE_ORIGIN: MobileGesturePoint = { x: 300, y: 280 }; + +function gesturePoint(dx: number, dy: number): MobileGesturePoint { + return { + x: GESTURE_ORIGIN.x + dx, + y: GESTURE_ORIGIN.y + dy, + }; +} + +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, + GESTURE_ORIGIN, + displayOriginAwayFromThumb(GESTURE_ORIGIN, GESTURE_BOUNDS), + ); +} + +function gestureCompleteState(points: MobileGesturePoint[]): MobileGestureTrackingState { + const state = gestureState(points); + return completeMobileGesture(state) ?? state; +} + +function GestureSnapshotFrame({ + state, + confirmation, +}: { + state: MobileGestureTrackingState; + confirmation?: 'ctrlC' | 'paste'; +}) { + return ( +
+ + {confirmation ? ( + {}} + onConfirm={() => {}} + /> + ) : null} +
+ ); +} + export const TypePane: Story = { args: { defaultSection: 'type', @@ -118,3 +247,97 @@ export const CursorTouchAvailable: Story = { }, render: (args) => , }; + +export const GestureMenuOpened: Story = { + render: () => , +}; + +export const GestureRootFadeStart: Story = { + render: () => , +}; + +export const GestureEastHighlight: Story = { + render: () => , +}; + +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: () => , +}; + +export const GestureEastReturnRightComplete: 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: () => ( + + ), +}; + +export const GestureQuitCtrlXComplete: Story = { + render: () => ( + + ), +}; diff --git a/lib/src/theme.css b/lib/src/theme.css index c518918..5b2160c 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -137,6 +137,33 @@ 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; transform: scale(0.88); } + to { opacity: 1; transform: scale(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 320ms cubic-bezier(0.22, 1, 0.36, 1) both; + transform-origin: center; +} + +@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. */ diff --git a/website/src/pages/Tether.tsx b/website/src/pages/Tether.tsx index 45f1897..f5d193a 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); + }} /> ); }