diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index 15765007..aa748cb6 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -4994,6 +4994,11 @@

clip: null, // Find state: { query, matches:[{s,e}], idx } | null. find: null, + // Emacs mark (spec 0206): true while C-Space has armed a region, so the + // next caret motion extends the native selection instead of collapsing + // it. The DOM selection's own anchor *is* the mark, so no offset is + // stored here — only whether the region is active. + mark: false, // Desktop pointer hover affordance for Playbook shimmer/session clips. // { kind, sessionId, anchorEl, token, term, clientX, clientY } | null. hover: null, @@ -10599,6 +10604,7 @@

async function playbookMountSession(id) { state.playbook.mountedId = id; playbookStopShimmer(); + playbookDeactivateMark(); playbookCloseClipMenu(); playbookHideSelectionMenu(); playbookCloseFind(); @@ -11776,6 +11782,9 @@

} function playbookOnInput() { + // An edit consumes the region the way emacs `delete-selection-mode` does — + // the browser has already replaced or deleted it — so the mark stops here. + playbookDeactivateMark(); playbookNormalizeLines(); if (!playbookSyncFencedSourcePresentation()) playbookFormatInlineCodeAtCaret(); playbookApplyLineDecorations(); @@ -11789,6 +11798,117 @@

playbookOnLocalEdit(); } +// --- Emacs mark and region (spec 0206) --------------------------------- +// The TUI Playbook has had `C-Space` set a zero-width mark that the next +// movement key extends; the web editor had no mark at all, so `C-Space` fell +// through to the contenteditable and inserted a space. The two clients share +// one keymap, so this mirrors the TUI's semantics rather than inventing web +// ones: `C-Space` marks, motion extends, `C-g` / `Escape` cancel. +// +// The browser's own selection anchor plays the role of the mark, so extending +// is just `Selection.modify("extend", ...)` — no shadow offsets to keep in +// sync with a DOM the renderer rewrites underneath us. + +/** Collapse the selection onto the caret (its focus end), if it is ours. */ +function playbookCollapseToCaret() { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount || !sel.focusNode) return; + if (!playbookInputEl.contains(sel.focusNode)) return; + try { sel.collapse(sel.focusNode, sel.focusOffset); } catch (_) {} +} + +/** `C-Space`: arm the region at the caret. Returns false when unfocused. */ +function playbookSetMark() { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount || !sel.focusNode) return false; + if (!playbookInputEl.contains(sel.focusNode)) return false; + // A mark set over an existing selection starts fresh at the caret, matching + // the TUI's `begin_playbook_selection` (anchor == head == cursor). + playbookCollapseToCaret(); + playbookCloseClipMenu(); + state.playbook.mark = true; + playbookUpdateSelectionMenu(); + playbookSetMsg("mark set"); + return true; +} + +/** + * Drop the region flag without touching the DOM selection. + * + * Used by the paths where the browser already owns what happens to the + * highlight — typing replaces it, a click restarts it, copy leaves it up — + * so only the "arrows extend" mode has to end. + */ +function playbookDeactivateMark() { + if (!state.playbook.mark) return false; + state.playbook.mark = false; + return true; +} + +/** `C-g` / `Escape`: deactivate the region and leave the caret where it is. */ +function playbookCancelMark() { + if (!playbookDeactivateMark()) return false; + playbookCollapseToCaret(); + playbookUpdateSelectionMenu(); + playbookPublishCursor(); + playbookRenderCursors(); + playbookSetMsg("mark cleared"); + return true; +} + +/** + * Resolve a keydown to a caret motion as [direction, granularity] for + * `Selection.modify`, or null when the key does not move the caret. + * + * Covers the TUI Playbook's movement bindings — arrows, Home/End, emacs + * `C-f`/`C-b`/`C-n`/`C-p`/`C-a`/`C-e` — plus the word and + * line/document-boundary motions each platform spells with its own modifier. + * Alt combinations are matched on `code`, because macOS turns `M-f` into `ƒ` + * and `M-b` into `∫` in `key`. + */ +function playbookMotionForKey(e) { + const ctrl = e.ctrlKey; + const alt = e.altKey; + const meta = e.metaKey; + switch (e.key) { + case "ArrowLeft": + if (meta) return ["backward", "lineboundary"]; + return ["backward", ctrl || alt ? "word" : "character"]; + case "ArrowRight": + if (meta) return ["forward", "lineboundary"]; + return ["forward", ctrl || alt ? "word" : "character"]; + case "ArrowUp": + return ["backward", meta ? "documentboundary" : "line"]; + case "ArrowDown": + return ["forward", meta ? "documentboundary" : "line"]; + case "Home": + return ["backward", ctrl ? "documentboundary" : "lineboundary"]; + case "End": + return ["forward", ctrl ? "documentboundary" : "lineboundary"]; + } + if (alt && !ctrl && !meta) { + if (e.code === "KeyF") return ["forward", "word"]; + if (e.code === "KeyB") return ["backward", "word"]; + } + if (ctrl && !meta && !alt) { + switch (String(e.key).toLowerCase()) { + case "f": return ["forward", "character"]; + case "b": return ["backward", "character"]; + case "n": return ["forward", "line"]; + case "p": return ["backward", "line"]; + case "a": return ["backward", "lineboundary"]; + case "e": return ["forward", "lineboundary"]; + } + } + return null; +} + +/** True when this keydown is `C-Space` on any layout. */ +function playbookIsSetMarkKey(e) { + if (!e.ctrlKey || e.metaKey || e.altKey) return false; + return e.key === " " || e.key === "Spacebar" || e.code === "Space"; +} + function playbookOnKeyDown(e) { const mod = e.ctrlKey || e.metaKey; if (state.playbook.clip) { @@ -11810,6 +11930,40 @@

