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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/specs/mobile-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ 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_LAYOUT` | `92px` | Base circular radius for exploded option anchors around the offset compass rose origin. Diagonal exploded labels use normalized compass vectors, so their x/y offsets are `RADIUS_LAYOUT * Math.SQRT1_2`. Root labels use separate packed square-keypad geometry 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. |
Expand Down Expand Up @@ -223,9 +223,9 @@ 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 to the right of the
center option, one above and one below. NW and SW place their secondary options
to the left of the center option, 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.
labels use circular direction anchors at `RADIUS_LAYOUT` from the reset center.
The root label pack stays close to the select circle, while preserving enough
room for long labels like Backspace.

Each diagonal root cluster uses `GAP_CLUSTER = 2px`. The first option in each
diagonal group is the cluster center. Secondary options use the same edge-and-gap
Expand Down
214 changes: 60 additions & 154 deletions docs/specs/tutorial.md

Large diffs are not rendered by default.

50 changes: 42 additions & 8 deletions lib/src/components/MobileGestureRadialMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,8 @@ 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 MIN_CIRCLE_SCALE = 0.3;
const CIRCLE_TWEEN = 'cubic-bezier(0.22, 1, 0.36, 1)';

const ROOT_CARDINAL_ANCHORS: Partial<Record<MobileGestureDirection, MobileGesturePoint>> = {
n: { x: ROOT_LABEL_CENTER_X, y: -ROOT_CARDINAL_Y },
Expand Down Expand Up @@ -188,7 +185,7 @@ function directionPoint(
center: { x: number; y: number },
radius: number,
): { x: number; y: number } {
const vector = squareDirectionVector(direction);
const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction];
return {
x: center.x + vector.x * radius,
y: center.y + vector.y * radius,
Expand Down Expand Up @@ -290,13 +287,41 @@ function rootOptionLayout(
};
}

function circleTransform(
state: ActiveGestureState,
phaseDisplayOrigin: MobileGesturePoint,
): { scale: number; opacity: number; origin: MobileGesturePoint; durationMs: number } {
if (state.phase === 'complete') {
// Collapse toward the chosen item.
return {
scale: 0,
opacity: 0,
origin: directionPoint(state.candidate.direction, phaseDisplayOrigin, RADIUS_SELECT),
durationMs: 200,
};
}
if (state.phase === 'options' || state.phase === 'quit') {
// Collapsed while the drag keeps pushing in the opening direction (overshoot); once
// it stops or turns toward an option, `expanded` latches and the compass stays full
// size until the final selection.
return {
scale: state.expanded ? 1 : MIN_CIRCLE_SCALE,
opacity: 1,
origin: phaseDisplayOrigin,
durationMs: 150,
};
}
return { scale: 1, opacity: 1, origin: phaseDisplayOrigin, durationMs: 150 };
}

export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackingState }) {
if (state.phase === 'idle') return null;

const directRootComplete = state.phase === 'complete' && state.candidate.phase === 'root';
const phaseOrigin = state.phase === 'root' || directRootComplete ? state.origin : state.optionOrigin;
const phaseDisplayOrigin = state.phase === 'root' || directRootComplete ? state.displayOrigin : state.displayOptionOrigin;
const currentDisplayPoint = translatedPoint(phaseDisplayOrigin, phaseOrigin, state.currentPoint);
const circle = circleTransform(state, phaseDisplayOrigin);
const rootDirection = activeRootDirection(state);
const tickDirection = activeTickDirection(state);
const selectTicks = SELECT_TICK_DIRECTIONS.map((direction) => {
Expand Down Expand Up @@ -424,13 +449,22 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin
x2={currentDisplayPoint.x}
y2={currentDisplayPoint.y}
stroke="var(--color-focus-ring)"
strokeOpacity="1"
strokeOpacity={state.phase === 'complete' ? 0 : 1}
strokeWidth="2"
strokeLinecap="round"
style={{ transition: 'stroke-opacity 200ms ease-out' }}
/>
<g
className={state.phase === 'root' ? 'mobile-gesture-circle-spawn' : undefined}
style={{ transformOrigin: `${phaseDisplayOrigin.x}px ${phaseDisplayOrigin.y}px` }}
style={{
transformBox: 'view-box',
transformOrigin: `${circle.origin.x}px ${circle.origin.y}px`,
transform: `scale(${circle.scale})`,
opacity: circle.opacity,
transition: state.phase === 'root'
? undefined
: `transform ${circle.durationMs}ms ${CIRCLE_TWEEN}, opacity ${circle.durationMs}ms ease-out`,
}}
>
<circle
cx={phaseDisplayOrigin.x}
Expand Down
3 changes: 3 additions & 0 deletions lib/src/components/MobileTerminalUi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
type MobileGestureTrackingState,
} from '../lib/mobile-gesture-menu';
import { useDynamicPalette } from '../lib/themes/use-dynamic-palette';
import { TouchUiContext } from './touch-ui-context';
import type { SessionStatus } from '../lib/terminal-registry';

export type MobileTerminalKeyboardMode = 'sessions' | 'recent' | 'type' | 'draft';
Expand Down Expand Up @@ -766,6 +767,7 @@ export function MobileTerminalUi({
}, [commitGestureState]);

return (
<TouchUiContext.Provider value={true}>
<div
data-mobile-terminal-ui
className={clsx(
Expand Down Expand Up @@ -879,5 +881,6 @@ export function MobileTerminalUi({
className="absolute left-0 top-0 h-px w-px resize-none overflow-hidden border-0 bg-transparent p-0 opacity-0 outline-none"
/>
</div>
</TouchUiContext.Provider>
);
}
17 changes: 14 additions & 3 deletions lib/src/components/SelectionOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useSyncExternalStore, type CSSProperties } from 'react';
import { useContext, useSyncExternalStore, type CSSProperties } from 'react';
import {
DEFAULT_MOUSE_SELECTION_STATE,
getMouseSelectionSnapshot,
Expand All @@ -11,6 +11,7 @@ import { getTerminalOverlayDims } from '../lib/terminal-registry';
import { IS_MAC } from '../lib/platform';
import { useFocusRingColor } from '../lib/themes/use-focus-ring-color';
import { PopupButtonRow } from './design';
import { TouchUiContext } from './touch-ui-context';

interface Props {
terminalId: string;
Expand All @@ -21,6 +22,7 @@ interface Props {
* Re-measures on every render tick (scroll, resize, output).
*/
export function SelectionOverlay({ terminalId }: Props) {
const touchUi = useContext(TouchUiContext);
const states = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot);
// Subscribe to render tick so we re-render whenever xterm scrolls or resizes.
useSyncExternalStore(subscribeToRenderTick, getRenderTick);
Expand Down Expand Up @@ -65,7 +67,12 @@ export function SelectionOverlay({ terminalId }: Props) {
if (endViewportRow >= 0 && endViewportRow < dims.rows) {
const draggedDown = selection.endRow >= selection.startRow;
const left = Math.min(dims.elementWidth - 180, Math.max(4, gridLeft + selection.endCol * cellWidth));
if (draggedDown) {
if (touchUi) {
// Mobile: always sit above the selection so the dragging thumb doesn't cover it.
const topViewportRow = Math.min(selection.startRow, selection.endRow) - dims.viewportY;
const y = Math.max(gridTop + (topViewportRow - 1) * cellHeight - 4, 28);
hint = { left, bottom: dims.elementHeight - y };
} else if (draggedDown) {
const top = Math.min(
gridTop + (endViewportRow + 2) * cellHeight + 4,
dims.elementHeight - 24,
Expand Down Expand Up @@ -108,7 +115,11 @@ export function SelectionOverlay({ terminalId }: Props) {
style={{ left: hint.left, top: hint.top, bottom: hint.bottom }}
>
<div className="flex flex-col gap-0.5 leading-none text-muted">
<div>Hold {IS_MAC ? 'Opt' : 'Alt'} for block selection</div>
<div>
{touchUi
? 'Start drag with double-tap for block selection'
: `Hold ${IS_MAC ? 'Opt' : 'Alt'} for block selection`}
</div>
{state.hintToken && (
<div>
Press <span className="text-foreground">e</span> to select the full{' '}
Expand Down
57 changes: 38 additions & 19 deletions lib/src/components/SelectionPopup.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useLayoutEffect, useState, useEffect, useSyncExternalStore, type CSSProperties } from 'react';
import { useContext, useLayoutEffect, useState, useEffect, useSyncExternalStore, type CSSProperties } from 'react';
import {
DEFAULT_MOUSE_SELECTION_STATE,
flashCopy,
Expand All @@ -13,6 +13,7 @@ import { CheckIcon } from '@phosphor-icons/react';
import { IS_MAC } from '../lib/platform';
import { getTerminalOverlayDims } from '../lib/terminal-registry';
import { PopupButtonRow, popupButton, Shortcut } from './design';
import { TouchUiContext } from './touch-ui-context';

interface Props {
terminalId: string;
Expand All @@ -23,6 +24,7 @@ interface Props {
* and Copy Rewrapped. Dismissed on Esc, click-outside, or a successful copy.
*/
export function SelectionPopup({ terminalId }: Props) {
const touchUi = useContext(TouchUiContext);
const states = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot);
useSyncExternalStore(subscribeToRenderTick, getRenderTick);

Expand Down Expand Up @@ -52,7 +54,13 @@ export function SelectionPopup({ terminalId }: Props) {
// selection than the hint did on drag-up.
const draggedDown = selection.endRow >= selection.startRow;
const left = Math.min(dims.elementWidth - 300, Math.max(0, gridLeft + selection.endCol * cellWidth));
if (draggedDown) {
if (touchUi) {
// Mobile: always sit above the selection so the dragging thumb (which ends
// at the selection's lower edge) never covers the copy buttons.
const topRow = Math.max(0, Math.min(dims.rows - 1, Math.min(selection.startRow, selection.endRow) - dims.viewportY));
const y = Math.max(gridTop + (topRow - 1) * cellHeight - 4, 28);
setAnchor({ left, bottom: dims.elementHeight - y });
} else if (draggedDown) {
const top = Math.min(
gridTop + (endRow + 2) * cellHeight + 4,
dims.elementHeight - 24,
Expand All @@ -64,7 +72,7 @@ export function SelectionPopup({ terminalId }: Props) {
const y = Math.max(gridTop + (endRow - 1) * cellHeight - 4, 28);
setAnchor({ left, bottom: dims.elementHeight - y });
}
}, [terminalId, shouldRender, selection]);
}, [terminalId, shouldRender, selection, touchUi]);

useEffect(() => {
if (!shouldRender) return;
Expand Down Expand Up @@ -118,6 +126,31 @@ export function SelectionPopup({ terminalId }: Props) {
const flashed = (kind: 'raw' | 'rewrapped') => state.copyFlash === kind;
const buttonClass = (kind: 'raw' | 'rewrapped') => popupButton({ flashed: flashed(kind) });

// The touch UI has no keyboard, so drop the shortcut hint there and keep only the
// copy-success check. On desktop the check sits over the (hidden) shortcut so the
// button width stays put while it flashes.
const leadingIndicator = (kind: 'raw' | 'rewrapped', shortcut: string) => {
if (touchUi) {
return flashed(kind) ? (
<span className="mr-1 inline-flex items-center align-middle">
<CheckIcon size={12} weight="bold" />
</span>
) : null;
}
return (
<>
<span className="relative inline-block">
<Shortcut className={flashed(kind) ? 'invisible' : undefined}>{shortcut}</Shortcut>
{flashed(kind) && (
<span className="absolute inset-0 flex items-center justify-center">
<CheckIcon size={12} weight="bold" />
</span>
)}
</span>{' '}
</>
);
};

return (
<PopupButtonRow
data-selection-popup-for={terminalId}
Expand All @@ -129,29 +162,15 @@ export function SelectionPopup({ terminalId }: Props) {
className={buttonClass('raw')}
onClick={() => onCopy(false)}
>
<span className="relative inline-block">
<Shortcut className={flashed('raw') ? 'invisible' : undefined}>{copyShortcut}</Shortcut>
{flashed('raw') && (
<span className="absolute inset-0 flex items-center justify-center">
<CheckIcon size={12} weight="bold" />
</span>
)}
</span>{' '}
{leadingIndicator('raw', copyShortcut)}
Copy Raw
</button>
<button
type="button"
className={buttonClass('rewrapped')}
onClick={() => onCopy(true)}
>
<span className="relative inline-block">
<Shortcut className={flashed('rewrapped') ? 'invisible' : undefined}>{rewrapShortcut}</Shortcut>
{flashed('rewrapped') && (
<span className="absolute inset-0 flex items-center justify-center">
<CheckIcon size={12} weight="bold" />
</span>
)}
</span>{' '}
{leadingIndicator('rewrapped', rewrapShortcut)}
Copy Rewrapped
</button>
</PopupButtonRow>
Expand Down
8 changes: 8 additions & 0 deletions lib/src/components/touch-ui-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createContext } from 'react';

/**
* True when the surrounding UI is the touch-first mobile terminal, where there is
* no physical keyboard — so keyboard shortcut hints (e.g. on the selection popup)
* should be omitted. Defaults to false for the desktop UI.
*/
export const TouchUiContext = createContext(false);
65 changes: 65 additions & 0 deletions lib/src/lib/mobile-gesture-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MOBILE_GESTURE_COMPLETE_MS,
MOBILE_GESTURE_DIRECTION_VECTORS,
MOBILE_GESTURE_OPTION_DIRECTIONS,
OPTION_EXPAND_RELEASE,
RADIUS_HIGHLIGHT,
RADIUS_LAYOUT,
RADIUS_SELECT,
Expand Down Expand Up @@ -95,6 +96,19 @@ describe('mobile gesture menu state machine', () => {
expect(MOBILE_GESTURE_COMPLETE_MS).toBe(220);
});

it('treats the layout radius as a circular radius for direction vectors', () => {
for (const vector of Object.values(MOBILE_GESTURE_DIRECTION_VECTORS)) {
expect(Math.hypot(vector.x * RADIUS_LAYOUT, vector.y * RADIUS_LAYOUT)).toBeCloseTo(
RADIUS_LAYOUT,
6,
);
}
expect(MOBILE_GESTURE_DIRECTION_VECTORS.ne.x * RADIUS_LAYOUT).toBeCloseTo(
RADIUS_LAYOUT * Math.SQRT1_2,
6,
);
});

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']);
Expand Down Expand Up @@ -181,6 +195,57 @@ describe('mobile gesture menu state machine', () => {
expect(updateMobileGesture(complete, optionSelectionPoint('se', 2))).toBe(complete);
});

it('tracks the reference origin while the drag overshoots the opening direction', () => {
let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se'));
state = updateMobileGesture(state, pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3));
expect(state.phase).toBe('options');
if (state.phase !== 'options') return;
// The reference origin slid out to meet the overshooting finger.
expect(state.optionOrigin).toEqual(pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3));
});

it('keeps the compass collapsed through overshoot, then latches expanded for good', () => {
let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se'));
expect(state.phase === 'options' && state.expanded).toBe(false);

// Still dragging in the opening direction (overshoot) — stays collapsed.
state = updateMobileGesture(state, pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 2));
expect(state.phase === 'options' && state.expanded).toBe(false);
state = updateMobileGesture(state, pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3));
expect(state.phase === 'options' && state.expanded).toBe(false);

// Turning back toward an option latches expanded.
const overshoot = pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3);
state = updateMobileGesture(state, pointInDirection(overshoot, 'nw', RADIUS_HIGHLIGHT));
expect(state.phase === 'options' && state.expanded).toBe(true);

// Pushing back out in the wrong direction does not collapse it again.
state = updateMobileGesture(state, pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 5));
expect(state.phase === 'options' && state.expanded).toBe(true);
});

it('expands once the overshoot drag settles, without a deliberate move back', () => {
let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se'));
// Brisk overshoot keeps it collapsed.
state = updateMobileGesture(state, pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3));
expect(state.phase === 'options' && state.expanded).toBe(false);
// A tiny continued nudge in the same direction (a settle, not a hard push) expands it.
const overshoot = pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3);
state = updateMobileGesture(state, pointInDirection(overshoot, 'se', OPTION_EXPAND_RELEASE - 1));
expect(state.phase === 'options' && state.expanded).toBe(true);
});

it('selects the back option after an overshoot without undoing the whole overshoot', () => {
// Drive past the southeast selection, keep dragging southeast, then reverse just
// far enough to break out toward the back ("Enter") option.
const overshoot = pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3);
const backOption = pointInDirection(overshoot, 'nw', RADIUS_SELECT + 1);
expect(runGesture([rootSelectionPoint('se'), overshoot, backOption])).toEqual({
kind: 'input',
input: 'enter',
});
});

it('clears the option highlight when the drag moves back inside the highlight radius', () => {
let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se'));
state = updateMobileGesture(state, pointInDirection(optionOrigin('se'), 'n', RADIUS_HIGHLIGHT + 1));
Expand Down
Loading
Loading