From 6e47fb47257921c87d616c2bdf31a1fb16ca6377 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 14:46:43 -0400 Subject: [PATCH] regex-search: GUI regex prompt + protocol v10 (Q#RX5/RX6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries regex mode to the GUI status band and lets the GUI start a regex search. SearchPrompt gains `regex` + `invalid` (protocol v10; SUPPORTED grows to [6,7,8,9,10]). The fields changed that variant's encoding, so the daemon's per-session gate moves from >= 9 to >= 10 — a v9 peer negotiates v9 and is simply sent no SearchPrompt (the decorations still highlight) rather than mis-decoding the wider shape. The producer fills both from the active SearchSession. GUI: `is_search_entry_chord` also forwards C-M-s / C-M-r (Ctrl+Alt) so a regex search can start; M-r (the toggle) already round-trips via the intercept path once a search runs. The status band reads `Regex I-search:` in regex mode and `[invalid]` when the pattern won't compile. Multi-line regex matches needed no GUI change — push_glyph_extent_rects already fans a byte range across lines. Tests: SearchPrompt postcard round-trip extended to regex/invalid shapes; protocol version pin 9→10 + ladder grows to v10; GUI entry chord accepts C-s/C-r and C-M-s/C-M-r. (last_search_prompt's 5-tuple factored into a SearchPromptFacts alias to satisfy type_complexity.) Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 55 +++++++++++++++++++++++------------ pmacs-protocol/src/message.rs | 30 +++++++++++++++---- src/daemon.rs | 8 +++-- src/protocol.rs | 41 ++++++++++++++++---------- src/semantic_render.rs | 31 +++++++++++--------- 5 files changed, 111 insertions(+), 54 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 35abe0a..41edc39 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -551,14 +551,16 @@ struct StatusFactsLocal { diag_warnings: u32, } -/// The live incremental-search prompt (Q#SR5, protocol v9), mirrored -/// from a `SearchPrompt` message whose `query` was `Some`. +/// The live incremental-search prompt (Q#SR5/Q#RX6, protocol v10), +/// mirrored from a `SearchPrompt` message whose `query` was `Some`. #[derive(Clone, Debug, PartialEq, Eq)] struct SearchPromptLocal { buffer_id: BufferId, query: String, active: Option, total: u32, + regex: bool, + invalid: bool, } /// pmacs-gpu's own cursor position, mirrored from `CursorByte`. @@ -2097,23 +2099,27 @@ impl State { self.window.request_redraw(); None } - // Q#SR5 — the live isearch prompt (protocol v9). `query: - // None` clears the band (search ended); `Some` shows - // `I-search: (n/m)` on the band's left side. The - // matches themselves arrive as SearchMatch decorations and - // the keys round-trip via the DispatchIdle gate, so this + // Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10). + // `query: None` clears the band (search ended); `Some` shows + // `[Regex] I-search: (n/m)` on the band's left side. + // The matches themselves arrive as SearchMatch decorations + // and the keys round-trip via the intercept gate, so this // handler only drives the prompt text. InstanceMessage::SearchPrompt { buffer_id, query, active, total, + regex, + invalid, } => { self.search_prompt = query.map(|q| SearchPromptLocal { buffer_id, query: q, active, total, + regex, + invalid, }); self.window.request_redraw(); None @@ -2628,14 +2634,21 @@ impl State { .as_ref() .filter(|s| Some(s.buffer_id) == self.current_buffer_id) { + let label = if sp.regex { + "Regex I-search: " + } else { + "I-search: " + }; let count = if sp.query.is_empty() { String::new() + } else if sp.invalid { + " [invalid]".to_string() } else if sp.total == 0 { " [no match]".to_string() } else { format!(" ({}/{})", sp.active.map_or(0, |a| a + 1), sp.total) }; - return format!("I-search: {}{}", sp.query, count); + return format!("{}{}{}", label, sp.query, count); } match self .status_facts @@ -4126,12 +4139,14 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { ) } -/// `C-s` / `C-r` — the chords that begin an incremental search -/// (Q#SR5). Forwarded even when idle (they are otherwise withheld as -/// command chords by [`should_forward_key`]) so a search can start; -/// once it is running every key round-trips via the intercept path. +/// The chords that begin an incremental search: `C-s` / `C-r` (literal) +/// and `C-M-s` / `C-M-r` (regex, Q#RX5). Forwarded even when idle (they +/// are otherwise withheld as command chords by [`should_forward_key`]) +/// so a search can start; once it is running every key round-trips via +/// the intercept path (including `M-r`, the regex toggle). fn is_search_entry_chord(key: ProtocolKey, mods: Modifiers) -> bool { - mods == Modifiers::CTRL && matches!(key, ProtocolKey::Char('s' | 'r')) + matches!(key, ProtocolKey::Char('s' | 'r')) + && (mods == Modifiers::CTRL || mods == Modifiers::CTRL | Modifiers::ALT) } fn is_plain_text_modifiers(mods: Modifiers) -> bool { @@ -5175,9 +5190,11 @@ mod tests { } #[test] - fn search_entry_chord_is_ctrl_s_or_ctrl_r_only() { - // C-s / C-r start a search (Q#SR5) — forwarded even though - // `should_forward_key` withholds them as command chords. + fn search_entry_chord_is_ctrl_or_ctrl_alt_s_r() { + // C-s / C-r (literal) and C-M-s / C-M-r (regex, Q#RX5) start a + // search — forwarded even though `should_forward_key` withholds + // them as command chords. + let ctrl_alt = Modifiers::CTRL | Modifiers::ALT; assert!(is_search_entry_chord( ProtocolKey::Char('s'), Modifiers::CTRL @@ -5186,11 +5203,13 @@ mod tests { ProtocolKey::Char('r'), Modifiers::CTRL )); + assert!(is_search_entry_chord(ProtocolKey::Char('s'), ctrl_alt)); + assert!(is_search_entry_chord(ProtocolKey::Char('r'), ctrl_alt)); assert!( !should_forward_key(ProtocolKey::Char('s'), Modifiers::CTRL), "C-s is otherwise a withheld chord; the search-entry path is what forwards it" ); - // Other Ctrl chords, and C-s without Ctrl, are not entry chords. + // Other Ctrl chords, and s/r without Ctrl, are not entry chords. assert!(!is_search_entry_chord( ProtocolKey::Char('x'), Modifiers::CTRL @@ -5201,7 +5220,7 @@ mod tests { )); assert!(!is_search_entry_chord( ProtocolKey::Char('s'), - Modifiers::CTRL | Modifiers::ALT + Modifiers::ALT )); } diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 1504f54..e153eec 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -823,9 +823,11 @@ pub enum InstanceMessage { /// `None` means no search is running — the frontend hides the band. /// /// Emitted by the semantic producer when the search state changes - /// (cached-compare suppressed, like [`Self::StatusFacts`]). Kept - /// off wires negotiated `< 9` by the daemon's per-session filter - /// (additive variant — an older peer would hard-error decoding it). + /// (cached-compare suppressed, like [`Self::StatusFacts`]). The + /// `regex` / `invalid` fields (Q#RX6) changed this variant's + /// encoding, so the daemon's per-session filter now keeps it off + /// wires negotiated `< 10` (was `< 9` for the original four-field + /// shape — see `PROTOCOL_VERSION`). SearchPrompt { /// Buffer the search is anchored in (the active buffer). buffer_id: crate::BufferId, @@ -837,6 +839,13 @@ pub enum InstanceMessage { active: Option, /// Total number of matches for the current query. total: u32, + /// `true` when the search is in regex mode (Q#RX3) — the + /// frontend prefixes the prompt with `Regex `. + regex: bool, + /// `true` when a regex pattern failed to compile — the frontend + /// shows `[invalid]` instead of a match count. Always `false` + /// in literal mode. + invalid: bool, }, } @@ -1098,7 +1107,13 @@ pub enum ResourceBody { /// Q#SR5 (incremental search): bumped from 8 to 9 for /// [`InstanceMessage::SearchPrompt`]. Additive and daemon-gated per /// session, identical shape to the `StatusFacts` (v8) bump. -pub const PROTOCOL_VERSION: u32 = 9; +/// +/// Q#RX6 (regex search): bumped from 9 to 10 — `SearchPrompt` gained +/// `regex` / `invalid` fields, changing that variant's postcard +/// encoding. Still daemon-gated per session (now at `< 10`); a v9 peer +/// negotiates v9 and simply receives no `SearchPrompt` (the decorations +/// still highlight), rather than mis-decoding the wider shape. +pub const PROTOCOL_VERSION: u32 = 10; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1141,7 +1156,12 @@ pub const PROTOCOL_VERSION: u32 = 9; /// /// Q#SR5: extended to `[6, 7, 8, 9]`. `InstanceMessage::SearchPrompt` /// is additive and daemon-gated per session. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9]; +/// +/// Q#RX6: extended to `[6, 7, 8, 9, 10]`. `SearchPrompt` gained +/// `regex` / `invalid` (encoding change to that variant); v9 and v10 +/// interoperate because the variant is daemon-gated per session, so a +/// v9 peer is simply never sent the wider shape. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. diff --git a/src/daemon.rs b/src/daemon.rs index 84b2c2a..1e6513f 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1021,11 +1021,13 @@ fn dispatcher_loop( let peer_knows_status_facts = session_registry .session_state(*fid) .is_some_and(|s| s.negotiated_protocol_version >= 8); - // Q#SR5 — `SearchPrompt` is a v9 variant; gate it the - // same way so an < 9 peer never sees the new shape. + // Q#SR5 / Q#RX6 — `SearchPrompt` gained regex/invalid + // fields in v10 (encoding change); gate at >= 10 so a v9 + // peer is sent no SearchPrompt rather than the wider + // shape it would mis-decode. let peer_knows_search_prompt = session_registry .session_state(*fid) - .is_some_and(|s| s.negotiated_protocol_version >= 9); + .is_some_and(|s| s.negotiated_protocol_version >= 10); for msg in &messages { if !peer_knows_status_facts && matches!(msg, InstanceMessage::StatusFacts { .. }) diff --git a/src/protocol.rs b/src/protocol.rs index 7d1a653..52a579d 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_nine_for_search_prompt() { + fn protocol_version_is_ten_for_regex_search_prompt() { // 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 @@ -1696,7 +1696,9 @@ mod tests { // Q#S1 bumped 7→8 (`InstanceMessage::StatusFacts`, additive // + daemon-gated per session). Q#SR5 bumped 8→9 // (`InstanceMessage::SearchPrompt`, additive + daemon-gated). - assert_eq!(PROTOCOL_VERSION, 9); + // Q#RX6 bumped 9→10 (`SearchPrompt` gained regex/invalid; + // encoding change to that variant, still daemon-gated). + assert_eq!(PROTOCOL_VERSION, 10); } #[test] @@ -1705,18 +1707,19 @@ mod tests { // every cell-carrying message, ending the v1–v5 ladder — // pre-v6 peers are refused at the handshake (a clean // VersionMismatch) rather than garbling postcard mid-session. - // Q#M4 / Q#S1 / Q#SR5: the ladder resumes above that floor — v7 - // (`TripleDown`, frontend-gated), v8 (`StatusFacts`) and v9 - // (`SearchPrompt`, both daemon-gated) are additive, so v6 - // through v9 interoperate. + // Q#M4 / Q#S1 / Q#SR5 / Q#RX6: the ladder resumes above that + // floor — v7 (`TripleDown`, frontend-gated), v8 (`StatusFacts`), + // v9 + v10 (`SearchPrompt` and its regex/invalid extension, both + // daemon-gated) interoperate, so v6 through v10 talk. assert!(is_supported_protocol_version(6)); assert!(is_supported_protocol_version(7)); assert!(is_supported_protocol_version(8)); assert!(is_supported_protocol_version(9)); - for rejected in [0, 1, 2, 3, 4, 5, 10, u32::MAX] { + assert!(is_supported_protocol_version(10)); + for rejected in [0, 1, 2, 3, 4, 5, 11, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v9 binary" + "v{rejected} must be rejected by a v10 binary" ); } } @@ -1882,20 +1885,24 @@ mod tests { #[test] fn search_prompt_round_trips_through_postcard() { - // Q#SR5 — the v9 wire variant. Cover the three shapes the - // producer emits: an active search with matches, a failing - // search (active=None), and a cleared band (query=None). + // Q#SR5 / Q#RX6 — the v10 wire variant. Cover the shapes the + // producer emits: an active literal search with matches, an + // invalid regex (regex=true, invalid=true, no matches), a regex + // with matches, and a cleared band (query=None). let cases = [ - (Some("foo".to_owned()), Some(2u32), 5u32), - (Some("zzz".to_owned()), None, 0u32), - (None, None, 0u32), + (Some("foo".to_owned()), Some(2u32), 5u32, false, false), + (Some("foo(".to_owned()), None, 0u32, true, true), + (Some(r"\d".to_owned()), Some(0u32), 3u32, true, false), + (None, None, 0u32, false, false), ]; - for (query, active, total) in cases { + for (query, active, total, regex, invalid) in cases { let msg = InstanceMessage::SearchPrompt { buffer_id: crate::buffer::BufferId::next(), query: query.clone(), active, total, + regex, + invalid, }; let bytes = postcard::to_allocvec(&msg).expect("encode"); let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); @@ -1904,11 +1911,15 @@ mod tests { query: q, active: a, total: t, + regex: rx, + invalid: inv, .. } => { assert_eq!(q, query); assert_eq!(a, active); assert_eq!(t, total); + assert_eq!(rx, regex); + assert_eq!(inv, invalid); } other => panic!("expected SearchPrompt, got {other:?}"), } diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 990baa5..d0116f1 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -76,6 +76,11 @@ struct LastFrame { generation: u64, } +/// Cached `SearchPrompt` payload for cached-compare suppression +/// (Q#SR5 / Q#RX6): `(query, active, total, regex, invalid)`. A `None` +/// query means the last emission cleared the band. +type SearchPromptFacts = (Option, Option, u32, bool, bool); + /// Owns one `semantic_render` session's projection state: the last /// viewport the frontend declared, and the diff baseline per buffer /// for the `StyleSpans` and `Decorations` families. @@ -121,10 +126,9 @@ pub struct SemanticRenderState { /// `(name, modified, diag_errors, diag_warnings)` last emitted as /// `StatusFacts` (Q#S1) — cached-compare suppression. last_status: HashMap, - /// `(query, active, total)` last emitted as `SearchPrompt` - /// (Q#SR5) — cached-compare suppression. A `None` query means the - /// last emission cleared the band (no active search). - last_search_prompt: HashMap, Option, u32)>, + /// Last emitted `SearchPrompt` payload per buffer, for + /// cached-compare suppression (see [`SearchPromptFacts`]). + last_search_prompt: HashMap, /// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs /// the tree-sitter highlights query over the *whole declared /// viewport* (which the GPU frontend sets to the entire buffer) @@ -426,18 +430,17 @@ impl SemanticRenderState { Some(core.search_query().to_owned()), active_idx.and_then(|i| u32::try_from(i).ok()), u32::try_from(total).unwrap_or(u32::MAX), + core.search_is_regex(), + core.search_is_invalid(), ) } else { - // No search → a cleared band. active/total are zeroed so - // the inactive state is one canonical tuple (the GPU only - // reads them when `query` is `Some`). The accepted matches - // keep highlighting via Decorations regardless. - (None, None, 0) + // No search → a cleared band. active/total/regex/invalid + // are zeroed so the inactive state is one canonical tuple + // (the GPU only reads them when `query` is `Some`). The + // accepted matches keep highlighting via Decorations. + (None, None, 0, false, false) } }; - if self.last_search_prompt.get(&buffer_id) == Some(&facts) { - return None; - } let cached = self.last_search_prompt.get(&buffer_id); if cached == Some(&facts) { return None; @@ -456,6 +459,8 @@ impl SemanticRenderState { query: facts.0.clone(), active: facts.1, total: facts.2, + regex: facts.3, + invalid: facts.4, }; self.last_search_prompt.insert(buffer_id, facts); Some(msg) @@ -3096,7 +3101,7 @@ mod tests { // Begin + type "foo": the live query + active/total ship. { let mut core = state.core.borrow_mut(); - core.search_begin(true); + core.search_begin(true, false); for ch in "foo".chars() { core.search_input_char(ch); }