if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); playbookAcceptClip(); return; } if (e.key === "Escape") { e.preventDefault(); playbookCloseClipMenu(); return; } } + // C-Space sets the mark. `preventDefault` is mandatory — the contenteditable + // would otherwise take the keystroke as a literal space — and this handler + // sits on the editor itself, so it runs at target before the document-level + // capture keymap has any interest in the key (`C-space` is bound to nothing + // there, and is not a prefix of the `C-x Space` chord). + if (playbookIsSetMarkKey(e)) { + e.preventDefault(); + playbookSetMark(); + return; + } + // C-g is the emacs spelling of Escape's cancel, as in the TUI: it peels off + // the transient Playbook UI, then deactivates the region. + if (e.ctrlKey && !e.metaKey && !e.altKey && (e.key === "g" || e.key === "G")) { + e.preventDefault(); + if (state.playbook.clip) { playbookCloseClipMenu(); return; } + if (state.playbook.find) { playbookCloseFind(); playbookInputEl.focus(); return; } + playbookCancelMark(); + return; + } + // With the mark armed, every caret motion extends the region from it rather + // than collapsing the selection — the whole point of setting a mark. This + // precedes the C-f binding below so C-f extends too. + if (state.playbook.mark) { + const motion = playbookMotionForKey(e); + if (motion) { + e.preventDefault(); + const sel = window.getSelection(); + if (sel && typeof sel.modify === "function") sel.modify("extend", motion[0], motion[1]); + playbookUpdateSelectionMenu(); + playbookPublishCursor(); + playbookRenderCursors(); + return; + } + } if (mod && (e.key === "s" || e.key === "S")) { e.preventDefault(); playbookSave(); return; } if (mod && e.key === "Enter") { e.preventDefault(); playbookRun(e.shiftKey); return; } if (!mod && e.key === "Backspace" && playbookRevealInlineCodeForBackspace()) { @@ -11825,6 +11979,7 @@

