search: GPU isearch surface (Q#SR5, protocol v9)
Brings incremental search to pmacs-gpu, which has no minibuffer, by
reusing the shared daemon-side search core from the previous commit.
Key routing needs no new mechanism: `dispatch_idle` now also reports
false while a search is running, so the GPU's existing M11.6
optimistic-apply gate round-trips every keystroke to the daemon —
where `dispatch_search_key` extends the query / steps — instead of
self-inserting it. The match highlights were already wired (commit
2's SearchMatch / SearchMatchActive decoration colors), so they
light up live the moment keys round-trip.
The one thing a semantic frontend can't derive locally is the query
text, so a new additive `InstanceMessage::SearchPrompt { buffer_id,
query, active, total }` carries it (protocol v9, SUPPORTED grows to
[6,7,8,9]). The producer emits it cached-compare-suppressed like
StatusFacts — `query: Some` while searching, `None` to clear on
accept/cancel (matches keep highlighting via decorations), and
stays silent on a fresh buffer that never searched. The daemon's
per-session filter keeps the variant off wires negotiated < 9. The
GPU mirrors it into the status band: while searching, the band's
left side shows `I-search: <query> (n/m)` (or `[no match]`) in
place of the buffer name, returning to the name when the search
ends.
Tests: protocol version pin + SearchPrompt postcard round-trip
(active / failing / cleared shapes); producer emit-on-change +
suppress + clear-on-accept + first-sight silence; dispatch_idle
flips false during search (the GPU round-trip contract).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
58b68f6ac0
commit
5111ae82e7
|
|
@ -529,6 +529,12 @@ struct State {
|
|||
status_left_text: String,
|
||||
/// Q#S1 — the wire-authoritative status facts (protocol v8).
|
||||
status_facts: Option<StatusFactsLocal>,
|
||||
/// Q#SR5 — the live incremental-search prompt (protocol v9), or
|
||||
/// `None` when no search is running. While `Some`, the status
|
||||
/// band's left side shows `I-search: <query> (n/m)` in place of
|
||||
/// the buffer name; the matches highlight via `SearchMatch`
|
||||
/// decorations.
|
||||
search_prompt: Option<SearchPromptLocal>,
|
||||
/// Minimap vertex bytes cached by [`MinimapCacheKey`] —
|
||||
/// rebuilding rescanned every line shape per frame.
|
||||
minimap_cache: Option<(MinimapCacheKey, Vec<u8>)>,
|
||||
|
|
@ -545,6 +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`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct SearchPromptLocal {
|
||||
buffer_id: BufferId,
|
||||
query: String,
|
||||
active: Option<u32>,
|
||||
total: u32,
|
||||
}
|
||||
|
||||
/// pmacs-gpu's own cursor position, mirrored from `CursorByte`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct OwnCursor {
|
||||
|
|
@ -1417,6 +1433,7 @@ impl State {
|
|||
status_left_buffer,
|
||||
status_left_text: String::new(),
|
||||
status_facts: None,
|
||||
search_prompt: None,
|
||||
minimap_cache: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -2010,6 +2027,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
|
||||
// handler only drives the prompt text.
|
||||
InstanceMessage::SearchPrompt {
|
||||
buffer_id,
|
||||
query,
|
||||
active,
|
||||
total,
|
||||
} => {
|
||||
self.search_prompt = query.map(|q| SearchPromptLocal {
|
||||
buffer_id,
|
||||
query: q,
|
||||
active,
|
||||
total,
|
||||
});
|
||||
self.window.request_redraw();
|
||||
None
|
||||
}
|
||||
// Session 9.3 — peer presence. The editing frontend's
|
||||
// cursor + selection drive the `CurrentLine` / `Selection`
|
||||
// washes for this read-only mirror (finding QB1). Store
|
||||
|
|
@ -2510,9 +2548,23 @@ impl State {
|
|||
spans
|
||||
}
|
||||
|
||||
/// The band's left side: buffer name + modified dot, from the
|
||||
/// v8 `StatusFacts` (empty until the daemon ships them).
|
||||
/// The band's left side. While an incremental search is running
|
||||
/// (Q#SR5) it shows `I-search: <query> (n/m)` — the prompt takes
|
||||
/// over the band like Emacs's echo area, returning to the buffer
|
||||
/// name + modified dot (v8 `StatusFacts`) when the search ends.
|
||||
fn compose_status_left(&self) -> String {
|
||||
if let Some(sp) = self
|
||||
.search_prompt
|
||||
.as_ref()
|
||||
.filter(|s| Some(s.buffer_id) == self.current_buffer_id)
|
||||
{
|
||||
let count = 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);
|
||||
}
|
||||
match self
|
||||
.status_facts
|
||||
.as_ref()
|
||||
|
|
@ -3885,6 +3937,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
|
|||
InstanceMessage::InlineAdornments { .. } => "InlineAdornments",
|
||||
InstanceMessage::FileStyleSummary { .. } => "FileStyleSummary",
|
||||
InstanceMessage::StatusFacts { .. } => "StatusFacts",
|
||||
InstanceMessage::SearchPrompt { .. } => "SearchPrompt",
|
||||
InstanceMessage::BlockAdornments { .. } => "BlockAdornments",
|
||||
InstanceMessage::FoldState { .. } => "FoldState",
|
||||
InstanceMessage::ResourceOffer { .. } => "ResourceOffer",
|
||||
|
|
|
|||
|
|
@ -814,6 +814,30 @@ pub enum InstanceMessage {
|
|||
/// frontend must round-trip via [`FrontendEvent::Key`].
|
||||
idle: bool,
|
||||
},
|
||||
/// Q#SR5 (incremental search, protocol v9) — the live isearch
|
||||
/// prompt for a semantic frontend that cannot host a minibuffer.
|
||||
/// Carries the query as typed and the match readout so the
|
||||
/// frontend can render an `I-search: <query> (n/m)` band; the
|
||||
/// matches themselves arrive as [`DecorationKind::SearchMatch`] /
|
||||
/// [`DecorationKind::SearchMatchActive`] decorations. A `query` of
|
||||
/// `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).
|
||||
SearchPrompt {
|
||||
/// Buffer the search is anchored in (the active buffer).
|
||||
buffer_id: crate::BufferId,
|
||||
/// The query as typed so far, or `None` when no search runs.
|
||||
/// `Some("")` is a freshly-started search with an empty query.
|
||||
query: Option<String>,
|
||||
/// 0-based index of the active match, or `None` when the query
|
||||
/// has no matches (a failing search).
|
||||
active: Option<u32>,
|
||||
/// Total number of matches for the current query.
|
||||
total: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// Flat selection state for the wire.
|
||||
|
|
@ -1070,7 +1094,11 @@ pub enum ResourceBody {
|
|||
/// *daemon* this time (the variant travels instance→frontend): the
|
||||
/// per-session filter keeps it off wires negotiated `< 8`, the same
|
||||
/// shape as the `DispatchIdle` (v4) gate.
|
||||
pub const PROTOCOL_VERSION: u32 = 8;
|
||||
///
|
||||
/// 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;
|
||||
|
||||
/// 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
|
||||
|
|
@ -1110,7 +1138,10 @@ pub const PROTOCOL_VERSION: u32 = 8;
|
|||
///
|
||||
/// Q#S1: extended to `[6, 7, 8]`. `InstanceMessage::StatusFacts` is
|
||||
/// additive and daemon-gated per session.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8];
|
||||
///
|
||||
/// 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];
|
||||
|
||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
||||
|
|
|
|||
|
|
@ -1021,12 +1021,22 @@ 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.
|
||||
let peer_knows_search_prompt = session_registry
|
||||
.session_state(*fid)
|
||||
.is_some_and(|s| s.negotiated_protocol_version >= 9);
|
||||
for msg in &messages {
|
||||
if !peer_knows_status_facts
|
||||
&& matches!(msg, InstanceMessage::StatusFacts { .. })
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !peer_knows_search_prompt
|
||||
&& matches!(msg, InstanceMessage::SearchPrompt { .. })
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
|
||||
// jitter site: render-write latency.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -450,7 +450,8 @@ impl EditorState {
|
|||
/// - the dispatcher holds a pending multi-key prefix (e.g. the
|
||||
/// user has typed `C-x` and the daemon is waiting for the next
|
||||
/// chord), or
|
||||
/// - a minibuffer prompt is active and absorbing keys.
|
||||
/// - a minibuffer prompt is active and absorbing keys, or
|
||||
/// - an incremental search is running and absorbing keys (Q#SR5).
|
||||
///
|
||||
/// Used by the daemon to drive the `InstanceMessage::DispatchIdle`
|
||||
/// wire signal that gates `crdt_replica` frontends' optimistic-apply
|
||||
|
|
@ -458,13 +459,18 @@ impl EditorState {
|
|||
/// plain-char keystroke into the active document while the
|
||||
/// daemon's actual intent is to route the keystroke into the
|
||||
/// minibuffer prompt — the M10.10 "documented limitation" that
|
||||
/// surfaced during session-5 manual validation.
|
||||
/// surfaced during session-5 manual validation. Isearch reuses the
|
||||
/// exact same gate: while a search runs every keystroke must
|
||||
/// round-trip so the daemon's `dispatch_search_key` receives it
|
||||
/// (extend the query / step) instead of the frontend self-inserting
|
||||
/// it into the buffer.
|
||||
#[must_use]
|
||||
pub fn dispatch_idle(&self) -> bool {
|
||||
if !self.dispatcher.pending().is_empty() {
|
||||
return false;
|
||||
}
|
||||
!self.core.borrow().minibuffer.is_active()
|
||||
let core = self.core.borrow();
|
||||
!core.minibuffer.is_active() && !core.search_active()
|
||||
}
|
||||
|
||||
/// `frontend_id` records which frontend produced the event. v0.1
|
||||
|
|
@ -2100,6 +2106,21 @@ mod tests {
|
|||
assert_eq!(s.core.borrow().search_query(), "foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isearch_flips_dispatch_idle_so_gpu_round_trips() {
|
||||
// The GPU's optimistic-apply gate (M11.6) keys off dispatch_idle.
|
||||
// An active isearch must drive it false so the GPU round-trips
|
||||
// keystrokes to the daemon's dispatch_search_key instead of
|
||||
// self-inserting them — the shared-core contract for Q#SR5.
|
||||
let mut s = fresh_with(b"foo foo");
|
||||
assert!(s.dispatch_idle(), "idle before any search");
|
||||
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
|
||||
assert!(s.core.borrow().search_active());
|
||||
assert!(!s.dispatch_idle(), "search active ⇒ keys must round-trip");
|
||||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Enter)); // accept
|
||||
assert!(s.dispatch_idle(), "search ended ⇒ optimistic apply resumes");
|
||||
}
|
||||
|
||||
// ---- T M11.6 — DispatchIdle ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -384,6 +384,10 @@ impl Frontend {
|
|||
| InstanceMessage::FoldState { .. }
|
||||
| InstanceMessage::FileStyleSummary { .. }
|
||||
| InstanceMessage::StatusFacts { .. }
|
||||
// Q#SR5 — SearchPrompt is a semantic-frontend status-band
|
||||
// family member; the cell-grid TUI never negotiates it and
|
||||
// drops it silently if one arrives.
|
||||
| InstanceMessage::SearchPrompt { .. }
|
||||
| InstanceMessage::ResourceOffer { .. }
|
||||
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
|
||||
// optimistic-apply gate; if any reaches this render path
|
||||
|
|
|
|||
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
|||
// --- M5.5a handshake & postcard round-trips ---
|
||||
|
||||
#[test]
|
||||
fn protocol_version_is_eight_for_status_facts() {
|
||||
fn protocol_version_is_nine_for_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
|
||||
|
|
@ -1694,8 +1694,9 @@ mod tests {
|
|||
// making v6 the ladder's encoding floor. Q#M4 bumped 6→7
|
||||
// (`PointerKind::TripleDown`, additive + frontend-gated).
|
||||
// Q#S1 bumped 7→8 (`InstanceMessage::StatusFacts`, additive
|
||||
// + daemon-gated per session).
|
||||
assert_eq!(PROTOCOL_VERSION, 8);
|
||||
// + daemon-gated per session). Q#SR5 bumped 8→9
|
||||
// (`InstanceMessage::SearchPrompt`, additive + daemon-gated).
|
||||
assert_eq!(PROTOCOL_VERSION, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1704,16 +1705,18 @@ 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: the ladder resumes above that floor — v7
|
||||
// (`TripleDown`, frontend-gated) and v8 (`StatusFacts`,
|
||||
// daemon-gated) are additive, so v6 through v8 interoperate.
|
||||
// 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.
|
||||
assert!(is_supported_protocol_version(6));
|
||||
assert!(is_supported_protocol_version(7));
|
||||
assert!(is_supported_protocol_version(8));
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 9, u32::MAX] {
|
||||
assert!(is_supported_protocol_version(9));
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 10, u32::MAX] {
|
||||
assert!(
|
||||
!is_supported_protocol_version(rejected),
|
||||
"v{rejected} must be rejected by a v8 binary"
|
||||
"v{rejected} must be rejected by a v9 binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1877,6 +1880,41 @@ 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).
|
||||
let cases = [
|
||||
(Some("foo".to_owned()), Some(2u32), 5u32),
|
||||
(Some("zzz".to_owned()), None, 0u32),
|
||||
(None, None, 0u32),
|
||||
];
|
||||
for (query, active, total) in cases {
|
||||
let msg = InstanceMessage::SearchPrompt {
|
||||
buffer_id: crate::buffer::BufferId::next(),
|
||||
query: query.clone(),
|
||||
active,
|
||||
total,
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||||
match decoded {
|
||||
InstanceMessage::SearchPrompt {
|
||||
query: q,
|
||||
active: a,
|
||||
total: t,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(q, query);
|
||||
assert_eq!(a, active);
|
||||
assert_eq!(t, total);
|
||||
}
|
||||
other => panic!("expected SearchPrompt, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_event_to_crossterm_round_trips() {
|
||||
// Build a protocol KeyEvent, translate to crossterm, translate
|
||||
|
|
|
|||
|
|
@ -121,6 +121,10 @@ 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)>,
|
||||
/// `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)
|
||||
|
|
@ -189,6 +193,7 @@ impl SemanticRenderState {
|
|||
last_sent: HashMap::new(),
|
||||
last_decorations: HashMap::new(),
|
||||
last_adornments: HashMap::new(),
|
||||
last_search_prompt: HashMap::new(),
|
||||
last_summary: HashMap::new(),
|
||||
last_status: HashMap::new(),
|
||||
last_style_gate: HashMap::new(),
|
||||
|
|
@ -389,9 +394,73 @@ impl SemanticRenderState {
|
|||
out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation));
|
||||
// --- StatusFacts (status band; Q#S1, protocol v8) ---
|
||||
out.extend(self.status_facts_msg(state, vp.buffer_id));
|
||||
// --- SearchPrompt (isearch band; Q#SR5, protocol v9) ---
|
||||
out.extend(self.search_prompt_msg(state, vp.buffer_id));
|
||||
out
|
||||
}
|
||||
|
||||
/// The `SearchPrompt` message for this frame, or `None` when the
|
||||
/// search state for `buffer_id` is unchanged. Only the active
|
||||
/// buffer carries a live prompt: a search shadows dispatch, so it
|
||||
/// always runs in the active buffer, and emitting for that buffer's
|
||||
/// viewport keeps the per-buffer cached-compare honest. When no
|
||||
/// search runs the active buffer emits `query: None` once (to clear
|
||||
/// the frontend's band), then stays silent. The daemon's write loop
|
||||
/// keeps the variant off wires negotiated `< 9`.
|
||||
fn search_prompt_msg(
|
||||
&mut self,
|
||||
state: &EditorState,
|
||||
buffer_id: BufferId,
|
||||
) -> Option<InstanceMessage> {
|
||||
// Off-active-buffer viewports never touch the search band — the
|
||||
// active buffer owns it. (Without this, switching buffers mid-
|
||||
// session would let an inactive viewport clobber the cache.)
|
||||
let facts = {
|
||||
let core = state.core.borrow();
|
||||
if buffer_id != core.active_buffer_id() {
|
||||
return None;
|
||||
}
|
||||
if core.search_active() {
|
||||
let (active_idx, total) = core.search_match_summary();
|
||||
(
|
||||
Some(core.search_query().to_owned()),
|
||||
active_idx.and_then(|i| u32::try_from(i).ok()),
|
||||
u32::try_from(total).unwrap_or(u32::MAX),
|
||||
)
|
||||
} 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)
|
||||
}
|
||||
};
|
||||
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;
|
||||
}
|
||||
// First sight of this buffer with no active search: there is
|
||||
// nothing to clear, so stay silent rather than ship an empty
|
||||
// band on every fresh buffer. Record the baseline so a *later*
|
||||
// search→clear transition still diffs. (Mirrors the inline-
|
||||
// adornments "speak only if there's something to show" rule.)
|
||||
if cached.is_none() && facts.0.is_none() {
|
||||
self.last_search_prompt.insert(buffer_id, facts);
|
||||
return None;
|
||||
}
|
||||
let msg = InstanceMessage::SearchPrompt {
|
||||
buffer_id,
|
||||
query: facts.0.clone(),
|
||||
active: facts.1,
|
||||
total: facts.2,
|
||||
};
|
||||
self.last_search_prompt.insert(buffer_id, facts);
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
/// The `StatusFacts` message for this frame, or `None` when
|
||||
/// nothing changed. Carries the facts a semantic frontend cannot
|
||||
/// derive locally: buffer name, modified flag, whole-file
|
||||
|
|
@ -1462,8 +1531,8 @@ mod tests {
|
|||
|
||||
/// All `InstanceMessage` variants the semantic projection may
|
||||
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
|
||||
/// `FileStyleSummary`, or `StatusFacts` (Q#S1) — never
|
||||
/// `CellDelta`, grid `Cursor`, or the still-unwired
|
||||
/// `FileStyleSummary`, `StatusFacts` (Q#S1), or `SearchPrompt`
|
||||
/// (Q#SR5) — never `CellDelta`, grid `Cursor`, or the still-unwired
|
||||
/// `BlockAdornments` / `FoldState` families.
|
||||
fn assert_semantic_only(msgs: &[InstanceMessage]) {
|
||||
for m in msgs {
|
||||
|
|
@ -1475,6 +1544,7 @@ mod tests {
|
|||
| InstanceMessage::InlineAdornments { .. }
|
||||
| InstanceMessage::FileStyleSummary { .. }
|
||||
| InstanceMessage::StatusFacts { .. }
|
||||
| InstanceMessage::SearchPrompt { .. }
|
||||
),
|
||||
"semantic projection emitted an unexpected variant: {m:?}"
|
||||
);
|
||||
|
|
@ -2985,6 +3055,77 @@ mod tests {
|
|||
})
|
||||
}
|
||||
|
||||
fn search_prompt_of(msgs: &[InstanceMessage]) -> Option<(Option<String>, Option<u32>, u32)> {
|
||||
msgs.iter().find_map(|m| match m {
|
||||
InstanceMessage::SearchPrompt {
|
||||
query,
|
||||
active,
|
||||
total,
|
||||
..
|
||||
} => Some((query.clone(), *active, *total)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prompt_emits_on_change_and_clears_on_finish() {
|
||||
let state = empty_state();
|
||||
let mut s = local();
|
||||
let bid = active_buffer(&state);
|
||||
// Three "foo" matches.
|
||||
{
|
||||
let core = state.core.borrow();
|
||||
core.registry
|
||||
.clone()
|
||||
.borrow_mut()
|
||||
.get_mut(bid)
|
||||
.expect("active buffer")
|
||||
.apply_edit(crate::buffer::EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"foo foo foo",
|
||||
})
|
||||
.expect("seed");
|
||||
}
|
||||
s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0);
|
||||
|
||||
// No search yet: any prompt that ships carries a cleared query.
|
||||
if let Some((q, _, _)) = search_prompt_of(&s.render_frame(&state)) {
|
||||
assert!(q.is_none(), "no search ⇒ no live query");
|
||||
}
|
||||
|
||||
// Begin + type "foo": the live query + active/total ship.
|
||||
{
|
||||
let mut core = state.core.borrow_mut();
|
||||
core.search_begin(true);
|
||||
for ch in "foo".chars() {
|
||||
core.search_input_char(ch);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
search_prompt_of(&s.render_frame(&state)),
|
||||
Some((Some("foo".to_owned()), Some(0), 3)),
|
||||
"live isearch ships query + (active, total)"
|
||||
);
|
||||
// Unchanged → suppressed (cached-compare).
|
||||
assert!(search_prompt_of(&s.render_frame(&state)).is_none());
|
||||
|
||||
// Step: active index advances and re-emits.
|
||||
state.core.borrow_mut().search_step(true);
|
||||
assert_eq!(
|
||||
search_prompt_of(&s.render_frame(&state)),
|
||||
Some((Some("foo".to_owned()), Some(1), 3))
|
||||
);
|
||||
|
||||
// Accept: the prompt band clears (query None) even though the
|
||||
// matches stay in the store for navigation + highlight.
|
||||
state.core.borrow_mut().search_finish(true);
|
||||
assert_eq!(
|
||||
search_prompt_of(&s.render_frame(&state)),
|
||||
Some((None, None, 0)),
|
||||
"accept clears the prompt band"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_facts_emit_on_change_and_freeze_counts_while_stale() {
|
||||
let state = empty_state();
|
||||
|
|
|
|||
Loading…
Reference in New Issue