regex-search: GUI regex prompt + protocol v10 (Q#RX5/RX6)

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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-27 14:46:43 -04:00
parent 7723f51f12
commit 6e47fb4725
5 changed files with 111 additions and 54 deletions

View File

@ -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<u32>,
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: <query> (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: <query> (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
));
}

View File

@ -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<u32>,
/// 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`].

View File

@ -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 { .. })

View File

@ -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 v1v5 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:?}"),
}

View File

@ -76,6 +76,11 @@ struct LastFrame<T> {
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<String>, Option<u32>, 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<BufferId, (String, bool, u32, u32)>,
/// `(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<BufferId, (Option<String>, Option<u32>, u32)>,
/// Last emitted `SearchPrompt` payload per buffer, for
/// cached-compare suppression (see [`SearchPromptFacts`]).
last_search_prompt: HashMap<BufferId, SearchPromptFacts>,
/// `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);
}