return; } if (e.key === "Escape" && state.playbook.find) { e.preventDefault(); playbookCloseFind(); playbookInputEl.focus(); return; } + if (e.key === "Escape" && state.playbook.mark) { e.preventDefault(); playbookCancelMark(); return; } if (e.key === "Tab" && !mod) { e.preventDefault(); document.execCommand("insertText", false, " "); @@ -11866,7 +12021,14 @@

playbookMsg(`preview failed: ${err.message}`, true)); }); playbookInputEl.addEventListener("click", () => { playbookUpdateClipMenu(); playbookUpdateSelectionMenu(); playbookPublishCursor(); playbookRenderCursors(); }); + // A click (or drag) hands selection back to the mouse; the flag must not + // survive it, or the next arrow key would extend from the new caret. + playbookInputEl.addEventListener("pointerdown", () => playbookDeactivateMark()); playbookInputEl.addEventListener("pointerup", () => playbookUpdateSelectionMenu()); + // Copy/cut consume the region (emacs deactivates the mark after + // kill-ring-save); the highlight itself is left to the browser. + playbookInputEl.addEventListener("copy", () => playbookDeactivateMark()); + playbookInputEl.addEventListener("cut", () => playbookDeactivateMark()); playbookInputEl.addEventListener("pointermove", playbookHandleHoverPointerMove); playbookInputEl.addEventListener("pointerleave", playbookHideHover); playbookInputEl.addEventListener("scroll", () => { playbookRenderCursors(); playbookHideHover(); playbookHideSelectionMenu(); }); @@ -11879,7 +12041,7 @@

