diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 5a75a0a..94a3bd8 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -960,6 +960,36 @@ impl AttachClient { })) } + /// Send a `FrontendEvent::TextInput` (GUI arc 1a): text the user + /// committed, by keypress or IME. + /// + /// **The caller must gate on [`Self::session_protocol_version`] + /// `>= TEXT_INPUT_MIN_VERSION` and fall back to `Key`** — this + /// method does not check, because the fallback needs the untranslated + /// key and only the caller has it. Withholding is the whole + /// old-peer contract: a `< 24` daemon keeps the behaviour it has, + /// including today's first-scalar truncation. + /// + /// Rejects oversize here as well as at the daemon, so a payload + /// that could never be accepted is not written to the socket at + /// all; rejection rather than truncation, per `TEXT_INPUT_MAX_BYTES`. + pub fn send_text_input(&self, text: &str) -> Result<(), TransportError> { + if text.len() > pmacs_protocol::TEXT_INPUT_MAX_BYTES { + return Err(TransportError::Io(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "TextInput of {} bytes exceeds the {}-byte cap", + text.len(), + pmacs_protocol::TEXT_INPUT_MAX_BYTES + ), + ))); + } + self.send_event(FrontendEvent::TextInput { + frontend_id: self.frontend_id, + text: text.to_owned(), + }) + } + /// Send a `FrontendEvent::Pointer` (session M-2): a locally /// hit-tested gesture in source bytes. Callers gate on /// [`Self::session_protocol_version`] `>= 5`. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 3b16815..ab898a8 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -46,8 +46,8 @@ use pmacs_protocol::{ MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, MinibufferRow, Modifiers, MouseButton as ProtocolMouseButton, MouseKind as ProtocolMouseKind, PointerKind, - SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame, - UnderlineStyle, + SelectionSnapshot, StatuslineSegment, StyleSegment, StyleSpan, TAB_STOP_COLUMNS, + TEXT_INPUT_MIN_VERSION, TerminalFrame, UnderlineStyle, cell::{Color as CellColor, Style as CellStyle}, is_builtin_pair_char, is_modeline_face_name, panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload}, @@ -3213,23 +3213,27 @@ impl App { .as_ref() .is_some_and(State::completion_open_for_current_buffer); - // Escape cancels an active intercept (e.g. a running - // search) or dismisses the completion popup; otherwise it - // stays the local quit. + // A4 / Q#S1-1 — **every** Escape reaches the daemon, and none + // exits. + // + // It used to quit the frontend when nothing was intercepting, + // which made the GUI's most common "get me out of this" key + // destroy the window instead of cancelling. Q#S1-1 settles the + // exits and Escape is not among them: a native close detaches + // this frontend, `editor.quit` shuts the daemon and its + // attachments down, and **Escape only cancels or round-trips**. + // + // The `intercept || completion_open` test went with the quit + // branch. It never decided what to SEND — both arms sent the + // same `Escape` — only whether to send at all, so with one + // behaviour left there is nothing for it to choose. (Both flags + // remain live below, for the OS-paste, round-trip and + // completion-accept paths.) if matches!(key.logical_key, Key::Named(NamedKey::Escape)) { - if intercept || completion_open { - if let Some(client) = self.attach_client.as_ref() - && let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE) - { - eprintln!("pmacs-gpu: send Escape (cancel) failed: {e}"); - } - } else { - // Q#S1-1 / A4 — the local quit, unchanged here and - // deleted by Stage 1a: an idle Escape must reach the - // daemon. `window_event` performs the exit. This is the - // only reason a BODY needs an outcome; `EventOutcome` - // itself outlives A4, since a native close still exits. - return EventOutcome::Exit; + if let Some(client) = self.attach_client.as_ref() + && let Err(e) = client.send_key(ProtocolKey::Escape, Modifiers::NONE) + { + eprintln!("pmacs-gpu: send Escape failed: {e}"); } return EventOutcome::Continue; } @@ -3314,6 +3318,32 @@ impl App { return EventOutcome::Continue; } + // A5 — multi-scalar text travels as one `TextInput`, if the + // session can carry it. + // + // Sited AFTER the command-chord and OS-paste branches and + // BEFORE the optimistic path, which is the order §5's rules + // describe: a chord is never text, and text must not be + // optimistically applied one scalar at a time when the whole + // point is that it is one edit. + // + // **The version gate withholds rather than degrades.** A `< 24` + // daemon keeps exactly the behaviour it has — including today's + // truncation to the first scalar — because the fallback below + // is the unchanged `Key` path. That is the no-regression + // promise, not retroactive correctness. + if let Some(text) = text_input_payload(&key.logical_key, key.text.as_deref(), pmods) + && client.session_protocol_version() >= TEXT_INPUT_MIN_VERSION + { + if let Some(state) = self.state.as_mut() { + state.mark_cursor_stale_after_round_trip(); + } + if let Err(e) = client.send_text_input(text) { + eprintln!("pmacs-gpu: send_text_input failed: {e}"); + } + return EventOutcome::Continue; + } + // Session B2 forwards cursor motion + plain text editing // (Char / Backspace / Enter / Delete / Tab). Command chords // are handled above; Meta/Super-only chords fall through @@ -12070,9 +12100,26 @@ fn translate_key( NamedKey::Enter => ProtocolKey::Enter, NamedKey::Delete => ProtocolKey::Delete, NamedKey::Insert => ProtocolKey::Insert, + // A2 — Shift+Tab is `BackTab`, a key of its own, and the + // Shift stays set. The daemon's keymap binds the two + // differently (indent versus outdent), and a `Tab` that + // merely carries Shift is indistinguishable from a Tab the + // user shifted by accident. The TUI has always sent + // `BackTab`; this closes the divergence rather than + // inventing a convention. + NamedKey::Tab if mods.shift_key() => ProtocolKey::BackTab, NamedKey::Tab => ProtocolKey::Tab, NamedKey::Space => ProtocolKey::Char(' '), - _ => return None, + // A3 — the menu key. `ProtocolKey::Menu` already exists and + // the TUI already sends it; only the GPU translation was + // missing, so the key did nothing in the GUI. + NamedKey::ContextMenu => ProtocolKey::Menu, + // A1 — F1..=F35. winit names each as its own variant, so + // there is no arithmetic to do and no range to trust: the + // mapping is total over the variants winit defines, and + // `named_function_key` is exhaustive rather than a + // computed offset. + named => named_function_key(*named).map(ProtocolKey::F)?, }, Key::Character(s) => ProtocolKey::Char(s.chars().next()?), _ => return None, @@ -12080,6 +12127,112 @@ fn translate_key( Some((pkey, pmods)) } +/// A5 / Q#S1-9 — whether a keypress should travel as `TextInput` +/// rather than `Key`, and with what payload. +/// +/// **A keypress stays `Key` unless a rule below moves it.** That +/// default is the contract, not an implementation convenience: every +/// mode keymap, every command chord and today's typed provenance are +/// built on `Key`, so widening the exception is how a working binding +/// silently becomes an insert. +/// +/// The rules, in the order they are checked: +/// +/// 1. **Named keys and control text stay `Key`**, whatever +/// `KeyEvent.text` says — `Enter` reports `"\r"`, and an `Enter` +/// that arrived as text would insert a newline in dired instead of +/// opening a file. +/// 2. **Ctrl/Alt chords stay `Key`**, except printable Ctrl+Alt that +/// the existing `AltGr` rule already recognizes — the caller strips +/// those modifiers before this is reached, so they arrive here as +/// plain text. +/// 3. **Meta/Super-only text stays reserved to the OS** (Q#S1-7 moves +/// the whole question to Stage 2). +/// 4. **Plain printable SINGLE-scalar stays `Key`.** This is the +/// conservative half of the ruling: it preserves mode keymaps and +/// the one-codepoint typed provenance that already exists. +/// 5. **Printable MULTI-scalar becomes one `TextInput`** — the case +/// that is broken today, truncated to its first scalar. +/// +/// `Key::Dead` is 1d's, and 1a buffers nothing (rule 7); `Shift` is +/// already baked into the resolved text and is not carried (rule 8). +fn text_input_payload<'a>( + logical: &Key, + text: Option<&'a str>, + mods: Modifiers, +) -> Option<&'a str> { + // Rule 1 — a named key is never text, regardless of what winit + // reports as its text. + if matches!(logical, Key::Named(_)) { + return None; + } + // Rules 2 and 3 — any command modifier still held at this point is + // a chord. The AltGr case has already had its modifiers stripped by + // the caller, so reaching here with Ctrl/Alt means a genuine chord. + if !is_plain_text_modifiers(mods) { + return None; + } + let text = text?; + // Rule 1, second half — control text is not text. + if text.is_empty() || text.chars().any(char::is_control) { + return None; + } + // Rules 4 and 5 — the single/multi split. + if text.chars().count() < 2 { + return None; + } + Some(text) +} + +/// A1 — winit's function-key variants to the protocol's 1-based `F(n)`. +/// +/// Written as an exhaustive match rather than parsed from the variant +/// name or computed as an offset from `F1`: winit's `NamedKey` is +/// `#[non_exhaustive]` and its ordering is not a contract, so arithmetic +/// over it would be a silent-corruption bug the day a variant is +/// inserted. `None` for anything that is not a function key, which is +/// what makes the caller's `?` fall through to "unmapped". +fn named_function_key(named: NamedKey) -> Option { + Some(match named { + NamedKey::F1 => 1, + NamedKey::F2 => 2, + NamedKey::F3 => 3, + NamedKey::F4 => 4, + NamedKey::F5 => 5, + NamedKey::F6 => 6, + NamedKey::F7 => 7, + NamedKey::F8 => 8, + NamedKey::F9 => 9, + NamedKey::F10 => 10, + NamedKey::F11 => 11, + NamedKey::F12 => 12, + NamedKey::F13 => 13, + NamedKey::F14 => 14, + NamedKey::F15 => 15, + NamedKey::F16 => 16, + NamedKey::F17 => 17, + NamedKey::F18 => 18, + NamedKey::F19 => 19, + NamedKey::F20 => 20, + NamedKey::F21 => 21, + NamedKey::F22 => 22, + NamedKey::F23 => 23, + NamedKey::F24 => 24, + NamedKey::F25 => 25, + NamedKey::F26 => 26, + NamedKey::F27 => 27, + NamedKey::F28 => 28, + NamedKey::F29 => 29, + NamedKey::F30 => 30, + NamedKey::F31 => 31, + NamedKey::F32 => 32, + NamedKey::F33 => 33, + NamedKey::F34 => 34, + NamedKey::F35 => 35, + _ => return None, + }) +} + /// Cursor-motion keys — forwarded with any modifier set (e.g. `C-Left` /// is word-motion, `S-Down` extends a selection; the daemon's keymap /// decides). @@ -12117,6 +12270,20 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { if matches!(key, ProtocolKey::Backspace | ProtocolKey::Delete) { return true; } + // A1–A3 — function keys, `BackTab` and `Menu` forward with ANY + // modifier set, for the same reason motion keys do: they are + // command keys that never insert text, so the chord-withholding + // rule below has nothing to protect them from. Translating them + // without forwarding them would have been the more expensive + // mistake — the key would map correctly and still do nothing, + // which reads as a daemon-side keymap gap rather than a frontend + // one. + if matches!( + key, + ProtocolKey::F(_) | ProtocolKey::BackTab | ProtocolKey::Menu + ) { + return true; + } if !is_plain_text_modifiers(mods) { return false; } diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index 6976a8c..3a857b7 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -68,9 +68,9 @@ pub use message::{ MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment, - StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name, - is_supported_protocol_version, is_ui_face_name, negotiate_capabilities, - negotiated_session_version, requested_protocol_version, + StyleSegment, StyleSpan, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, ThemeFace, + is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, + negotiate_capabilities, negotiated_session_version, requested_protocol_version, }; pub use panel::{ MAX_PANEL_VISIBLE_CELLS, PANEL_MIN_VERSION, PanelFrame, PanelFrameError, PanelFramePayload, diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 8516d4d..712eff5 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -513,8 +513,62 @@ pub enum FrontendEvent { /// Modifiers held during the gesture. mods: Modifiers, }, + /// Committed text from a keypress or an IME composition — GUI arc + /// Stage 1a, **protocol v24** ([`TEXT_INPUT_MIN_VERSION`]). + /// + /// **APPENDED, never widened.** postcard encodes an enum variant by + /// its positional index, so adding a field to any variant above + /// would silently re-interpret every older peer's bytes. A new + /// variant at the end is the only backward-compatible shape, which + /// is also why the frozen-byte pin in the tests sits on + /// [`FrontendEvent::PanelPointer`] — the *previous* final variant — + /// rather than on this one: an appended variant's own round-trip + /// cannot detect a discriminant shift beneath it. + /// + /// **This is not [`FrontendEvent::Paste`], and the difference is + /// behavioural rather than cosmetic.** A paste is bulk data from + /// elsewhere; this is what the user *typed*, so a terminal receives + /// it as **raw UTF-8 and never inside bracketed-paste markers** — a + /// shell that sees `ESC[200~` around typed input treats it as + /// pasted, which changes how it handles newlines and completion. + /// + /// **One `TextInput` is ONE edit**: one undo unit, one + /// `buffer.after-edit`, one eligible CRDT op. The multi-scalar case + /// is the whole reason the variant exists — a two-scalar grapheme + /// arriving as two keypresses is two undo units and, worse, can be + /// split by an intervening remote edit. + /// + /// `text` is capped at [`TEXT_INPUT_MAX_BYTES`]; an oversize payload + /// is **rejected, never truncated**, because truncating a UTF-8 + /// sequence at a byte boundary silently corrupts the last character + /// and a silently-shortened insert is worse than a refused one. + TextInput { + /// Which frontend produced the text. **Untrusted**, like every + /// other `frontend_id` on this enum — the daemon uses the + /// authenticated source, not this field. + frontend_id: FrontendId, + /// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`]. + text: String, + }, } +/// First protocol version carrying [`FrontendEvent::TextInput`]. +/// +/// A frontend older than this **retains its existing `Key` behaviour and +/// its existing limitations** — it truncates multi-scalar input to the +/// first scalar today and ignores IME, and it continues to. The promise +/// is **no regression, not retroactive correctness**: nothing a `< 24` +/// peer already had degrades, and the daemon simply never receives a +/// variant such a peer cannot encode. +pub const TEXT_INPUT_MIN_VERSION: u32 = 24; + +/// Cap on [`FrontendEvent::TextInput::text`], in bytes of UTF-8. +/// +/// 64 KiB is far above any keystroke or IME commit and far below a +/// pathological paste, which has its own event. **Oversize is rejected +/// rather than truncated** — see the variant's own documentation. +pub const TEXT_INPUT_MAX_BYTES: usize = 64 * 1024; + /// Gesture step for [`FrontendEvent::Pointer`]. Double-click /// detection is frontend-side (`DoubleDown` instead of a second /// `Down`): only the frontend knows pixel proximity and its own @@ -562,7 +616,8 @@ impl FrontendEvent { | Self::TerminalPointer { frontend_id, .. } | Self::FrontendCellGeometry { frontend_id, .. } | Self::PanelResizeRows { frontend_id, .. } - | Self::PanelPointer { frontend_id, .. } => *frontend_id, + | Self::PanelPointer { frontend_id, .. } + | Self::TextInput { frontend_id, .. } => *frontend_id, } } } @@ -1840,7 +1895,7 @@ pub enum ResourceBody { /// encoding makes an in-place widening a wire break rather than an /// evolution, and gating the widened form would have left those peers /// with no minibuffer message at all. -pub const PROTOCOL_VERSION: u32 = 23; +pub const PROTOCOL_VERSION: u32 = 24; /// Protocol version placed in the daemon's server-first [`Hello`]. /// @@ -2022,7 +2077,7 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 { /// `>= 23` peer receives only the rows form, and no peer ever receives /// both. [`ADVERTISED_PROTOCOL_VERSION`] does not move. pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[ - 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, + 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, ]; /// T M10.5: predicate for the handshake check. Returns `true` if diff --git a/src/daemon.rs b/src/daemon.rs index 121b469..ed67c3e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -71,6 +71,7 @@ use crate::protocol::{ InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PANEL_MIN_VERSION, PointerKind, SelectionSnapshot, SessionBootstrapRequest, + TEXT_INPUT_MAX_BYTES, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; use crate::transport::{read_message, write_message}; @@ -2518,6 +2519,36 @@ fn handle_dispatcher_event( handle_inbound_paste(editor, source, claimed_fid, &data); } } + FrontendEvent::TextInput { text, .. } => { + // GUI arc 1a / A5 — committed text from a keypress + // or an IME composition. Handled here, beside + // `Paste`, for the same two reasons: the + // authenticated `source` is in scope (the event's + // own `frontend_id` is client-supplied and not + // trusted), and the semantic input dispatcher would + // otherwise drop it, which is exactly how GPU + // Ctrl-V was a no-op before Q#KR10a. + // + // A9 — the cap is enforced at the BOUNDARY, before + // anything is inserted. Rejected, never truncated: + // cutting UTF-8 at a byte offset corrupts the last + // character, and a silently-shortened insert is + // worse than a refused one. An empty payload is + // dropped too — it would be an edit that edits + // nothing, and would still cost an undo unit. + if text.is_empty() { + return; + } + if text.len() > TEXT_INPUT_MAX_BYTES { + eprintln!( + "pmacs: rejecting oversize TextInput from {source:?} \ + ({} bytes > {TEXT_INPUT_MAX_BYTES})", + text.len() + ); + return; + } + editor.dispatch_text_input(source, &text); + } _ => { let Some(&term_size) = term_sizes.get(&source) else { eprintln!( @@ -3588,12 +3619,15 @@ fn apply_event( render_state.resize(size); *term_size = size; } - // Q#KR10a — Paste is handled in the dispatcher's own - // `FrontendEvent::Paste` arm (unified for grid and semantic - // sessions, keyed by the authenticated source), and never - // reaches here. Listed explicitly so a future reshuffle can't - // silently re-route it through this payload-trusting path. + // Q#KR10a (Paste) and GUI arc 1a (TextInput) — both are handled + // in the dispatcher's own arms, unified for grid and semantic + // sessions and keyed by the AUTHENTICATED source, and neither + // reaches here. Listed explicitly rather than left to a + // wildcard so a future reshuffle cannot silently re-route + // either through this payload-trusting path: both carry a + // client-supplied `frontend_id` this function would believe. FrontendEvent::Paste { .. } + | FrontendEvent::TextInput { .. } | FrontendEvent::FocusGained(_) | FrontendEvent::FocusLost(_) // T M11.1: the semantic-frontend viewport declaration. Its diff --git a/src/editor.rs b/src/editor.rs index 458751b..3860258 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1881,6 +1881,110 @@ impl EditorState { true } + /// GUI arc Stage 1a / A5 — apply committed text from a keypress or + /// an IME composition. + /// + /// **This is typed text, not a paste**, and the two differ in three + /// observable ways: + /// + /// * a terminal gets **raw UTF-8, never bracketed paste** (A8) — a + /// shell that sees `ESC[200~` treats the input as pasted and + /// changes how it handles newlines and completion; + /// * the clipboard slot is **not** touched, because nothing was + /// copied; + /// * the document path performs **one atomic edit** (A6) — one undo + /// unit, one `buffer.after-edit`, one eligible CRDT op — which is + /// the entire reason the wire variant exists. Delivering a + /// two-scalar grapheme as two keypresses makes two undo units and + /// lets a remote edit interleave between them. + /// + /// The modal precedence is `dispatch_key`'s, deliberately: menu, + /// search, query-replace and minibuffer are full keymap shadows, so + /// text that arrives while one is up belongs to it and not to the + /// buffer underneath. Those surfaces have no notion of a + /// multi-scalar commit, so the text is fed to them **one scalar at + /// a time, in order** (A7) — order is the contract, since a prompt + /// accumulates a query. + /// + /// Returns `true` when the text was consumed by a shadow or a + /// terminal, `false` when the caller should treat it as an ordinary + /// document edit — the same convention as [`Self::dispatch_paste`]. + pub fn dispatch_text_input(&mut self, frontend_id: FrontendId, text: &str) { + self.core.borrow_mut().active_frontend = frontend_id; + + // Shadows first, one scalar at a time and in order (A7). + if self.feed_shadow_scalars(frontend_id, text) { + return; + } + + // A8 — a terminal takes the bytes exactly as typed. + if let Some(key) = self.active_terminal_key(frontend_id) { + self.claim_terminal_controller(key); + self.send_terminal_bytes(key.buffer_id, text.as_bytes()); + return; + } + + // A6 — the ordinary document path: ONE edit. + self.with_after_edit_check(|state| { + let mut core = state.core.borrow_mut(); + // Provenance follows §5. A single-scalar commit is + // indistinguishable from a keypress and keeps today's + // one-codepoint typed provenance; a multi-scalar commit is + // not a command and breaks the chain, exactly as a paste + // does. + if text.chars().count() > 1 { + core.break_command_chain(frontend_id); + } + if let Err(e) = core.insert_text_input(text) { + eprintln!("pmacs: text input failed: {e}"); + } + }); + } + + /// Feed `text` to whichever modal shadow owns input, one scalar at a + /// time and in order. Returns `true` when a shadow consumed it. + /// + /// Each scalar becomes a plain `Char` chord, which is what these + /// handlers already take from `dispatch_key`; routing through them + /// rather than reaching into their state is what keeps a prompt's + /// own editing rules — history, completion, acceptance — in one + /// place. + fn feed_shadow_scalars(&mut self, frontend_id: FrontendId, text: &str) -> bool { + enum Shadow { + Menu, + Search, + QueryReplace, + Minibuffer, + } + let shadow = { + let core = self.core.borrow(); + if core.menu_is_open() { + Some(Shadow::Menu) + } else if core.search_active() { + Some(Shadow::Search) + } else if core.query_replace_active() { + Some(Shadow::QueryReplace) + } else if core.minibuffer.is_active() { + Some(Shadow::Minibuffer) + } else { + None + } + }; + let Some(shadow) = shadow else { + return false; + }; + for ch in text.chars() { + let chord = Chord::plain(KeyCode::Char(ch)); + match shadow { + Shadow::Menu => self.dispatch_menu_key(frontend_id, chord), + Shadow::Search => self.dispatch_search_key(chord), + Shadow::QueryReplace => self.dispatch_query_replace_key(chord), + Shadow::Minibuffer => self.dispatch_minibuffer_key(frontend_id, chord), + } + } + true + } + /// Apply authenticated frontend focus to terminal control/reporting. pub fn dispatch_focus(&mut self, frontend_id: FrontendId, gained: bool) { self.core.borrow_mut().active_frontend = frontend_id; diff --git a/src/editor_core.rs b/src/editor_core.rs index 517989c..51f71b7 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -5009,6 +5009,18 @@ impl EditorCore { self.insert_bytes_over_region(data) } + /// GUI arc Stage 1a / A6 — insert committed text as **one edit**. + /// + /// Shares [`Self::insert_bytes_over_region`] with paste, which is + /// what makes it a single `EditOp` and therefore a single undo + /// unit, a single `buffer.after-edit`, and a single eligible CRDT + /// op. **It deliberately does NOT touch `clipboard_slot`**: typed + /// text was never copied, and recording it would let the next yank + /// resurrect something the user merely typed. + pub fn insert_text_input(&mut self, text: &str) -> Result<(), String> { + self.insert_bytes_over_region(text.as_bytes()) + } + /// Shared insert/replace for paste: `Replace` over the active /// region, else `Insert` at the cursor. The cursor lands just past /// the inserted bytes and any selection is cleared. No-op insert for