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:
parent
cd888bed5e
commit
211241a437
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
104
src/editor.rs
104
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue