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:
parent
7723f51f12
commit
6e47fb4725
|
|
@ -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
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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 { .. })
|
||||
|
|
|
|||
|
|
@ -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:?}"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue