diff --git a/AGENTS.md b/AGENTS.md index f1cd309..cbfda4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ The primary job of a spec is to be an accurate reference for the current state o - **`docs/specs/vscode.md`** — VS Code extension architecture: hosting modes (WebviewView + WebviewPanel), PTY lifecycle and buffering, message protocol between webview and extension host, session persistence flow, reconnection protocol, theme integration, CSP, build pipeline, and invariants (save-before-kill ordering, PTY ownership, alarm state merging). Read this when touching: `extension.ts`, `webview-view-provider.ts`, `message-router.ts`, `message-types.ts`, `pty-manager.ts`, `pty-host.js`, `session-state.ts`, `webview-html.ts`, `vscode-adapter.ts`, or `pty-core.js`. - **`docs/specs/tutorial.md`** — Playground tutorial on the website: 3-pane initial layout, `tut` command and TutorialShell, 6-step progressive tutorial with detection logic, theme picker, FakePtyAdapter extensions, and Pond event hooks. Read this when touching: `website/src/pages/Playground.tsx`, `website/src/lib/tutorial-shell.ts`, `website/src/lib/tutorial-detection.ts`, `lib/src/components/ThemePicker.tsx`, `lib/src/lib/themes/`, `lib/src/lib/platform/fake-scenarios.ts` (tutorial scenarios), or the `onApiReady`/`onEvent`/`initialPaneIds` props on Pond. - **`docs/specs/theme.md`** — Theme system: two-layer CSS variable strategy, theme data model, conversion pipeline, bundled themes, localStorage store, shared ThemePicker component, standalone AppBar picker, runtime OpenVSX installer. Read this when touching: `lib/src/lib/themes/`, `lib/src/components/ThemePicker.tsx`, `lib/src/theme.css`, `lib/scripts/bundle-themes.mjs`, `standalone/src/AppBar.tsx` (theme picker), `standalone/src/main.tsx` (theme restore), or `website/src/components/SiteHeader.tsx` (themeAware mode). +- **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned text selection, copy (Raw / Rewrapped), bracketed paste, smart URL/path extension, mouse-reporting override UI (icon + banner), and the state matrix for which layer owns mouse events. Read this when touching: `lib/src/lib/mouse-selection.ts`, `lib/src/lib/mouse-mode-observer.ts`, `lib/src/lib/clipboard.ts`, `lib/src/lib/rewrap.ts`, `lib/src/lib/selection-text.ts`, `lib/src/lib/smart-token.ts`, `lib/src/components/SelectionOverlay.tsx`, `lib/src/components/SelectionPopup.tsx`, the mouse icon / override banner / Cmd+C-V handling in `lib/src/components/Pond.tsx`, or the parser hooks + mouse listeners in `lib/src/lib/terminal-registry.ts`. When updating code covered by a spec, update the spec to match. When the two specs overlap (e.g. pane header elements appear in both), layout.md documents placement and sizing while alarm.md documents behavior and visual states. diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md new file mode 100644 index 0000000..6b75c98 --- /dev/null +++ b/docs/specs/mouse-and-clipboard.md @@ -0,0 +1,341 @@ +# Terminal Mouse and Clipboard Behavior Specification + +## Overview + +Mouse handling and clipboard (copy and paste) behavior for the terminal across macOS, Linux, and Windows. The core design goal is to make text selection, copying, pasting, and mouse-driven interaction with TUI programs coexist cleanly, with visible state and predictable transitions between modes. + +## Background: The Two Mouse Regimes + +At any moment, mouse events in the terminal belong to one of two consumers: + +1. **The terminal itself.** Drags paint a selection on the terminal surface; clicks shift focus or interact with terminal chrome. This is the default. +2. **The running application inside the terminal.** When a program emits a mouse-reporting escape sequence (e.g. `\e[?1000h`, `\e[?1002h`, `\e[?1003h`, with optional `\e[?1006h` SGR encoding), the terminal forwards mouse events to the program as input. Programs such as `tmux`, `vim`, `less`, and `htop` use this. The terminal's own selection behavior becomes unreachable while mouse reporting is active. + +The terminal makes the current regime visible in the pane header, provides a way for the user to override it when they want to select text, and preserves selection actions (copy, copy-rewrapped, extend-to-URL) across both regimes. + +## Terminology + +- **Live region:** the portion of the terminal showing the active screen buffer (what the running program is currently drawing). +- **Scrollback:** the history of previously-drawn content above the live region. +- **Mouse reporting:** the state in which the inside program has requested and is receiving mouse events. +- **Override:** a state in which the terminal intercepts mouse events for selection purposes even though the inside program has requested mouse reporting. +- **Mouse icon:** a header indicator showing the current mouse regime. + +--- + +## 1. The Mouse Icon (Header Indicator) + +### 1.1 Visibility + +- When the inside program has **not** requested mouse reporting: no icon is shown. +- When the inside program **has** requested mouse reporting: a **Mouse icon** (Phosphor `CursorClickIcon`) is shown in the terminal header. +- When the user has activated an override: the Mouse icon is replaced by a **No-Mouse icon** (Phosphor `SelectionSlashIcon`) in the same header location. + +### 1.2 Hover Text + +- Mouse icon hover text: `TUI is intercepting mouse commands. Click to override.` +- No-Mouse icon hover text: `You're overriding the TUI's mouse capture. Click to restore.` + +### 1.3 Click Behavior + +- Clicking the **Mouse icon** activates a **temporary override** (see §2). +- Clicking the **No-Mouse icon** ends the override immediately and restores mouse reporting to the inside program. + +--- + +## 2. Override State + +### 2.1 Temporary Override + +Activated by clicking the Mouse icon. While the temporary override is active: + +- Mouse events are handled by the terminal, not forwarded to the inside program. +- The Mouse icon is replaced with the No-Mouse icon. +- A banner appears below the No-Mouse icon reading `Temporary mouse override until mouse-up.` followed by two buttons: **Make sticky** and **Cancel**. +- The override persists until the **next mouse-up event inside the terminal content area** (live region or scrollback) that is paired with a prior mouse-down in the same area. This includes plain clicks (a mouse-down/up pair that never crossed the drag threshold) as well as completed drags. The click on the No-Mouse icon itself, the banner's buttons, and any "orphan" mouse-up from a drag that started outside the terminal do **not** count as that mouse-up. +- After that mouse-up, the override automatically ends: mouse reporting is restored to the inside program, the banner is dismissed, and the icon reverts to the Mouse icon. + +### 2.2 Making the Override Sticky + +- Clicking **Make sticky** in the banner converts the temporary override into a sticky one. +- The banner is dismissed. +- The No-Mouse icon remains visible with its "click to restore" hover text. +- The override persists until the user clicks the No-Mouse icon, or until the inside program stops requesting mouse reporting. + +### 2.3 Canceling the Temporary Override + +- Clicking **Cancel** in the banner ends the override immediately. +- The banner is dismissed, mouse reporting is restored, and the icon reverts to the Mouse icon. + +### 2.4 No Keyboard Activation + +The mouse icon, No-Mouse icon, and banner buttons are mouse-only. They are not keyboard-activatable. + +### 2.5 Edge Case: No Drag After Override + +If the user activates an override and then never performs a mouse action, the override remains in place indefinitely. There is no timeout. + +### 2.6 Auto-Cleared on Reporting Off + +If the inside program stops requesting mouse reporting (e.g. exits or explicitly sends DECRST `?1000l`/`?1002l`/`?1003l`) while an override is active, the override is cleared. The icon and banner are removed because there is no longer anything to override. + +--- + +## 3. Selection Behavior + +Selection is available whenever the terminal is handling mouse events — that is, whenever mouse reporting is not active, or an override is in effect, or the drag originates in scrollback (see §3.5). + +### 3.1 Initiating a Selection + +- A click-and-drag in the terminal content area begins a selection. A small movement threshold (~4px) separates a plain click (which only shifts pane focus) from a drag (which begins a selection). +- The selection is rendered by the terminal in a compositor layer **above** the cell grid, not by writing into the grid. This avoids conflicts with programs redrawing the screen. +- The selection rectangle is drawn as a single perimeter outline tracing the union of selected cells. Color is taken from `--vscode-focusBorder` with fallbacks to terminal foreground and selection background. + +### 3.2 Selection Shapes + +- **Linewise (default):** click-and-drag selects text in reading order, wrapping from end-of-line to start-of-next-line. +- **Block (rectangular):** hold **Alt** (Option on macOS) during the drag to select a rectangular region. +- The selection shape updates live as Alt is pressed and released during the drag, including while the mouse is stationary: pressing Alt mid-drag converts the current selection to block; releasing Alt converts it back to linewise. + +### 3.3 Selection Hint Text + +While a drag is in progress, a small hint is displayed adjacent to the selection (below when dragging downward, above when dragging upward): + +- `Hold Alt for block selection` on Windows and Linux. +- `Hold Opt for block selection` on macOS. + +The hint is always shown during an active drag. It does not fade with use. + +When a URL or path token is detected near the current drag position, an additional extension hint (`Press e to select the full URL` / `Press e to select the full path`) is shown alongside it. See §5 for full details. + +### 3.4 Selection Follows Content + +The selection is anchored to the characters under it, not to screen coordinates. Internally the selection is stored in absolute buffer rows (scrollback + viewport). + +- **Pure scroll:** if content scrolls (translates vertically with no character changes), the selection scrolls with it. This is coordinate math only; no matching is required. +- **Content change:** if any cell overlapped by the selection changes after it is finalized, the selection is immediately canceled. Repaints outside the selected cells (e.g. a status line, clock, or progress bar elsewhere on screen) are irrelevant and do not cancel the selection. The check runs on each xterm render: a text snapshot of the selected cells is taken at finalize time and compared on each render. +- **Terminal resize:** a resize counts as a content change and cancels any active selection. +- There is no partial-match or content-tracking heuristic. Cancel-on-change is the rule. + +### 3.5 Selection in the Live Region vs. Scrollback + +- **Live region:** selection is available only when mouse reporting is off, or an override is in effect. +- **Scrollback:** selection is **always** available, regardless of mouse reporting or override state. The override state of the Mouse icon is irrelevant for drags that originate in scrollback. +- **Crossing the boundary:** a drag that begins in scrollback and continues into the live region is allowed and produces a single continuous selection. A drag that begins in the live region while mouse reporting is active (with no override) is forwarded to the inside program, not treated as a selection. + +### 3.6 During a Drag + +- **Keyboard routing:** while a terminal-handled drag is in progress, the terminal consumes keystrokes relevant to the drag — **Alt** for block-selection shape (§3.2), **e** for smart extension (§5), **Esc** to cancel the drag and any in-progress selection. All other keystrokes are consumed by the terminal and **not** forwarded to the inside program for the duration of the drag. Normal keyboard routing resumes when the mouse button is released. + +### 3.7 Ending a Selection + +- Releasing the mouse button ends the drag and fixes the selection. +- The selection popup (§4) appears. +- The selection persists until the user acts on it (copy, extend, etc.), clicks elsewhere to dismiss it, presses **Esc**, or the underlying content changes. +- Starting a new drag (mouse-down in the terminal content area) immediately replaces any existing selection with the new one; the previous popup is dismissed. + +--- + +## 4. Selection Popup + +When a selection is finalized, a popup appears adjacent to the selection (on the side opposite the drag direction, mirroring where the drag hint sat) with action buttons. + +### 4.1 Copy Buttons + +The popup shows two copy buttons: + +- `[Cmd+C] Copy Raw` +- `[Cmd+Shift+C] Copy Rewrapped` + +On non-macOS platforms, the labels show `Ctrl` and `Ctrl+Shift` respectively. + +#### 4.1.1 Copy Raw + +Copies the selected text to the system clipboard exactly as it appears in the terminal cells, including hard line breaks and any box-drawing or decorative characters. + +#### 4.1.2 Copy Rewrapped + +Copies the selected text with two transformations applied (see `lib/src/lib/rewrap.ts`): + +1. **Drop frame-only lines** and **strip leading/trailing runs of box-drawing characters** (Unicode `U+2500–U+259F`, covering both Box Drawing and Block Elements) from each line. +2. **Group remaining lines into paragraphs** separated by blank lines. Lines within a paragraph are joined with a single space (unwrapping display wrapping). Paragraphs are joined with `\n\n`. + +Block-shape selections are never rewrapped — they are intentionally rectangular slabs, so the Copy Rewrapped action falls back to the raw text for them. + +### 4.2 Keyboard Shortcuts + +While the terminal has an active, finalized selection: + +- **Cmd+C** (Ctrl+C on non-macOS) triggers Copy Raw. +- **Cmd+Shift+C** (Ctrl+Shift+C on non-macOS) triggers Copy Rewrapped. + +These shortcuts work whether or not the popup is focused. The precedence rule is narrow: Ctrl+C is intercepted as Copy Raw **only** when a terminal selection is active. With no terminal selection, Ctrl+C is forwarded to the inside program as usual (SIGINT for shells, app-defined behavior for TUIs). An in-program selection maintained by a TUI (e.g. vim visual mode, less search highlight) is **not** a terminal selection for this purpose and does not change Ctrl+C routing. + +### 4.3 Dismissing the Popup + +- Pressing **Esc** dismisses the popup and cancels the selection. +- Clicking outside the selection dismisses the popup and cancels the selection. +- Performing a copy action (button click or keyboard shortcut) replaces the shortcut text on the active button with a checkmark for ~700 ms, then clears the selection and dismisses the popup. + +### 4.4 Extensibility + +The popup can accommodate additional copy modes in the future (e.g. strip ANSI codes, strip line numbers, strip prompt markers). They would appear as additional buttons or within an overflow menu. Only Copy Raw and Copy Rewrapped are wired today. + +--- + +## 5. Smart Extension (URL / Path Detection) + +Smart extension is offered **mid-drag**, in parallel with the Alt block-selection modifier (§3.2–§3.3). During an active drag, the terminal continuously examines the characters at the current drag cursor cell for a URL-shaped or path-shaped token; if one is detected, a hint is shown alongside the existing block-selection hint inviting the user to press **e** to extend to the full token. + +### 5.1 Detection + +A token is whitespace-delimited and matches one of (in priority order, see `lib/src/lib/smart-token.ts`): + +- A URL: `https?://...`, `file://...`. +- An error location: `:line` or `:line:col`. (Matched first so it beats the generic path patterns; trailing `:line` digits are preserved.) +- An absolute path beginning with `~/`, `/`, `./`, or `../`. +- A Windows-style path (`C:\...`). + +For all kinds **except** error locations, trailing characters that are unlikely to be part of the token — `.`, `,`, `;`, `:`, `!`, `?`, single quotes, double quotes — are stripped from the detected token's end. Unmatched closing brackets (`)`, `]`, `}`, `>`) are also stripped, but matched pairs are preserved (e.g. `https://en.wikipedia.org/wiki/Foo_(bar)` keeps its trailing `)`). + +### 5.2 Mid-Drag Hint + +When a qualifying token is detected during a drag, a hint is shown alongside the existing `Hold Alt for block selection` hint: + +- `Press e to select the full URL` (for URLs) +- `Press e to select the full path` (for paths and error locations) + +The hint appears and disappears live as the drag moves into and out of qualifying tokens. If no qualifying token is present at the current drag position, no extension hint is shown. + +### 5.3 Extension Action + +- Pressing **e** during a drag, while the hint is visible, immediately extends the selection to cover the full detected token. The drag anchor is preserved; the drag's far end moves to the token boundary on the side away from the anchor. +- After extension, the drag continues normally: further mouse movement updates the selection from the new boundary, and the Alt modifier continues to toggle block-selection shape. +- If **e** is pressed when no qualifying token is present, the keypress is consumed (per §3.6) but no extension occurs. +- Pressing **e** has no effect after the drag has ended (i.e. once the popup has appeared, §4). Extension is a mid-drag action only. + +### 5.4 Interaction with Selection Completion + +When the user releases the mouse button, the selection is finalized at whatever boundaries the drag (including any `e`-extensions) produced. The popup (§4) then appears with the standard copy actions operating on the final selection. + +### 5.5 Simplicity Bound + +Only the single extension step described above is offered. There is no multi-level extension (token → line → paragraph) and no "open URL" or "open in editor" action in the popup. + +--- + +## 6. Interaction Summary + +### 6.1 State Matrix + +| Inside program requests mouse | Override active | Drag in live region goes to... | Drag in scrollback goes to... | +|-------------------------------|-----------------|--------------------------------|-------------------------------| +| No | — | Terminal (selection) | Terminal (selection) | +| Yes | No | Inside program | Terminal (selection) | +| Yes | Temporary | Terminal (selection), ends on mouse-up | Terminal (selection) | +| Yes | Sticky | Terminal (selection) | Terminal (selection) | + +### 6.2 Header Icon States + +| Condition | Icon shown | Banner shown | +|-----------------------------------------------------------|---------------|---------------------------------------------------------------------------| +| Inside program does not request mouse reporting | None | None | +| Inside program requests mouse, no override | Mouse | None | +| Temporary override active | No-Mouse | `Temporary mouse override until mouse-up.` + `[Make sticky]` `[Cancel]` | +| Sticky override active | No-Mouse | None | + +--- + +## 7. Rendering Notes + +- The selection highlight (perimeter outline) is rendered in a compositor SVG layer above the cell grid, sized to the measured xterm cell grid (not to evenly-divided element width) so it stays aligned across xterm's internal padding. +- The header icon and banner are part of persistent terminal chrome and are not affected by inside-program redraws. +- The selection popup is rendered above the cell grid and anchored to the selection; it repositions on scroll, resize, and output (subscribing to the same render-tick signal as the overlay), and dismisses if the selection is canceled. +- All hint text (`Hold Alt for block selection`, `Press e to select the full URL`, etc.) is rendered by the terminal above the cell grid and does not interfere with the inside program's output. + +--- + +## 8. Paste Behavior + +### 8.1 Overview + +Paste reads the system clipboard and writes the content to the PTY. Paste keystrokes are **intercepted by the terminal**, not forwarded to the inside program. The inside program only receives the pasted bytes (optionally wrapped in bracketed-paste markers; see §8.5). + +Paste behavior differs by platform to match each OS's native convention. + +### 8.2 Paste Keybindings + +#### 8.2.1 macOS + +| Keystroke | Behavior | +|----------------|-----------------------------------------------------------------------------------| +| **Cmd+V** | Terminal intercepts and performs a bracketed paste. | +| **Cmd+Shift+V**| Terminal intercepts and performs a bracketed paste. (Alias for Cmd+V.) | +| **Ctrl+V** | Not intercepted. Forwarded to the inside program as the raw control byte `0x16`. | + +macOS users have a clean separation: Cmd is the paste modifier, Ctrl passes through to the program. + +#### 8.2.2 Windows and Linux + +| Keystroke | Behavior | +|-----------------|-----------------------------------------------------------------------------------| +| **Ctrl+V** | Terminal intercepts and performs a bracketed paste. | +| **Ctrl+Shift+V**| Terminal intercepts and performs a bracketed paste. (Alias for Ctrl+V.) | + +Because Ctrl+V is needed as both the paste shortcut (universal user expectation) and as the raw control byte `0x16` (for shell `quoted-insert`, vim literal-next, etc.), Ctrl+V is always intercepted; the raw byte is not sent to the inside program by this key. Users needing to send `0x16` can use the shell mechanism in §8.3. + +### 8.3 Sending `0x16` on Windows and Linux (Ctrl+Q) + +Users needing to insert a literal control character at a shell prompt can use the existing readline feature: press **Ctrl+Q**, then the desired key. This is a feature of bash, zsh, fish, and other readline-aware shells; the terminal does nothing special to enable it. The terminal provides no equivalent for programs that do not support Ctrl+Q-style `quoted-insert` (e.g. vim insert mode). + +### 8.4 Platform Detection + +Platform is detected at startup from `navigator.userAgentData.platform` (preferred), `navigator.platform`, or the user-agent string, matched against `/Mac|iPhone|iPad/`. The result is exposed as the `IS_MAC` constant in `lib/src/lib/platform/index.ts` and consulted by every place that selects between Cmd and Ctrl conventions. + +### 8.5 Bracketed Paste + +When the inside program has opted in via `\e[?2004h` (tracked as the `bracketedPaste` field on the per-terminal mouse-selection state), the terminal writes `\e[200~`, then the clipboard content, then `\e[201~`, to the PTY. Otherwise the content is written without brackets. This is standard xterm behavior; it allows shells and TUIs to distinguish pasted content from typed input. + +The bracketed-paste mode is read at paste time from xterm's public `terminal.modes.bracketedPasteMode`, kept in sync via a parser hook on `CSI ? ... h`/`l` (see `lib/src/lib/mouse-mode-observer.ts`). + +### 8.6 Paste Content + +The terminal pastes plain text only. It calls `navigator.clipboard.readText()` and writes the resulting string to the PTY (with bracketed-paste wrapping when enabled). If `readText` returns an empty string or throws (e.g. the document lacks focus or permission was denied), the paste is silently a no-op. + +File-URL handling, image paste, content-aware transformations, paste history, and credential warnings are out of scope (see §9). + +### 8.7 Right-Click and Menu Paste + +Right-click and OS Edit-menu paste are not currently implemented; users paste via the keyboard shortcuts in §8.2. + +--- + +## 9. Out of Scope / Future Considerations + +The following are explicitly not implemented today; they may be added in response to user feedback. + +### 9.1 Mouse and Selection + +- Auto-scroll during a drag that reaches the viewport edge. +- Double-click to select word, triple-click to select line. +- Additional copy modes beyond Raw and Rewrapped (strip ANSI, strip line numbers, strip prompts, join hyphenated line-breaks). +- Contextual actions in the popup (Open URL, Open in `$EDITOR`, Copy hash). +- Multi-level `e` extension (token → line → paragraph). +- A "quiet mode" setting to suppress hints for experienced users. +- Content-matching selection tracking when the underlying content changes (current behavior is cancel-on-change). +- Keyboard activation of the mouse icon and banner buttons. +- Refining the Copy Rewrapped heuristics based on dogfooding. + +### 9.2 Paste + +- Right-click context-menu Paste and OS Edit → Paste menu wiring. +- A settings toggle to disable Ctrl+V interception on Windows and Linux. +- A paste popup (parallel to the copy popup) for previewing or transforming paste content before it is committed. +- Paste content transformations (strip trailing whitespace, normalize line endings, convert smart quotes). +- File URL handling: pasting a `file://` URL as the bare path (Finder/Explorer drag-as-text). +- Image paste: detecting image data on the clipboard and offering to paste it as a temp file path or inline base64. +- Paste history. +- Credential-shaped content detection and warnings. +- Multi-line paste confirmation dialogs. +- A "literal next keystroke" terminal-level shortcut (Ctrl+Alt+V or similar) for programs that don't support Ctrl+Q-style `quoted-insert`. +- Middle-click paste / X11 PRIMARY selection integration on Linux. diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 628333b..931ef2e 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -144,3 +144,60 @@ The picker restores the persisted active theme on mount. The playground header i - `FakePtyAdapter` extensions: `setInputHandler(id, fn)` routes `writePty` calls to a custom handler; `sendOutput(id, data)` writes to a terminal's output stream. - `Pond` extensions: `initialPaneIds` prop seeds the first pane(s); `onApiReady` callback prop exposes `DockviewApi`; `onEvent` callback prop fires `PondEvent` for mode/zoom/detach/selection/split changes (types: `modeChange`, `zoomChange`, `detachChange`, `split`, `selectionChange`). - `SCENARIO_TUTORIAL_MOTD` scenario added to `lib/src/lib/platform/fake-scenarios.ts`. + +## Mouse and Clipboard Feature Coverage + +The Playground is the primary dogfood surface for the features in `docs/specs/mouse-and-clipboard.md`. As of the current three-pane layout (tutorial MOTD, `npm install`, `ls -la`) most of those features are not reachable from the Playground — the scenarios don't emit the relevant escape sequences or the right kinds of text. + +### Current state + +Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable. + +| Spec § | Feature | Status | Why | +|---|---|---|---| +| §1 | Mouse icon visible when program requests reporting | ❌ | No scenario emits `\x1b[?1000h` / `?1002h` / `?1003h` / `?1006h`. | +| §2 | Temporary/permanent override, banner, Make-permanent / Cancel | ❌ | Blocked on §1. | +| §3.1–§3.3 | Drag, Alt-block shape, "Hold Alt" hint | ✅ | Works on any visible text. | +| §3.3 | "Press e to select the full URL/path" hint | ❌ | No qualifying tokens; bare filenames like `package.json` don't match the patterns in `lib/src/lib/smart-token.ts`. | +| §3.4 | Pure-scroll follows, cancel-on-change, cancel-on-resize | ⚠️ | Scenarios are too short to scroll; nothing emits additional output after the initial burst; resize cancel works. | +| §3.5 | Scrollback-origin / cross-boundary drags | ⚠️ | Scrollback is too short to exercise. | +| §3.6 | Keyboard routing during drag | ⚠️ | Works, but hard to observe — no program in Playground reacts to dropped keystrokes. | +| §3.7 | Popup on mouse-up, new-drag-replaces | ✅ | Any selection. | +| §4.1.1 | Copy Raw | ✅ | Any selection. | +| §4.1.2 | Copy Rewrapped (box-strip + paragraph unwrap) | ❌ | No box-drawing characters anywhere; no multi-line prose. Rewrapped output is identical to Raw. | +| §4.2 | Cmd+C / Cmd+Shift+C | ✅ | Any selection. | +| §4.3 | Esc / click-outside dismiss | ✅ | Any selection popup. | +| §5 | Smart-extension (URL / abs path / rel path / Windows path / error location) | ❌ | No matching tokens in the scenarios. | +| §5.3 | Press `e` to extend | ❌ | Blocked on §5 coverage. | +| §8.2 | Cmd+V / Cmd+Shift+V / Ctrl+V / Ctrl+Shift+V paste | ⚠️ | The shortcut fires and writes to the fake PTY, but `TutorialShell.handleInput` (`website/src/lib/tutorial-shell.ts:77-96`) echoes characters one by one and does not interpret bracketed-paste markers. | +| §8.5 | Bracketed paste wraps `\e[200~ … \e[201~` | ❌ | No scenario emits `\x1b[?2004h`, so `getMouseSelectionState(id).bracketedPaste` stays `false` and `doPaste` sends the raw text. | + +`§3.6` auto-scroll and `§8.7` right-click paste are deferred in the implementation itself — not Playground gaps. + +### Remediation plan + +Add three new scenarios in `lib/src/lib/platform/fake-scenarios.ts` and expand the Playground layout in `website/src/pages/Playground.tsx` to surface them alongside the existing tutorial pane. Each scenario closes a specific set of gaps; all three together plus the tutorial MOTD make every currently-implemented feature reachable. + +1. **`SCENARIO_MOUSE_TUI`** — closes §1, §2, §8.5. + Emits `\x1b[?1000h\x1b[?1006h\x1b[?2004h` and then draws an idle `htop`-style ANSI-framed view. A minimal input handler for this pane discards any mouse-report bytes xterm forwards. With this pane present the Mouse icon appears in its header, clicking it activates the temporary-override banner, and pastes into it are wrapped in `\x1b[200~ … \x1b[201~`. + +2. **`SCENARIO_SMART_TOKENS`** — closes §3.3 extension hint, §5.1–§5.3. + Prints one of each detectable shape so every branch in `lib/src/lib/smart-token.ts`'s `PATTERNS` list has a live example: + + ``` + ✗ src/components/Pond.tsx:1576:7 — unused import + ✗ ../sibling/util.rs:42 — panic here + see https://en.wikipedia.org/wiki/Foo_(bar) + docs: /usr/local/share/doc/mouseterm/README + cwd: ~/projects/mouseterm + windows: C:\Users\me\work.log + ``` + + Dragging across any of them shows "Press e to select the full URL/path" and `e` extends. + +3. **`SCENARIO_BOXED_OUTPUT`** — closes §4.1.2 and §3.4. + A short release-notes-shaped message framed in `┌─│└` so Copy Rewrapped (via `lib/src/lib/rewrap.ts`) strips the frame and joins the wrapped lines — clipboard contents visibly differ from Copy Raw. A slowly-updating ticker line at the bottom gives cancel-on-change something concrete to react to. + +**Playground layout:** keep `PANE_MAIN` as the tutorial entry; replace `PANE_NPM` / `PANE_LS` with `PANE_TUI` / `PANE_TOKENS` / `PANE_BOXED` (three `api.addPanel` calls in `handleApiReady`, same pattern as the existing ones at `website/src/pages/Playground.tsx:62-75`). A 2×2 grid fits on load. + +**Optional:** teach `TutorialShell.handleInput` to recognize `\x1b[200~ … \x1b[201~` and print `[pasted: …]` so bracketed-paste wrapping is visually distinct for users who paste into `PANE_MAIN`. diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 49a926a..5bda944 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -14,7 +14,20 @@ import { createPortal } from 'react-dom'; import { TerminalPane } from './TerminalPane'; import { Baseboard } from './Baseboard'; import { tv } from 'tailwind-variants'; -import { BellIcon, BellSlashIcon, SplitHorizontalIcon, SplitVerticalIcon, ArrowsOutIcon, ArrowsInIcon, ArrowLineDownIcon, XIcon } from '@phosphor-icons/react'; +import { PopupButtonRow, popupButton } from './design'; +import { BellIcon, BellSlashIcon, SplitHorizontalIcon, SplitVerticalIcon, ArrowsOutIcon, ArrowsInIcon, ArrowLineDownIcon, XIcon, CursorClickIcon, SelectionSlashIcon } from '@phosphor-icons/react'; +import { + DEFAULT_MOUSE_SELECTION_STATE, + extendSelectionToToken, + flashCopy, + getMouseSelectionSnapshot, + getMouseSelectionState, + setOverride as setMouseOverride, + setSelection as setMouseSelection, + subscribeToMouseSelection, +} from '../lib/mouse-selection'; +import { copyRaw, copyRewrapped, doPaste } from '../lib/clipboard'; +import { IS_MAC } from '../lib/platform'; import { type AlarmButtonActionResult, clearSessionAttention, @@ -202,6 +215,71 @@ function HeaderActionButton({ // --- Alarm context menu (right-click on bell) --- +/** + * Portal banner shown while a temporary mouse-capture override is active. + * Positioned below a given anchor element (the No-Mouse icon) and kept in + * sync with scroll/resize. Spec §2.1 / §2.4: mouse-only, no keyboard. + */ +function MouseOverrideBanner({ + anchor, + onMakePermanent, + onCancel, +}: { + anchor: HTMLElement; + onMakePermanent: () => void; + onCancel: () => void; +}) { + const [pos, setPos] = useState<{ x: number; y: number } | null>(null); + const [flashed, setFlashed] = useState<'sticky' | 'cancel' | null>(null); + + useLayoutEffect(() => { + const update = () => { + const r = anchor.getBoundingClientRect(); + setPos({ x: r.left, y: r.bottom + 4 }); + }; + update(); + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, [anchor]); + + useEffect(() => { + if (!flashed) return; + const id = window.setTimeout(() => { + if (flashed === 'sticky') onMakePermanent(); + else onCancel(); + }, 260); + return () => window.clearTimeout(id); + }, [flashed, onMakePermanent, onCancel]); + + if (!pos) return null; + + return createPortal( + e.stopPropagation()} + role="status" + > + Temporary mouse override until mouse-up. + + + , + document.body, + ); +} + function clampOverlayPosition({ left, top, width, height }: { left: number; top: number; @@ -529,12 +607,21 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const zoomed = useContext(ZoomedContext); const windowFocused = useContext(WindowFocusedContext); const sessionStates = useSyncExternalStore(subscribeToSessionStateChanges, getSessionStateSnapshot); + const mouseStates = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); const actions = useContext(PondActionsContext); const sessionState = sessionStates.get(api.id) ?? DEFAULT_SESSION_UI_STATE; + const mouseState = mouseStates.get(api.id) ?? DEFAULT_MOUSE_SELECTION_STATE; + const showMouseIcon = mouseState.mouseReporting !== 'none'; + const inOverride = mouseState.override !== 'off'; + const mouseIconTooltip = inOverride + ? "You're overriding the TUI's mouse capture. Click to restore." + : 'TUI is intercepting mouse commands. Click to override.'; + const mouseIconAriaLabel = inOverride ? 'Restore mouse capture' : 'Override mouse capture'; const isSelected = selectedId === api.id; const showSelectedHeader = mode === 'passthrough' && isSelected && windowFocused; const isRenaming = renamingId === api.id; const tabRef = useRef(null); + const [mouseIconAnchor, setMouseIconAnchor] = useState(null); const suppressAlarmClickRef = useRef(false); const [tier, setTier] = useState('full'); const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number } | null>(null); @@ -684,6 +771,35 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { {!isRenaming && ( <> + {showMouseIcon && ( +
+ e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + setMouseOverride(api.id, inOverride ? 'off' : 'temporary'); + }} + ariaLabel={mouseIconAriaLabel} + tooltip={mouseIconTooltip} + > + + {inOverride ? ( + + ) : ( + + )} + + +
+ )} + {mouseIconAnchor && mouseState.override === 'temporary' && ( + setMouseOverride(api.id, 'permanent')} + onCancel={() => setMouseOverride(api.id, 'off')} + /> + )} {/* Split/Zoom controls — hidden at compact and minimal tiers */} {tier === 'full' && (
@@ -1596,6 +1712,64 @@ export function Pond({ return; } + // Mid-drag keystrokes and copy/paste shortcuts. Spec §5.3, §3.6, §4.2, §8.2. + { + const sid = selectedIdRef.current; + if (sid) { + const mouseState = getMouseSelectionState(sid); + const sel = mouseState.selection; + + // During a terminal-owned drag, `e` extends to the detected token + // and Esc cancels. Per spec §3.6, ALL keystrokes are consumed + // during a drag so they don't reach the inside program. Alt is + // allowed to propagate because terminal-registry's onAltChange + // listener uses it for block-selection shape toggling (§3.2). + if (sel?.dragging) { + if (e.key === 'e' && mouseState.hintToken) { + e.preventDefault(); + e.stopImmediatePropagation(); + extendSelectionToToken(sid, mouseState.hintToken); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopImmediatePropagation(); + setMouseSelection(sid, null); + return; + } + // Let Alt propagate for block-selection toggling; consume + // everything else. + if (e.key !== 'Alt') { + e.preventDefault(); + e.stopImmediatePropagation(); + } + return; + } + + // Copy is narrow: only when the terminal has a finalized selection. + // Paste is broad: always intercepted on the platform's paste chord. + // macOS: Cmd+V, Cmd+Shift+V. Ctrl+V passes through to the program. + // Other: Ctrl+V, Ctrl+Shift+V. Both always intercepted. + const keyLower = e.key.toLowerCase(); + const mod = IS_MAC ? e.metaKey : e.ctrlKey; + if (sel && !sel.dragging && mod && keyLower === 'c') { + e.preventDefault(); + e.stopImmediatePropagation(); + const rewrapped = e.shiftKey; + void (rewrapped ? copyRewrapped(sid) : copyRaw(sid)).then(() => { + flashCopy(sid, rewrapped ? 'rewrapped' : 'raw'); + }); + return; + } + if (mod && keyLower === 'v') { + e.preventDefault(); + e.stopImmediatePropagation(); + void doPaste(sid); + return; + } + } + } + // In terminal mode, only the Meta gesture above matters — everything else goes to xterm if (currentMode === 'passthrough') return; diff --git a/lib/src/components/SelectionOverlay.test.ts b/lib/src/components/SelectionOverlay.test.ts new file mode 100644 index 0000000..c1352de --- /dev/null +++ b/lib/src/components/SelectionOverlay.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; +import { __testing } from './SelectionOverlay'; +import { normalizeSelection } from '../lib/selection-text'; +import type { Selection } from '../lib/mouse-selection'; + +const { computeRects, rectsToPath } = __testing; + +function sel(overrides: Partial): Selection { + return { + startRow: 0, + startCol: 0, + endRow: 0, + endCol: 0, + shape: 'linewise', + dragging: false, + startedInScrollback: false, + ...overrides, + }; +} + +describe('normalizeSelection', () => { + it('forward linewise selection passes through', () => { + const n = normalizeSelection(sel({ startRow: 2, startCol: 3, endRow: 5, endCol: 8 })); + expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8 }); + }); + + it('reversed linewise selection swaps start/end', () => { + const n = normalizeSelection(sel({ startRow: 5, startCol: 8, endRow: 2, endCol: 3 })); + expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8 }); + }); + + it('block selection normalizes min/max independently', () => { + const n = normalizeSelection(sel({ + startRow: 5, startCol: 8, endRow: 2, endCol: 3, shape: 'block', + })); + expect(n).toEqual({ r0: 2, c0: 3, r1: 5, c1: 8 }); + }); +}); + +describe('computeRects: linewise', () => { + const cellWidth = 10; + const cellHeight = 20; + + it('single-row selection → one rect', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 5, endRow: 0, endCol: 14 }), + 80, 0, 24, cellWidth, cellHeight, + ); + expect(rects).toEqual([{ top: 0, left: 50, width: 100, height: 20 }]); + }); + + it('multi-row linewise: first row trimmed, middle rows full, last row trimmed', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 5, endRow: 2, endCol: 9 }), + 80, 0, 24, cellWidth, cellHeight, + ); + expect(rects).toHaveLength(3); + expect(rects[0]).toEqual({ top: 0, left: 50, width: (80 - 5) * 10, height: 20 }); + expect(rects[1]).toEqual({ top: 20, left: 0, width: 800, height: 20 }); + expect(rects[2]).toEqual({ top: 40, left: 0, width: 100, height: 20 }); + }); + + it('scrollback-only selection above viewport → no rects', () => { + // viewportY=50 means visible rows are 50..73. Selection in rows 10..20 + // is entirely above the viewport. + const rects = computeRects( + sel({ startRow: 10, startCol: 0, endRow: 20, endCol: 9 }), + 80, 50, 24, cellWidth, cellHeight, + ); + expect(rects).toEqual([]); + }); + + it('selection clipped to viewport start', () => { + // viewportY=10 (rows 10..33). Selection 5..15 → only rows 10..15 visible. + const rects = computeRects( + sel({ startRow: 5, startCol: 0, endRow: 15, endCol: 39 }), + 80, 10, 24, cellWidth, cellHeight, + ); + // First visible row is row 10 — that's a "middle" row (not the original + // startRow), so full-width. + expect(rects[0]).toEqual({ top: 0, left: 0, width: 800, height: 20 }); + // Last rect is the original endRow (15) in reading order. + expect(rects[rects.length - 1]).toEqual({ top: (15 - 10) * 20, left: 0, width: 400, height: 20 }); + }); + + it('selection with equal start/end columns renders one cell', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 10, endRow: 0, endCol: 10 }), + 80, 0, 24, cellWidth, cellHeight, + ); + expect(rects).toEqual([{ top: 0, left: 100, width: 10, height: 20 }]); + }); +}); + +describe('computeRects: block', () => { + const cellWidth = 10; + const cellHeight = 20; + + it('block selection is a single rect', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 3, endRow: 2, endCol: 8, shape: 'block' }), + 80, 0, 24, cellWidth, cellHeight, + ); + expect(rects).toHaveLength(1); + expect(rects[0]).toEqual({ top: 0, left: 30, width: (8 - 3 + 1) * 10, height: (2 - 0 + 1) * 20 }); + }); + + it('block selection clipped to viewport', () => { + const rects = computeRects( + sel({ startRow: 5, startCol: 3, endRow: 15, endCol: 8, shape: 'block' }), + 80, 10, 24, cellWidth, cellHeight, + ); + expect(rects[0].top).toBe(0); + expect(rects[0].height).toBe((15 - 10 + 1) * 20); + }); + + it('block selection entirely in scrollback → no rect', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 0, endRow: 5, endCol: 5, shape: 'block' }), + 80, 100, 24, cellWidth, cellHeight, + ); + expect(rects).toEqual([]); + }); +}); + +describe('rectsToPath', () => { + it('empty rect list → empty path', () => { + expect(rectsToPath([])).toBe(''); + }); + + it('single rect traces four corners', () => { + // 20x10 rect at origin + const path = rectsToPath([{ top: 0, left: 0, width: 20, height: 10 }]); + // Right side going down, then left side going up (reversed). + expect(path).toBe('M 20 0 L 20 10 L 0 10 L 0 0 Z'); + }); + + it('two-row linewise "Z" shape — first row narrower, second wider', () => { + // Row 0 is a tail: left=50, right=80 (narrower) + // Row 1 starts at col 0: left=0, right=30 (head) + const path = rectsToPath([ + { top: 0, left: 50, width: 30, height: 10 }, + { top: 10, left: 0, width: 30, height: 10 }, + ]); + // Expected vertex walk: + // top-right of row 0 → bottom-right of row 0 (= top-right of connector) + // top-right of row 1 → bottom-right of row 1 + // bottom-left of row 1 → top-left of row 1 (= bottom-left of connector) + // bottom-left of row 0 → top-left of row 0 → close + expect(path).toBe( + 'M 80 0 L 80 10 L 30 10 L 30 20 L 0 20 L 0 10 L 50 10 L 50 0 Z', + ); + }); + + it('three-row linewise with full-width middle row', () => { + const path = rectsToPath([ + { top: 0, left: 40, width: 40, height: 10 }, // row 0: tail, 40..80 + { top: 10, left: 0, width: 80, height: 10 }, // row 1: full, 0..80 + { top: 20, left: 0, width: 20, height: 10 }, // row 2: head, 0..20 + ]); + expect(path).toBe( + [ + 'M 80 0', // top-right of row 0 + 'L 80 10', // bottom-right of row 0 + 'L 80 10', // top-right of row 1 (same point, 0-length connector) + 'L 80 20', // bottom-right of row 1 + 'L 20 20', // top-right of row 2 + 'L 20 30', // bottom-right of row 2 + 'L 0 30', // bottom-left of row 2 + 'L 0 20', // top-left of row 2 + 'L 0 20', // bottom-left of row 1 (same point) + 'L 0 10', // top-left of row 1 + 'L 40 10', // bottom-left of row 0 + 'L 40 0', // top-left of row 0 + 'Z', + ].join(' '), + ); + }); + + it('block selection (single rect) — same as single rectangle', () => { + const rects = computeRects( + sel({ startRow: 0, startCol: 3, endRow: 2, endCol: 8, shape: 'block' }), + 80, 0, 24, 10, 20, + ); + const path = rectsToPath(rects); + // Rect: top=0, left=30, width=60, height=60 → right=90, bottom=60 + expect(path).toBe('M 90 0 L 90 60 L 30 60 L 30 0 Z'); + }); +}); diff --git a/lib/src/components/SelectionOverlay.tsx b/lib/src/components/SelectionOverlay.tsx new file mode 100644 index 0000000..7dbcd27 --- /dev/null +++ b/lib/src/components/SelectionOverlay.tsx @@ -0,0 +1,210 @@ +import { useSyncExternalStore, type CSSProperties } from 'react'; +import { + DEFAULT_MOUSE_SELECTION_STATE, + getMouseSelectionSnapshot, + getRenderTick, + subscribeToMouseSelection, + subscribeToRenderTick, + type Selection, +} from '../lib/mouse-selection'; +import { normalizeSelection } from '../lib/selection-text'; +import { getTerminalOverlayDims } from '../lib/terminal-registry'; +import { IS_MAC } from '../lib/platform'; + +interface Rect { + top: number; + left: number; + width: number; + height: number; +} + +/** + * Trace the perimeter of a linewise/block selection's visible rects as a + * single closed SVG path. + * + * Rects are row-adjacent and in top-to-bottom order (guaranteed by + * `computeRects`). We walk the right edges going down, the bottom edge of + * the last rect, the left edges going up, then the top edge of the first + * rect. Horizontal connector segments between rows of different widths + * naturally fall out of the vertex sequence. + */ +function rectsToPath(rects: Rect[]): string { + if (rects.length === 0) return ''; + const pts: Array<[number, number]> = []; + // Right side going down — each rect contributes (top-right, bottom-right). + for (const r of rects) { + pts.push([r.left + r.width, r.top]); + pts.push([r.left + r.width, r.top + r.height]); + } + // Left side going up — walk the rects in reverse, contributing + // (bottom-left, top-left) each. + for (let i = rects.length - 1; i >= 0; i--) { + const r = rects[i]; + pts.push([r.left, r.top + r.height]); + pts.push([r.left, r.top]); + } + return 'M ' + pts.map(([x, y]) => `${x} ${y}`).join(' L ') + ' Z'; +} + +function computeRects( + sel: Selection, + cols: number, + viewportY: number, + rows: number, + cellWidth: number, + cellHeight: number, +): Rect[] { + const n = normalizeSelection(sel); + + const viewportStart = viewportY; + const viewportEnd = viewportY + rows; + + if (sel.shape === 'block') { + const top = Math.max(viewportStart, n.r0); + const bottom = Math.min(viewportEnd - 1, n.r1); + if (top > bottom) return []; + const left = n.c0; + const right = n.c1; + return [{ + top: (top - viewportStart) * cellHeight, + left: left * cellWidth, + width: (right - left + 1) * cellWidth, + height: (bottom - top + 1) * cellHeight, + }]; + } + + // Linewise — one rect per visible row in [r0..r1]. + const rects: Rect[] = []; + const firstRow = Math.max(viewportStart, n.r0); + const lastRow = Math.min(viewportEnd - 1, n.r1); + for (let r = firstRow; r <= lastRow; r++) { + let c0 = 0; + let c1 = cols; + if (r === n.r0) c0 = n.c0; + if (r === n.r1) c1 = n.c1 + 1; + if (c1 <= c0) continue; + rects.push({ + top: (r - viewportStart) * cellHeight, + left: c0 * cellWidth, + width: (c1 - c0) * cellWidth, + height: cellHeight, + }); + } + return rects; +} + +interface Props { + terminalId: string; +} + +/** + * Compositor-layer selection highlight rendered above xterm's cell grid. + * Re-measures on every render tick (scroll, resize, output). + */ +export function SelectionOverlay({ terminalId }: Props) { + const states = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); + // Subscribe to render tick so we re-render whenever xterm scrolls or resizes. + useSyncExternalStore(subscribeToRenderTick, getRenderTick); + + const state = states.get(terminalId) ?? DEFAULT_MOUSE_SELECTION_STATE; + const selection = state.selection; + if (!selection) return null; + + const dims = getTerminalOverlayDims(terminalId); + if (!dims || dims.cols === 0 || dims.rows === 0) return null; + + // cellWidth / cellHeight come from measuring xterm's `.xterm-screen`, and + // gridLeft / gridTop are its offset within the element. Using these + // instead of elementWidth/cols keeps the highlight aligned even when xterm + // adds a few pixels of padding around the cell grid. + const { cellWidth, cellHeight, gridLeft, gridTop } = dims; + const rects = computeRects(selection, dims.cols, dims.viewportY, dims.rows, cellWidth, cellHeight); + + const style: CSSProperties = { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + zIndex: 10, + }; + + // Border-only highlight. Pick a color with reliable contrast across themes: + // prefer focusBorder (typically fully opaque accent), fall back to the + // terminal foreground, then selectionBackground, then a hard-coded cornflower. + const styles = getComputedStyle(document.body); + const borderColor = + styles.getPropertyValue('--vscode-focusBorder').trim() + || styles.getPropertyValue('--vscode-terminal-foreground').trim() + || styles.getPropertyValue('--vscode-terminal-selectionBackground').trim() + || 'rgb(100, 149, 237)'; + const pathD = rectsToPath(rects); + + // Mid-drag hint. Placed outside the selection on the side opposite the + // drag direction: below when the user drags down, above when they drag up. + // Drag-down anchors by `top` (top edge aligned with where we want the + // near-selection edge); drag-up anchors by `bottom` so the near-selection + // edge lines up regardless of element height — this keeps the hint and + // the copy popup visually coincident, since the popup uses the same + // anchoring rules. Shown only while the user is dragging (spec §3.3). + let hint: { left: number; top?: number; bottom?: number } | null = null; + if (selection.dragging) { + const endViewportRow = selection.endRow - dims.viewportY; + 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) { + const top = Math.min( + gridTop + (endViewportRow + 2) * cellHeight + 4, + dims.elementHeight - 24, + ); + hint = { left, top }; + } else { + // Anchor the element's bottom edge one full cell above the + // selection — symmetric with the drag-down +2-row offset — so the + // row adjacent to the selection stays visible. Clamp the anchor y + // so there's at least ~24px of room above it for the hint to + // render inside the viewport. + const y = Math.max(gridTop + (endViewportRow - 1) * cellHeight - 4, 28); + hint = { left, bottom: dims.elementHeight - y }; + } + } + } + + return ( + + ); +} + +// Exported for unit tests. +export const __testing = { computeRects, rectsToPath }; diff --git a/lib/src/components/SelectionPopup.tsx b/lib/src/components/SelectionPopup.tsx new file mode 100644 index 0000000..4acbb63 --- /dev/null +++ b/lib/src/components/SelectionPopup.tsx @@ -0,0 +1,165 @@ +import { useLayoutEffect, useState, useEffect, useSyncExternalStore, type CSSProperties } from 'react'; +import { + DEFAULT_MOUSE_SELECTION_STATE, + flashCopy, + getMouseSelectionSnapshot, + getRenderTick, + setSelection, + subscribeToMouseSelection, + subscribeToRenderTick, +} from '../lib/mouse-selection'; +import { copyRaw, copyRewrapped } from '../lib/clipboard'; +import { CheckIcon } from '@phosphor-icons/react'; +import { IS_MAC } from '../lib/platform'; +import { getTerminalOverlayDims } from '../lib/terminal-registry'; +import { PopupButtonRow, popupButton } from './design'; + +interface Props { + terminalId: string; +} + +/** + * Popup shown after a selection is finalized (mouse-up). Offers Copy Raw + * and Copy Rewrapped. Dismissed on Esc, click-outside, or a successful copy. + */ +export function SelectionPopup({ terminalId }: Props) { + const states = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); + useSyncExternalStore(subscribeToRenderTick, getRenderTick); + + const state = states.get(terminalId) ?? DEFAULT_MOUSE_SELECTION_STATE; + const selection = state.selection; + const shouldRender = (!!selection && !selection.dragging) || !!state.copyFlash; + + const [anchor, setAnchor] = useState<{ left: number; top?: number; bottom?: number } | null>(null); + + useLayoutEffect(() => { + if (!shouldRender || !selection) { + setAnchor(null); + return; + } + const dims = getTerminalOverlayDims(terminalId); + if (!dims || dims.cols === 0 || dims.rows === 0) return; + // Use the measured cell grid so the anchor aligns with the border + // outline (the overlay pulls from the same dims). + const { cellWidth, cellHeight, gridLeft, gridTop } = dims; + const endViewportRow = selection.endRow - dims.viewportY; + const endRow = Math.max(0, Math.min(dims.rows - 1, endViewportRow)); + // Place the popup on the side opposite the drag direction, matching + // exactly where the Alt hint sat. Drag-down anchors by `top`, drag-up + // anchors by `bottom` — that way both elements have their near- + // selection edge at the same y regardless of their heights. Without + // this, the popup (shorter than the hint) would appear closer to the + // 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) { + const top = Math.min( + gridTop + (endRow + 2) * cellHeight + 4, + dims.elementHeight - 24, + ); + setAnchor({ left, top }); + } else { + // Bottom-anchored one full cell above the selection — symmetric with + // the drag-down +2-row offset on the top-anchored side. + const y = Math.max(gridTop + (endRow - 1) * cellHeight - 4, 28); + setAnchor({ left, bottom: dims.elementHeight - y }); + } + }, [terminalId, shouldRender, selection]); + + useEffect(() => { + if (!shouldRender) return; + const onKeyDown = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') { + ev.preventDefault(); + ev.stopPropagation(); + setSelection(terminalId, null); + } + }; + const onMouseDown = (ev: MouseEvent) => { + // Click anywhere outside the popup → dismiss. The overlay itself and + // the terminal body both qualify. A new mousedown inside the terminal + // will also begin a new drag (handled in terminal-registry), which + // also replaces the selection. + const target = ev.target as HTMLElement | null; + if (!target?.closest(`[data-selection-popup-for="${terminalId}"]`)) { + setSelection(terminalId, null); + } + }; + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('mousedown', onMouseDown, true); + return () => { + window.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('mousedown', onMouseDown, true); + }; + }, [shouldRender, terminalId]); + + if (!shouldRender || !anchor) return null; + + const copyShortcut = IS_MAC ? 'Cmd+C' : 'Ctrl+C'; + const rewrapShortcut = IS_MAC ? 'Cmd+Shift+C' : 'Ctrl+Shift+C'; + + const style: CSSProperties = { + position: 'absolute', + left: anchor.left, + top: anchor.top, + bottom: anchor.bottom, + zIndex: 20, + }; + + const onCopy = async (rewrapped: boolean) => { + if (rewrapped) { + await copyRewrapped(terminalId); + } else { + await copyRaw(terminalId); + } + flashCopy(terminalId, rewrapped ? 'rewrapped' : 'raw'); + }; + + const flashed = (kind: 'raw' | 'rewrapped') => state.copyFlash === kind; + const buttonClass = (kind: 'raw' | 'rewrapped') => popupButton({ flashed: flashed(kind) }); + const shortcutClass = (kind: 'raw' | 'rewrapped') => + flashed(kind) ? 'text-accent/70' : 'text-muted'; + + return ( + e.stopPropagation()} + > + + + + ); +} diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index 04324e0..8dd4364 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -7,6 +7,8 @@ import { refitTerminal, focusTerminal, } from '../lib/terminal-registry'; +import { SelectionOverlay } from './SelectionOverlay'; +import { SelectionPopup } from './SelectionPopup'; interface TerminalPaneProps { id: string; @@ -47,5 +49,10 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { focusTerminal(id, isFocused); }, [id, isFocused]); - return
; + return ( +
+ + +
+ ); } diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx new file mode 100644 index 0000000..2b86632 --- /dev/null +++ b/lib/src/components/design.tsx @@ -0,0 +1,35 @@ +import { clsx } from 'clsx'; +import { tv, type VariantProps } from 'tailwind-variants'; +import type { HTMLAttributes } from 'react'; + +export function PopupButtonRow({ + className, + ...props +}: HTMLAttributes) { + return ( +
+ ); +} + +export const popupButton = tv({ + base: 'm-0 px-1.5 py-0.5', + variants: { + tone: { + foreground: '', + muted: 'text-muted hover:text-foreground', + }, + flashed: { + true: 'animate-copy-flash bg-accent/25 text-accent', + false: 'hover:bg-foreground/10', + }, + }, + defaultVariants: { tone: 'foreground', flashed: false }, +}); + +export type PopupButtonVariants = VariantProps; diff --git a/lib/src/index.css b/lib/src/index.css index 33022a3..d520736 100644 --- a/lib/src/index.css +++ b/lib/src/index.css @@ -119,3 +119,18 @@ body { @keyframes marching-ants { to { stroke-dashoffset: var(--march-offset); } } + +/* --- Copy-confirmation flash for the selection popup button --- */ + +@keyframes copy-flash { + 0% { transform: scale(1); } + 45% { transform: scale(1.08); } + 100% { transform: scale(1); } +} +.animate-copy-flash { + animation: copy-flash 260ms cubic-bezier(0.34, 1.56, 0.64, 1); + transform-origin: center; +} +@media (prefers-reduced-motion: reduce) { + .animate-copy-flash { animation: none; } +} diff --git a/lib/src/lib/clipboard.ts b/lib/src/lib/clipboard.ts new file mode 100644 index 0000000..02f6e49 --- /dev/null +++ b/lib/src/lib/clipboard.ts @@ -0,0 +1,63 @@ +import { getMouseSelectionState } from './mouse-selection'; +import { rewrap } from './rewrap'; +import { extractSelectionText } from './selection-text'; +import { getPlatform } from './platform'; +import { getTerminalInstance } from './terminal-registry'; + +async function writeText(text: string): Promise { + if (!text) return; + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } + } catch { + // Clipboard write can fail when the document lacks focus or the + // Permissions API denied access. Silently ignore — the user will + // notice the paste didn't work and can retry. + } +} + +/** + * Copy the terminal's current selection to the clipboard as-is. + * No-op if no selection exists. + */ +export async function copyRaw(terminalId: string): Promise { + const terminal = getTerminalInstance(terminalId); + const sel = getMouseSelectionState(terminalId).selection; + if (!terminal || !sel) return; + await writeText(extractSelectionText(terminal, sel)); +} + +/** + * Copy the terminal's current selection with rewrap transformations applied. + * Block selections are not rewrapped (they're intentionally rectangular slabs). + * No-op if no selection exists. + */ +export async function copyRewrapped(terminalId: string): Promise { + const terminal = getTerminalInstance(terminalId); + const sel = getMouseSelectionState(terminalId).selection; + if (!terminal || !sel) return; + const raw = extractSelectionText(terminal, sel); + const out = sel.shape === 'block' ? raw : rewrap(raw); + await writeText(out); +} + +/** + * Read text from the clipboard and write it to the PTY, honoring the + * inside program's bracketed-paste mode when enabled (spec §8.5). + */ +export async function doPaste(terminalId: string): Promise { + let text: string; + try { + if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return; + text = await navigator.clipboard.readText(); + } catch { + // Clipboard read can fail when the document lacks focus or the + // Permissions API denied access. Silently ignore. + return; + } + if (!text) return; + const bracketed = getMouseSelectionState(terminalId).bracketedPaste; + const payload = bracketed ? `\x1b[200~${text}\x1b[201~` : text; + getPlatform().writePty(terminalId, payload); +} diff --git a/lib/src/lib/mouse-mode-observer.test.ts b/lib/src/lib/mouse-mode-observer.test.ts new file mode 100644 index 0000000..057f59b --- /dev/null +++ b/lib/src/lib/mouse-mode-observer.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Terminal } from '@xterm/xterm'; +import { attachMouseModeObserver } from './mouse-mode-observer'; +import { __resetMouseSelectionForTests, getMouseSelectionState } from './mouse-selection'; + +afterEach(() => { + __resetMouseSelectionForTests(); + vi.restoreAllMocks(); +}); + +interface MockDisposables { + setHandlers: Array<() => boolean>; + resetHandlers: Array<() => boolean>; +} + +function buildMockTerminal(): { terminal: Terminal; modes: { mouseTrackingMode: string; bracketedPasteMode: boolean }; handlers: MockDisposables } { + const handlers: MockDisposables = { setHandlers: [], resetHandlers: [] }; + const modes = { mouseTrackingMode: 'none' as const, bracketedPasteMode: false }; + const parser = { + registerCsiHandler(id: { prefix?: string; final?: string }, cb: () => boolean) { + if (id.prefix === '?' && id.final === 'h') { + handlers.setHandlers.push(cb); + } else if (id.prefix === '?' && id.final === 'l') { + handlers.resetHandlers.push(cb); + } + return { dispose: vi.fn() }; + }, + }; + const terminal = { parser, modes } as unknown as Terminal; + return { terminal, modes, handlers }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('attachMouseModeObserver', () => { + it('registers one DECSET and one DECRST handler', () => { + const { terminal, handlers } = buildMockTerminal(); + attachMouseModeObserver('a', terminal); + expect(handlers.setHandlers).toHaveLength(1); + expect(handlers.resetHandlers).toHaveLength(1); + }); + + it('DECSET handler returns false so xterm still processes the sequence', () => { + const { terminal, handlers } = buildMockTerminal(); + attachMouseModeObserver('a', terminal); + expect(handlers.setHandlers[0]()).toBe(false); + expect(handlers.resetHandlers[0]()).toBe(false); + }); + + it('syncs mouseReporting after a DECSET fires (modes updated in the mock)', async () => { + const { terminal, modes, handlers } = buildMockTerminal(); + attachMouseModeObserver('a', terminal); + + // Simulate xterm processing `\e[?1000h`: our handler fires, then xterm's + // builtin updates modes. We emulate by flipping the mode before the + // microtask runs. + handlers.setHandlers[0](); + modes.mouseTrackingMode = 'vt200'; + + await flushMicrotasks(); + expect(getMouseSelectionState('a').mouseReporting).toBe('vt200'); + }); + + it('syncs bracketedPaste after a DECSET fires', async () => { + const { terminal, modes, handlers } = buildMockTerminal(); + attachMouseModeObserver('a', terminal); + + handlers.setHandlers[0](); + modes.bracketedPasteMode = true; + + await flushMicrotasks(); + expect(getMouseSelectionState('a').bracketedPaste).toBe(true); + }); + + it('syncs mouseReporting to none after DECRST', async () => { + const { terminal, modes, handlers } = buildMockTerminal(); + attachMouseModeObserver('a', terminal); + + // Enable first + handlers.setHandlers[0](); + modes.mouseTrackingMode = 'any'; + await flushMicrotasks(); + expect(getMouseSelectionState('a').mouseReporting).toBe('any'); + + // Then disable + handlers.resetHandlers[0](); + modes.mouseTrackingMode = 'none'; + await flushMicrotasks(); + expect(getMouseSelectionState('a').mouseReporting).toBe('none'); + }); + + it('dispose tears down both handlers', () => { + const { terminal } = buildMockTerminal(); + const mockDispose1 = vi.fn(); + const mockDispose2 = vi.fn(); + const realParser = terminal.parser; + (terminal as unknown as { parser: unknown }).parser = { + registerCsiHandler(_id: unknown, _cb: unknown) { + return realParser === terminal.parser + ? { dispose: mockDispose1 } + : { dispose: mockDispose2 }; + }, + }; + + // Simpler: build a fresh mock with explicit disposables + const disposables: Array<{ dispose: ReturnType }> = []; + const term2 = { + parser: { + registerCsiHandler() { + const d = { dispose: vi.fn() }; + disposables.push(d); + return d; + }, + }, + modes: { mouseTrackingMode: 'none', bracketedPasteMode: false }, + } as unknown as Terminal; + + const observer = attachMouseModeObserver('a', term2); + observer.dispose(); + + expect(disposables).toHaveLength(2); + expect(disposables[0].dispose).toHaveBeenCalledOnce(); + expect(disposables[1].dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/lib/src/lib/mouse-mode-observer.ts b/lib/src/lib/mouse-mode-observer.ts new file mode 100644 index 0000000..d136870 --- /dev/null +++ b/lib/src/lib/mouse-mode-observer.ts @@ -0,0 +1,38 @@ +import type { Terminal, IDisposable } from '@xterm/xterm'; +import { setBracketedPaste, setMouseReporting } from './mouse-selection'; + +/** + * Wire an xterm terminal's mouse-tracking and bracketed-paste modes into the + * mouse-selection store. + * + * Installs CSI handlers for DECSET (`CSI ? ... h`) and DECRST (`CSI ? ... l`) + * that return false, letting xterm's built-in handler still process the + * sequence. After xterm updates its internal state we sync our store from + * the public `terminal.modes` getters in a microtask (the parser handler + * runs synchronously before xterm's, so `modes` isn't updated yet when our + * callback first fires). + */ +export function attachMouseModeObserver(id: string, terminal: Terminal): IDisposable { + const sync = () => { + queueMicrotask(() => { + setMouseReporting(id, terminal.modes.mouseTrackingMode); + setBracketedPaste(id, terminal.modes.bracketedPasteMode); + }); + }; + + const onSet = terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, () => { + sync(); + return false; + }); + const onReset = terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, () => { + sync(); + return false; + }); + + return { + dispose() { + onSet.dispose(); + onReset.dispose(); + }, + }; +} diff --git a/lib/src/lib/mouse-selection.test.ts b/lib/src/lib/mouse-selection.test.ts new file mode 100644 index 0000000..0e6703d --- /dev/null +++ b/lib/src/lib/mouse-selection.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_MOUSE_SELECTION_STATE, + __resetMouseSelectionForTests, + beginDrag, + endDrag, + getMouseSelectionSnapshot, + getMouseSelectionState, + isDragging, + removeMouseSelectionState, + setBracketedPaste, + setHintToken, + setMouseReporting, + setOverride, + setSelection, + subscribeToMouseSelection, + updateDrag, + type Selection, + type TokenHint, +} from './mouse-selection'; + +afterEach(() => { + __resetMouseSelectionForTests(); +}); + +describe('mouse-selection: default state', () => { + it('returns the default state for an unknown id', () => { + expect(getMouseSelectionState('missing')).toEqual(DEFAULT_MOUSE_SELECTION_STATE); + }); + + it('default state has mouse reporting off, override off, no selection', () => { + expect(DEFAULT_MOUSE_SELECTION_STATE).toEqual({ + mouseReporting: 'none', + bracketedPaste: false, + override: 'off', + selection: null, + hintToken: null, + copyFlash: null, + }); + }); +}); + +describe('mouse-selection: state setters', () => { + it('setMouseReporting updates the mode', () => { + setMouseReporting('a', 'vt200'); + expect(getMouseSelectionState('a').mouseReporting).toBe('vt200'); + + setMouseReporting('a', 'any'); + expect(getMouseSelectionState('a').mouseReporting).toBe('any'); + }); + + it('setBracketedPaste toggles the flag', () => { + setBracketedPaste('a', true); + expect(getMouseSelectionState('a').bracketedPaste).toBe(true); + setBracketedPaste('a', false); + expect(getMouseSelectionState('a').bracketedPaste).toBe(false); + }); + + it('setSelection stores a selection', () => { + const sel: Selection = { startRow: 5, startCol: 3, endRow: 5, endCol: 10, shape: 'linewise', dragging: false, startedInScrollback: false }; + setSelection('a', sel); + expect(getMouseSelectionState('a').selection).toBe(sel); + + setSelection('a', null); + expect(getMouseSelectionState('a').selection).toBeNull(); + }); + + it('setHintToken stores a hint', () => { + const hint: TokenHint = { kind: 'url', row: 1, startCol: 0, endCol: 20, text: 'https://example.com' }; + setHintToken('a', hint); + expect(getMouseSelectionState('a').hintToken).toBe(hint); + + setHintToken('a', null); + expect(getMouseSelectionState('a').hintToken).toBeNull(); + }); + + it('removeMouseSelectionState drops all state for an id', () => { + setMouseReporting('a', 'vt200'); + setSelection('a', { startRow: 0, startCol: 0, endRow: 0, endCol: 5, shape: 'linewise', dragging: false, startedInScrollback: false }); + removeMouseSelectionState('a'); + expect(getMouseSelectionState('a')).toEqual(DEFAULT_MOUSE_SELECTION_STATE); + }); +}); + +describe('mouse-selection: override rules', () => { + it('cannot activate override while mouse reporting is off', () => { + setOverride('a', 'temporary'); + expect(getMouseSelectionState('a').override).toBe('off'); + setOverride('a', 'permanent'); + expect(getMouseSelectionState('a').override).toBe('off'); + }); + + it('can activate override while mouse reporting is on', () => { + setMouseReporting('a', 'vt200'); + setOverride('a', 'temporary'); + expect(getMouseSelectionState('a').override).toBe('temporary'); + + setOverride('a', 'permanent'); + expect(getMouseSelectionState('a').override).toBe('permanent'); + }); + + it('can always deactivate an override', () => { + setMouseReporting('a', 'vt200'); + setOverride('a', 'temporary'); + setOverride('a', 'off'); + expect(getMouseSelectionState('a').override).toBe('off'); + }); + + it('mouse reporting going to none auto-ends the override', () => { + setMouseReporting('a', 'vt200'); + setOverride('a', 'temporary'); + setMouseReporting('a', 'none'); + expect(getMouseSelectionState('a').override).toBe('off'); + }); + + it('mouse reporting going to none auto-ends a permanent override too', () => { + setMouseReporting('a', 'drag'); + setOverride('a', 'permanent'); + setMouseReporting('a', 'none'); + expect(getMouseSelectionState('a').override).toBe('off'); + }); +}); + +describe('mouse-selection: subscription', () => { + it('subscribe + setMouseReporting notifies listeners', () => { + const listener = vi.fn(); + subscribeToMouseSelection(listener); + setMouseReporting('a', 'vt200'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('setting the same value does not notify', () => { + setMouseReporting('a', 'vt200'); + const listener = vi.fn(); + subscribeToMouseSelection(listener); + setMouseReporting('a', 'vt200'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('setBracketedPaste, setOverride, setSelection, setHintToken all notify', () => { + setMouseReporting('a', 'vt200'); // prerequisite for override activation + const listener = vi.fn(); + subscribeToMouseSelection(listener); + + setBracketedPaste('a', true); + setOverride('a', 'temporary'); + setSelection('a', { startRow: 0, startCol: 0, endRow: 0, endCol: 1, shape: 'linewise', dragging: true, startedInScrollback: false }); + setHintToken('a', { kind: 'path', row: 0, startCol: 0, endCol: 5, text: '/tmp' }); + + expect(listener).toHaveBeenCalledTimes(4); + }); + + it('unsubscribe stops notifications', () => { + const listener = vi.fn(); + const unsub = subscribeToMouseSelection(listener); + unsub(); + setMouseReporting('a', 'vt200'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('removeMouseSelectionState notifies', () => { + setMouseReporting('a', 'vt200'); + const listener = vi.fn(); + subscribeToMouseSelection(listener); + removeMouseSelectionState('a'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('removing a state that was never set does not notify', () => { + const listener = vi.fn(); + subscribeToMouseSelection(listener); + removeMouseSelectionState('never-existed'); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('mouse-selection: drag lifecycle', () => { + it('beginDrag creates a selection anchored at the starting cell', () => { + beginDrag('a', { row: 5, col: 10, altKey: false, startedInScrollback: false }); + const sel = getMouseSelectionState('a').selection; + expect(sel).not.toBeNull(); + expect(sel).toMatchObject({ + startRow: 5, + startCol: 10, + endRow: 5, + endCol: 10, + shape: 'linewise', + dragging: true, + startedInScrollback: false, + }); + }); + + it('beginDrag with altKey starts in block shape', () => { + beginDrag('a', { row: 0, col: 0, altKey: true, startedInScrollback: false }); + expect(getMouseSelectionState('a').selection?.shape).toBe('block'); + }); + + it('beginDrag replaces an existing selection (spec §3.7)', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 2, col: 5, altKey: false }); + endDrag('a'); + + beginDrag('a', { row: 8, col: 3, altKey: false, startedInScrollback: false }); + const sel = getMouseSelectionState('a').selection; + expect(sel?.startRow).toBe(8); + expect(sel?.dragging).toBe(true); + }); + + it('updateDrag moves the end of an active drag', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 4, col: 12, altKey: false }); + const sel = getMouseSelectionState('a').selection; + expect(sel?.endRow).toBe(4); + expect(sel?.endCol).toBe(12); + }); + + it('updateDrag flips shape live as Alt is pressed / released (spec §3.2)', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 4, col: 12, altKey: true }); + expect(getMouseSelectionState('a').selection?.shape).toBe('block'); + updateDrag('a', { row: 4, col: 12, altKey: false }); + expect(getMouseSelectionState('a').selection?.shape).toBe('linewise'); + }); + + it('updateDrag is a no-op after endDrag', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + endDrag('a'); + updateDrag('a', { row: 9, col: 9, altKey: false }); + const sel = getMouseSelectionState('a').selection; + expect(sel?.endRow).toBe(0); + expect(sel?.endCol).toBe(0); + expect(sel?.dragging).toBe(false); + }); + + it('updateDrag with no change does not notify', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 3, col: 5, altKey: false }); + const listener = vi.fn(); + subscribeToMouseSelection(listener); + updateDrag('a', { row: 3, col: 5, altKey: false }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('endDrag freezes the selection but does not clear it', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 3, col: 5, altKey: false }); + endDrag('a'); + const sel = getMouseSelectionState('a').selection; + expect(sel?.dragging).toBe(false); + expect(sel?.endRow).toBe(3); + expect(sel?.endCol).toBe(5); + }); + + it('endDrag is a no-op when no drag is active', () => { + const listener = vi.fn(); + subscribeToMouseSelection(listener); + endDrag('a'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('isDragging reflects the drag state', () => { + expect(isDragging('a')).toBe(false); + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + expect(isDragging('a')).toBe(true); + endDrag('a'); + expect(isDragging('a')).toBe(false); + }); + + it('beginDrag with startedInScrollback=true preserves the flag', () => { + beginDrag('a', { row: 2, col: 0, altKey: false, startedInScrollback: true }); + expect(getMouseSelectionState('a').selection?.startedInScrollback).toBe(true); + }); +}); + +describe('mouse-selection: snapshot caching', () => { + it('returns the same snapshot reference between changes', () => { + setMouseReporting('a', 'vt200'); + const s1 = getMouseSelectionSnapshot(); + const s2 = getMouseSelectionSnapshot(); + expect(s1).toBe(s2); + }); + + it('invalidates the snapshot after a change', () => { + setMouseReporting('a', 'vt200'); + const s1 = getMouseSelectionSnapshot(); + setMouseReporting('a', 'any'); + const s2 = getMouseSelectionSnapshot(); + expect(s1).not.toBe(s2); + }); + + it('snapshot contains state for every known id', () => { + setMouseReporting('a', 'vt200'); + setMouseReporting('b', 'any'); + const snap = getMouseSelectionSnapshot(); + expect(snap.get('a')?.mouseReporting).toBe('vt200'); + expect(snap.get('b')?.mouseReporting).toBe('any'); + }); +}); diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts new file mode 100644 index 0000000..1d1892f --- /dev/null +++ b/lib/src/lib/mouse-selection.ts @@ -0,0 +1,301 @@ +/** + * Per-terminal mouse and selection state. + * + * Owns the data that the mouse-and-clipboard feature needs: + * - Which mouse-reporting regime the inside program requested. + * - Whether bracketed paste is on. + * - Whether the user has activated an override (temporary / permanent). + * - The current text selection and the current smart-extension hint. + * + * Exposes a `useSyncExternalStore`-compatible subscription API. Pure state, + * no DOM dependencies — safe to unit-test. + */ + +export type MouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'; +export type OverrideState = 'off' | 'temporary' | 'permanent'; +export type SelectionShape = 'linewise' | 'block'; + +export interface Selection { + /** Absolute buffer row (scrollback + viewport), 0-indexed. */ + startRow: number; + /** Cell column at the drag anchor. */ + startCol: number; + /** Absolute buffer row. */ + endRow: number; + /** Cell column at the current drag position (or release position). */ + endCol: number; + shape: SelectionShape; + /** True while the user is still dragging; false once the mouse is released. */ + dragging: boolean; + /** + * True when the drag originated in scrollback. Scrollback-origin drags are + * always handled by the terminal regardless of the inside program's mouse + * reporting (spec §3.5). + */ + startedInScrollback: boolean; +} + +export interface TokenHint { + kind: 'url' | 'path'; + /** Absolute buffer row the token occupies. */ + row: number; + startCol: number; + /** Exclusive. */ + endCol: number; + text: string; +} + +export type CopyFlashKind = 'raw' | 'rewrapped'; + +export interface MouseSelectionState { + mouseReporting: MouseTrackingMode; + bracketedPaste: boolean; + override: OverrideState; + selection: Selection | null; + hintToken: TokenHint | null; + /** + * Set briefly after Cmd+C / Cmd+Shift+C or a popup-button click, so the + * popup can flash a "Copied!" confirmation before everything clears. + */ + copyFlash: CopyFlashKind | null; +} + +export const DEFAULT_MOUSE_SELECTION_STATE: MouseSelectionState = Object.freeze({ + mouseReporting: 'none', + bracketedPaste: false, + override: 'off', + selection: null, + hintToken: null, + copyFlash: null, +}) as MouseSelectionState; + +const states = new Map(); +const listeners = new Set<() => void>(); +let cachedSnapshot: Map | null = null; + +function notify(): void { + cachedSnapshot = null; + listeners.forEach((l) => l()); +} + +function ensure(id: string): MouseSelectionState { + let s = states.get(id); + if (!s) { + s = { ...DEFAULT_MOUSE_SELECTION_STATE }; + states.set(id, s); + } + return s; +} + +export function subscribeToMouseSelection(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getMouseSelectionSnapshot(): Map { + if (cachedSnapshot) return cachedSnapshot; + cachedSnapshot = new Map(states); + return cachedSnapshot; +} + +export function getMouseSelectionState(id: string): MouseSelectionState { + return states.get(id) ?? DEFAULT_MOUSE_SELECTION_STATE; +} + +export function setMouseReporting(id: string, mode: MouseTrackingMode): void { + const s = ensure(id); + if (s.mouseReporting === mode) return; + s.mouseReporting = mode; + // Per spec §1.1 / §2: when the inside program stops requesting mouse reporting, + // any active override is no longer meaningful. End it. + if (mode === 'none' && s.override !== 'off') { + s.override = 'off'; + } + notify(); +} + +export function setBracketedPaste(id: string, on: boolean): void { + const s = ensure(id); + if (s.bracketedPaste === on) return; + s.bracketedPaste = on; + notify(); +} + +export function setOverride(id: string, override: OverrideState): void { + const s = ensure(id); + if (s.override === override) return; + // Override only makes sense while the inside program is requesting mouse + // reporting. Ignore attempts to activate it otherwise. + if (override !== 'off' && s.mouseReporting === 'none') return; + s.override = override; + notify(); +} + +export function setSelection(id: string, selection: Selection | null): void { + const s = ensure(id); + if (s.selection === null && selection === null) return; + s.selection = selection; + notify(); +} + +/** + * Begin a new drag. Replaces any existing selection (spec §3.7: starting a + * new drag in the terminal content area replaces the existing selection). + */ +export function beginDrag( + id: string, + args: { row: number; col: number; altKey: boolean; startedInScrollback: boolean }, +): void { + const s = ensure(id); + s.selection = { + startRow: args.row, + startCol: args.col, + endRow: args.row, + endCol: args.col, + shape: args.altKey ? 'block' : 'linewise', + dragging: true, + startedInScrollback: args.startedInScrollback, + }; + notify(); +} + +/** + * Update an in-progress drag. No-op if no drag is active or the drag has + * already been released. The shape can flip live as Alt is pressed / released + * (spec §3.2). + */ +export function updateDrag( + id: string, + args: { row: number; col: number; altKey: boolean }, +): void { + const s = ensure(id); + const sel = s.selection; + if (!sel || !sel.dragging) return; + const shape: SelectionShape = args.altKey ? 'block' : 'linewise'; + if (sel.endRow === args.row && sel.endCol === args.col && sel.shape === shape) return; + s.selection = { ...sel, endRow: args.row, endCol: args.col, shape }; + notify(); +} + +/** + * Finalize the drag. Selection remains but is no longer in the dragging + * state. Subsequent mouse moves are ignored until a new drag starts. No-op + * if no drag is active. + */ +export function endDrag(id: string): void { + const s = ensure(id); + const sel = s.selection; + if (!sel || !sel.dragging) return; + s.selection = { ...sel, dragging: false }; + notify(); +} + +/** True if a drag is currently in progress. */ +export function isDragging(id: string): boolean { + const s = states.get(id); + return !!s?.selection?.dragging; +} + +/** + * Extend the in-progress selection to fully cover a detected token (spec §5.3). + * No-op when no drag is active. Preserves the drag anchor; adjusts the end + * toward whichever token boundary is farther from the anchor so the drag + * direction is respected. + */ +export function extendSelectionToToken(id: string, token: TokenHint): void { + const s = states.get(id); + if (!s?.selection?.dragging) return; + const sel = s.selection; + const anchorOnTokenRow = sel.startRow === token.row; + const forward = anchorOnTokenRow + ? sel.startCol <= token.startCol + : sel.startRow < token.row; + s.selection = { + ...sel, + endRow: token.row, + endCol: forward ? token.endCol - 1 : token.startCol, + }; + notify(); +} + +/** + * Flip the in-progress drag's shape based on the current Alt-key state. + * No-op when no drag is active. Used to react to Alt press/release while + * the mouse is stationary (spec §3.2). + */ +export function setDragAlt(id: string, altKey: boolean): void { + const s = states.get(id); + if (!s?.selection?.dragging) return; + const shape: SelectionShape = altKey ? 'block' : 'linewise'; + if (s.selection.shape === shape) return; + s.selection = { ...s.selection, shape }; + notify(); +} + +/** + * Trigger the "Copied!" flash. The popup reads `copyFlash` and renders a + * confirmation state; after `durationMs` the flash clears along with the + * selection, dismissing the popup. + */ +export function flashCopy(id: string, kind: CopyFlashKind, durationMs = 700): void { + const s = ensure(id); + s.copyFlash = kind; + notify(); + setTimeout(() => { + const current = states.get(id); + if (!current || current.copyFlash !== kind) return; + current.copyFlash = null; + current.selection = null; + notify(); + }, durationMs); +} + +export function setHintToken(id: string, hint: TokenHint | null): void { + const s = ensure(id); + if (s.hintToken === null && hint === null) return; + s.hintToken = hint; + notify(); +} + +export function removeMouseSelectionState(id: string): void { + if (!states.has(id)) return; + states.delete(id); + notify(); +} + +// --- Render tick --- +// +// A tiny counter that terminal-registry bumps whenever xterm renders (scroll, +// resize, output arrives). The selection overlay subscribes to this so it +// re-measures and re-positions its rectangles whenever anything that could +// affect cell layout happens. + +let renderTick = 0; +const renderTickListeners = new Set<() => void>(); + +export function subscribeToRenderTick(listener: () => void): () => void { + renderTickListeners.add(listener); + return () => { + renderTickListeners.delete(listener); + }; +} + +export function getRenderTick(): number { + return renderTick; +} + +export function bumpRenderTick(): void { + renderTick++; + renderTickListeners.forEach((l) => l()); +} + +/** Test-only helper. Do not use in application code. */ +export function __resetMouseSelectionForTests(): void { + states.clear(); + listeners.clear(); + cachedSnapshot = null; + renderTick = 0; + renderTickListeners.clear(); +} diff --git a/lib/src/lib/platform/index.ts b/lib/src/lib/platform/index.ts index 4798a23..a379fd7 100644 --- a/lib/src/lib/platform/index.ts +++ b/lib/src/lib/platform/index.ts @@ -16,6 +16,17 @@ export { SCENARIO_FAST_OUTPUT, } from './fake-scenarios'; +/** + * True when running on macOS. Used to pick native keyboard conventions + * (Cmd vs Ctrl for copy/paste, etc.). Computed once at module load. + */ +export const IS_MAC: boolean = (() => { + if (typeof navigator === 'undefined') return false; + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + const platform = nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent ?? ''; + return /Mac|iPhone|iPad/i.test(platform); +})(); + let adapter: PlatformAdapter | null = null; /** Set an externally-created platform adapter (e.g. TauriAdapter from standalone). */ diff --git a/lib/src/lib/rewrap.test.ts b/lib/src/lib/rewrap.test.ts new file mode 100644 index 0000000..eadbf75 --- /dev/null +++ b/lib/src/lib/rewrap.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { rewrap, __testing } from './rewrap'; + +const { isFrameOnlyLine, stripLeadingAndTrailingFrame } = __testing; + +describe('isFrameOnlyLine', () => { + it.each([ + ['┌──────┐', true], + ['└──────┘', true], + ['├──────┤', true], + ['═══════', true], + [' │ │ ', true], // spaces + verticals is considered frame-like chrome + ['│ foo │', false], + ['foo', false], + ['', false], + ])('"%s" → %s', (input, expected) => { + expect(isFrameOnlyLine(input)).toBe(expected); + }); + + it('a line of spaces-only is not considered a frame line', () => { + expect(isFrameOnlyLine(' ')).toBe(false); + }); +}); + +describe('stripLeadingAndTrailingFrame', () => { + it('strips left + right border chars along with one adjacent space', () => { + expect(stripLeadingAndTrailingFrame('│ foo │')).toBe('foo'); + }); + it('strips heavy block chars along with one adjacent space', () => { + expect(stripLeadingAndTrailingFrame('▌ hello ▐')).toBe('hello'); + }); + it('leaves normal text untouched', () => { + expect(stripLeadingAndTrailingFrame('hello world')).toBe('hello world'); + }); + it('keeps inner whitespace after border strip', () => { + expect(stripLeadingAndTrailingFrame('│ abc │')).toBe(' abc '); + }); +}); + +describe('rewrap', () => { + it('joins single-paragraph hard-wrapped text with spaces', () => { + expect(rewrap('The quick brown\nfox jumps\nover the lazy dog.')) + .toBe('The quick brown fox jumps over the lazy dog.'); + }); + + it('preserves blank lines as paragraph separators', () => { + expect(rewrap('Para one\nstill one.\n\nPara two\nalso two.')) + .toBe('Para one still one.\n\nPara two also two.'); + }); + + it('strips a simple box-drawing frame', () => { + const boxed = [ + '┌─────────┐', + '│ message │', + '└─────────┘', + ].join('\n'); + expect(rewrap(boxed)).toBe('message'); + }); + + it('strips a double-line frame', () => { + const boxed = [ + '╔═════════╗', + '║ hello ║', + '║ world ║', + '╚═════════╝', + ].join('\n'); + expect(rewrap(boxed)).toBe('hello world'); + }); + + it('strips side borders on a table-like block', () => { + const tbl = [ + '│ foo │', + '│ bar │', + '│ baz │', + ].join('\n'); + // All three rows become non-blank lines and join into one paragraph. + expect(rewrap(tbl)).toBe('foo bar baz'); + }); + + it('leading/trailing whitespace on each line is trimmed', () => { + expect(rewrap(' foo\n bar \n baz ')).toBe('foo bar baz'); + }); + + it('empty string → empty', () => { + expect(rewrap('')).toBe(''); + }); + + it('only blank lines → empty', () => { + expect(rewrap('\n\n\n')).toBe(''); + }); + + it('single line unchanged', () => { + expect(rewrap('Just one line.')).toBe('Just one line.'); + }); + + it('multiple blank lines collapse to a single paragraph separator', () => { + // Ambiguous case: the simple rule collapses any run of blanks to one + // separator. That's acceptable for MVP. + expect(rewrap('one\n\n\n\ntwo')).toBe('one\n\ntwo'); + }); +}); diff --git a/lib/src/lib/rewrap.ts b/lib/src/lib/rewrap.ts new file mode 100644 index 0000000..7eb94e1 --- /dev/null +++ b/lib/src/lib/rewrap.ts @@ -0,0 +1,71 @@ +/** + * Copy Rewrapped heuristics. Two transformations per spec §4.1.2: + * + * 1. Unwrap hard newlines that look like display wrapping inside a flowing + * paragraph. Blank lines stay as paragraph separators. + * 2. Strip runs of Unicode box-drawing characters that form UI chrome + * (frames, table borders), keeping the text they surround. + * + * The rules are deliberately simple first-cut heuristics — see the + * mouse-and-clipboard spec §9.1 (Out of Scope) for the follow-up to refine + * them based on dogfooding. + */ + +// U+2500..U+257F is Box Drawing; U+2580..U+259F is Block Elements. We treat +// both as "frame-like" for stripping purposes, since double-line frames +// (U+2550..U+256C) fall in 2500..257F and heavy blocks (2588 etc.) are often +// used for the same visual effect. +const BOX_CHAR = /[\u2500-\u259F]/; +const FRAME_ONLY = /^[\u2500-\u259F\s]+$/; + +function isFrameOnlyLine(line: string): boolean { + if (line.length === 0) return false; + if (!FRAME_ONLY.test(line)) return false; + return BOX_CHAR.test(line); +} + +function stripLeadingAndTrailingFrame(line: string): string { + // Strip leading run of box chars (with optional surrounding spaces). + let out = line.replace(/^[\u2500-\u259F]+\s?/, ''); + // Strip trailing run of box chars. + out = out.replace(/\s?[\u2500-\u259F]+$/, ''); + return out; +} + +/** + * Core rewrap. See module doc. + * + * Block-shape selections should NOT be run through this — they're + * intentionally rectangular slabs and the callers skip rewrap for them. + */ +export function rewrap(text: string): string { + const rawLines = text.split('\n'); + + // Pass 1: drop frame-only lines and strip leading/trailing box runs from + // the rest. + const cleaned = rawLines + .filter((l) => !isFrameOnlyLine(l)) + .map((l) => stripLeadingAndTrailingFrame(l).trimEnd()); + + // Pass 2: group into paragraphs. A blank line is a paragraph separator. + // Lines within a paragraph are joined with a single space. + const paragraphs: string[] = []; + let current: string[] = []; + for (const raw of cleaned) { + const line = raw.trim(); + if (line === '') { + if (current.length) { + paragraphs.push(current.join(' ')); + current = []; + } + } else { + current.push(line); + } + } + if (current.length) paragraphs.push(current.join(' ')); + + return paragraphs.join('\n\n'); +} + +// Exported for targeted unit tests. +export const __testing = { isFrameOnlyLine, stripLeadingAndTrailingFrame }; diff --git a/lib/src/lib/selection-text.test.ts b/lib/src/lib/selection-text.test.ts new file mode 100644 index 0000000..9275085 --- /dev/null +++ b/lib/src/lib/selection-text.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import type { Terminal } from '@xterm/xterm'; +import { extractSelectionText } from './selection-text'; +import type { Selection } from './mouse-selection'; + +function makeTerminal(lines: string[]): Terminal { + const cols = Math.max(...lines.map((l) => l.length)); + const getLine = (r: number) => { + const line = lines[r]; + if (line === undefined) return undefined; + return { + translateToString: (_trimRight?: boolean, start = 0, end = cols) => line.slice(start, end), + } as unknown as ReturnType; + }; + return { + cols, + rows: lines.length, + buffer: { active: { getLine } }, + } as unknown as Terminal; +} + +function sel(overrides: Partial): Selection { + return { + startRow: 0, + startCol: 0, + endRow: 0, + endCol: 0, + shape: 'linewise', + dragging: false, + startedInScrollback: false, + ...overrides, + }; +} + +describe('extractSelectionText', () => { + const t = makeTerminal([ + 'The quick brown fox', + 'jumps over the lazy', + 'dog and runs away.', + ]); + + it('single-row linewise', () => { + const s = sel({ startRow: 0, startCol: 4, endRow: 0, endCol: 8 }); + expect(extractSelectionText(t, s)).toBe('quick'); + }); + + it('multi-row linewise trims each end', () => { + const s = sel({ startRow: 0, startCol: 10, endRow: 2, endCol: 2 }); + expect(extractSelectionText(t, s)).toBe('brown fox\njumps over the lazy\ndog'); + }); + + it('reversed linewise (user dragged right-to-left)', () => { + const s = sel({ startRow: 0, startCol: 8, endRow: 0, endCol: 4 }); + expect(extractSelectionText(t, s)).toBe('quick'); + }); + + it('block shape extracts the rectangular slab', () => { + // Columns 4..8 across all three rows + const s = sel({ startRow: 0, startCol: 4, endRow: 2, endCol: 8, shape: 'block' }); + expect(extractSelectionText(t, s)).toBe('quick\ns ove\nand r'); + }); +}); diff --git a/lib/src/lib/selection-text.ts b/lib/src/lib/selection-text.ts new file mode 100644 index 0000000..8b949ad --- /dev/null +++ b/lib/src/lib/selection-text.ts @@ -0,0 +1,60 @@ +import type { Terminal } from '@xterm/xterm'; +import type { Selection } from './mouse-selection'; + +export interface NormalizedSelection { + r0: number; + c0: number; + r1: number; + c1: number; +} + +/** + * Normalize a selection so start comes before end in reading order. + * For block shape we normalize min/max on both axes independently. + */ +export function normalizeSelection(sel: Selection): NormalizedSelection { + if (sel.shape === 'block') { + return { + r0: Math.min(sel.startRow, sel.endRow), + c0: Math.min(sel.startCol, sel.endCol), + r1: Math.max(sel.startRow, sel.endRow), + c1: Math.max(sel.startCol, sel.endCol), + }; + } + const before = sel.startRow < sel.endRow || (sel.startRow === sel.endRow && sel.startCol <= sel.endCol); + return { + r0: before ? sel.startRow : sel.endRow, + c0: before ? sel.startCol : sel.endCol, + r1: before ? sel.endRow : sel.startRow, + c1: before ? sel.endCol : sel.startCol, + }; +} + +/** + * Read the cells covered by `sel` from the terminal's active buffer and + * return them as a single string. Rows are joined with `\n`. Block shapes + * are rectangular slabs; linewise shapes follow reading order. + */ +export function extractSelectionText(terminal: Terminal, sel: Selection): string { + const n = normalizeSelection(sel); + const buf = terminal.buffer.active; + const lines: string[] = []; + + if (sel.shape === 'block') { + for (let r = n.r0; r <= n.r1; r++) { + const line = buf.getLine(r); + if (!line) continue; + lines.push(line.translateToString(false, n.c0, n.c1 + 1).replace(/\s+$/, '')); + } + return lines.join('\n'); + } + + for (let r = n.r0; r <= n.r1; r++) { + const line = buf.getLine(r); + if (!line) continue; + const c0 = r === n.r0 ? n.c0 : 0; + const c1 = r === n.r1 ? n.c1 + 1 : terminal.cols; + lines.push(line.translateToString(false, c0, c1).replace(/\s+$/, '')); + } + return lines.join('\n'); +} diff --git a/lib/src/lib/smart-token.test.ts b/lib/src/lib/smart-token.test.ts new file mode 100644 index 0000000..a8d1a88 --- /dev/null +++ b/lib/src/lib/smart-token.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import { detectTokenAt } from './smart-token'; + +function at(line: string, anchor: string) { + const col = line.indexOf(anchor); + return detectTokenAt(line, col); +} + +describe('detectTokenAt: URL', () => { + it('http URL', () => { + const line = 'see https://example.com for docs'; + const t = at(line, 'https'); + expect(t).toMatchObject({ kind: 'url', text: 'https://example.com' }); + }); + + it('https URL', () => { + const t = detectTokenAt('https://x.com', 3); + expect(t?.text).toBe('https://x.com'); + }); + + it('file URL', () => { + const t = at('open file:///tmp/a.txt please', 'file'); + expect(t?.text).toBe('file:///tmp/a.txt'); + }); + + it('strips trailing period', () => { + const t = detectTokenAt('https://x.com.', 3); + expect(t?.text).toBe('https://x.com'); + }); + + it('strips multiple trailing punctuation', () => { + const t = detectTokenAt('https://x.com?!!', 3); + expect(t?.text).toBe('https://x.com'); + }); + + it('keeps balanced trailing paren (wikipedia)', () => { + const line = 'https://en.wikipedia.org/wiki/Foo_(bar)'; + const t = detectTokenAt(line, 3); + expect(t?.text).toBe('https://en.wikipedia.org/wiki/Foo_(bar)'); + }); + + it('strips unmatched trailing paren', () => { + const line = '(see https://x.com)'; + const t = at(line, 'https'); + expect(t?.text).toBe('https://x.com'); + }); + + it('strips unmatched bracket and period together', () => { + const t = detectTokenAt('https://x.com].', 3); + expect(t?.text).toBe('https://x.com'); + }); +}); + +describe('detectTokenAt: path', () => { + it('absolute path', () => { + const t = at('run /usr/local/bin/foo now', '/usr'); + expect(t).toMatchObject({ kind: 'path', text: '/usr/local/bin/foo' }); + }); + + it('tilde path', () => { + const t = at('cd ~/projects/repo', '~/'); + expect(t?.text).toBe('~/projects/repo'); + }); + + it('dot-slash relative path', () => { + const t = at('run ./bin/ok.sh', './'); + expect(t?.text).toBe('./bin/ok.sh'); + }); + + it('dot-dot relative path', () => { + const t = at('cp ../a/b .', '../'); + expect(t?.text).toBe('../a/b'); + }); + + it('windows path', () => { + const t = at(String.raw`open C:\Users\me now`, 'C:\\'); + expect(t?.text).toBe(String.raw`C:\Users\me`); + }); + + it('error location file:line', () => { + const t = at('src/foo.ts:42 panicked', 'src/'); + expect(t).toMatchObject({ kind: 'path', text: 'src/foo.ts:42' }); + }); + + it('error location file:line:col preserves trailing colons/digits', () => { + const t = at('src/foo.ts:42:7 panicked', 'src/'); + expect(t?.text).toBe('src/foo.ts:42:7'); + }); + + it('strips trailing period on absolute path', () => { + const t = detectTokenAt('/tmp/a.', 0); + expect(t?.text).toBe('/tmp/a'); + }); +}); + +describe('detectTokenAt: non-matches', () => { + it('plain word returns null', () => { + expect(detectTokenAt('hello world', 0)).toBeNull(); + }); + + it('whitespace position returns null', () => { + expect(detectTokenAt('hello world', 5)).toBeNull(); + }); + + it('empty line returns null', () => { + expect(detectTokenAt('', 0)).toBeNull(); + }); + + it('out-of-range column returns null', () => { + expect(detectTokenAt('hi', -1)).toBeNull(); + }); + + it('a bare word with colon but no digits is not an error location', () => { + expect(detectTokenAt('foo:bar baz', 0)).toBeNull(); + }); +}); + +describe('detectTokenAt: position sensitivity', () => { + it('anywhere within the token finds it', () => { + const line = 'go to https://example.com/path now'; + const tokenStart = line.indexOf('https'); + for (let i = tokenStart; i < tokenStart + 'https://example.com/path'.length; i++) { + expect(detectTokenAt(line, i)?.text).toBe('https://example.com/path'); + } + }); +}); diff --git a/lib/src/lib/smart-token.ts b/lib/src/lib/smart-token.ts new file mode 100644 index 0000000..e521f40 --- /dev/null +++ b/lib/src/lib/smart-token.ts @@ -0,0 +1,98 @@ +/** + * Detect URL-shaped and path-shaped tokens around a cursor position. + * Used by the smart-extension feature to offer "Press e to select the full + * URL/path" during a mid-drag (spec §5). + */ + +export interface DetectedToken { + kind: 'url' | 'path'; + /** Inclusive start column in the original line. */ + start: number; + /** Exclusive end column in the original line. */ + end: number; + text: string; +} + +interface Pattern { + kind: 'url' | 'path'; + re: RegExp; + /** When true, trailing-punctuation stripping is skipped — the pattern's + * trailing characters are significant (e.g. error-location `:line:col`). */ + skipStrip?: boolean; +} + +const PATTERNS: Pattern[] = [ + { kind: 'url', re: /^https?:\/\/\S+$/ }, + { kind: 'url', re: /^file:\/\/\S+$/ }, + { kind: 'path', re: /^\S+:\d+(:\d+)?$/, skipStrip: true }, // error-location first (so it beats generic path) + { kind: 'path', re: /^~\/\S*$/ }, + { kind: 'path', re: /^\/\S+$/ }, + { kind: 'path', re: /^\.\.?\/\S*$/ }, + { kind: 'path', re: /^[A-Za-z]:\\\S*$/ }, +]; + +const TRAILING_PUNCT = /[.,;:!?'"]+$/; +const PAIRS: Array<[string, string]> = [['(', ')'], ['[', ']'], ['{', '}'], ['<', '>']]; + +function isBalanced(text: string, open: string, close: string): boolean { + let depth = 0; + for (const ch of text) { + if (ch === open) depth++; + else if (ch === close) { + depth--; + if (depth < 0) return false; + } + } + return depth === 0; +} + +function stripTrailing(token: string): string { + let out = token; + let changed = true; + while (changed) { + changed = false; + const afterPunct = out.replace(TRAILING_PUNCT, ''); + if (afterPunct !== out) { + out = afterPunct; + changed = true; + } + for (const [open, close] of PAIRS) { + if (out.endsWith(close) && !isBalanced(out, open, close)) { + out = out.slice(0, -1); + changed = true; + } + } + } + return out; +} + +function isWhitespace(ch: string): boolean { + return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; +} + +/** + * Return the token at or adjacent to `col` if it matches one of the known + * URL/path patterns, or null otherwise. Trailing punctuation that is + * unlikely to be part of the token is stripped per spec §5.1. + */ +export function detectTokenAt(line: string, col: number): DetectedToken | null { + if (col < 0 || line.length === 0) return null; + const probe = Math.min(col, line.length - 1); + if (isWhitespace(line[probe])) return null; + + let start = probe; + while (start > 0 && !isWhitespace(line[start - 1])) start--; + let end = probe; + while (end < line.length && !isWhitespace(line[end])) end++; + + const raw = line.slice(start, end); + if (!raw) return null; + + for (const { kind, re, skipStrip } of PATTERNS) { + if (!re.test(raw)) continue; + const text = skipStrip ? raw : stripTrailing(raw); + if (!text) continue; + return { kind, start, end: start + text.length, text }; + } + return null; +} diff --git a/lib/src/lib/terminal-registry.alarm.test.ts b/lib/src/lib/terminal-registry.alarm.test.ts index 934a24b..26eb9f8 100644 --- a/lib/src/lib/terminal-registry.alarm.test.ts +++ b/lib/src/lib/terminal-registry.alarm.test.ts @@ -18,6 +18,14 @@ vi.mock('@xterm/xterm', () => { private dataListeners = new Set<(data: string) => void>(); private resizeListeners = new Set<(size: { cols: number; rows: number }) => void>(); + parser = { + registerCsiHandler: () => ({ dispose: () => {} }), + }; + modes = { + mouseTrackingMode: 'none' as const, + bracketedPasteMode: false, + }; + loadAddon(): void {} open(): void {} @@ -44,6 +52,10 @@ vi.mock('@xterm/xterm', () => { }; } + onRender(): { dispose: () => void } { + return { dispose: () => {} }; + } + focus(): void {} blur(): void {} @@ -129,6 +141,12 @@ class MockElement { } this.parentElement = null; } + + addEventListener(): void {} + removeEventListener(): void {} + getBoundingClientRect(): DOMRect { + return { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect; + } } type PlatformModuleWithMock = typeof platformModule & { __fakePlatform: FakePtyAdapter }; @@ -220,6 +238,10 @@ describe('terminal-registry alarm behavior', () => { return 0; }); vi.stubGlobal('MutationObserver', class { observe() {} disconnect() {} }); + vi.stubGlobal('window', { + addEventListener: () => {}, + removeEventListener: () => {}, + }); }); afterEach(() => { diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 64e8c1d..a7d34e3 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -5,6 +5,22 @@ import type { SessionStatus } from './activity-monitor'; import { TODO_OFF, isSoftTodo, type TodoState, type AlarmButtonActionResult } from './alarm-manager'; import type { AlarmStateDetail } from './platform/types'; import type { PersistedAlarmState } from './session-types'; +import { attachMouseModeObserver } from './mouse-mode-observer'; +import { + beginDrag, + bumpRenderTick, + endDrag, + getMouseSelectionState, + isDragging, + removeMouseSelectionState, + setDragAlt, + setHintToken, + setOverride, + setSelection as setMouseSelection, + updateDrag, +} from './mouse-selection'; +import { detectTokenAt } from './smart-token'; +import { extractSelectionText } from './selection-text'; export type { SessionStatus } from './activity-monitor'; export { TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo, isHardTodo, hasTodo, type TodoState, type AlarmButtonActionResult } from './alarm-manager'; @@ -354,17 +370,182 @@ function setupTerminalEntry(id: string): TerminalEntry { getPlatform().writePty(id, data); }); - // Resize → PTY + // Resize → PTY. Also cancel any finalized selection (spec §3.4: resize + // counts as a content change). const resizeDisposable = terminal.onResize(({ cols, rows }) => { getPlatform().alarmResize(id); getPlatform().resizePty(id, cols, rows); + bumpRenderTick(); + if (getMouseSelectionState(id).selection) { + setMouseSelection(id, null); + } + selectionBaseline = null; + }); + + // Cancel-on-change: snapshot the selected text when the drag ends, then + // on every subsequent render check whether any covered cell has changed. + // If so, cancel the selection (spec §3.4). + let selectionBaseline: string | null = null; + + const renderDisposable = terminal.onRender(() => { + bumpRenderTick(); + if (selectionBaseline === null) return; + const sel = getMouseSelectionState(id).selection; + if (!sel || sel.dragging) { + selectionBaseline = null; + return; + } + const current = extractSelectionText(terminal, sel); + if (current !== selectionBaseline) { + setMouseSelection(id, null); + selectionBaseline = null; + } }); + // Observe DECSET/DECRST for mouse-reporting and bracketed-paste modes. + const mouseModeObserver = attachMouseModeObserver(id, terminal); + + // Mouse event router. Capture phase so we see events before xterm's own + // handlers. We defer beginDrag (and stopPropagation) until the cursor has + // actually moved past a small threshold — a plain click stays a plain + // click so the pane-click handler upstream can still shift focus. + const computeCell = (ev: MouseEvent): { row: number; col: number; startedInScrollback: boolean } => { + // Use the same measured cell grid as the selection overlay so mouse + // hit-testing and highlight rendering can never drift apart. + const dims = getTerminalOverlayDims(id); + if (!dims) { + return { row: 0, col: 0, startedInScrollback: false }; + } + const elementRect = element.getBoundingClientRect(); + const offsetX = ev.clientX - elementRect.left - dims.gridLeft; + const offsetY = ev.clientY - elementRect.top - dims.gridTop; + const col = Math.min(dims.cols - 1, Math.max(0, Math.floor(offsetX / dims.cellWidth))); + const viewportRow = Math.min(dims.rows - 1, Math.max(0, Math.floor(offsetY / dims.cellHeight))); + const absRow = dims.viewportY + viewportRow; + const startedInScrollback = absRow < dims.baseY; + return { row: absRow, col, startedInScrollback }; + }; + + const DRAG_THRESHOLD_PX_SQ = 16; // 4px squared — typical click-vs-drag threshold + let pendingDrag: { + row: number; + col: number; + altKey: boolean; + startedInScrollback: boolean; + clientX: number; + clientY: number; + } | null = null; + + const onMouseDown = (ev: MouseEvent) => { + if (ev.button !== 0) return; // only left-click can start a selection + const state = getMouseSelectionState(id); + const cell = computeCell(ev); + // Per spec §3.5 and §6.1: + // - reporting off: terminal handles + // - reporting on + override active: terminal handles + // - reporting on + no override + scrollback-origin: terminal handles + // - reporting on + no override + live region: inside program handles + const terminalOwns = + state.mouseReporting === 'none' + || state.override !== 'off' + || cell.startedInScrollback; + if (!terminalOwns) return; + // Record the anchor but don't commit — this might be a plain click. We + // deliberately do NOT preventDefault / stopPropagation here so pane + // focus-on-click (and xterm's own focus handling) still work. + pendingDrag = { + row: cell.row, + col: cell.col, + altKey: ev.altKey, + startedInScrollback: cell.startedInScrollback, + clientX: ev.clientX, + clientY: ev.clientY, + }; + }; + const onWindowMouseMove = (ev: MouseEvent) => { + if (pendingDrag) { + const dx = ev.clientX - pendingDrag.clientX; + const dy = ev.clientY - pendingDrag.clientY; + if (dx * dx + dy * dy < DRAG_THRESHOLD_PX_SQ) return; + // Threshold crossed — promote pending anchor into a real drag. + beginDrag(id, { + row: pendingDrag.row, + col: pendingDrag.col, + altKey: pendingDrag.altKey, + startedInScrollback: pendingDrag.startedInScrollback, + }); + // Wipe any nascent xterm selection that started on the bare mousedown. + terminal.clearSelection(); + pendingDrag = null; + } + if (!isDragging(id)) return; + const cell = computeCell(ev); + updateDrag(id, { row: cell.row, col: cell.col, altKey: ev.altKey }); + ev.preventDefault(); + ev.stopPropagation(); + // Smart-extension hint (spec §5): scan the line under the current drag + // cursor for a URL/path token. + const line = terminal.buffer.active.getLine(cell.row); + const text = line?.translateToString(false, 0, terminal.cols); + const token = text ? detectTokenAt(text, cell.col) : null; + setHintToken(id, token ? { + kind: token.kind, + row: cell.row, + startCol: token.start, + endCol: token.end, + text: token.text, + } : null); + }; + const onWindowMouseUp = (ev: MouseEvent) => { + if (ev.button !== 0) return; // only left-button release ends a drag + if (pendingDrag) { + // Pure click — never crossed the drag threshold. Still counts as the + // mouse-up that terminates a temporary override (spec §2.1). + if (getMouseSelectionState(id).override === 'temporary') { + setOverride(id, 'off'); + } + pendingDrag = null; + return; + } + if (!isDragging(id)) return; + endDrag(id); + setHintToken(id, null); + // Take a text snapshot of the finalized selection for cancel-on-change. + const sel = getMouseSelectionState(id).selection; + selectionBaseline = sel ? extractSelectionText(terminal, sel) : null; + // Per spec §2.1: a temporary override ends on the mouse-up that + // finalizes the drag. + if (getMouseSelectionState(id).override === 'temporary') { + setOverride(id, 'off'); + } + ev.preventDefault(); + ev.stopPropagation(); + }; + element.addEventListener('mousedown', onMouseDown, true); + window.addEventListener('mousemove', onWindowMouseMove, true); + window.addEventListener('mouseup', onWindowMouseUp, true); + + // Live-flip block/linewise shape when Alt is pressed/released without + // mouse movement (spec §3.2). + const onAltChange = (ev: KeyboardEvent) => { + if (!isDragging(id)) return; + setDragAlt(id, ev.altKey); + }; + window.addEventListener('keydown', onAltChange, true); + window.addEventListener('keyup', onAltChange, true); + const cleanup = () => { getPlatform().offPtyData(handleData); getPlatform().offPtyExit(handleExit); inputDisposable.dispose(); resizeDisposable.dispose(); + renderDisposable.dispose(); + mouseModeObserver.dispose(); + element.removeEventListener('mousedown', onMouseDown, true); + window.removeEventListener('mousemove', onWindowMouseMove, true); + window.removeEventListener('mouseup', onWindowMouseUp, true); + window.removeEventListener('keydown', onAltChange, true); + window.removeEventListener('keyup', onAltChange, true); }; const entry: TerminalEntry = { @@ -528,6 +709,7 @@ export function destroyTerminal(id: string): void { entry.element.remove(); entry.terminal.dispose(); registry.delete(id); + removeMouseSelectionState(id); notifySessionStateListeners(); } @@ -583,6 +765,78 @@ export function refitTerminal(id: string): void { entry.fit.fit(); } +/** + * Dimensions the selection overlay needs to position its highlight rectangles. + * Returns null if the terminal isn't live. + */ +export interface TerminalOverlayDims { + cols: number; + rows: number; + viewportY: number; + baseY: number; + /** Pixel width of the persistent terminal element (container for the canvas). */ + elementWidth: number; + /** Pixel height of the persistent terminal element. */ + elementHeight: number; + /** Measured pixel width of a single cell. */ + cellWidth: number; + /** Measured pixel height of a single cell. */ + cellHeight: number; + /** Left offset of the cell grid (`.xterm-screen`) within the element. */ + gridLeft: number; + /** Top offset of the cell grid within the element. */ + gridTop: number; +} + +/** + * Get the raw xterm Terminal instance for a pane id. Used by features that + * need to read the buffer directly (selection extraction). Returns null + * when the terminal isn't live. + */ +export function getTerminalInstance(id: string): Terminal | null { + return registry.get(id)?.terminal ?? null; +} + +export function getTerminalOverlayDims(id: string): TerminalOverlayDims | null { + const entry = registry.get(id); + if (!entry) return null; + const elementRect = entry.element.getBoundingClientRect(); + // Measure xterm's actual cell grid, not the surrounding element. xterm puts + // a few pixels of padding around `.xterm-screen`, so element-divided-by- + // cols/rows is slightly off and the error accumulates with row count. + const screen = entry.element.querySelector('.xterm-screen'); + let cellWidth: number; + let cellHeight: number; + let gridLeft: number; + let gridTop: number; + if (screen) { + const screenRect = screen.getBoundingClientRect(); + cellWidth = screenRect.width / entry.terminal.cols; + cellHeight = screenRect.height / entry.terminal.rows; + gridLeft = screenRect.left - elementRect.left; + gridTop = screenRect.top - elementRect.top; + } else { + // Before xterm has rendered, fall back to element dimensions. Not + // perfectly aligned but selection UI isn't usable at this point anyway. + cellWidth = elementRect.width / entry.terminal.cols; + cellHeight = elementRect.height / entry.terminal.rows; + gridLeft = 0; + gridTop = 0; + } + return { + cols: entry.terminal.cols, + rows: entry.terminal.rows, + viewportY: entry.terminal.buffer.active.viewportY, + baseY: entry.terminal.buffer.active.baseY, + elementWidth: elementRect.width, + elementHeight: elementRect.height, + cellWidth, + cellHeight, + gridLeft, + gridTop, + }; +} + /** * Focus or blur the terminal. */ diff --git a/lib/src/stories/MouseHeaderIcon.stories.tsx b/lib/src/stories/MouseHeaderIcon.stories.tsx new file mode 100644 index 0000000..b4ed8a7 --- /dev/null +++ b/lib/src/stories/MouseHeaderIcon.stories.tsx @@ -0,0 +1,107 @@ +import { useEffect } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { + TerminalPaneHeader, + ModeContext, + SelectedIdContext, + PondActionsContext, + RenamingIdContext, + type PondMode, + type PondActions, +} from '../components/Pond'; +import { + setMouseReporting, + setOverride, + type MouseTrackingMode, + type OverrideState, +} from '../lib/mouse-selection'; + +const SESSION_ID = 'mouse-story'; + +const noopActions: PondActions = { + onKill: () => {}, + onDetach: () => {}, + onAlarmButton: () => 'noop', + onToggleTodo: () => {}, + onSplitH: () => {}, + onSplitV: () => {}, + onZoom: () => {}, + onClickPanel: () => {}, + onStartRename: () => {}, + onFinishRename: () => {}, + onCancelRename: () => {}, +}; + +function MouseIconStoryFrame({ + mouseReporting = 'none' as MouseTrackingMode, + override = 'off' as OverrideState, + title = 'build-server', + mode = 'command' as PondMode, + width = 360, +}: { + mouseReporting?: MouseTrackingMode; + override?: OverrideState; + title?: string; + mode?: PondMode; + width?: number; +}) { + useEffect(() => { + setMouseReporting(SESSION_ID, mouseReporting); + setOverride(SESSION_ID, override); + return () => { + setMouseReporting(SESSION_ID, 'none'); + }; + }, [mouseReporting, override]); + + const mockApi = { id: SESSION_ID, title } as unknown as Parameters[0]['api']; + + return ( + + + + +
+
+ [0]['containerApi']} + params={{}} + tabLocation={'header' as Parameters[0]['tabLocation']} + /> +
+
+
+
+
+
+ ); +} + +const meta: Meta = { + title: 'Components/MouseHeaderIcon', + component: MouseIconStoryFrame, + argTypes: { + mouseReporting: { control: 'radio', options: ['none', 'x10', 'vt200', 'drag', 'any'] }, + override: { control: 'radio', options: ['off', 'temporary', 'permanent'] }, + mode: { control: 'radio', options: ['command', 'passthrough'] }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Hidden: Story = { + args: { mouseReporting: 'none', override: 'off' }, +}; + +export const ReportingOn: Story = { + args: { mouseReporting: 'vt200', override: 'off' }, +}; + +export const TemporaryOverride: Story = { + args: { mouseReporting: 'vt200', override: 'temporary' }, +}; + +export const PermanentOverride: Story = { + args: { mouseReporting: 'any', override: 'permanent' }, +}; diff --git a/lib/src/stories/TextSelection.stories.tsx b/lib/src/stories/TextSelection.stories.tsx new file mode 100644 index 0000000..9fde90f --- /dev/null +++ b/lib/src/stories/TextSelection.stories.tsx @@ -0,0 +1,139 @@ +import { useEffect } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { TerminalPane } from '../components/TerminalPane'; +import { flattenScenario, SCENARIO_LS_OUTPUT } from '../lib/platform'; +import { setSelection, type Selection } from '../lib/mouse-selection'; +import { getTerminalOverlayDims } from '../lib/terminal-registry'; + +/** + * Wires a programmatic selection state onto a live TerminalPane so we can + * visualize the overlay, the Alt hint, and the copy popup in their various + * positions without scripting a real mouse drag. + */ +function TextSelectionStory({ + id, + selection, +}: { + id: string; + selection: Omit; +}) { + useEffect(() => { + let cancelled = false; + let timer: ReturnType; + + const tryApply = () => { + if (cancelled) return; + // Wait until xterm has actually rendered — getTerminalOverlayDims + // reads `.xterm-screen`, which doesn't exist until after the first + // paint. Without this the overlay would compute garbage positions. + const dims = getTerminalOverlayDims(id); + if (!dims || dims.cellHeight === 0) { + timer = setTimeout(tryApply, 50); + return; + } + setSelection(id, { ...selection, startedInScrollback: false }); + }; + + timer = setTimeout(tryApply, 100); + return () => { + cancelled = true; + clearTimeout(timer); + setSelection(id, null); + }; + }, [id, selection]); + + return ( +
+ +
+ ); +} + +const meta: Meta = { + title: 'Terminal/TextSelection', + component: TextSelectionStory, + parameters: { + fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) }, + }, +}; + +export default meta; +type Story = StoryObj; + +// --- Outlines ------------------------------------------------------------ + +export const LinewiseOutline: Story = { + args: { + id: 'text-sel-linewise', + selection: { + startRow: 2, startCol: 6, + endRow: 5, endCol: 34, + shape: 'linewise', + dragging: false, + }, + }, +}; + +export const BlockOutline: Story = { + args: { + id: 'text-sel-block', + selection: { + startRow: 2, startCol: 6, + endRow: 5, endCol: 26, + shape: 'block', + dragging: false, + }, + }, +}; + +// --- Alt hint positioning ------------------------------------------------ + +export const HintWhenDraggingDown: Story = { + args: { + id: 'text-sel-hint-down', + selection: { + startRow: 2, startCol: 5, + endRow: 6, endCol: 24, + shape: 'linewise', + dragging: true, + }, + }, +}; + +export const HintWhenDraggingUp: Story = { + args: { + id: 'text-sel-hint-up', + selection: { + startRow: 8, startCol: 22, + endRow: 4, endCol: 6, + shape: 'linewise', + dragging: true, + }, + }, +}; + +// --- Copy popup positioning --------------------------------------------- + +export const PopupAfterDragDown: Story = { + args: { + id: 'text-sel-popup-down', + selection: { + startRow: 2, startCol: 5, + endRow: 6, endCol: 24, + shape: 'linewise', + dragging: false, + }, + }, +}; + +export const PopupAfterDragUp: Story = { + args: { + id: 'text-sel-popup-up', + selection: { + startRow: 8, startCol: 22, + endRow: 4, endCol: 6, + shape: 'linewise', + dragging: false, + }, + }, +};