diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index ab898a8..5584d44 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -3162,7 +3162,7 @@ impl App { Route::Keyboard { action: KeyAction::Press, key, - } => return self.apply_keyboard(key), + } => self.apply_keyboard(key), Route::Pointer(PointerRoute::Moved { x, y }) => self.apply_cursor_moved(x, y), Route::Pointer(PointerRoute::Left(button_state)) => { self.apply_left_button(button_state); @@ -3186,7 +3186,7 @@ impl App { /// Perform [`KeyAction::Press`]. The router has already /// discarded key-ups, so `key` is always a press. #[allow(clippy::too_many_lines)] // one linear key pipeline; splitting hides the order. - fn apply_keyboard(&mut self, key: &KeyEvent) -> EventOutcome { + fn apply_keyboard(&mut self, key: &KeyEvent) { // While the daemon is intercepting keystrokes — an active // incremental search (Q#SR5), or a minibuffer / pending // prefix — every key belongs to its handler, not the @@ -3235,11 +3235,11 @@ impl App { { eprintln!("pmacs-gpu: send Escape failed: {e}"); } - return EventOutcome::Continue; + return; } let Some((pkey, mut pmods)) = translate_key(&key.logical_key, self.modifiers) else { - return EventOutcome::Continue; + return; }; // AltGr / international text (audit F-004). winit reports @@ -3275,13 +3275,57 @@ impl App { { eprintln!("pmacs-gpu: send_paste failed: {e}"); } - return EventOutcome::Continue; + return; } let Some(client) = self.attach_client.as_ref() else { - return EventOutcome::Continue; + return; }; + // A5 — multi-scalar text travels as ONE `TextInput`, if the + // session can carry it. + // + // **This MUST precede the intercept branch below, and that + // placement is the contract rather than a preference.** A + // modal prompt or a focused terminal is exactly what makes + // `daemon_intercepts_keys` true, so classifying after it would + // leave A7 (prompts consume scalars in order) and A8 (terminals + // take raw UTF-8) reachable only when neither a prompt nor a + // terminal is present — which is to say, never. Sited here, the + // producer sends the same `TextInput` in every state and the + // daemon's `dispatch_text_input` applies §5's modal precedence, + // which is where that decision belongs: the frontend cannot see + // which shadow is up. + // + // Ordering against the branches below is safe by construction, + // not by luck: `text_input_payload` returns `None` whenever a + // command modifier is held, so the Ctrl-V paste and + // command-chord paths can never be shadowed by it. + // + // **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 is the + // unchanged `Key` path below. No regression, 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 debug_input() { + eprintln!( + "pmacs-gpu send_text_input: {} scalars", + text.chars().count() + ); + } + let client = self.attach_client.as_ref().expect("client checked above"); + if let Err(e) = client.send_text_input(text) { + eprintln!("pmacs-gpu: send_text_input failed: {e}"); + } + return; + } + // Intercept path: round-trip every key into the daemon's // active handler (search query / step / accept / cancel). if intercept { @@ -3294,7 +3338,7 @@ impl App { if let Err(e) = client.send_key(pkey, pmods) { eprintln!("pmacs-gpu: send_key (intercepted) failed: {e}"); } - return EventOutcome::Continue; + return; } // Idle: forward any command chord (Char/Enter/Tab with @@ -3315,33 +3359,7 @@ impl App { if let Err(e) = client.send_key(pkey, pmods) { eprintln!("pmacs-gpu: send_key (command chord) failed: {e}"); } - 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; + return; } // Session B2 forwards cursor motion + plain text editing @@ -3350,7 +3368,7 @@ impl App { // here and are withheld, leaving OS/WM shortcuts (Cmd-Q, // Cmd-C) to the platform. if !should_forward_key(pkey, pmods) { - return EventOutcome::Continue; + return; } // Arc 1a Q#C6 — with the popup open, RET and TAB mean @@ -3387,7 +3405,7 @@ impl App { { eprintln!("pmacs-gpu: send Viewport failed: {e}"); } - return EventOutcome::Continue; + return; } if let Some(state) = self.state.as_mut() { if state.defer_round_trip_key_if_needed(pkey, pmods) { @@ -3397,7 +3415,7 @@ impl App { pending optimistic cursor" ); } - return EventOutcome::Continue; + return; } state.mark_cursor_stale_after_round_trip(); } @@ -3407,7 +3425,6 @@ impl App { if let Err(e) = client.send_key(pkey, pmods) { eprintln!("pmacs-gpu: send_key failed: {e}"); } - EventOutcome::Continue } } @@ -3448,23 +3465,19 @@ enum Route<'a> { /// What the event loop must do once a family's body has run. /// -/// **Two producers, and they are not the same kind of thing.** -/// `LifecycleRoute::Exit` is a native window close, which must always -/// exit; `apply_keyboard` returns `Exit` for an idle Escape, which is a -/// local quit. Returning the decision rather than taking an -/// `&ActiveEventLoop` is what keeps every body reachable from a test: -/// the crate has **exactly one** executable `event_loop.exit()`, in -/// `window_event`. +/// **Since A4, `LifecycleRoute::Exit` — a native window close — is the +/// SOLE producer.** `apply_keyboard` used to be the second, for the +/// idle-Escape local quit; A4 deleted that branch and with it the +/// keyboard body's need to return anything, so it returns `()` and the +/// obsolete channel is gone rather than merely unused. /// -/// **Stage 1a's A4 removes the KEYBOARD producer only** — an idle -/// Escape must reach the daemon and never exit — leaving **one** `Exit` -/// producer, the native close. -/// -/// **One producer is not one variant.** This type survives A4 because +/// **One producer is not one variant.** The type stays because /// `dispatch_window_event` must still distinguish `Continue` from -/// `Exit` on every event it handles: nearly all of them must not exit, -/// and the close must. What A4 changes is `apply_keyboard`'s signature, -/// not this type. +/// `Exit` on every event it handles: nearly all must not exit, and the +/// close must. Returning the decision rather than taking an +/// `&ActiveEventLoop` is also what keeps the bodies reachable from a +/// test — the crate has exactly one executable `event_loop.exit()`, in +/// `window_event`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EventOutcome { Continue, diff --git a/src/daemon.rs b/src/daemon.rs index ed67c3e..c9d5973 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -71,7 +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, + TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; use crate::transport::{read_message, write_message}; @@ -2536,6 +2536,25 @@ fn handle_dispatcher_event( // 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. + // **The producer gate is only half the contract.** + // A frontend that negotiated v6–v23 can still encode + // this variant — it is compiled from the same crate, + // and postcard will happily write the discriminant — + // so a peer that never declared v24 could otherwise + // mutate the buffer through a variant its own + // session does not include. Gate on the + // AUTHENTICATED session's negotiated version, not on + // the payload and not on what the daemon supports. + let peer_declared_text_input = session_registry + .session_state(source) + .is_some_and(|s| s.negotiated_protocol_version >= TEXT_INPUT_MIN_VERSION); + if !peer_declared_text_input { + eprintln!( + "pmacs: dropping TextInput from {source:?}, which negotiated \ + below v{TEXT_INPUT_MIN_VERSION}" + ); + return; + } if text.is_empty() { return; } diff --git a/src/editor.rs b/src/editor.rs index 3860258..52bc0f7 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1906,9 +1906,11 @@ impl EditorState { /// 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`]. + /// Returns nothing, unlike [`Self::dispatch_paste`], and the + /// difference is real rather than stylistic: §5's precedence is + /// **total** — shadow, terminal, or document, every state routes + /// somewhere — so there is no "unhandled" case for a caller to fall + /// back on. pub fn dispatch_text_input(&mut self, frontend_id: FrontendId, text: &str) { self.core.borrow_mut().active_frontend = frontend_id; @@ -1924,21 +1926,57 @@ impl EditorState { 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); + // §5's provenance split. A SINGLE-scalar commit is + // indistinguishable from a keypress, so it must be + // indistinguishable downstream too: it rotates to + // `buffer.self-insert` and produces the same one-codepoint + // typed-edit record a keystroke does. Without that, auto-pairing + // (Q#AP9) and every other typed-edit consumer silently stop + // recognizing GUI input, and `this_command` goes stale — a + // regression that shows up as "auto-pair stopped working in the + // GUI", far from its cause. + // + // A MULTI-scalar commit is not a keystroke: it breaks the + // command chain and creates no typed provenance, exactly as a + // paste does. + let single = { + let mut chars = text.chars(); + match (chars.next(), chars.next()) { + (Some(ch), None) => Some(ch), + _ => None, } + }; + + let pre_revision = self.active_buffer_revision(); + { + let mut core = self.core.borrow_mut(); + match single { + Some(ch) => { + core.rotate_command(frontend_id, "buffer.self-insert"); + core.typed_edit_arm(frontend_id, ch); + } + None => core.break_command_chain(frontend_id), + } + // A6 — ONE edit, whichever branch armed it. if let Err(e) = core.insert_text_input(text) { eprintln!("pmacs: text input failed: {e}"); } - }); + } + + // The dispatch tail `dispatch_key` runs, for the same reason: a + // typed-edit record is only meaningful to a hook that can see + // it, so it is armed across the fan-out and cleared after. + let typed_edit = self.core.borrow_mut().typed_edit_finish(frontend_id); + if pre_revision != self.active_buffer_revision() { + if let Some(record) = typed_edit { + self.core + .borrow_mut() + .typed_edit_set_armed(frontend_id, record); + } + self.lua_host + .run_hook("buffer.after-edit", mlua::MultiValue::new()); + self.core.borrow_mut().typed_edit_clear_armed(); + } } /// Feed `text` to whichever modal shadow owns input, one scalar at a diff --git a/src/protocol.rs b/src/protocol.rs index 33beee0..483ed59 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_twenty_three_for_minibuffer_prompt_rows() { + fn protocol_version_is_twenty_four_for_text_input() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1740,7 +1740,11 @@ mod tests { // and gating the wider form would have left them with no // minibuffer at all. `MinibufferPrompt` is therefore frozen and // pinned by literal bytes below. - assert_eq!(PROTOCOL_VERSION, 23); + // + // v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) — an + // APPENDED variant, which is why the freeze above survives it + // untouched: nothing in `MinibufferPrompt`'s encoding moved. + assert_eq!(PROTOCOL_VERSION, 24); } #[test] @@ -1817,18 +1821,19 @@ mod tests { // (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`), // v18 (`StatuslineSegments`), v19 (the vterm terminal family), // v20 (semantic initial-target bootstrap), v21 (the bottom - // panel band), v22 (`LineWrapFacts`), and v23 - // (`MinibufferPromptRows`) all interoperate. - for accepted in 6..=23 { + // panel band), v22 (`LineWrapFacts`), v23 + // (`MinibufferPromptRows`), and v24 (`TextInput`, GUI arc Stage + // 1a) all interoperate. + for accepted in 6..=24 { assert!( is_supported_protocol_version(accepted), "v{accepted} must be accepted" ); } - for rejected in [0, 1, 2, 3, 4, 5, 24, u32::MAX] { + for rejected in [0, 1, 2, 3, 4, 5, 25, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v23 binary" + "v{rejected} must be rejected by a v24 binary" ); } } @@ -2738,9 +2743,20 @@ mod tests { #[test] fn m4_6_handshake_accepts_v6_peer() { + // ANCHORED ON THE LITERAL 6, deliberately. The body used to + // assert `is_supported_protocol_version(PROTOCOL_VERSION)` — + // "the current wire accepts itself" — which is a different and + // much weaker claim than the name and the M4.6 contract make: + // **v6 is the FLOOR**, the oldest peer the handshake still + // admits, and it must keep being accepted no matter how far the + // ceiling moves. Written against the moving constant, the test + // would have gone on passing after v6 was dropped from the + // supported set, which is the only regression it exists to + // catch. Found when the v24 bump made it fail for the unrelated + // reason that `SUPPORTED_PROTOCOL_VERSIONS` had not been widened. assert!( - is_supported_protocol_version(PROTOCOL_VERSION), - "the current wire version must accept itself" + is_supported_protocol_version(6), + "v6 is the floor and must stay accepted" ); } diff --git a/tests/bottom_panel_stage2b_gpu_acceptance.rs b/tests/bottom_panel_stage2b_gpu_acceptance.rs index 32c197d..7da08ea 100644 --- a/tests/bottom_panel_stage2b_gpu_acceptance.rs +++ b/tests/bottom_panel_stage2b_gpu_acceptance.rs @@ -337,9 +337,10 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() { #[test] fn the_baseline_stays_and_the_counter_offer_activates() { // A deliberate tripwire: bumping the wire must be a conscious edit - // here, not a silent one. v23 is `MinibufferPromptRows` (Discovery - // Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3). - assert_eq!(PROTOCOL_VERSION, 23); + // here, not a silent one. v24 is `TextInput` (GUI arc Stage 1a); + // v23 was `MinibufferPromptRows` (Discovery Stage 2); v22 was + // `LineWrapFacts` (long-lines Stage 3). + assert_eq!(PROTOCOL_VERSION, 24); assert_eq!( ADVERTISED_PROTOCOL_VERSION, 20, "moving this is the incompatible act the mechanism exists to avoid" diff --git a/tests/discovery_stage2_acceptance.rs b/tests/discovery_stage2_acceptance.rs index 571c605..8afa29a 100644 --- a/tests/discovery_stage2_acceptance.rs +++ b/tests/discovery_stage2_acceptance.rs @@ -67,10 +67,10 @@ use common::daemon::{TestDaemon, build_default_caps}; /// server-first, so moving it locks out every already-shipped frontend /// before it can counter-offer. An additive family never needs it. #[test] -fn the_wire_is_v23_and_the_advertised_baseline_is_unmoved() { +fn the_wire_is_v24_and_the_advertised_baseline_is_unmoved() { assert_eq!( - PROTOCOL_VERSION, 23, - "v23 is MinibufferPromptRows (Discovery Stage 2)" + PROTOCOL_VERSION, 24, + "v24 is TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows" ); assert_eq!( ADVERTISED_PROTOCOL_VERSION, 20, @@ -85,7 +85,10 @@ fn the_wire_is_v23_and_the_advertised_baseline_is_unmoved() { "v{version} must still be supported" ); } - assert!(!is_supported_protocol_version(24)); + // The ceiling: the supported set ENDS at the current wire, which + // is what makes an accidentally-widened set a failure rather than + // a silent pass. Probes one PAST the top, so it moves with it. + assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1)); } // --------------------------------------------------------------------------- @@ -612,7 +615,7 @@ fn one_daemon_serves_a_v23_rows_session_and_a_frozen_v22_session() { // rather than after the interesting half has already passed. let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22); let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION); - assert_eq!(PROTOCOL_VERSION, 23); + assert_eq!(PROTOCOL_VERSION, 24); // Open the real `M-x` through the real key path, then narrow to the // probe command by typing it — the candidate window is ten rows out diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index c577af4..c927956 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -804,11 +804,11 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() { // three lines on purpose. The ceiling assertion is the load-bearing // one — it says the supported set ENDS here, which is what makes an // accidentally-widened set a failure rather than a silent pass. - assert_eq!(PROTOCOL_VERSION, 23); - for version in 6..=23 { + assert_eq!(PROTOCOL_VERSION, 24); + for version in 6..=24 { assert!(is_supported_protocol_version(version)); } - assert!(!is_supported_protocol_version(24)); + assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1)); let sample = InstanceMessage::StatuslineSegments { buffer_id: BufferId::from_raw(9), left: vec![StatuslineSegment { diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 92c287b..837d242 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -888,10 +888,10 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { panic!("timed out waiting for {what}"); } - // Tripwire: a wire bump must be a conscious edit here. v23 is - // `MinibufferPromptRows` (Discovery Stage 2); v22 was - // `LineWrapFacts` (long-lines Stage 3). - assert_eq!(PROTOCOL_VERSION, 23); + // Tripwire: a wire bump must be a conscious edit here. v24 is + // `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows` + // (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3). + assert_eq!(PROTOCOL_VERSION, 24); let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( &[ ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),