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}