Skip to main content

scrive_iced/
code_editor.rs

1//! `CodeEditor` — the batteries-included editor tier.
2//!
3//! [`Editor`] is a *controlled* widget: it borrows a
4//! [`Document`] immutably to draw and emits semantic [`Action`]s the host
5//! applies. That gives maximum control at the cost of a large amount of
6//! plumbing — driving highlighting, find, focus, and the intel controllers is
7//! all on the host. [`CodeEditor`] is the other end of that trade: it **owns** a
8//! `Document`, runs the plumbing internally, and reduces integration to three
9//! wires — [`update`](CodeEditor::update), [`view`](CodeEditor::view),
10//! [`subscription`](CodeEditor::subscription) — plus registering
11//! [`required_fonts`](crate::required_fonts) at startup.
12//!
13//! # Mechanism vs policy
14//!
15//! The split that keeps this honest: **mechanism** (the highlight pump, find
16//! rescan, focus rings, autoscroll, the intel drive loops) is owned here, hidden,
17//! and always-correct — a host cannot forget a step it never had to take.
18//! **Policy** (grammar, theme, providers, sizing) is a builder override with a
19//! sensible default. The one genuine fork is ownership: `CodeEditor` owns the
20//! `Document` and exposes reads plus *blessed* mutations that run the post-edit
21//! tail — there is deliberately no raw `&mut Document`, because that is the
22//! trapdoor that lets a caller mutate behind the tail's back (the exact shape of
23//! the cold-load highlight bug this tier exists to prevent). A host that must own
24//! the buffer itself drops to the [`Editor`] power tier.
25//!
26//! This module is built up across milestones; M1 is the synchronous core loop
27//! (owned document, the three wires, and the cold-load highlight fix). Find,
28//! the intel drive loops, the large-document parallel sweep, and the async
29//! intel round-trip land on top in later milestones.
30
31use std::ops::Range;
32use std::time::Instant;
33
34use iced::advanced::widget;
35use iced::alignment::{Horizontal, Vertical};
36use iced::widget::operation::{focus, focus_next, focus_previous, is_focused};
37use iced::widget::{button, column, container, row, stack, text, text_input};
38use iced::{Alignment, Color, Element, Font, Length, Shadow, Subscription, Task, Theme, Vector};
39
40use scrive_core::{
41    default_indent_size, is_completion_word_char, CompletionController, CompletionCx, CompletionItem,
42    CompletionState, CompletionTrigger, Completions, Diagnostic, DiagnosticsOutcome, Document, EditOp,
43    FindQuery, Hover,
44    HoverCx, HoverInfo, InsertText, Point, Revision, Selection, SelectionId, SelectionSet, Severity,
45    SignatureCx, SignatureHelp, SignatureInfo, Snippet, SnippetSession, SyntaxDef, TabOutcome,
46    TokenTheme, LOOKBACK_LINES,
47};
48
49use crate::editor::{Action, Editor};
50use crate::highlight_pool::{HighlightPool, PARALLEL_MIN_BYTES};
51
52/// The default widget id the editor is addressable by (focus / future
53/// multi-pane). Overridable with [`CodeEditor::id`].
54const DEFAULT_ID: &str = "scrive-editor";
55
56/// Focusable ids for the find bar's inputs. iced's `focus` operation focuses one
57/// and unfocuses every other focusable, so moving focus between an input and the
58/// editor is a proper single-focus model.
59const FIND_INPUT: &str = "scrive-find-input";
60const REPLACE_INPUT: &str = "scrive-replace-input";
61
62/// The opaque message a [`CodeEditor`] emits and consumes. The host never
63/// matches on it — it only maps it through the three wires
64/// (`self.editor.update(e).map(Message::Editor)` and the same for `view` /
65/// `subscription`). It is deliberately opaque: the internal set churns with
66/// every refactor, so host-relevant signals come through the curated read
67/// accessors and builder callbacks instead. A host that genuinely needs the raw
68/// semantic vocabulary drops to the [`Editor`] power tier, where
69/// [`Action`] is the stable, match-on-me enum.
70#[non_exhaustive]
71#[derive(Debug, Clone)]
72pub enum Event {
73    /// A semantic action published by the underlying widget.
74    Editor(Action),
75    /// One frame tick of the highlight sweep — the internal pump that drives
76    /// tokenization to convergence (and fixes cold load). Emitted by
77    /// [`subscription`](CodeEditor::subscription) only while a dirty highlight
78    /// frontier remains, so an idle document does zero per-frame work.
79    HighlightSweep,
80    // ── find bar chrome (internal; the host only maps these through) ─────────
81    /// Open the find bar (Ctrl+F), seeding the query from a single-line selection.
82    OpenFind,
83    /// Open the find bar with the replace row expanded (Ctrl+H).
84    OpenReplace,
85    /// Close the find bar (Escape), returning focus to the editor.
86    CloseFind,
87    /// The query text changed.
88    FindQuery(String),
89    /// Toggle the case-sensitive (`Aa`) option.
90    ToggleCase,
91    /// Toggle the whole-word (`ab|`) option.
92    ToggleWholeWord,
93    /// Toggle the regex (`.*`) option.
94    ToggleRegex,
95    /// Toggle find-in-selection: capture the current selection as the scope.
96    ToggleFindInSelection,
97    /// Advance to the next match.
98    FindNext,
99    /// Step to the previous match.
100    FindPrev,
101    /// Turn every match into a caret (Alt+Enter).
102    FindSelectAll,
103    /// Toggle the replace row (the chevron).
104    ToggleReplace,
105    /// The replacement text changed.
106    ReplaceText(String),
107    /// Replace the active match and advance.
108    ReplaceOne,
109    /// Replace every match in one undo step.
110    ReplaceAll,
111    /// Toggle the preserve-case (`AB`) replace option.
112    TogglePreserveCase,
113    /// Tab / Shift+Tab moved focus between the bar's inputs and the editor.
114    CycleFocus {
115        /// Whether focus moved backwards (Shift+Tab).
116        back: bool,
117    },
118    /// A left button press landed somewhere — re-assert single focus.
119    PointerDown,
120    /// A bar input gained or lost native focus; mirror it into the ring flags.
121    Focused {
122        /// Whether this is the replace input (else the find input).
123        replace: bool,
124        /// Whether it is now focused.
125        on: bool,
126    },
127}
128
129/// A batteries-included code editor: owns a [`Document`], renders the
130/// [`Editor`] widget, and runs the editing/highlighting plumbing
131/// internally. See the [module docs](self) for the mechanism-vs-policy split and
132/// the ownership fork.
133pub struct CodeEditor {
134    /// The owned document — the single source of truth. Read through
135    /// [`document`](CodeEditor::document); mutated only through the blessed
136    /// tail-running methods, never a raw `&mut`.
137    doc: Document,
138    /// The one viewport fact (last range the widget reported). It aims the
139    /// tokenize target and the highlight retention window from a single owner so
140    /// they cannot drift.
141    viewport: Range<u32>,
142    /// The syntax theme (default [`scrive_dark_theme`](crate::scrive_dark_theme)).
143    /// Retained (cheaply cloneable) so [`load`](CodeEditor::load) and
144    /// [`set_theme`](CodeEditor::set_theme) can re-apply it across a reload or
145    /// grammar swap.
146    theme: TokenTheme,
147    /// Whether a grammar has been attached — marks whether there is a highlight
148    /// cache to pump.
149    has_syntax: bool,
150    /// The editor's widget id (focus addressing).
151    id: widget::Id,
152    /// Rendering policy.
153    font: Font,
154    text_size: f32,
155    /// Monotonic clock start — the injected `now_ms` for find's debounce.
156    start: Instant,
157    /// Set by every committed edit; a host reads it for a dirty indicator via
158    /// [`is_dirty`](CodeEditor::is_dirty) / [`take_dirty`](CodeEditor::take_dirty).
159    dirty: bool,
160    /// Whether the built-in find bar (Ctrl+F / Ctrl+H) is available. Default on;
161    /// [`find`](CodeEditor::find) disables it.
162    find_enabled: bool,
163    /// Whether the find bar is currently open, and its live query text.
164    find_open: bool,
165    find_query: String,
166    /// The `Aa` / `ab|` / `.*` options. Each is part of the QUERY, not chrome:
167    /// flipping one re-scans exactly like a text change.
168    find_case: bool,
169    find_whole_word: bool,
170    find_regex: bool,
171    /// Whether the bar is expanded into find+replace (the chevron), and the live
172    /// replacement text.
173    replace_open: bool,
174    replace_text: String,
175    /// Which field wears the focus ring (a container border can't read its
176    /// field's focus, so these mirror it).
177    find_focused: bool,
178    replace_focused: bool,
179    /// The `AB` replace option — whether a replacement is re-cased to the match.
180    replace_preserve_case: bool,
181    // ── language intelligence (view-state + injected providers) ─────────────
182    /// The completion controller (popup state) and the host-supplied provider.
183    /// `None` provider ⇒ no completions. Driven on edits; its popup passes to the
184    /// widget each frame.
185    completion: CompletionController,
186    comp_provider: Option<Box<dyn Completions>>,
187    /// The active snippet tab-stop session, if any.
188    snippet: Option<SnippetSession>,
189    /// The signature-help provider and the current one-line box.
190    sig_provider: Option<Box<dyn SignatureHelp>>,
191    signature: Option<SignatureInfo>,
192    /// The hover provider and the open hover popup.
193    hover_provider: Option<Box<dyn Hover>>,
194    hover: Option<HoverInfo>,
195    /// A pending async completion request (recorded when the drive loop would
196    /// query completions but no synchronous provider is set), for the host to
197    /// pull via [`take_completion_request`](CodeEditor::take_completion_request).
198    pending_completion_request: Option<CompletionRequest>,
199    /// A pending async signature-help request (same pattern as completions).
200    pending_signature_request: Option<SignatureRequest>,
201    /// A pending async hover request (same pattern), recorded when the pointer
202    /// rested over a word and no synchronous hover provider is set.
203    pending_hover_request: Option<HoverRequest>,
204    /// The off-thread parallel highlight sweep — `Some` for a large document
205    /// (`PARALLEL_MIN_BYTES`), `None` for small ones (the synchronous path). Owned
206    /// here so a batteries-included host gets large-document highlighting for free.
207    hl_pool: Option<HighlightPool>,
208}
209
210/// What an applied edit means for the completion controller — computed from the
211/// [`Action`] before it is consumed, then threaded through the post-edit tail.
212#[derive(Clone, Copy)]
213enum CompletionEvent {
214    /// A character was typed (opens/filters the popup, or is a trigger char).
215    Typed(char),
216    /// A deletion (refilters an open popup; closes at the word start).
217    Deleting,
218    /// Anything else — a caret move, paste, undo… — closes the popup.
219    CaretOrClose,
220}
221
222/// A request for completions the host fulfills asynchronously (off-thread or via
223/// a language server). The editor emits one where it would call a synchronous
224/// provider but none is set; the host pulls it with
225/// [`CodeEditor::take_completion_request`], runs its query, and returns the
226/// result through [`CodeEditor::set_completions`] stamped with `revision`.
227#[non_exhaustive]
228#[derive(Clone, Debug)]
229pub struct CompletionRequest {
230    /// The revision the request was made at — pass it back to `set_completions`
231    /// so a result computed against a stale snapshot is dropped.
232    pub revision: Revision,
233    /// The caret position (row, col) to query completions at.
234    pub position: Point,
235}
236
237/// A request for signature help the host fulfills asynchronously. Emitted where
238/// the editor would call a synchronous [`SignatureHelp`] provider but none is
239/// set; the host pulls it with
240/// [`CodeEditor::take_signature_request`], queries, and returns the result (or
241/// `None` to close the box) through [`CodeEditor::set_signature`].
242#[non_exhaustive]
243#[derive(Clone, Debug)]
244pub struct SignatureRequest {
245    /// The revision the request was made at — pass it back to `set_signature`.
246    pub revision: Revision,
247    /// The caret position (row, col) to query signature help at.
248    pub position: Point,
249}
250
251/// A request for hover documentation the host fulfills asynchronously. Emitted
252/// where the editor would call a synchronous [`Hover`] provider but none is set;
253/// the host pulls it with [`CodeEditor::take_hover_request`], queries, and
254/// returns the card (or `None`) through [`CodeEditor::set_hover`]. Diagnostics at
255/// the point are shown synchronously regardless; a host that wants them in the
256/// async card can read them from [`CodeEditor::document`] and include them.
257#[non_exhaustive]
258#[derive(Clone, Debug)]
259pub struct HoverRequest {
260    /// The revision the request was made at — pass it back to `set_hover`.
261    pub revision: Revision,
262    /// The byte offset the pointer rested over.
263    pub offset: u32,
264}
265
266impl CodeEditor {
267    /// A new editor over `source`, with the default theme
268    /// ([`scrive_dark_theme`](crate::scrive_dark_theme)) but **no grammar** —
269    /// plain text until [`language`](CodeEditor::language) attaches one.
270    ///
271    /// # Panics
272    /// Panics if `source` does not fit scrive's `u32` offset space (~4 GiB) —
273    /// far past any editable document.
274    #[must_use]
275    pub fn new(source: impl Into<String>) -> Self {
276        let source = source.into();
277        let doc = Document::new(&source).expect("source fits the u32 offset space");
278        Self {
279            doc,
280            viewport: 0..0,
281            theme: crate::scrive_dark_theme(),
282            has_syntax: false,
283            id: widget::Id::new(DEFAULT_ID),
284            font: Font::MONOSPACE,
285            text_size: 14.0,
286            start: Instant::now(),
287            dirty: false,
288            find_enabled: true,
289            find_open: false,
290            find_query: String::new(),
291            find_case: false,
292            find_whole_word: false,
293            find_regex: false,
294            replace_open: false,
295            replace_text: String::new(),
296            find_focused: false,
297            replace_focused: false,
298            replace_preserve_case: false,
299            completion: CompletionController::new(),
300            comp_provider: None,
301            snippet: None,
302            sig_provider: None,
303            signature: None,
304            hover_provider: None,
305            hover: None,
306            pending_completion_request: None,
307            pending_signature_request: None,
308            pending_hover_request: None,
309            hl_pool: None,
310        }
311    }
312
313    // ── builder (policy; every knob defaulted) ──────────────────────────────
314
315    /// Attach a grammar, enabling syntax highlighting. Uses the theme set by
316    /// [`theme`](CodeEditor::theme) if one was staged, else the bundled default.
317    /// Order-independent with `theme`. Seeds the whole (small) document's colors
318    /// immediately so the first paint is highlighted without waiting for a
319    /// viewport report — the cold-load fix.
320    #[must_use]
321    pub fn language(mut self, grammar: SyntaxDef) -> Self {
322        self.doc.set_syntax(grammar, self.theme.clone());
323        self.has_syntax = true;
324        self.seed_highlight();
325        self
326    }
327
328    /// Override the syntax theme (default:
329    /// [`scrive_dark_theme`](crate::scrive_dark_theme)). Order-independent with
330    /// [`language`](CodeEditor::language): before a grammar is attached it is
331    /// staged; after, it re-themes the live cache.
332    #[must_use]
333    pub fn theme(mut self, theme: TokenTheme) -> Self {
334        if self.has_syntax {
335            self.doc.set_theme(theme.clone());
336        }
337        self.theme = theme;
338        self
339    }
340
341    /// Set the (monospace) font. Default [`Font::MONOSPACE`].
342    #[must_use]
343    pub fn font(mut self, font: Font) -> Self {
344        self.font = font;
345        self
346    }
347
348    /// Set the font size in logical pixels. Default `14.0`.
349    #[must_use]
350    pub fn text_size(mut self, px: f32) -> Self {
351        self.text_size = px;
352        self
353    }
354
355    /// Give the editor a widget id for focus addressing (default
356    /// `"scrive-editor"`). Needed only if a multi-pane host targets focus at it.
357    #[must_use]
358    pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
359        self.id = id.into();
360        self
361    }
362
363    /// Enable or disable the built-in find bar and its Ctrl+F / Ctrl+H bindings.
364    /// Default on.
365    #[must_use]
366    pub fn find(mut self, enabled: bool) -> Self {
367        self.find_enabled = enabled;
368        self
369    }
370
371    /// Set the line-comment marker used by toggle-comment (e.g. `Some("//")`);
372    /// `None` (the default) disables toggle-comment. It also drives comment-aware
373    /// bracket matching, so brackets inside `// …` stop being matched / coloured /
374    /// folded. Language config, like the grammar — the core ships none.
375    #[must_use]
376    pub fn line_comment(mut self, marker: Option<&str>) -> Self {
377        self.doc.set_line_comment(marker);
378        self
379    }
380
381    /// Configure comment/string-aware bracket matching's string and char-literal
382    /// delimiters — brackets inside a string (e.g. `"{}"`) or char literal are not
383    /// matched, coloured, folded, or indent-guided. (Line comments come from
384    /// [`line_comment`](CodeEditor::line_comment).) `char_delim` is off by default:
385    /// in some languages `'` is ambiguous (Rust lifetimes), so it is opt-in.
386    #[must_use]
387    pub fn bracket_lexing(mut self, string_delims: Vec<u8>, char_delim: Option<u8>) -> Self {
388        self.doc.set_bracket_lexing(string_delims, char_delim);
389        self
390    }
391
392    /// Supply a completion provider (default: none). The editor drives it on
393    /// edits, filters and renders the popup, and applies an accepted item —
394    /// including expanding a snippet insert into an interactive tab-stop session.
395    #[must_use]
396    pub fn completions(mut self, provider: impl Completions + 'static) -> Self {
397        self.comp_provider = Some(Box::new(provider));
398        self
399    }
400
401    /// Supply a hover provider (default: none). The editor queries it on the
402    /// idle-hover action and renders the returned card above the word.
403    #[must_use]
404    pub fn hover(mut self, provider: impl Hover + 'static) -> Self {
405        self.hover_provider = Some(Box::new(provider));
406        self
407    }
408
409    /// Supply a signature-help provider (default: none). `(` opens the box;
410    /// while open, edits/moves re-query and a `None` reply closes it.
411    #[must_use]
412    pub fn signature(mut self, provider: impl SignatureHelp + 'static) -> Self {
413        self.sig_provider = Some(Box::new(provider));
414        self
415    }
416
417    // ── reads (safe; no mutation) ───────────────────────────────────────────
418
419    /// The owned document, for saving / diffing / inspection
420    /// (`document().serialize(..)`, `.snapshot()`, `.revision()`). Read-only:
421    /// mutations go through the blessed methods so the post-edit tail always runs.
422    #[must_use]
423    pub fn document(&self) -> &Document {
424        &self.doc
425    }
426
427    /// Whether an edit has landed since the last [`take_dirty`](CodeEditor::take_dirty).
428    #[must_use]
429    pub fn is_dirty(&self) -> bool {
430        self.dirty
431    }
432
433    /// Read and clear the dirty flag — for a host that marks a title bar or
434    /// schedules a save on change.
435    pub fn take_dirty(&mut self) -> bool {
436        std::mem::take(&mut self.dirty)
437    }
438
439    /// The primary caret position as (row, col).
440    #[must_use]
441    pub fn cursor(&self) -> Point {
442        let head = self.doc.selections().newest().head();
443        self.doc.buffer().offset_to_point(head)
444    }
445
446    /// The primary selection's byte range (empty at a bare caret).
447    #[must_use]
448    pub fn selection(&self) -> Range<u32> {
449        let s = self.doc.selections().newest();
450        s.start()..s.end()
451    }
452
453    // ── blessed mutations (run the post-edit tail) ──────────────────────────
454
455    /// Apply a programmatic batch of edits as one transaction, then run the
456    /// post-edit tail. The tail is why this exists instead of a raw `&mut
457    /// Document`: it keeps highlighting, find, and the intel controllers current.
458    pub fn edit(&mut self, ops: Vec<EditOp>) {
459        let before = self.doc.revision();
460        if self.doc.edit(ops).is_ok() {
461            self.after_edit(CompletionEvent::CaretOrClose);
462            if self.doc.revision() != before {
463                self.dirty = true;
464            }
465        }
466    }
467
468    /// Swap the whole buffer (load a new file), keeping the current grammar and
469    /// theme unless `grammar` supplies a new language. The blessed whole-buffer
470    /// swap: it runs as one transaction and re-tokenizes the visible document, so
471    /// highlighting is correct immediately — never mutate the buffer behind the
472    /// tail's back. (The swap is a normal transaction, so it is undoable.)
473    pub fn load(&mut self, source: impl Into<String>, grammar: Option<SyntaxDef>) {
474        let source = source.into();
475        // Replace the whole buffer as one transaction; the grammar, theme, and
476        // highlight cache stay attached and re-tokenize via `on_commit`.
477        let len = self.doc.buffer().len();
478        let _ = self.doc.edit(vec![EditOp::new(0..len, &source)]);
479        if let Some(g) = grammar {
480            self.doc.set_syntax(g, self.theme.clone());
481            self.has_syntax = true;
482        }
483        // Re-seed: the document may have grown/shrunk, a grammar swap left the
484        // cache all-dirty, and a new grammar needs a fresh pool engine (fixing the
485        // grammar-swap-strands-the-pool bug).
486        self.seed_highlight();
487        self.dirty = true;
488    }
489
490    /// Collapse every foldable region (the "Fold All" command). Folds are view
491    /// state, so this records nothing on the undo stack and runs no post-edit tail.
492    pub fn fold_all(&mut self) {
493        for (open, ..) in self.doc.collapsible_pairs() {
494            self.doc.toggle_fold_opener(open);
495        }
496    }
497
498    /// Swap the syntax theme at runtime. The retained theme updates too, so a
499    /// later [`load`](CodeEditor::load) keeps it.
500    pub fn set_theme(&mut self, theme: TokenTheme) {
501        self.doc.set_theme(theme.clone());
502        self.theme = theme;
503        self.doc.tokenize_highlight(self.viewport.end);
504    }
505
506    /// Publish a diagnostic set from the host's (debounced, off-thread) compile
507    /// or language-server pass. Stamped by `revision`: a set computed against a
508    /// snapshot the buffer has moved past is dropped (the previous squiggles keep
509    /// riding edits). This is the ingest half of recompile-on-edit — pair it with
510    /// an edit signal ([`take_dirty`](CodeEditor::take_dirty) or a revision
511    /// compare) and [`document`](CodeEditor::document)`().snapshot()`.
512    pub fn set_diagnostics(
513        &mut self,
514        revision: Revision,
515        diags: Vec<Diagnostic>,
516    ) -> DiagnosticsOutcome {
517        self.doc.set_diagnostics(revision, diags)
518    }
519
520    /// Take the pending async completion request, if any. A host with an
521    /// off-thread / language-server provider (rather than a synchronous
522    /// [`completions`](CodeEditor::completions) one) polls this after `update`,
523    /// runs its query at the request's position and revision, and returns the
524    /// result through [`set_completions`](CodeEditor::set_completions).
525    pub fn take_completion_request(&mut self) -> Option<CompletionRequest> {
526        self.pending_completion_request.take()
527    }
528
529    /// Ingest completion items from an async provider, stamped by `revision`.
530    /// Strict staleness: if the buffer has moved since the request the items are
531    /// dropped (the host re-requests on the next edit). On a current revision they
532    /// open/refilter the popup against the *live* word — including snippet-format
533    /// items ([`InsertText::Snippet`](scrive_core::InsertText)), which expand into
534    /// an interactive tab-stop session on accept exactly like a synchronous
535    /// provider's.
536    pub fn set_completions(&mut self, revision: Revision, items: Vec<CompletionItem>) {
537        if revision != self.doc.revision() {
538            return; // stale — the host re-requests on the next edit
539        }
540        let word = self.completion_word_text();
541        let anchor = self.completion_word().start;
542        self.completion.set_items(items, &word, anchor);
543    }
544
545    /// Take the pending async signature-help request, if any — the signature
546    /// twin of [`take_completion_request`](CodeEditor::take_completion_request).
547    pub fn take_signature_request(&mut self) -> Option<SignatureRequest> {
548        self.pending_signature_request.take()
549    }
550
551    /// Ingest a signature-help result from an async provider, stamped by
552    /// `revision` (strict staleness). `None` closes the box. Current-revision
553    /// results replace the shown signature.
554    pub fn set_signature(&mut self, revision: Revision, info: Option<SignatureInfo>) {
555        if revision == self.doc.revision() {
556            self.signature = info;
557        }
558    }
559
560    /// Take the pending async hover request, if any — the hover twin of
561    /// [`take_completion_request`](CodeEditor::take_completion_request).
562    pub fn take_hover_request(&mut self) -> Option<HoverRequest> {
563        self.pending_hover_request.take()
564    }
565
566    /// Ingest a hover card from an async provider, stamped by `revision` (strict
567    /// staleness). Replaces the shown card (the host builds it, and may fold in
568    /// diagnostics read from [`document`](CodeEditor::document)); `None` clears it.
569    pub fn set_hover(&mut self, revision: Revision, info: Option<HoverInfo>) {
570        if revision == self.doc.revision() {
571            self.hover = info;
572        }
573    }
574
575    /// Enable or disable the incremental-change log — for a host mirroring edits
576    /// to a language server (`textDocument/didChange`). Off by default (zero
577    /// overhead); forwards to [`Document::observe_changes`]. Full-document sync
578    /// hosts leave this off and re-read `document().snapshot()` instead.
579    pub fn observe_changes(&mut self, on: bool) {
580        self.doc.observe_changes(on);
581    }
582
583    /// Drain the incremental-change log: every applied edit since the last drain,
584    /// as `EditOp` deltas ready to translate into LSP content changes. Empty
585    /// unless [`observe_changes`](CodeEditor::observe_changes) is on. Forwards to
586    /// [`Document::drain_changes`].
587    pub fn drain_changes(&mut self) -> Vec<EditOp> {
588        self.doc.drain_changes()
589    }
590
591    // ── the three wires ─────────────────────────────────────────────────────
592
593    /// Fold one [`Event`] into the editor. Map it back to your message type:
594    /// `Message::Editor(e) => self.editor.update(e).map(Message::Editor)`.
595    pub fn update(&mut self, event: Event) -> Task<Event> {
596        match event {
597            // The widget reported a new visible range (scroll / resize /
598            // autoscroll). Aim the retention window there and tokenize down to
599            // it. Not an edit — no history, no find rescan — so it bypasses
600            // `apply`.
601            Event::Editor(Action::ViewportChanged(rows)) => {
602                self.viewport = rows.clone();
603                self.doc.set_highlight_window(rows.clone());
604                if self.doc.buffer().len() >= PARALLEL_MIN_BYTES {
605                    // Large document: the off-thread sweep owns dirt-clearing; the
606                    // viewport is painted synchronously now and verified in place.
607                    // Do NOT run the whole-doc synchronous walk (it would race the
608                    // pool). Create the pool on first sight.
609                    if let Some(mut pool) = self.hl_pool.take() {
610                        pool.reaim(&mut self.doc, rows);
611                        self.hl_pool = Some(pool);
612                    } else if let Some(pool) = HighlightPool::new(&self.doc, rows.clone()) {
613                        pool.speculate(&mut self.doc, rows);
614                        self.hl_pool = Some(pool);
615                    }
616                } else {
617                    self.doc.tokenize_highlight(rows.end);
618                }
619                self.hover = None; // scroll closes the hover
620                Task::none()
621            }
622            // Escape with the bar open closes the BAR and keeps the selections
623            // (matching mainstream editors) — this covers the editor-focused
624            // press; the input-focused one arrives as `CloseFind` via the chord.
625            Event::Editor(Action::Collapse) if self.find_open => {
626                self.close_find();
627                Task::none()
628            }
629            // Folds are view state (no text change, no undo step, no rehighlight);
630            // handled here rather than through the edit tail.
631            Event::Editor(Action::ToggleFold { opener }) => {
632                self.doc.toggle_fold_opener(opener);
633                Task::none()
634            }
635            Event::Editor(Action::FoldAtCarets { unfold }) => {
636                self.doc.fold_at_carets(unfold);
637                Task::none()
638            }
639            // Completion popup navigation (captured by the widget while open) —
640            // drive the controller; no document edit except on accept.
641            Event::Editor(Action::PopupUp) => {
642                self.completion.move_selection(false);
643                Task::none()
644            }
645            Event::Editor(Action::PopupDown) => {
646                self.completion.move_selection(true);
647                Task::none()
648            }
649            Event::Editor(Action::PopupDismiss) => {
650                self.completion.escape();
651                Task::none()
652            }
653            Event::Editor(Action::PopupAccept) => {
654                self.accept_completion();
655                Task::none()
656            }
657            Event::Editor(Action::PopupClickAccept(idx)) => {
658                self.completion.set_selected(idx);
659                self.accept_completion();
660                Task::none()
661            }
662            // Snippet tab-stop navigation (captured while a session is active).
663            Event::Editor(Action::SnippetTab) => {
664                self.snippet_tab(true);
665                Task::none()
666            }
667            Event::Editor(Action::SnippetTabPrev) => {
668                self.snippet_tab(false);
669                Task::none()
670            }
671            Event::Editor(Action::SnippetCancel) => {
672                if let Some(mut s) = self.snippet.take() {
673                    s.cancel(self.doc.decorations_mut());
674                }
675                Task::none()
676            }
677            Event::Editor(Action::SignatureClose) => {
678                self.signature = None;
679                Task::none()
680            }
681            // Hover: the pointer rested over `offset` — diagnostics first (their
682            // messages ride the decoration store), then the provider's docs.
683            Event::Editor(Action::HoverQuery(offset)) => {
684                let diags: Vec<(Range<u32>, String)> = self
685                    .doc
686                    .diagnostics_in(offset..offset + 1)
687                    .map(|(r, sev, msg)| (r, format!("**{}:** {msg}", severity_label(sev))))
688                    .collect();
689                let cx = self.build_hover_cx(offset);
690                let word = (cx.word.start != cx.word.end)
691                    .then(|| self.hover_provider.as_mut().and_then(|p| p.hover(&cx)))
692                    .flatten();
693                self.hover = if diags.is_empty() {
694                    word
695                } else {
696                    let range = diags[0].0.clone();
697                    let mut md: Vec<String> = diags.into_iter().map(|(_, m)| m).collect();
698                    if let Some(w) = word {
699                        md.push(String::new());
700                        md.push(w.markdown);
701                    }
702                    Some(HoverInfo { markdown: md.join("\n"), range })
703                };
704                // With no synchronous provider, record an async request so the
705                // host can supply docs; the diagnostics-only card (if any) shows
706                // meanwhile and `set_hover` replaces it when the docs arrive.
707                if self.hover_provider.is_none() && cx.word.start != cx.word.end {
708                    self.pending_hover_request =
709                        Some(HoverRequest { revision: self.doc.revision(), offset });
710                }
711                Task::none()
712            }
713            Event::Editor(Action::HoverDismiss) => {
714                self.hover = None;
715                Task::none()
716            }
717            Event::Editor(action) => {
718                self.apply(action);
719                Task::none()
720            }
721            // The idle sweep: one budgeted batch per frame toward convergence.
722            // The subscription drops itself once the frontier is clean, so this
723            // stops firing on an idle document.
724            Event::HighlightSweep => {
725                if self.doc.buffer().len() >= PARALLEL_MIN_BYTES {
726                    if let Some(mut pool) = self.hl_pool.take() {
727                        if pool.rev != self.doc.revision() {
728                            // An edit landed: re-sweep from a fresh snapshot AND
729                            // repaint the viewport now (the verified prefix in the
730                            // cache survives).
731                            pool.restart(&mut self.doc, self.viewport.clone());
732                        } else {
733                            // Drain finished jobs and advance the verified chain.
734                            pool.poll(&mut self.doc);
735                        }
736                        let idle = !pool.active;
737                        self.hl_pool = Some(pool);
738                        // Once the sweep is idle, the synchronous phase-2 path
739                        // refills any window rows the sweep evicted (dirt is
740                        // cleared, so this is a cheap window refill, not O(doc)).
741                        if idle {
742                            let n = self.doc.buffer().line_count();
743                            self.doc.tokenize_highlight(n);
744                        }
745                    } else if let Some(pool) = HighlightPool::new(&self.doc, self.viewport.clone()) {
746                        // Large but no pool yet (grew past the threshold): create
747                        // it rather than tokenize the whole document synchronously.
748                        self.hl_pool = Some(pool);
749                    }
750                } else {
751                    // Small document (or shrunk below the threshold): deactivate
752                    // any lingering pool and drive the synchronous path.
753                    if let Some(pool) = &mut self.hl_pool {
754                        pool.active = false;
755                    }
756                    let n = self.doc.buffer().line_count();
757                    self.doc.tokenize_highlight(n);
758                }
759                Task::none()
760            }
761
762            // ── find bar ────────────────────────────────────────────────────
763            Event::OpenFind if self.find_enabled => {
764                self.find_open = true;
765                // Seed (or re-seed) the query from a non-empty, single-line
766                // selection, as mainstream editors do; an empty selection leaves
767                // the current query untouched.
768                let sel = self.doc.selections().newest();
769                let seed = (!sel.is_empty())
770                    .then(|| self.doc.buffer().slice(sel.start()..sel.end()).into_owned())
771                    .filter(|t| !t.contains('\n'));
772                if let Some(text) = seed {
773                    self.find_query = text;
774                    self.push_find_query();
775                }
776                self.find_focused = true;
777                self.replace_focused = false;
778                focus(FIND_INPUT)
779            }
780            Event::OpenReplace if self.find_enabled => {
781                // Ctrl+H is Ctrl+F with the replace row already out.
782                self.replace_open = true;
783                self.update(Event::OpenFind)
784            }
785            Event::CycleFocus { back } => {
786                let moved = if back { focus_previous() } else { focus_next() };
787                moved.chain(Self::sync_rings())
788            }
789            Event::PointerDown if self.find_open => {
790                Task::batch([Self::resync_focus(), Self::sync_rings()])
791            }
792            Event::Focused { replace, on } => {
793                if replace {
794                    self.replace_focused = on;
795                } else {
796                    self.find_focused = on;
797                }
798                Task::none()
799            }
800            Event::CloseFind if self.find_open => {
801                self.close_find();
802                focus(self.id.clone())
803            }
804            Event::FindQuery(q) => {
805                self.find_query = q;
806                self.push_find_query();
807                Task::none()
808            }
809            Event::ToggleCase => {
810                self.find_case = !self.find_case;
811                self.push_find_query();
812                Task::none()
813            }
814            Event::ToggleWholeWord => {
815                self.find_whole_word = !self.find_whole_word;
816                self.push_find_query();
817                Task::none()
818            }
819            Event::ToggleRegex => {
820                self.find_regex = !self.find_regex;
821                self.push_find_query();
822                Task::none()
823            }
824            Event::ToggleFindInSelection if self.find_open => {
825                // The scope lives in the document (it rides every edit), so read
826                // the live one and set its opposite.
827                let scope = if self.doc.find_scope().is_some() {
828                    None
829                } else {
830                    let sel = self.doc.selections().newest();
831                    (!sel.is_empty()).then(|| sel.start()..sel.end())
832                };
833                let now = self.now_ms();
834                self.doc.set_find_scope(scope, now);
835                Task::none()
836            }
837            Event::FindNext if self.find_open => {
838                let now = self.now_ms();
839                self.doc.find_next(now);
840                Task::none()
841            }
842            Event::FindPrev if self.find_open => {
843                let now = self.now_ms();
844                self.doc.find_prev(now);
845                Task::none()
846            }
847            Event::FindSelectAll if self.find_open => {
848                // Every match becomes a caret; focus returns to the editor.
849                if self.doc.select_find_matches() {
850                    return focus(self.id.clone());
851                }
852                Task::none()
853            }
854            Event::ToggleReplace => {
855                self.replace_open = !self.replace_open;
856                self.replace_focused = self.replace_open;
857                self.find_focused = !self.replace_open;
858                focus(if self.replace_open { REPLACE_INPUT } else { FIND_INPUT })
859            }
860            Event::ReplaceText(t) => {
861                self.replace_text = t;
862                Task::none()
863            }
864            Event::ReplaceOne if self.find_open => {
865                let now = self.now_ms();
866                let before = self.doc.revision();
867                self.doc.replace_next(&self.replace_text, self.replace_preserve_case, now);
868                // The first press only NAVIGATES (shows the match before
869                // overwriting it), which commits nothing — run the tail only if a
870                // replacement actually landed.
871                if self.doc.revision() != before {
872                    self.after_edit(CompletionEvent::CaretOrClose);
873                    self.dirty = true;
874                }
875                Task::none()
876            }
877            Event::ReplaceAll if self.find_open => {
878                if self.doc.replace_all(&self.replace_text, self.replace_preserve_case) > 0 {
879                    self.after_edit(CompletionEvent::CaretOrClose);
880                    self.dirty = true;
881                }
882                Task::none()
883            }
884            Event::TogglePreserveCase => {
885                self.replace_preserve_case = !self.replace_preserve_case;
886                Task::none()
887            }
888            // Guarded find variants whose guard did not hold (bar closed, or find
889            // disabled): ignore.
890            Event::OpenFind
891            | Event::OpenReplace
892            | Event::CloseFind
893            | Event::FindNext
894            | Event::FindPrev
895            | Event::FindSelectAll
896            | Event::ToggleFindInSelection
897            | Event::ReplaceOne
898            | Event::ReplaceAll
899            | Event::PointerDown => Task::none(),
900        }
901    }
902
903    /// The editor element. Map it back to your message type:
904    /// `self.editor.view().map(Message::Editor)`.
905    #[must_use]
906    pub fn view(&self) -> Element<'_, Event> {
907        let popup = match self.completion.state() {
908            CompletionState::Open(list) => Some(list),
909            _ => None,
910        };
911        let editor = Editor::new(&self.doc, Event::Editor)
912            .popup(popup)
913            .snippet_active(self.snippet.is_some())
914            .signature(self.signature.as_ref())
915            .hover(self.hover.as_ref())
916            .font(self.font)
917            .text_size(self.text_size)
918            .id(self.id.clone());
919        if self.find_open {
920            // Float the find bar over the editor, top-right (where mainstream
921            // editors place it). The right padding clears the scrollbar lane so
922            // the bar never sits over it; the overlay is transparent except the
923            // bar, so clicks pass through.
924            let overlay = container(self.find_bar())
925                .width(Length::Fill)
926                .align_x(Horizontal::Right)
927                .padding(iced::Padding::new(8.0).right(8.0 + crate::SCROLLBAR_WIDTH));
928            stack([editor.into(), overlay.into()]).into()
929        } else {
930            editor.into()
931        }
932    }
933
934    /// The find bar: a floating panel with a query row (input + option toggles +
935    /// match count + prev/next/scope/close) and, once the chevron expands it, a
936    /// replace row (input + preserve-case + replace/replace-all).
937    fn find_bar(&self) -> Element<'_, Event> {
938        let count = self.doc.find_match_count();
939        // A half-typed regex (`(`, `[a-`) is a NORMAL state, not a failure — but
940        // it must say so rather than read as "No results".
941        let invalid = self.doc.find_pattern_error().is_some();
942        let label = if invalid {
943            "Invalid regex".to_string()
944        } else {
945            match self.doc.active_find_match() {
946                Some(i) => format!("{} of {}", i + 1, count),
947                None if count > 0 => format!("{count} matches"),
948                None if self.find_query.is_empty() => String::new(),
949                None => "No results".to_string(),
950            }
951        };
952        let label_color = if invalid || label == "No results" {
953            Color::from_rgb8(0xF4, 0x87, 0x71)
954        } else {
955            Color::from_rgb8(0xCC, 0xCC, 0xCC)
956        };
957        // Both inputs share this width so the two boxes align under each other.
958        const INPUT_W: f32 = 264.0;
959        // Fixed so the nav buttons never shuffle as the match-count digits change.
960        const COUNT_W: f32 = 78.0;
961        const SPACING: f32 = 4.0;
962        // One row's height, pinned so the chevron can span the rows exactly.
963        const ROW_H: f32 = 26.0;
964        // The box look lives on the CONTAINER (`box_of`), so the field is
965        // transparent and sits inside it beside the in-box buttons as a row
966        // sibling — not overlaid in a `Stack` (which early-returns on capture and
967        // would leave a field stale-focused). One style for BOTH fields.
968        let input_style = |_theme: &Theme, _status: text_input::Status| text_input::Style {
969            background: Color::TRANSPARENT.into(),
970            border: iced::border::rounded(0.0),
971            icon: Color::from_rgb8(0xCC, 0xCC, 0xCC),
972            placeholder: Color::from_rgb8(0xA6, 0xA6, 0xA6),
973            value: Color::from_rgb8(0xCC, 0xCC, 0xCC),
974            selection: Color::from_rgb8(0x26, 0x4F, 0x78),
975        };
976        const BTN_W: f32 = 22.0;
977        const IN_BTN: f32 = 20.0;
978        const IN_GAP: f32 = 3.0;
979        const IN_MARGIN: f32 = 3.0;
980        // Flat icon buttons: transparent at rest, translucent gray on hover; `on`
981        // latches the background + a focus-blue border so an engaged option reads
982        // as pressed at rest, not only under the pointer.
983        let sized_btn = |glyph: char, on: bool, msg, w: f32, h: f32| {
984            button(
985                text(glyph.to_string())
986                    .font(crate::CODICON)
987                    .size(15)
988                    .width(Length::Fill)
989                    .height(Length::Fill)
990                    .align_x(Horizontal::Center)
991                    .align_y(Vertical::Center),
992            )
993            .width(Length::Fixed(w))
994            .height(Length::Fixed(h))
995            .padding(0)
996            .on_press(msg)
997            .style(move |_theme, status| {
998                let hover = matches!(status, button::Status::Hovered | button::Status::Pressed);
999                button::Style {
1000                    background: (on || hover).then(|| Color::from_rgba8(90, 93, 94, 0.314).into()),
1001                    text_color: Color::from_rgb8(0xCC, 0xCC, 0xCC),
1002                    border: if on {
1003                        iced::border::rounded(5.0).width(1.0).color(Color::from_rgb8(0x00, 0x7F, 0xD4))
1004                    } else {
1005                        iced::border::rounded(5.0)
1006                    },
1007                    shadow: Shadow::default(),
1008                    snap: true,
1009                }
1010            })
1011        };
1012        let icon_btn = |glyph: char, on: bool, msg| sized_btn(glyph, on, msg, BTN_W, BTN_W);
1013        let btn = |glyph: char, msg| sized_btn(glyph, false, msg, BTN_W, BTN_W);
1014        let in_btn = |glyph: char, on: bool, msg| sized_btn(glyph, on, msg, IN_BTN, IN_BTN);
1015        let box_of =
1016            |field, buttons, focused| box_of(field, buttons, focused, INPUT_W, ROW_H, IN_GAP, IN_MARGIN);
1017
1018        let find_box = box_of(
1019            text_input("Find", &self.find_query)
1020                .id(FIND_INPUT)
1021                .on_input(Event::FindQuery)
1022                .on_submit(Event::FindNext)
1023                .padding(iced::Padding::new(4.0).left(2.0))
1024                .size(13)
1025                .width(Length::Fill)
1026                .style(input_style)
1027                .into(),
1028            vec![
1029                in_btn(crate::icon::CASE_SENSITIVE, self.find_case, Event::ToggleCase).into(),
1030                in_btn(crate::icon::WHOLE_WORD, self.find_whole_word, Event::ToggleWholeWord).into(),
1031                in_btn(crate::icon::REGEX, self.find_regex, Event::ToggleRegex).into(),
1032            ],
1033            self.find_focused,
1034        );
1035        let find_row = row![
1036            find_box,
1037            text(label)
1038                .size(12)
1039                .color(label_color)
1040                .width(Length::Fixed(COUNT_W))
1041                .align_x(Horizontal::Left),
1042            btn(crate::icon::ARROW_UP, Event::FindPrev),
1043            btn(crate::icon::ARROW_DOWN, Event::FindNext),
1044            icon_btn(crate::icon::SELECTION, self.scoped(), Event::ToggleFindInSelection),
1045            btn(crate::icon::CLOSE, Event::CloseFind),
1046        ]
1047        .spacing(SPACING)
1048        .height(Length::Fixed(ROW_H))
1049        .align_y(Alignment::Center);
1050        let rows = if self.replace_open {
1051            let replace_box = box_of(
1052                text_input("Replace", &self.replace_text)
1053                    .id(REPLACE_INPUT)
1054                    .on_input(Event::ReplaceText)
1055                    .on_submit(Event::ReplaceOne)
1056                    .padding(iced::Padding::new(4.0).left(2.0))
1057                    .size(13)
1058                    .width(Length::Fill)
1059                    .style(input_style)
1060                    .into(),
1061                vec![in_btn(crate::icon::PRESERVE_CASE, self.replace_preserve_case, Event::TogglePreserveCase)
1062                    .into()],
1063                self.replace_focused,
1064            );
1065            let replace_row = row![
1066                replace_box,
1067                btn(crate::icon::REPLACE, Event::ReplaceOne),
1068                btn(crate::icon::REPLACE_ALL, Event::ReplaceAll),
1069            ]
1070            .spacing(SPACING)
1071            .height(Length::Fixed(ROW_H))
1072            .align_y(Alignment::Center);
1073            column![find_row, replace_row].spacing(SPACING)
1074        } else {
1075            column![find_row]
1076        };
1077        container(
1078            row![
1079                // The chevron spans both rows — its hit target grows with the bar
1080                // it toggles, VS Code's shape and the honest one (it acts on the
1081                // panel, not the query row it sits next to).
1082                sized_btn(
1083                    if self.replace_open { crate::icon::CHEVRON_DOWN } else { crate::icon::CHEVRON_RIGHT },
1084                    false,
1085                    Event::ToggleReplace,
1086                    BTN_W,
1087                    if self.replace_open { ROW_H * 2.0 + SPACING } else { ROW_H },
1088                ),
1089                rows,
1090            ]
1091            .spacing(SPACING)
1092            .align_y(Alignment::Center),
1093        )
1094        .padding([6, 8])
1095        .style(|_theme: &Theme| container::Style {
1096            background: Some(Color::from_rgb8(0x25, 0x25, 0x26).into()),
1097            border: iced::border::rounded(8.0).color(Color::from_rgb8(0x45, 0x45, 0x45)).width(1.0),
1098            shadow: Shadow {
1099                color: Color::from_rgba8(0, 0, 0, 0.36),
1100                offset: Vector::new(0.0, 2.0),
1101                blur_radius: 8.0,
1102            },
1103            ..container::Style::default()
1104        })
1105        .into()
1106    }
1107
1108    /// The editor's subscription — the frames-gated highlight sweep. It runs
1109    /// only while a dirty highlight frontier remains, and because
1110    /// [`language`](CodeEditor::language) / an edit leaves the frontier dirty it
1111    /// fires on the very next frame with no input required (this is what makes
1112    /// highlighting appear at load instead of after the first scroll). At
1113    /// convergence it returns [`Subscription::none`], so an idle document does
1114    /// zero per-frame work. Map it: `self.editor.subscription().map(Message::Editor)`.
1115    pub fn subscription(&self) -> Subscription<Event> {
1116        let sweeping = self.doc.highlight_frontier().is_some()
1117            || self.hl_pool.as_ref().is_some_and(|p| p.active);
1118        let sweep = if sweeping {
1119            iced::window::frames().map(|_| Event::HighlightSweep)
1120        } else {
1121            Subscription::none()
1122        };
1123        // Find keys are chrome: caught here regardless of capture status, so
1124        // Escape closes the bar in one press even though the native input
1125        // captured it. `listen_with` filters, so non-find keys produce nothing
1126        // and the editor's own captured keystrokes still route through the widget.
1127        // The closure is a plain fn (non-capturing), so the `find_open` gating
1128        // happens in `update`.
1129        let keys = if self.find_enabled {
1130            iced::event::listen_with(|event, status, _window| match event {
1131                iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { key, modifiers, .. }) => {
1132                    find_chord(&key, modifiers, status)
1133                }
1134                // A press can move focus natively, behind the app's back, leaving
1135                // two widgets focused — watched here because the press is captured
1136                // by the input and never surfaces as a widget callback.
1137                iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) => {
1138                    Some(Event::PointerDown)
1139                }
1140                _ => None,
1141            })
1142        } else {
1143            Subscription::none()
1144        };
1145        Subscription::batch([sweep, keys])
1146    }
1147
1148    // ── internals ───────────────────────────────────────────────────────────
1149
1150    /// Milliseconds since construction — the injected clock for find's debounce.
1151    fn now_ms(&self) -> u64 {
1152        self.start.elapsed().as_millis() as u64
1153    }
1154
1155    /// Colour the document at load (or after a grammar swap / buffer load): the
1156    /// large-document parallel sweep for a big buffer, else a synchronous seed of
1157    /// the whole (small) buffer. The cold-load fix — the first paint is coloured
1158    /// with no viewport report required, and a huge buffer never blocks the UI
1159    /// thread (its pool sweeps in the background, aimed at the current viewport,
1160    /// which the first `ViewportChanged` reaims). Recreating the pool here also
1161    /// picks up a new grammar's engine after a `load` language swap.
1162    fn seed_highlight(&mut self) {
1163        if self.doc.buffer().len() >= PARALLEL_MIN_BYTES {
1164            self.hl_pool = HighlightPool::new(&self.doc, self.viewport.clone());
1165        } else {
1166            self.hl_pool = None;
1167            let n = self.doc.buffer().line_count();
1168            self.doc.set_highlight_window(0..n);
1169            self.doc.tokenize_highlight(n);
1170        }
1171    }
1172
1173    /// Push the live query text + its options into the document — the ONE place a
1174    /// [`FindQuery`] is built. Every input that changes what matches (the text
1175    /// and each option toggle) routes here, so the bar's controls cannot disagree
1176    /// about the live query. Empty text means "no query", never match-all.
1177    fn push_find_query(&mut self) {
1178        let query = (!self.find_query.is_empty()).then(|| {
1179            // `FindQuery` is `#[non_exhaustive]`: build via `new` + the fields.
1180            let mut q = FindQuery::new(self.find_query.clone());
1181            q.case_sensitive = self.find_case;
1182            q.whole_word = self.find_whole_word;
1183            q.regex = self.find_regex;
1184            q
1185        });
1186        let now = self.now_ms();
1187        self.doc.set_find_query(query, now);
1188    }
1189
1190    /// Close the bar and drop its query. Also collapses the replace row so the
1191    /// next open starts from a clean shape (Ctrl+F find-only, Ctrl+H with replace).
1192    fn close_find(&mut self) {
1193        self.find_open = false;
1194        self.replace_open = false;
1195        self.find_focused = false;
1196        self.replace_focused = false;
1197        self.find_query.clear();
1198        let now = self.now_ms();
1199        self.doc.set_find_query(None, now);
1200    }
1201
1202    /// Whether find is currently scoped to a selection (the find-in-selection
1203    /// toggle latches off the document's live scope, not an app-side copy).
1204    fn scoped(&self) -> bool {
1205        self.doc.find_scope().is_some()
1206    }
1207
1208    /// Read the live widget focus back into the ring flags — for focus changes
1209    /// that can't be predicted here (a click, a Tab).
1210    fn sync_rings() -> Task<Event> {
1211        Task::batch([
1212            is_focused(FIND_INPUT).map(|on| Event::Focused { replace: false, on }),
1213            is_focused(REPLACE_INPUT).map(|on| Event::Focused { replace: true, on }),
1214        ])
1215    }
1216
1217    /// Re-assert single focus after a press: a click inside an input focuses it
1218    /// natively and captures the event, so the editor's own press handler never
1219    /// runs to unfocus itself — focusing whichever input iced reports as focused
1220    /// is exactly the repair. When the press landed in the editor, neither input
1221    /// is focused and both arms are no-ops.
1222    fn resync_focus() -> Task<Event> {
1223        Task::batch([
1224            is_focused(FIND_INPUT).then(|f| if f { focus(FIND_INPUT) } else { Task::none() }),
1225            is_focused(REPLACE_INPUT).then(|f| if f { focus(REPLACE_INPUT) } else { Task::none() }),
1226        ])
1227    }
1228
1229    /// Apply one editing/selection [`Action`] to the document, then run the
1230    /// post-edit tail. The intel / popup / snippet / hover / viewport / fold
1231    /// actions are handled before `apply` (or are no-ops here) and land as the
1232    /// catch-all arm; they gain behavior as later milestones wire the controllers.
1233    fn apply(&mut self, action: Action) {
1234        // Capture what this action means for completion before the match consumes
1235        // `action`.
1236        let comp_event = match &action {
1237            Action::Type(c) => CompletionEvent::Typed(*c),
1238            Action::Backspace | Action::DeleteWordBack | Action::Delete | Action::DeleteWordForward => {
1239                CompletionEvent::Deleting
1240            }
1241            _ => CompletionEvent::CaretOrClose,
1242        };
1243        let rev_before = self.doc.revision();
1244        match action {
1245            Action::Type(ch) => self.doc.type_char(ch),
1246            Action::Backspace => self.doc.backspace(),
1247            Action::Delete => self.doc.delete_forward(),
1248            Action::DeleteWordBack => self.doc.delete_word_back(),
1249            Action::DeleteWordForward => self.doc.delete_word_forward(),
1250            Action::Enter => self.doc.enter(),
1251            Action::Tab => self.doc.tab(),
1252            Action::Outdent => self.doc.outdent(),
1253            Action::ToggleComment => self.doc.toggle_line_comment(),
1254            Action::DeleteLine => self.doc.delete_line(),
1255            Action::InsertLine { down } => self.doc.insert_line(down),
1256            Action::Cut => self.doc.cut(),
1257            Action::Paste { text, entire_line } => self.doc.paste(&text, entire_line),
1258            Action::Move { motion, extend } => self.doc.move_carets(motion, extend),
1259            Action::PlaceCaret(offset) => {
1260                let mut set = SelectionSet::new(0);
1261                set.set_single(Selection::caret(SelectionId(0), offset));
1262                self.doc.set_selections(set);
1263            }
1264            Action::DragSelect { granularity, origin, head } => {
1265                self.doc.drag_select(granularity, origin, head);
1266            }
1267            Action::AddCaret(offset) => self.doc.add_caret(offset),
1268            Action::AddNextOccurrence => self.doc.add_next_occurrence(),
1269            Action::SelectAllOccurrences => self.doc.select_all_occurrences(),
1270            Action::AddCaretVertical { down } => self.doc.add_caret_vertical(down),
1271            Action::JumpToBracket => self.doc.jump_to_bracket(),
1272            Action::NextDiagnostic { forward } => {
1273                self.doc.next_diagnostic(forward);
1274            }
1275            Action::ExpandSelection => self.doc.expand_selection(),
1276            Action::ShrinkSelection => self.doc.shrink_selection(),
1277            Action::Collapse => self.doc.collapse_selections(),
1278            Action::SelectAll => self.doc.select_all(),
1279            Action::Undo => {
1280                self.doc.undo();
1281            }
1282            Action::Redo => {
1283                self.doc.redo();
1284            }
1285            Action::ColumnSelect(dir) => self.doc.column_select(dir),
1286            Action::ColumnDrag { anchor, active } => self.doc.column_drag(anchor, active),
1287            Action::MoveLine { down } => self.doc.move_line(down),
1288            Action::CopyLine { down } => self.doc.copy_line(down),
1289            // Handled in `update` (viewport, folds) or not yet wired (popup /
1290            // snippet / signature / hover — later milestones). Exhaustive no-op.
1291            Action::ViewportChanged(_)
1292            | Action::PopupUp
1293            | Action::PopupDown
1294            | Action::PopupAccept
1295            | Action::PopupClickAccept(_)
1296            | Action::PopupDismiss
1297            | Action::SnippetTab
1298            | Action::SnippetTabPrev
1299            | Action::SnippetCancel
1300            | Action::SignatureClose
1301            | Action::HoverQuery(_)
1302            | Action::HoverDismiss
1303            | Action::ToggleFold { .. }
1304            | Action::FoldAtCarets { .. } => {}
1305        }
1306        self.after_edit(comp_event);
1307        // Dirty only on an actual text change — a bare caret move / selection must
1308        // not schedule a host recompile or flip the save indicator.
1309        if self.doc.revision() != rev_before {
1310            self.dirty = true;
1311        }
1312    }
1313
1314    /// Everything that must follow a document mutation — the ONE owner of the
1315    /// post-edit tail. Every edit entry point runs it, so no second entry point
1316    /// can silently do half of it (a path that skipped `tokenize_highlight` would
1317    /// paint stale colors; one that skipped `drive_completion` would strand the
1318    /// popup). The `on_edit` host signal joins it in a later milestone.
1319    fn after_edit(&mut self, comp_event: CompletionEvent) {
1320        // Bring the highlight cache current down to the reported viewport bottom
1321        // only — convergence stops at the edited lines for a normal edit, and the
1322        // viewport bound caps a state cascade to the screen.
1323        self.doc.tokenize_highlight(self.viewport.end);
1324        // Keep find fresh while editing: matches ride the edit via the decoration
1325        // mover; a debounced re-scan picks up appearing/disappearing matches.
1326        let now = self.now_ms();
1327        self.doc.maybe_rescan_find(now);
1328        // Drive completion (typing opens/filters, deleting refilters, else close),
1329        // signature help, and reconcile the snippet session; any edit closes hover.
1330        self.drive_completion(comp_event);
1331        self.drive_signature(comp_event);
1332        self.reconcile_snippet();
1333        self.hover = None;
1334        // `dirty` is set by the callers on an actual text change (a bare caret
1335        // move runs the tail but must not dirty the document — see `apply`).
1336    }
1337
1338    /// Drive the completion controller after an edit. The controller action is
1339    /// decided from the event here; whether it runs a synchronous provider or
1340    /// records an async request is [`request_completions`](Self::request_completions)'s
1341    /// job. No-op unless the host wired completions (sync provider or async pull).
1342    fn drive_completion(&mut self, event: CompletionEvent) {
1343        match event {
1344            CompletionEvent::Typed(c) => {
1345                let trigger = if is_completion_word_char(c) {
1346                    CompletionTrigger::Typed(c)
1347                } else if matches!(c, '(' | ',' | '=' | ':' | '.' | ' ') {
1348                    CompletionTrigger::TriggerChar(c)
1349                } else {
1350                    self.completion.on_boundary();
1351                    self.pending_completion_request = None;
1352                    return;
1353                };
1354                self.request_completions(trigger);
1355            }
1356            CompletionEvent::Deleting => {
1357                if self.completion.is_open() {
1358                    let word = self.completion_word_text();
1359                    match word.chars().last() {
1360                        Some(c) => self.request_completions(CompletionTrigger::Typed(c)),
1361                        None => {
1362                            self.completion.close();
1363                            self.pending_completion_request = None;
1364                        }
1365                    }
1366                }
1367            }
1368            CompletionEvent::CaretOrClose => {
1369                self.completion.close();
1370                self.pending_completion_request = None;
1371            }
1372        }
1373    }
1374
1375    /// Query completions for `trigger`: a synchronous provider fills the popup
1376    /// inline; with none set, record an async [`CompletionRequest`] the host
1377    /// fulfills via [`take_completion_request`](Self::take_completion_request) +
1378    /// [`set_completions`](Self::set_completions). No-op if neither is wired (the
1379    /// request is recorded regardless, but a host that never polls it just drops
1380    /// it — cheap).
1381    fn request_completions(&mut self, trigger: CompletionTrigger) {
1382        // Take the provider out so `self` is free for `build_cx`, then restore it
1383        // (avoids an is_some/unwrap dance and the whole-self borrow conflict).
1384        let Some(mut provider) = self.comp_provider.take() else {
1385            // No synchronous provider: record an async request for the host.
1386            let head = self.doc.selections().newest().head();
1387            self.pending_completion_request = Some(CompletionRequest {
1388                revision: self.doc.revision(),
1389                position: self.doc.buffer().offset_to_point(head),
1390            });
1391            return;
1392        };
1393        let cx = self.build_cx(trigger);
1394        let word = self.completion_word_text();
1395        self.completion.on_input(&cx, &word, &mut *provider);
1396        self.comp_provider = Some(provider);
1397    }
1398
1399    /// Drive the signature-help box: `(` opens it; while open, every relevant
1400    /// edit/move re-queries and a `None` reply closes it. No-op without a provider.
1401    fn drive_signature(&mut self, event: CompletionEvent) {
1402        let query = matches!(event, CompletionEvent::Typed('(')) || self.signature.is_some();
1403        if !query {
1404            return;
1405        }
1406        if let Some(mut provider) = self.sig_provider.take() {
1407            let cx = self.build_sig_cx();
1408            self.signature = provider.signature(&cx);
1409            self.sig_provider = Some(provider);
1410        } else {
1411            // No synchronous provider: record an async request for the host.
1412            let head = self.doc.selections().newest().head();
1413            self.pending_signature_request = Some(SignatureRequest {
1414                revision: self.doc.revision(),
1415                position: self.doc.buffer().offset_to_point(head),
1416            });
1417        }
1418    }
1419
1420    /// Accept the popup's selected item: replace the completion word with the
1421    /// item's insertion (a snippet expands and starts an interactive tab-stop
1422    /// session, selecting the first stop), sealed as one edit. Fires the retrigger
1423    /// if requested.
1424    fn accept_completion(&mut self) {
1425        let Some(item) = self.completion.accept() else { return };
1426        let replace = item.replace.clone().unwrap_or_else(|| self.completion_word());
1427        self.set_selection_range(replace.clone());
1428
1429        let expanded = match &item.insert {
1430            InsertText::Plain(s) => {
1431                self.doc.insert_text(s);
1432                None
1433            }
1434            InsertText::Snippet(body) => match Snippet::parse(body) {
1435                Ok(snip) => {
1436                    let indent = self.line_indent(replace.start);
1437                    let e = snip.for_insertion(&indent, default_indent_size() as usize);
1438                    self.doc.insert_text(&e.text);
1439                    Some(e)
1440                }
1441                Err(_) => {
1442                    self.doc.insert_text(body);
1443                    None
1444                }
1445            },
1446        };
1447
1448        if let Some(mut s) = self.snippet.take() {
1449            s.cancel(self.doc.decorations_mut());
1450        }
1451        if let Some(e) = expanded {
1452            match SnippetSession::start(&e, replace.start, self.doc.decorations_mut()) {
1453                Some((session, first)) => {
1454                    self.set_selection_range(first);
1455                    self.snippet = Some(session);
1456                }
1457                None => {
1458                    let fin = e.stops.last().map_or(e.text.len() as u32, |s| s.range.start);
1459                    self.set_caret(replace.start + fin);
1460                }
1461            }
1462        }
1463        self.doc.tokenize_highlight(self.viewport.end);
1464        let now = self.now_ms();
1465        self.doc.maybe_rescan_find(now);
1466        self.dirty = true;
1467
1468        if item.retrigger && self.snippet.is_none() {
1469            let cx = self.build_cx(CompletionTrigger::Manual);
1470            let word = self.completion_word_text();
1471            if let Some(provider) = self.comp_provider.as_mut() {
1472                self.completion.on_input(&cx, &word, &mut **provider);
1473            }
1474        }
1475    }
1476
1477    /// Tab / Shift+Tab through the active snippet session.
1478    fn snippet_tab(&mut self, forward: bool) {
1479        let Some(mut session) = self.snippet.take() else { return };
1480        match session.tab(forward, self.doc.decorations_mut()) {
1481            TabOutcome::Move(range) => {
1482                self.set_selection_range(range);
1483                self.snippet = Some(session);
1484            }
1485            TabOutcome::Finish(offset) => self.set_caret(offset),
1486            TabOutcome::Stay => self.snippet = Some(session),
1487        }
1488    }
1489
1490    /// Cancel the snippet session if the primary caret has left every stop.
1491    fn reconcile_snippet(&mut self) {
1492        if self.snippet.is_none() {
1493            return;
1494        }
1495        let head = self.doc.selections().newest().head();
1496        let escaped = self.snippet.as_ref().unwrap().edit_escapes(&(head..head), self.doc.decorations());
1497        if escaped {
1498            let mut s = self.snippet.take().unwrap();
1499            s.cancel(self.doc.decorations_mut());
1500        }
1501    }
1502
1503    /// Move the primary caret to `offset`.
1504    fn set_caret(&mut self, offset: u32) {
1505        let mut set = SelectionSet::new(0);
1506        set.set_single(Selection::caret(SelectionId(0), offset));
1507        self.doc.set_selections(set);
1508    }
1509
1510    /// Select `range` as the primary selection.
1511    fn set_selection_range(&mut self, range: Range<u32>) {
1512        let mut set = SelectionSet::new(0);
1513        set.set_single(Selection::from_anchor(SelectionId(0), range.start, range.end));
1514        self.doc.set_selections(set);
1515    }
1516
1517    /// The completion-word byte range ending at the primary caret (empty at a
1518    /// boundary), computed with `is_completion_word_char`.
1519    fn completion_word(&self) -> Range<u32> {
1520        let head = self.doc.selections().newest().head();
1521        let p = self.doc.buffer().offset_to_point(head);
1522        let line_start = head - p.col;
1523        let prefix = &self.doc.buffer().line(p.row)[..p.col as usize];
1524        let word_start = prefix
1525            .char_indices()
1526            .rev()
1527            .take_while(|(_, c)| is_completion_word_char(*c))
1528            .last()
1529            .map_or(prefix.len(), |(i, _)| i);
1530        (line_start + word_start as u32)..head
1531    }
1532
1533    /// The text of the completion word under the caret.
1534    fn completion_word_text(&self) -> String {
1535        let w = self.completion_word();
1536        self.doc.buffer().slice(w.start..w.end).into_owned()
1537    }
1538
1539    /// The leading whitespace of the line containing byte `offset`.
1540    fn line_indent(&self, offset: u32) -> String {
1541        let row = self.doc.buffer().offset_to_point(offset).row;
1542        self.doc.buffer().line(row).chars().take_while(|c| *c == ' ' || *c == '\t').collect()
1543    }
1544
1545    /// Build a completion request from the current document state. The lookback
1546    /// slice (up to `LOOKBACK_LINES`) is skipped when the popup is already open
1547    /// and a word char was typed — that path only refilters locally and never
1548    /// reads it, so the hot per-keystroke path avoids the slice copy.
1549    fn build_cx(&self, trigger: CompletionTrigger) -> CompletionCx {
1550        let head = self.doc.selections().newest().head();
1551        let position = self.doc.buffer().offset_to_point(head);
1552        let refilter_only =
1553            self.completion.is_open() && matches!(trigger, CompletionTrigger::Typed(_));
1554        let lookback = if refilter_only {
1555            String::new()
1556        } else {
1557            let start_row = position.row.saturating_sub(LOOKBACK_LINES - 1);
1558            let lb_start = self.doc.buffer().point_to_offset(Point::new(start_row, 0));
1559            self.doc.buffer().slice(lb_start..head).into_owned()
1560        };
1561        CompletionCx {
1562            doc: self.doc.buffer().doc_id(),
1563            revision: self.doc.revision().0,
1564            position,
1565            word: self.completion_word(),
1566            lookback,
1567            trigger,
1568        }
1569    }
1570
1571    /// Build a signature request from the current document state.
1572    fn build_sig_cx(&self) -> SignatureCx {
1573        let head = self.doc.selections().newest().head();
1574        let position = self.doc.buffer().offset_to_point(head);
1575        let start_row = position.row.saturating_sub(LOOKBACK_LINES - 1);
1576        let lb_start = self.doc.buffer().point_to_offset(Point::new(start_row, 0));
1577        SignatureCx {
1578            doc: self.doc.buffer().doc_id(),
1579            revision: self.doc.revision().0,
1580            position,
1581            lookback: self.doc.buffer().slice(lb_start..head).into_owned(),
1582        }
1583    }
1584
1585    /// The word range around `offset` (scanning both directions with
1586    /// `is_completion_word_char`) — the word under the hover pointer.
1587    fn word_around(&self, offset: u32) -> Range<u32> {
1588        let p = self.doc.buffer().offset_to_point(offset);
1589        let line = self.doc.buffer().line(p.row);
1590        let line_start = offset - p.col;
1591        let col = p.col as usize;
1592        let start = line[..col]
1593            .char_indices()
1594            .rev()
1595            .take_while(|(_, c)| is_completion_word_char(*c))
1596            .last()
1597            .map_or(col, |(i, _)| i);
1598        let mut end = col;
1599        for c in line[col..].chars().take_while(|c| is_completion_word_char(*c)) {
1600            end += c.len_utf8();
1601        }
1602        (line_start + start as u32)..(line_start + end as u32)
1603    }
1604
1605    /// Build a hover request for the word under `offset`. The lookback runs
1606    /// through the word's END, so the provider reads the full word from its tail.
1607    fn build_hover_cx(&self, offset: u32) -> HoverCx {
1608        let word = self.word_around(offset);
1609        let position = self.doc.buffer().offset_to_point(offset);
1610        let start_row = position.row.saturating_sub(LOOKBACK_LINES - 1);
1611        let lb_start = self.doc.buffer().point_to_offset(Point::new(start_row, 0));
1612        HoverCx {
1613            doc: self.doc.buffer().doc_id(),
1614            revision: self.doc.revision().0,
1615            position,
1616            word: word.clone(),
1617            lookback: self.doc.buffer().slice(lb_start..word.end).into_owned(),
1618        }
1619    }
1620}
1621
1622/// Place `[Fill field | buttons]` in a styled, fixed-width container — so both
1623/// find and replace boxes come out identical regardless of how many buttons each
1624/// holds. The focus ring lives here (a container can't read its field's focus),
1625/// driven by the caller-tracked `focused` flag.
1626fn box_of<'a>(
1627    field: Element<'a, Event>,
1628    buttons: Vec<Element<'a, Event>>,
1629    focused: bool,
1630    w: f32,
1631    h: f32,
1632    gap: f32,
1633    margin: f32,
1634) -> Element<'a, Event> {
1635    container(
1636        row![field, row(buttons).spacing(gap).align_y(Alignment::Center)]
1637            .spacing(gap)
1638            .align_y(Alignment::Center),
1639    )
1640    .width(Length::Fixed(w))
1641    .height(Length::Fixed(h))
1642    .padding(iced::Padding::from([0.0, margin]))
1643    .style(move |_theme: &Theme| container::Style {
1644        background: Some(Color::from_rgb8(0x3C, 0x3C, 0x3C).into()),
1645        border: iced::border::rounded(4.0).width(1.0).color(if focused {
1646            Color::from_rgb8(0x00, 0x7F, 0xD4) // focus ring
1647        } else {
1648            Color::TRANSPARENT
1649        }),
1650        ..container::Style::default()
1651    })
1652    .into()
1653}
1654
1655/// A severity's display name for the diagnostic hover.
1656fn severity_label(sev: Severity) -> &'static str {
1657    match sev {
1658        Severity::Error => "error",
1659        Severity::Warning => "warning",
1660        Severity::Info => "info",
1661        Severity::Hint => "hint",
1662    }
1663}
1664
1665/// The find bar's global chord table — a free fn so it is testable without a
1666/// window. `status` is what the widget tree did with the key BEFORE this saw it,
1667/// and is load-bearing for Tab: a focused editor captures Tab (it indents), so
1668/// gating on `Ignored` keeps indent working while the bar is open.
1669fn find_chord(
1670    key: &iced::keyboard::Key,
1671    modifiers: iced::keyboard::Modifiers,
1672    status: iced::event::Status,
1673) -> Option<Event> {
1674    use iced::keyboard::{key::Named, Key};
1675    let ctrl = modifiers.command() || modifiers.control();
1676    match key {
1677        Key::Character(c) if ctrl && c.as_str() == "f" => Some(Event::OpenFind),
1678        Key::Character(c) if ctrl && c.as_str() == "h" => Some(Event::OpenReplace),
1679        Key::Named(Named::Escape) => Some(Event::CloseFind),
1680        // Alt+Enter selects all matches — safe as a global chord because the
1681        // editor ignores Alt+Enter. Plain Enter is NOT here: in the input it
1682        // navigates via `on_submit`, in the editor it must only type a newline.
1683        Key::Named(Named::Enter) if modifiers.alt() => Some(Event::FindSelectAll),
1684        // Tab moves focus between the inputs — but only when nothing else took
1685        // the key (the editor captures Tab to indent).
1686        Key::Named(Named::Tab) if status == iced::event::Status::Ignored => {
1687            Some(Event::CycleFocus { back: modifiers.shift() })
1688        }
1689        _ => None,
1690    }
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695    use super::*;
1696
1697    // A minimal grammar: one keyword-scoped rule over word runs. Enough to drive
1698    // the highlight cache without pulling the example's Rust grammar into the lib.
1699    const GRAMMAR: &str = "%YAML 1.2\n---\nname: T\nscope: source.t\ncontexts:\n  main:\n    - match: '\\w+'\n      scope: keyword.t\n";
1700
1701    /// The cold-load fix, as a fails-first regression: attaching a grammar
1702    /// tokenizes the visible document *at construction*, so the first paint is
1703    /// coloured with NO `update`/`ViewportChanged` ever called. Without the seed
1704    /// in [`CodeEditor::language`] the frontier stays fully dirty after
1705    /// `set_syntax` and this assertion fails — which was exactly the
1706    /// "no highlighting until one scroll tick" bug.
1707    #[test]
1708    fn language_tokenizes_at_load_without_a_viewport_report() {
1709        let grammar = SyntaxDef::from_sublime_syntax(GRAMMAR).expect("grammar parses");
1710        let editor = CodeEditor::new("fn main() {}\nlet x = 1;\n").language(grammar);
1711        assert!(
1712            editor.document().highlight_frontier().is_none(),
1713            "grammar attach left the highlight frontier dirty — cold load would show uncoloured text until a scroll",
1714        );
1715    }
1716
1717    /// Plain-text (no grammar) is a valid state: no highlight cache, nothing to
1718    /// pump, the sweep subscription stays idle.
1719    #[test]
1720    fn no_grammar_leaves_no_frontier_to_pump() {
1721        let editor = CodeEditor::new("plain text\n");
1722        assert!(editor.document().highlight_frontier().is_none());
1723    }
1724
1725    /// Replace-all driven through the relocated find bar's [`Event`]s must land as
1726    /// one transaction: a single undo restores the document byte-for-byte. Proves
1727    /// the find/replace message path commits once, not once per match.
1728    #[test]
1729    fn replace_all_through_events_is_one_undo_step() {
1730        let mut ed = CodeEditor::new("foo foo foo\n");
1731        let _ = ed.update(Event::OpenFind);
1732        let _ = ed.update(Event::FindQuery("foo".into()));
1733        let _ = ed.update(Event::ReplaceText("bar".into()));
1734        let _ = ed.update(Event::ReplaceAll);
1735        assert_eq!(ed.document().text().into_owned(), "bar bar bar\n");
1736        let _ = ed.update(Event::Editor(Action::Undo));
1737        assert_eq!(ed.document().text().into_owned(), "foo foo foo\n");
1738    }
1739
1740    /// A disabled find bar swallows its open chords — the editor stays find-less.
1741    #[test]
1742    fn find_disabled_ignores_open() {
1743        let mut ed = CodeEditor::new("abc\n").find(false);
1744        let _ = ed.update(Event::OpenFind);
1745        assert!(!ed.find_open, "find(false) must not open the bar");
1746    }
1747
1748    /// A half-typed regex is a NORMAL invalid state (not "no results") — the bar
1749    /// reports the pattern error rather than implying the document lacks a match.
1750    #[test]
1751    fn half_typed_regex_reports_invalid() {
1752        let mut ed = CodeEditor::new("abc\n");
1753        let _ = ed.update(Event::OpenFind);
1754        let _ = ed.update(Event::ToggleRegex);
1755        let _ = ed.update(Event::FindQuery("(".into())); // unbalanced while typing
1756        assert!(
1757            ed.document().find_pattern_error().is_some(),
1758            "a half-typed regex is a normal invalid state, surfaced as a pattern error",
1759        );
1760    }
1761
1762    /// Find-in-selection scopes matches to the selection: the third `foo` outside
1763    /// the selected span is not counted.
1764    #[test]
1765    fn find_in_selection_scopes_the_matches() {
1766        let mut ed = CodeEditor::new("foo foo foo\n");
1767        let _ = ed.update(Event::OpenFind);
1768        let _ = ed.update(Event::Editor(Action::DragSelect {
1769            granularity: scrive_core::Granularity::Char,
1770            origin: 0,
1771            head: 7, // "foo foo"
1772        }));
1773        let _ = ed.update(Event::ToggleFindInSelection);
1774        assert!(ed.document().find_scope().is_some(), "the toggle sets the document scope");
1775        let _ = ed.update(Event::FindQuery("foo".into()));
1776        assert_eq!(ed.document().find_match_count(), 2, "matches are scoped to the selection");
1777    }
1778
1779    /// The replace button NAVIGATES to a match before it overwrites: the first
1780    /// press selects, the second replaces (so you always see what you replace).
1781    #[test]
1782    fn replace_navigates_before_it_overwrites() {
1783        let mut ed = CodeEditor::new("foo foo\n");
1784        let _ = ed.update(Event::OpenFind);
1785        let _ = ed.update(Event::FindQuery("foo".into()));
1786        let _ = ed.update(Event::ReplaceText("bar".into()));
1787        let _ = ed.update(Event::ReplaceOne);
1788        assert_eq!(ed.document().text().into_owned(), "foo foo\n", "first press only navigates");
1789        let _ = ed.update(Event::ReplaceOne);
1790        assert_eq!(ed.document().text().into_owned(), "bar foo\n", "second press overwrites the match");
1791    }
1792
1793    /// The find chord table maps the global keys (a pure fn, testable without a
1794    /// window).
1795    #[test]
1796    fn find_chords_map_the_global_keys() {
1797        use iced::keyboard::{key::Named, Key, Modifiers};
1798        let ignored = iced::event::Status::Ignored;
1799        assert!(matches!(
1800            find_chord(&Key::Character("f".into()), Modifiers::CTRL, ignored),
1801            Some(Event::OpenFind)
1802        ));
1803        assert!(matches!(
1804            find_chord(&Key::Character("h".into()), Modifiers::CTRL, ignored),
1805            Some(Event::OpenReplace)
1806        ));
1807        assert!(matches!(
1808            find_chord(&Key::Named(Named::Escape), Modifiers::empty(), ignored),
1809            Some(Event::CloseFind)
1810        ));
1811    }
1812
1813    struct OneCompletion;
1814    impl Completions for OneCompletion {
1815        fn complete(&mut self, _cx: &CompletionCx) -> Vec<scrive_core::CompletionItem> {
1816            vec![scrive_core::CompletionItem::plain("hello", scrive_core::CompletionKind::Keyword)]
1817        }
1818    }
1819
1820    /// End-to-end through the relocated intel loop: typing a word char opens the
1821    /// popup off the injected provider, and accepting it inserts the item as one
1822    /// edit. Proves `drive_completion` + `accept_completion` are wired to `update`.
1823    #[test]
1824    fn typing_opens_and_accepting_inserts_a_completion() {
1825        let mut ed = CodeEditor::new("").completions(OneCompletion);
1826        let _ = ed.update(Event::Editor(Action::Type('h')));
1827        assert!(
1828            matches!(ed.completion.state(), CompletionState::Open(_)),
1829            "typing a word char with a provider opens the popup",
1830        );
1831        let _ = ed.update(Event::Editor(Action::PopupAccept));
1832        assert_eq!(ed.document().text().into_owned(), "hello");
1833    }
1834
1835    /// No provider ⇒ no popup, even on a word char (the drive loop no-ops).
1836    #[test]
1837    fn no_provider_never_opens_a_popup() {
1838        let mut ed = CodeEditor::new("");
1839        let _ = ed.update(Event::Editor(Action::Type('h')));
1840        assert!(matches!(ed.completion.state(), CompletionState::Closed));
1841    }
1842
1843    /// The blessed whole-buffer swap replaces the text AND re-tokenizes the
1844    /// visible document at load — with no `ViewportChanged`. Fails-first against a
1845    /// `load` that swapped the buffer but left the (grammar-swapped) cache dirty.
1846    #[test]
1847    fn load_swaps_the_buffer_and_retokenizes() {
1848        let grammar = SyntaxDef::from_sublime_syntax(GRAMMAR).expect("grammar parses");
1849        let mut ed = CodeEditor::new("old\n").language(grammar);
1850        let g2 = SyntaxDef::from_sublime_syntax(GRAMMAR).expect("grammar parses");
1851        ed.load("brand new content\nsecond line\n", Some(g2));
1852        assert_eq!(ed.document().text().into_owned(), "brand new content\nsecond line\n");
1853        assert!(
1854            ed.document().highlight_frontier().is_none(),
1855            "load must re-tokenize the visible document",
1856        );
1857    }
1858
1859    /// The `bracket_lexing` builder wires through to the document's bracket
1860    /// matching: a bracket inside a string is not counted, so it is not coloured,
1861    /// folded, or indent-guided.
1862    #[test]
1863    fn bracket_lexing_skips_in_string_brackets() {
1864        let grammar = SyntaxDef::from_sublime_syntax(GRAMMAR).expect("grammar parses");
1865        // `let s = "(";\nf(x);\n` — the ( at 9 is inside the string; f(x)'s ( ) code.
1866        let ed = CodeEditor::new("let s = \"(\";\nf(x);\n")
1867            .language(grammar)
1868            .bracket_lexing(vec![b'"'], None);
1869        let offs: Vec<u32> = ed.document().brackets().all().iter().map(|b| b.offset).collect();
1870        assert_eq!(offs, vec![14, 16], "the ( inside the string is skipped; f(x) counts");
1871    }
1872
1873    /// A programmatic edit runs the tail (applies, marks dirty).
1874    #[test]
1875    fn edit_applies_and_marks_dirty() {
1876        let mut ed = CodeEditor::new("abc\n");
1877        ed.edit(vec![scrive_core::EditOp::new(0..0, "X")]);
1878        assert_eq!(ed.document().text().into_owned(), "Xabc\n");
1879        assert!(ed.is_dirty());
1880    }
1881
1882    /// Async completions, end to end: with no sync provider, typing a word char
1883    /// records a request; the host fulfills it and ingests the result, which opens
1884    /// the popup; accepting inserts the item.
1885    #[test]
1886    fn async_completion_request_and_ingest_round_trip() {
1887        let mut ed = CodeEditor::new("");
1888        let _ = ed.update(Event::Editor(Action::Type('h')));
1889        let req = ed.take_completion_request().expect("a word char records an async request");
1890        ed.set_completions(
1891            req.revision,
1892            vec![CompletionItem::plain("hello", scrive_core::CompletionKind::Keyword)],
1893        );
1894        assert!(
1895            matches!(ed.completion.state(), CompletionState::Open(_)),
1896            "ingesting items at the current revision opens the popup",
1897        );
1898        let _ = ed.update(Event::Editor(Action::PopupAccept));
1899        assert_eq!(ed.document().text().into_owned(), "hello");
1900    }
1901
1902    /// Strict staleness: a result computed against a revision the buffer has moved
1903    /// past is dropped (the host re-requests on the next edit).
1904    #[test]
1905    fn stale_set_completions_is_dropped() {
1906        let mut ed = CodeEditor::new("");
1907        let _ = ed.update(Event::Editor(Action::Type('h')));
1908        let req = ed.take_completion_request().unwrap();
1909        // The buffer moves on before the async result arrives.
1910        let _ = ed.update(Event::Editor(Action::Type('i')));
1911        ed.set_completions(
1912            req.revision,
1913            vec![CompletionItem::plain("hello", scrive_core::CompletionKind::Keyword)],
1914        );
1915        assert!(
1916            matches!(ed.completion.state(), CompletionState::Closed),
1917            "a stale completion result must be dropped, not shown",
1918        );
1919    }
1920
1921    /// Snippets ride completions with no async-specific work: an async-ingested
1922    /// snippet item, once accepted, starts an interactive tab-stop session.
1923    #[test]
1924    fn async_snippet_item_starts_a_session_on_accept() {
1925        let mut ed = CodeEditor::new("");
1926        let _ = ed.update(Event::Editor(Action::Type('i')));
1927        let req = ed.take_completion_request().unwrap();
1928        let snippet = CompletionItem::new(
1929            "iflet",
1930            scrive_core::CompletionKind::Keyword,
1931            InsertText::Snippet("if ${1:cond} {\n\t$0\n}".into()),
1932        );
1933        ed.set_completions(req.revision, vec![snippet]);
1934        assert!(matches!(ed.completion.state(), CompletionState::Open(_)));
1935        let _ = ed.update(Event::Editor(Action::PopupAccept));
1936        assert!(
1937            ed.snippet.is_some(),
1938            "accepting an async-ingested snippet item starts a tab-stop session",
1939        );
1940    }
1941
1942    /// Typing `(` with no synchronous signature provider records an async
1943    /// request — the signature twin of the completion seam.
1944    #[test]
1945    fn typing_open_paren_records_async_signature_request() {
1946        let mut ed = CodeEditor::new("");
1947        let _ = ed.update(Event::Editor(Action::Type('(')));
1948        assert!(
1949            ed.take_signature_request().is_some(),
1950            "'(' with no provider records an async signature request",
1951        );
1952    }
1953
1954    /// Hovering a word with no synchronous hover provider records an async
1955    /// request — the hover twin of the completion seam.
1956    #[test]
1957    fn hover_over_a_word_records_async_request() {
1958        let mut ed = CodeEditor::new("hello world\n");
1959        let _ = ed.update(Event::Editor(Action::HoverQuery(2))); // inside "hello"
1960        assert!(
1961            ed.take_hover_request().is_some(),
1962            "hovering a word with no provider records an async hover request",
1963        );
1964    }
1965
1966    /// A large document (≥ the parallel threshold) spins up the off-thread pool
1967    /// at load; a small one keeps the synchronous path. This is what stops a huge
1968    /// buffer from blocking the UI thread tokenizing synchronously.
1969    #[test]
1970    fn large_document_uses_the_parallel_pool() {
1971        let grammar = SyntaxDef::from_sublime_syntax(GRAMMAR).expect("grammar parses");
1972        let big = "fn f() {}\n".repeat(230_000); // ~2.3 MB, over PARALLEL_MIN_BYTES
1973        let ed = CodeEditor::new(big).language(grammar);
1974        assert!(ed.hl_pool.is_some(), "a large document spins up the parallel highlight pool");
1975
1976        let g2 = SyntaxDef::from_sublime_syntax(GRAMMAR).expect("grammar parses");
1977        let small = CodeEditor::new("fn f() {}\n").language(g2);
1978        assert!(small.hl_pool.is_none(), "a small document keeps the synchronous path");
1979    }
1980
1981    /// `dirty` tracks actual text change: a bare caret move must not dirty the
1982    /// document (else a host would schedule a needless recompile / save), but
1983    /// typing does.
1984    #[test]
1985    fn caret_move_does_not_dirty_but_typing_does() {
1986        let mut ed = CodeEditor::new("abc\n");
1987        let _ = ed.update(Event::Editor(Action::PlaceCaret(1)));
1988        assert!(!ed.is_dirty(), "a caret move must not dirty the document");
1989        let _ = ed.update(Event::Editor(Action::Type('x')));
1990        assert!(ed.is_dirty(), "typing dirties the document");
1991    }
1992
1993    /// The incremental-change log (LSP `didChange`) is off by default and logs
1994    /// applied edits once enabled, draining clean. Full-sync hosts never touch it.
1995    #[test]
1996    fn drain_changes_mirrors_edits_when_observing() {
1997        let mut ed = CodeEditor::new("hello\n");
1998        assert!(ed.drain_changes().is_empty(), "the change log is off by default");
1999        ed.observe_changes(true);
2000        let _ = ed.update(Event::Editor(Action::Type('X'))); // insert 'X' at the caret (offset 0)
2001        let changes = ed.drain_changes();
2002        assert_eq!(changes.len(), 1, "one keystroke logs one change");
2003        assert_eq!(changes[0].text, "X");
2004        assert!(ed.drain_changes().is_empty(), "draining clears the log");
2005    }
2006}