feat(gpu,protocol): 1a --- A1-A5 and the v24 TextInput variant

The mechanism, without its witnesses yet; tests follow in the next
commits.

**A1-A3 were mapping gaps, and forwarding was half the fix.**
`translate_key` gained F1-F35, Shift+Tab -> `BackTab`, and
`ContextMenu` -> `Menu`. All three already existed in the protocol
`Key` enum and the TUI already sent them, so this closes a divergence
rather than inventing a convention. **`should_forward_key` had to learn
them too** --- translated but unforwarded, they would have mapped
correctly and still done nothing, which reads as a daemon keymap gap
rather than a frontend one. They forward with ANY modifier, like motion
keys: they are command keys that never insert text, so the
chord-withholding rule has nothing to protect them from.

F-keys are an exhaustive match, not arithmetic off `F1`: winit's
`NamedKey` is `#[non_exhaustive]` and its ordering is not a contract, so
an offset would corrupt silently the day a variant is inserted.

**A4 --- every Escape now reaches the daemon and none exits.** 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, and with one behaviour left there is nothing to choose.
Both flags remain live for the OS-paste, round-trip and
completion-accept paths.

**The v24 wire variant is APPENDED and the reason is postcard.** It
encodes a variant by positional index, so widening any variant above
would re-interpret every older peer's bytes. `TextInput` carries an
untrusted `frontend_id` like its neighbours --- the daemon uses the
authenticated source --- plus the text.

**It is not `Paste`, and the difference is behavioural.** A terminal
receives it as RAW UTF-8, never bracketed (A8): a shell that sees
`ESC[200~` treats input as pasted and changes how it handles newlines
and completion. The clipboard slot is untouched, because nothing was
copied. And the document path is ONE edit (A6) --- one undo unit, one
`buffer.after-edit`, one eligible CRDT op --- which is the entire reason
the variant exists, since a two-scalar grapheme sent as two keypresses
is two undo units that a remote edit can interleave.

**A5's precedence is a pure function** (`text_input_payload`) so the
eight rules are testable without a window. A keypress stays `Key` unless
a rule moves it, and only printable MULTI-scalar moves; the version gate
WITHHOLDS rather than degrades, so a `< 24` daemon keeps exactly the
behaviour it has, truncation included.

**A7's ordering falls out of routing through the existing shadow
handlers** one scalar at a time, rather than reaching into prompt state:
history, completion and acceptance stay in one place.

THE 1-PRE EFFECT HARNESS CAUGHT A REAL DEFECT IN THIS COMMIT. Bumping
`PROTOCOL_VERSION` to 24 while leaving `SUPPORTED_PROTOCOL_VERSIONS` at
`..=23` made the handshake reject its own version. All NINE effect rows
failed while the thirteen routing rows passed --- the M21 signature,
meaning `EffectHarness::new` could not attach at all. A pure-routing
harness would have stayed green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-12 16:35:21 +02:00
parent cd888bed5e
commit 211241a437
No known key found for this signature in database
7 changed files with 432 additions and 30 deletions

View File

@ -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`.

View File

@ -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<u8> {
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;
}

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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