playbookHideSelectionMenu(); } }); - playbookInputEl.addEventListener("blur", () => { setTimeout(playbookCloseClipMenu, 120); }); + playbookInputEl.addEventListener("blur", () => { playbookDeactivateMark(); setTimeout(playbookCloseClipMenu, 120); }); // Keep the editor plain text: paste as text/plain, never rich HTML. // Pasted files become session attachments whose Markdown links land at // the caret (spec 0099). diff --git a/crates/e2e/tests/web_smoke.rs b/crates/e2e/tests/web_smoke.rs index 72a20b28..079e2b1e 100644 --- a/crates/e2e/tests/web_smoke.rs +++ b/crates/e2e/tests/web_smoke.rs @@ -18,6 +18,7 @@ use construct_e2e::{artifact_dir, Daemon}; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine as _; use chromiumoxide::browser::{Browser, BrowserConfig}; +use chromiumoxide::cdp::browser_protocol::input::{DispatchKeyEventParams, DispatchKeyEventType}; use chromiumoxide::cdp::browser_protocol::page::{ EventScreencastFrame, ScreencastFrameAckParams, StartScreencastFormat, StartScreencastParams, StopScreencastParams, @@ -1525,6 +1526,151 @@ async fn web_client_loads_and_websocket_connects() { "preview terminal should contain replayed PTY output: {playbook_hover:?}" ); + // Playbook mark and region (spec 0206): `C-Space` arms a region at the + // caret and the next motion extends it, as in the TUI. Driven with real + // CDP key events rather than synthesized `KeyboardEvent`s, because the + // bug this guards was the contenteditable eating `C-Space` as a literal + // space — only a trusted event exercises that default action at all. + page.evaluate( + r#" + (() => { + window.__markSaved = { + sessions: state.sessions, + currentId: state.currentId, + mode: state.mode, + mountedId: state.playbook.mountedId, + docById: state.playbook.docById, + html: playbookInputEl.innerHTML, + wrapHidden: playbookWrapEl.hidden, + }; + const markdown = 'alpha beta gamma\ndelta epsilon\n'; + state.sessions = [ + { id: 's-mark', title: 'Mark', harness: 'smith', state: 'running', has_pty: true }, + ]; + state.currentId = 's-mark'; + state.mode = 'playbook'; + state.playbook.mountedId = 's-mark'; + state.playbook.docById = new Map([['s-mark', { + version: 1, + templateId: null, + saved: playbookNormalizeClipIds(markdown), + live: playbookNormalizeClipIds(markdown), + blocks: playbookBlockSpans(markdown), + pendingLive: 0, + }]]); + playbookWrapEl.hidden = false; + playbookRenderDoc(markdown); + window.__markKeys = []; + // Bubble phase on `document`: this runs after every listener on the + // editor, so `defaultPrevented` already reflects the handler's call. + window.__markProbe = (e) => { + window.__markKeys.push({ + key: e.key, + ctrl: e.ctrlKey, + defaultPrevented: e.defaultPrevented, + }); + }; + document.addEventListener('keydown', window.__markProbe); + playbookInputEl.focus(); + const line = playbookInputEl.children[0]; + const range = document.createRange(); + range.setStart(line.firstChild || line, 0); + range.collapse(true); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + return true; + })() + "#, + ) + .await + .expect("stage playbook mark fixture"); + + press_key_chord(&page, " ", "Space", 32, CDP_MODIFIER_CTRL).await; + for _ in 0..6 { + press_key_chord(&page, "ArrowRight", "ArrowRight", 39, 0).await; + } + press_key_chord(&page, "f", "KeyF", 70, CDP_MODIFIER_CTRL).await; + + let mark_region: serde_json::Value = page + .evaluate( + r#" + (() => ({ + selection: window.getSelection().toString(), + mark: state.playbook.mark, + text: playbookSerialize(), + setMarkPrevented: window.__markKeys[0] && window.__markKeys[0].defaultPrevented, + menuVisible: !playbookSelectionMenuEl.hidden, + }))() + "#, + ) + .await + .expect("evaluate playbook mark region") + .into_value::() + .expect("json object"); + assert_eq!( + mark_region["setMarkPrevented"], true, + "C-Space must reach the Playbook handler and be prevented: {mark_region:?}" + ); + assert_eq!( + mark_region["mark"], true, + "C-Space should arm the region: {mark_region:?}" + ); + assert_eq!( + mark_region["selection"], "alpha b", + "motion after C-Space should extend from the mark: {mark_region:?}" + ); + assert_eq!( + mark_region["menuVisible"], true, + "a non-empty region should surface the selection menu: {mark_region:?}" + ); + assert_eq!( + mark_region["text"], "alpha beta gamma\ndelta epsilon\n", + "C-Space must not type a space into the document: {mark_region:?}" + ); + + press_key_chord(&page, "g", "KeyG", 71, CDP_MODIFIER_CTRL).await; + press_key_chord(&page, "ArrowRight", "ArrowRight", 39, 0).await; + let mark_cleared: serde_json::Value = page + .evaluate( + r#" + (() => { + const out = { + selection: window.getSelection().toString(), + mark: state.playbook.mark, + text: playbookSerialize(), + }; + document.removeEventListener('keydown', window.__markProbe); + const saved = window.__markSaved; + state.sessions = saved.sessions; + state.currentId = saved.currentId; + state.mode = saved.mode; + state.playbook.mountedId = saved.mountedId; + state.playbook.docById = saved.docById; + playbookInputEl.innerHTML = saved.html; + playbookWrapEl.hidden = saved.wrapHidden; + playbookHideSelectionMenu(); + return out; + })() + "#, + ) + .await + .expect("evaluate playbook mark cancel") + .into_value::() + .expect("json object"); + assert_eq!( + mark_cleared["mark"], false, + "C-g should deactivate the region: {mark_cleared:?}" + ); + assert_eq!( + mark_cleared["selection"], "", + "motion after C-g should move the caret, not extend: {mark_cleared:?}" + ); + assert_eq!( + mark_cleared["text"], "alpha beta gamma\ndelta epsilon\n", + "cancelling the mark must not edit the document: {mark_cleared:?}" + ); + // Mobile regression: selecting a PTY-backed session from the list must not // focus xterm when the native keyboard is hidden, or iOS/Android pop the // keyboard just because the user changed selection. If the keyboard was @@ -4755,6 +4901,29 @@ impl Drop for ScreencastRecording { /// Poll until `state.harnesses` is populated (the connect flow fetches it /// asynchronously after the socket opens), then return a probe of the /// Playbook clip picker's root rows built from it (#1098). +/// CDP's `Input.dispatchKeyEvent` modifier bit for Ctrl. +const CDP_MODIFIER_CTRL: i64 = 2; + +/// Dispatch a real (trusted) key press, modifiers included. +/// +/// `press_key` in chromiumoxide cannot carry modifiers, and a synthesized +/// `KeyboardEvent` never runs the browser's default action — which is exactly +/// what a `preventDefault` assertion needs to be meaningful. +async fn press_key_chord(page: &Page, key: &str, code: &str, vk: i64, modifiers: i64) { + for kind in [DispatchKeyEventType::KeyDown, DispatchKeyEventType::KeyUp] { + let params = DispatchKeyEventParams::builder() + .r#type(kind) + .key(key) + .code(code) + .windows_virtual_key_code(vk) + .native_virtual_key_code(vk) + .modifiers(modifiers) + .build() + .expect("build key event"); + page.execute(params).await.expect("dispatch key event"); + } +} + async fn wait_for_harness_roster(page: &Page) -> serde_json::Value { let deadline = Instant::now() + Duration::from_secs(15); loop { diff --git a/specs/0206-playbook-mark-and-region.md b/specs/0206-playbook-mark-and-region.md new file mode 100644 index 00000000..2d9a69a7 --- /dev/null +++ b/specs/0206-playbook-mark-and-region.md @@ -0,0 +1,79 @@ +# 0206-playbook-mark-and-region + +Status: accepted +Date: 2026-08-21 +Area: ux +Scope: How the Playbook editor arms and extends a keyboard-driven selection region, in every client that offers one. + +## Decision + +A Playbook editor that offers keyboard region selection must implement the whole +emacs mark contract, not a fragment of it: + +- **Set** — `C-Space` places a zero-width mark at the caret. It never inserts + text; a client whose text surface would consume the keystroke must suppress + that default explicitly. +- **Extend** — while the mark is armed, every caret motion the editor supports + extends the region from the mark instead of collapsing it. That means the + arrows, `Home`/`End`, the emacs motions `C-f`/`C-b`/`C-n`/`C-p`/`C-a`/`C-e`, + and whatever word and line/document-boundary motions the platform spells with + its own modifiers. +- **Act** — the region is the selection the editor's selection-scoped actions + operate on: copy, cut, run-selection, and the selection verb menu. +- **Cancel** — `C-g` and `Escape` both deactivate the region and leave the caret + where it is. Cancelling never edits the document. + +The region also ends when the user takes it somewhere else: an edit consumes it, +a pointer press hands selection back to the mouse, and mounting a different +session's Playbook starts with no region. + +Clients are free to differ on *how* the region is stored — a client built on a +native text surface should let that surface's own selection anchor be the mark +rather than shadowing it — but not on which keys do what. + +## Reason + +Playbook is one document with more than one editor, and the mark is muscle +memory: a user who sets a mark and presses an arrow expects a region no matter +which client they happen to be in. Half an implementation is worse than none — +a `C-Space` that types a space silently corrupts the document the user is +composing, and a mark that only some motions extend teaches the user to distrust +the binding and fall back to the mouse. + +Cancel needs two spellings because the two clients arrived at it from different +directions: `C-g` is the emacs quit that the binding vocabulary implies, and +`Escape` is Construct's browser-safe universal cancel. + +## Consequences + +- Adding a caret motion to a Playbook editor is not complete until the motion + also extends an armed region. A motion that only moves the caret silently + drops the region and looks like a bug in `C-Space`. +- `C-Space` must be claimed at a point where it still can be — before the text + surface's own default action, and before any client-global keymap that might + otherwise route it. Clients must verify the keystroke actually arrives rather + than assuming it does; a host OS may bind `C-Space` (macOS input-source + switching) above the application entirely, which no client can override. +- Region state is per-editor and transient. It is never saved, never published + as document content, and never survives a remount. +- This is additive to native selection, not a replacement: pointer selection, + shift-selection, and the platform clipboard keep working unchanged. + +## Non-Goals + +- A full emacs mark ring, `exchange-point-and-mark`, or transient-mark + bookkeeping beyond "the region is active or it is not". +- Keystroke-for-keystroke equivalence between clients on everything else. Spec + 0059 still governs: web Playbook parity is defined by capability, and native + per-platform text affordances remain the baseline. This spec constrains only + the mark bindings a client chooses to offer. + +## Examples + +- Caret mid-line, `C-Space`, then six right-arrows: six characters are selected, + the document is unchanged, and the selection-scoped menu appears. +- With that region up, `C-e` extends to end of line and `C-n` extends a line + further down; `C-g` then clears the highlight and leaves the caret where the + last motion put it. +- With the region up, copy places the region on the clipboard and disarms the + mark; the next arrow key moves the caret normally.