Skip to main content

scrive_iced/
editor.rs

1//! The editor widget: a direct `iced::advanced::Widget`.
2//!
3//! It renders a [`scrive_core::Document`] — gutter with line numbers, text,
4//! N carets, and selection highlights — and translates raw key/mouse input
5//! into semantic [`Action`]s the application applies to its `Document` (the
6//! controlled-widget pattern: the widget borrows `&Document` for drawing and
7//! never mutates it behind the app's back). Implementing `Widget` directly
8//! (not `canvas::Program`) is what lets it participate in iced's focus/operation
9//! protocol — only the low-level widget API exposes the `operate()` hook that
10//! protocol needs.
11//!
12//! **Font and size are configurable** ([`Editor::font`], [`Editor::text_size`]);
13//! the cell advance is **measured** from the renderer for that font (see
14//! [`crate::metrics`]), never hardcoded — so the caret tracks the
15//! glyphs for whatever monospace face the app picks, in any renderer.
16//!
17//! The widget owns its **scroll offset**: the wheel moves it
18//! directly, and autoscroll-to-caret is a *deferred* request — set only after a
19//! caret-moving action and resolved at layout — so the view never chases the
20//! caret while the user is scrolling by hand.
21
22use iced::advanced::clipboard::Kind as ClipboardKind;
23use iced::advanced::text::{Alignment, LineHeight, Renderer as _, Shaping, Text, Wrapping};
24use iced::advanced::widget::operation::Focusable;
25use iced::advanced::widget::{self, Operation, Widget};
26use iced::advanced::{layout, mouse, renderer, Clipboard, Layout, Shell};
27use iced::alignment::Vertical;
28use iced::keyboard::key::Named;
29use iced::keyboard::{Event as Keyboard, Key, Modifiers};
30use iced::{border, window, Color, Element, Event, Font, Length, Pixels, Point, Rectangle, Shadow, Size, Vector};
31
32use std::cell::Ref;
33use std::ops::Range;
34use std::time::{Duration, Instant};
35
36use scrive_core::{
37    display_map, BufferRow, ColumnDir, CompletionKind, Document, FoldMap, Granularity,
38    HighlightSpan, HoverInfo, Motion, Point as BufPoint, PopupList, RevealMode, RowLayout, Severity,
39    SignatureInfo, HOVER_IDLE_DELAY_MS,
40};
41
42use crate::geo::{Geo, ScrollAnchor, CHIP_PILL_RADIUS, TEXT_PAD};
43use crate::popup;
44
45use crate::metrics::Metrics;
46
47/// The two scrollbar-overview lanes filled per frame by
48/// [`Document::overview_marks`]: per track-pixel bucket, the diagnostic lane's
49/// `(encoded severity, winner offset)` and the find lane's first-match offset.
50/// Named so the retained per-thread scratch stays under `clippy::type_complexity`.
51type OverviewLanes = (Vec<(u8, u32)>, Vec<Option<u32>>);
52
53/// Default font size in logical pixels.
54const DEFAULT_SIZE: f32 = 14.0;
55/// Row height as a multiple of the font size (the editor convention).
56const LINE_HEIGHT_RATIO: f32 = 1.43;
57/// Padding on each side of the gutter's line numbers.
58const GUTTER_PAD: f32 = 8.0;
59/// Gap between the line numbers and the fold-chevron column — breathing
60/// room so the chevron doesn't crowd the numbers.
61const FOLD_GAP: f32 = 7.0;
62/// Ctrl+hover collapse affordance: the dashed box drawn around a collapsible
63/// while Ctrl is held. A warm yellow (the fold-chip / find-match accent) at three
64/// intensities — a dim outline on every collapsible in the pointer's nest, a
65/// bright outline plus a faint wash on the innermost (the Ctrl+Click target).
66const ARM_DASH: Color = Color::from_rgba8(0xfc, 0xe5, 0x66, 0.38);
67/// The innermost (Ctrl+Click target) box outline — brighter than the nest.
68const ARM_DASH_ACTIVE: Color = Color::from_rgba8(0xfc, 0xe5, 0x66, 0.85);
69/// The faint interior wash under the innermost box.
70const ARM_FILL: Color = Color::from_rgba8(0xfc, 0xe5, 0x66, 0.07);
71/// Dash run, gap, and stroke width (px) of the collapse box.
72const ARM_DASH_LEN: f32 = 4.0;
73/// Gap between dashes (px).
74const ARM_DASH_GAP: f32 = 3.0;
75/// Stroke width of the dashed collapse box (px).
76const ARM_STROKE: f32 = 1.0;
77/// Corner radius (px) of the collapse box — the straight dashed edges are inset by
78/// this and the corners are rounded with a short arc.
79const ARM_RADIUS: f32 = 4.0;
80/// Plain-hover expand affordance: the wash over a collapsed fold's `…`
81/// pill while the pointer rests on the chip — quiet hover feedback that a
82/// click expands it. A neutral foreground tint (theme-derived) so it reads as
83/// button-hover, not selection. Perception-calibrated; provisional until judged
84/// on the running app.
85const CHIP_HOVER_A: f32 = 0.10;
86/// Feature gate for the Ctrl+hover *collapse* affordance — the dashed discovery
87/// boxes, the Ctrl+Click-to-collapse gesture, its finger cursor, and the redraws
88/// that flash the boxes as Ctrl/the pointer move. Disabled — folding stays on the
89/// gutter chevrons and `Ctrl+Shift+[`/`]`. This does **not** touch the plain-hover
90/// *expand* affordance (the wash + finger cursor + click on a collapsed `…` chip),
91/// which is intentionally always on. Flip to `true` to bring the collapse boxes back.
92const SHOW_CTRL_COLLAPSE_AFFORDANCE: bool = false;
93
94/// Tab size for display expansion — *derived* from the core's one owner, never
95/// a hand-copied mirror (a change there propagates here at compile time).
96const TAB: u32 = display_map::default_tab_size();
97/// Rows moved per wheel notch.
98const WHEEL_ROWS: f32 = 3.0;
99/// Columns scrolled per horizontal wheel-line notch (trackpads send pixels).
100const WHEEL_COLS: f32 = 3.0;
101/// Caret bar width, in logical pixels. The caret is centered on the insertion
102/// point (drawn at `x − CARET_WIDTH/2`), so it straddles the character boundary
103/// rather than sitting to its right — the offset is derived from the width, not
104/// a calibration constant. (Field-confirmed at 2 px: the caret needed to move
105/// exactly `width/2` left.)
106const CARET_WIDTH: f32 = 2.0;
107/// Caret blink half-period, in milliseconds: the caret shows for
108/// one interval and hides for the next while the editor is focused.
109const BLINK_MS: u128 = 500;
110/// Current-line highlight alpha over the foreground: a faint full-row tint under
111/// a single bare cursor. Perception-calibrated; provisional until judged on the
112/// running app.
113const LINE_HIGHLIGHT_A: f32 = 0.05;
114/// Scrollbar overlay width (px), on the right edge, shown only on overflow.
115/// Exposed so app chrome (e.g. a floating find bar) can inset itself clear of
116/// the scrollbar lane.
117pub const SCROLLBAR_WIDTH: f32 = 12.0;
118/// Minimum scrollbar thumb height (px), so a huge file still has a grabbable thumb.
119const SCROLLBAR_MIN_THUMB: f32 = 24.0;
120/// Height (px) of a scrollbar overview marker tick (diagnostics / find matches).
121const SCROLLBAR_MARK_H: f32 = 2.0;
122/// Scrollbar slider fill — a neutral gray, theme-agnostic like the other chrome.
123const SCROLLBAR_THUMB: Color = Color::from_rgba8(0x79, 0x79, 0x79, 0.4);
124/// Slider while dragging — brighter than the idle fill.
125/// (A distinct hover state needs cursor-over-thumb tracking and is not yet drawn.)
126const SCROLLBAR_THUMB_ACTIVE: Color = Color::from_rgba8(0xbf, 0xbf, 0xbf, 0.4);
127/// Extra rows below the fold to keep tokenized, so a small scroll doesn't
128/// reveal an uncolored line before the next viewport report catches up.
129const VIEWPORT_TOKENIZE_MARGIN: u32 = 8;
130/// Bracket-pair colorization depth colors, cycled by `depth mod N`. The
131/// mainstream-editor default palette — gold / orchid / blue; deeper levels wrap
132/// back to gold, so the cycle is exactly 3. A theme palette may override it.
133const BRACKET_DEPTH: [Color; 3] = [
134    Color::from_rgb8(0xFF, 0xD7, 0x00), // gold
135    Color::from_rgb8(0xDA, 0x70, 0xD6), // orchid
136    Color::from_rgb8(0x17, 0x9F, 0xFF), // blue
137];
138/// An unmatched bracket's color — a strong red so a dangling delimiter stands out.
139const UNMATCHED_BRACKET: Color = Color::from_rgba8(0xFF, 0x12, 0x12, 0.8);
140/// Indentation-guide line width (px).
141const GUIDE_WIDTH: f32 = 1.0;
142/// Indent-guide alpha over the foreground: idle (dim) and the active guide (the
143/// guide at the caret's own indent level, drawn brighter). Perception-calibrated;
144/// provisional until judged on the running app.
145const GUIDE_IDLE_A: f32 = 0.14;
146/// Alpha for the active indent guide.
147const GUIDE_ACTIVE_A: f32 = 0.42;
148/// Shared floating-popup surface — a neutral dark panel color used by the hover,
149/// signature-help box, and completion popup so the three read as one widget
150/// family (rendered via [`fill_panel`]).
151const POPUP_SURFACE: Color = Color::from_rgb8(0x25, 0x25, 0x26);
152/// The popups' hairline edge — `#CCCCCC` at 20%, a neutral widget border.
153const POPUP_BORDER: Color = Color::from_rgba8(0xCC, 0xCC, 0xCC, 0.2);
154/// Neutral selection fill for the completion popup's focused row — a coloured
155/// accent would clash with the kind-coloured labels.
156const POPUP_SELECT: Color = Color::from_rgb8(0x37, 0x37, 0x3d);
157/// Inline-code pill background in hover markdown — a subtle darker wash behind
158/// `` `code` `` spans.
159const CODE_PILL_BG: Color = Color::from_rgba8(0x0a, 0x0a, 0x0a, 0.4);
160/// Max visible wrapped lines in the hover before it scrolls (rather than
161/// truncate).
162const HOVER_MAX_VISIBLE: usize = 12;
163/// Width (px) of the hover's auto scrollbar — thinner than the editor's.
164const HOVER_SB_W: f32 = 6.0;
165/// Find-match highlight wash: an amber fill behind every match, brighter
166/// for the active one — a warm yellow at two alphas (a theme may override).
167const FIND_MATCH: Color = Color::from_rgba8(0xfc, 0xe5, 0x66, 0.22);
168/// The active find match's brighter wash.
169const FIND_MATCH_ACTIVE: Color = Color::from_rgba8(0xfc, 0xe5, 0x66, 0.48);
170/// Rows of context the indent-guide walks may consult beyond the visible
171/// window: blank-line level interpolation and the active guide's extent only
172/// need NEARBY context for visual continuity, and clamping the walk to this
173/// slack keeps a blank-heavy file from scanning to the document edges each frame.
174const INDENT_NEIGHBOR_SLACK: u32 = 256;
175
176/// Rows above the visible window the fold-affordance queries (Ctrl+hover boxes)
177/// look for a foldable pair's header: a pair headed this far above the viewport
178/// still arms; a block taller than this enclosing the pointer is the accepted
179/// miss (fold it from its header chevron instead). Bounds the per-mouse-move
180/// `collapsible_pairs_in_rows` scan so it stays viewport-proportional.
181const FOLD_QUERY_SLACK: u32 = 2048;
182
183/// How many lines of a block the Ctrl+hover box measures for its width: a
184/// block can span the whole document, so the width scan is capped here rather
185/// than walking every line — the box reads the same, and the per-frame cost
186/// stays proportional to the viewport, not the document.
187const FOLD_BOX_SCAN_ROWS: u32 = 512;
188
189/// Word-under-caret occurrence wash (perception-calibrated, provisional until
190/// judged on the running app): the find wash's neutral, dimmer cousin — a
191/// text-color tint visible enough to trace a symbol, quiet enough to read past.
192/// Suppressed while find is live (its amber wash owns the screen then).
193const OCCURRENCE_MATCH: Color = Color::from_rgba8(0xf7, 0xf1, 0xff, 0.09);
194/// Diagnostic squiggle geometry (field-calibrated): the wave's half-period
195/// (zero-to-zero), amplitude, and stroke, in px.
196const SQUIGGLE_HALF_PERIOD: f32 = 2.0;
197/// Squiggle wave amplitude (px).
198const SQUIGGLE_AMPLITUDE: f32 = 1.5;
199/// Squiggle stroke thickness (px).
200const SQUIGGLE_STROKE: f32 = 1.0;
201
202/// The default row height for a given font size.
203fn default_line_height(size: f32) -> f32 {
204    (size * LINE_HEIGHT_RATIO).round()
205}
206
207/// Whether the caret is in the *solid* half of the blink cycle after `elapsed`
208/// milliseconds: on for `[0, 500)`, off for `[500, 1000)`, and so on. Pure, so
209/// the phase is unit-testable without a clock.
210fn blink_on(elapsed_ms: u128) -> bool {
211    (elapsed_ms / BLINK_MS).is_multiple_of(2)
212}
213
214/// A semantic editor action produced by the widget for the app to apply to its
215/// [`Document`] — an edit verb or a caret motion.
216#[derive(Clone, Debug, PartialEq)]
217pub enum Action {
218    /// Insert a typed character (replacing any selection).
219    Type(char),
220    /// Backspace.
221    Backspace,
222    /// Forward delete.
223    Delete,
224    /// Delete the word before the caret (Ctrl+Backspace).
225    DeleteWordBack,
226    /// Delete the word after the caret (Ctrl+Delete).
227    DeleteWordForward,
228    /// Split the line (Enter).
229    Enter,
230    /// Indent / insert-to-tab-stop (Tab).
231    Tab,
232    /// Outdent to the previous tab stop (Shift+Tab).
233    Outdent,
234    /// Toggle the language's line comment on the spanned lines (Ctrl+/).
235    ToggleComment,
236    /// Delete each selection's whole-line block (Ctrl+Shift+K).
237    DeleteLine,
238    /// Open a fresh indent-carrying line below (Ctrl+Enter) or above
239    /// (Ctrl+Shift+Enter) the caret's line, caret landing on it.
240    InsertLine {
241        /// Below if true, above if false.
242        down: bool,
243    },
244    /// Cut's edit half (the copy half runs in the widget): delete each
245    /// selection, an empty caret expanding to its whole line.
246    Cut,
247    /// Insert clipboard text at the carets (Paste).
248    Paste {
249        /// The pasted text (the core normalizes CRLF to LF).
250        text: String,
251        /// Whether this is our own whole-line copy (recognized from a side
252        /// table) — pasted above the caret's line, caret staying put.
253        entire_line: bool,
254    },
255    /// Move (or, with `extend`, drag) every caret.
256    Move {
257        /// Which motion.
258        motion: Motion,
259        /// Extend the selection instead of collapsing.
260        extend: bool,
261    },
262    /// Place a single caret at a byte offset (mouse click).
263    PlaceCaret(u32),
264    /// Drag-select from `origin` to `head` at a granularity — the click-drag
265    /// family. `head == origin` is the double/triple-click initial selection;
266    /// Word/Line drags extend by whole units keeping the origin unit selected.
267    DragSelect {
268        /// Character / word / line units.
269        granularity: Granularity,
270        /// The fixed origin (where the gesture began).
271        origin: u32,
272        /// The moving end (the cursor).
273        head: u32,
274    },
275    /// Add a caret at a byte offset (Alt+Click), keeping existing selections.
276    AddCaret(u32),
277    /// Add the next occurrence of the selection as a new caret (Ctrl+D).
278    AddNextOccurrence,
279    /// Select every occurrence of the selection (Ctrl+Shift+L).
280    SelectAllOccurrences,
281    /// Add a caret one display row above/below every caret (Ctrl+Alt+↑/↓).
282    AddCaretVertical {
283        /// Below if true, above if false.
284        down: bool,
285    },
286    /// Jump each caret to its matching bracket (Ctrl+Shift+\).
287    JumpToBracket,
288    /// Grow each selection one structural step (Shift+Alt+Right).
289    ExpandSelection,
290    /// Walk back down the expansion ladder (Shift+Alt+Left).
291    ShrinkSelection,
292    /// Collapse a multi-cursor set (or a range) to the primary caret (Escape).
293    Collapse,
294    /// Select the whole document (Ctrl+A).
295    SelectAll,
296    /// Undo the last edit (Ctrl+Z).
297    Undo,
298    /// Redo the last undone edit (Ctrl+Shift+Z / Ctrl+Y).
299    Redo,
300    /// Grow/shrink the column (box) selection (Ctrl+Shift+Alt+Arrow).
301    ColumnSelect(ColumnDir),
302    /// Mouse box (column) selection drag (Shift+Alt+drag): corners as
303    /// `(visible buffer row, virtual display cell)` — the core resolves the
304    /// cells per spanned display row and clamps each to its line.
305    ColumnDrag {
306        /// The fixed corner `(row, display cell)`.
307        anchor: (u32, u32),
308        /// The moving corner `(row, display cell)`.
309        active: (u32, u32),
310    },
311    /// Move the selected line-block up/down (Alt+↑/↓).
312    MoveLine {
313        /// Down if true, up if false.
314        down: bool,
315    },
316    /// Duplicate the selected line-block above/below (Shift+Alt+↑/↓).
317    CopyLine {
318        /// Below if true, above if false.
319        down: bool,
320    },
321    /// The visible buffer-row range changed (scroll / resize / autoscroll),
322    /// margin included. The app aims the highlight retention window here
323    /// (`Document::set_highlight_window`) and tokenizes highlights down to its
324    /// end — the view reports its viewport to the model so highlighting only
325    /// ever runs for what is on screen (see `update`). Not an edit: it never
326    /// moves a caret or touches history.
327    ViewportChanged(Range<u32>),
328    /// Move the completion popup's selection up / down. Published (and the
329    /// key captured) only while the popup is open; the app drives the controller.
330    PopupUp,
331    /// See [`PopupUp`](Action::PopupUp).
332    PopupDown,
333    /// Accept the popup's selected item (Enter / Tab).
334    PopupAccept,
335    /// Accept the popup row at this filtered-list index (a mouse click on it).
336    PopupClickAccept(u32),
337    /// Dismiss the popup (Escape) — sticky until a word boundary.
338    PopupDismiss,
339    /// Advance / retreat the active snippet tab stop (Tab / Shift+Tab).
340    /// Captured only while a session is active; the app drives it.
341    SnippetTab,
342    /// See [`SnippetTab`](Action::SnippetTab).
343    SnippetTabPrev,
344    /// Cancel the snippet session (Escape).
345    SnippetCancel,
346    /// Close the signature-help box (Escape — after the popup, before the
347    /// snippet session, in dismissal order).
348    SignatureClose,
349    /// Jump to the next/previous diagnostic (F8 / Shift+F8).
350    NextDiagnostic {
351        /// Forward if true (F8), backward if false (Shift+F8).
352        forward: bool,
353    },
354    /// The pointer rested over byte `offset` long enough for a hover query.
355    /// The app resolves the word + queries its provider.
356    HoverQuery(u32),
357    /// Close the hover popup (pointer left the word).
358    HoverDismiss,
359    /// Toggle the fold whose pair opens at byte offset `opener` — a
360    /// gutter-chevron click or `Ctrl+Shift+[`/`]`. The app calls
361    /// `Document::toggle_fold_opener`; not an edit (folds are view state, never on
362    /// the undo stack). Keyed by the opener so a single-line row with two `[..]`
363    /// pairs stays unambiguous.
364    ToggleFold {
365        /// Byte offset of the fold's opening bracket.
366        opener: u32,
367    },
368    /// Fold (`unfold == false`) or unfold (`unfold == true`) the block at EVERY
369    /// caret — `Ctrl+Shift+[` / `Ctrl+Shift+]`. The app calls
370    /// `Document::fold_at_carets`; not an edit (folds are view state). Distinct
371    /// from [`Action::ToggleFold`], which acts on one gutter-clicked opener.
372    FoldAtCarets {
373        /// `true` = unfold (`Ctrl+Shift+]`), `false` = fold (`Ctrl+Shift+[`).
374        unfold: bool,
375    },
376}
377
378impl Action {
379    /// Whether applying this action can move the primary caret to a possibly
380    /// off-screen spot (and so should trigger autoscroll). Clicks (`PlaceCaret`,
381    /// `AddCaret`) already land in view, and `Collapse` keeps an existing caret.
382    /// The find / multi-cursor jump verbs (`AddNextOccurrence`, `NextDiagnostic`,
383    /// …) are excluded: they reveal through the core's `request_reveal`,
384    /// which bumps the reveal generation ONLY when something changed — so the
385    /// reveal is the single source, and a no-op Ctrl+D never yanks the viewport
386    /// the way a spurious `moves_caret` autoscroll would.
387    fn moves_caret(&self) -> bool {
388        // `DragSelect` autoscrolls: for the double/triple-click case the head is
389        // in view (reveal holds), but a real drag past the edge must scroll.
390        // `SelectAll` keeps the view (Ctrl+A shouldn't jump to end-of-document).
391        !matches!(
392            self,
393            Action::PlaceCaret(_)
394                | Action::AddCaret(_)
395                | Action::Collapse
396                | Action::SelectAll
397                | Action::ViewportChanged(_)
398                | Action::PopupUp
399                | Action::PopupDown
400                | Action::PopupAccept
401                | Action::PopupClickAccept(_)
402                | Action::PopupDismiss
403                | Action::SnippetTab
404                | Action::SnippetTabPrev
405                | Action::SnippetCancel
406                | Action::SignatureClose
407                | Action::HoverQuery(_)
408                | Action::HoverDismiss
409                | Action::ToggleFold { .. }
410                | Action::FoldAtCarets { .. }
411                // These verbs reveal through the core's request_reveal —
412                // bumped only when something actually changed, so a no-op F8 /
413                // bracket jump / edge add-caret / Ctrl+D never yanks the viewport
414                // back. Ctrl+D's real add still reveals (FitForce) via that bump.
415                | Action::NextDiagnostic { .. }
416                | Action::JumpToBracket
417                | Action::ExpandSelection
418                | Action::ShrinkSelection
419                | Action::AddCaretVertical { .. }
420                | Action::SelectAllOccurrences
421                | Action::AddNextOccurrence
422        )
423    }
424}
425
426/// Keyboard focus plus the caret-blink clock (mirrors iced `text_input`'s
427/// `Focus`). Absent ⇒ unfocused (no caret).
428#[derive(Debug, Clone, Copy)]
429struct Focus {
430    /// When the caret last became solid — set on focus and reset on caret
431    /// activity, so the caret is always solid right after the user acts.
432    updated_at: Instant,
433    /// The most recent frame time, advanced on every `RedrawRequested`. The
434    /// blink phase is computed from `now − updated_at` using only timestamps
435    /// iced hands us (never `Instant::now()` inside `draw`), so a frame paints
436    /// the same caret regardless of when `draw` runs.
437    now: Instant,
438}
439
440/// An in-progress left-button drag: the unit it extends by and the origin the
441/// selection stays anchored to.
442#[derive(Copy, Clone, Debug)]
443struct Drag {
444    granularity: Granularity,
445    origin: u32,
446}
447
448/// Per-widget state held in the iced widget tree.
449#[derive(Debug)]
450struct State {
451    /// Keyboard focus + blink clock, via `operation::focusable`. Starts focused
452    /// so the single-editor scratch window (and headless capture) type/paint
453    /// immediately; focus *changes* go through the operation protocol.
454    focus: Option<Focus>,
455    /// Vertical scroll position (the widget owns this), as a display-row
456    /// [`ScrollAnchor`] — a scroll position in line units rather than a flat
457    /// pixel `f32`. A flat `f32` offset quantizes into visible multi-pixel row
458    /// steps once content passes ~2²⁴ px (a many-megabyte document); the anchor
459    /// (an integer row index plus a bounded sub-row offset) keeps position math
460    /// exact at any document size. Consumers compute in `f64` row units via
461    /// [`ScrollAnchor::rows`].
462    scroll: ScrollAnchor,
463    /// Horizontal scroll offset in pixels. Long lines don't wrap, so
464    /// the code area scrolls left by this much; the gutter stays fixed.
465    scroll_x: f32,
466    /// A pending autoscroll-to-caret, resolved at the next layout.
467    autoscroll: bool,
468    /// Latest keyboard modifiers, tracked from `ModifiersChanged` — a mouse
469    /// event carries none, so Alt+Click reads this (as iced `text_input` does).
470    modifiers: Modifiers,
471    /// The active left-button drag (granularity + origin), while in progress;
472    /// `None` otherwise. Set on press, extended on move, cleared on release.
473    drag: Option<Drag>,
474    /// The anchor `(row, cell_column)` of an in-progress Shift+Alt box (column)
475    /// drag; `None` otherwise. Takes precedence over `drag` while set.
476    column_drag_anchor: Option<(u32, u32)>,
477    /// While the scrollbar thumb is being dragged: the grab offset within the
478    /// thumb (`cursor.y - thumb_y` at press), so the thumb tracks the cursor
479    /// from where it was grabbed. `None` when not dragging the scrollbar.
480    scrollbar_grab: Option<f32>,
481    /// The horizontal analog of `scrollbar_grab`: the grab offset within the
482    /// bottom scrollbar thumb while dragging it. `None` otherwise.
483    hscrollbar_grab: Option<f32>,
484    /// The last visible row range reported to the app (the tokenize target +
485    /// highlight retention window), to dedupe `Action::ViewportChanged` so it
486    /// fires only when the visible range actually moves. `MAX..MAX` until the
487    /// first report.
488    last_reported_viewport: Range<u32>,
489    /// The previous left-button click, for double/triple-click detection (iced
490    /// `mouse::Click` — consecutive within 6 px and 300 ms).
491    last_click: Option<mouse::Click>,
492    /// Cached measured metrics and the font they were measured for; re-measured
493    /// when the configured font or size changes.
494    metrics: Metrics,
495    measured_font: Option<Font>,
496    /// The last `Document::reveal_seq` this view acted on — a bump means a
497    /// jump-class action (find navigation) moved the caret outside the input
498    /// path, so the next layout should autoscroll to it.
499    last_reveal_seq: u64,
500    /// Hover idle timer: the last pointer position over the text, whether a
501    /// re-arm is pending (set on move, resolved to `hover_at` on the next frame
502    /// so it stamps an accurate `now`), and the instant a hover query should
503    /// fire. Cleared/reset on every move so a moving pointer never queries.
504    hover_pos: Option<Point>,
505    hover_rearm: bool,
506    hover_at: Option<Instant>,
507    /// Vertical scroll offset (px) of the open hover popup's content, for hovers
508    /// taller than `HOVER_MAX_VISIBLE`. Reset to 0 when the hovered word changes.
509    hover_scroll: f32,
510    /// Whether the pointer is over the gutter: the expanded (chevron-down) fold
511    /// controls show only while it is, so an unfolded block reveals its control
512    /// on hover — as mainstream editors do. Tracked so a move on/off the gutter
513    /// triggers exactly one redraw.
514    gutter_hover: bool,
515    /// The buffer row under the pointer while it is over the gutter — drives
516    /// the hovered fold chevron's brightening (disambiguating adjacent
517    /// chevrons). Tracked so a row-to-row move inside the gutter repaints
518    /// exactly once; `None` off the gutter.
519    gutter_hover_row: Option<u32>,
520    /// The collapsed fold (its opening-bracket offset) whose `…` chip the pointer
521    /// is resting on — drives the widget-drawn hover preview of its hidden
522    /// content. Set when the hover idle timer fires over a chip; cleared on any move.
523    fold_preview: Option<u32>,
524    /// The collapsed fold whose chip the (non-Ctrl) pointer is currently over —
525    /// drives the immediate plain-hover expand highlight. Tracked so a move
526    /// on/off a chip repaints exactly once; `None` when off any chip or Ctrl is held.
527    hover_chip: Option<u32>,
528}
529
530impl Default for State {
531    fn default() -> Self {
532        let now = Instant::now();
533        Self {
534            focus: Some(Focus { updated_at: now, now }),
535            scroll: ScrollAnchor::TOP,
536            scroll_x: 0.0,
537            autoscroll: false,
538            modifiers: Modifiers::default(),
539            drag: None,
540            column_drag_anchor: None,
541            scrollbar_grab: None,
542            hscrollbar_grab: None,
543            last_reported_viewport: u32::MAX..u32::MAX,
544            last_click: None,
545            metrics: Metrics { advance: DEFAULT_SIZE * 0.6, line_height: default_line_height(DEFAULT_SIZE), size: DEFAULT_SIZE },
546            measured_font: None,
547            last_reveal_seq: 0,
548            hover_pos: None,
549            hover_rearm: false,
550            hover_at: None,
551            hover_scroll: 0.0,
552            gutter_hover: false,
553            gutter_hover_row: None,
554            fold_preview: None,
555            hover_chip: None,
556        }
557    }
558}
559
560impl State {
561    /// Whether the editor holds keyboard focus.
562    fn is_focused(&self) -> bool {
563        self.focus.is_some()
564    }
565
566    /// Take focus (or refocus), restarting the blink at solid-on.
567    fn focus(&mut self) {
568        let now = Instant::now();
569        self.focus = Some(Focus { updated_at: now, now });
570    }
571
572    /// Drop focus; the caret stops being painted.
573    fn unfocus(&mut self) {
574        self.focus = None;
575    }
576
577    /// Restart the blink at solid-on for caret activity (typing/moving), so the
578    /// caret is visible immediately after the user acts. No-op if unfocused.
579    fn ping(&mut self) {
580        if let Some(focus) = &mut self.focus {
581            let now = Instant::now();
582            focus.updated_at = now;
583            focus.now = now;
584        }
585    }
586
587    /// Whether to paint the caret this frame: focused *and* in the solid half of
588    /// the blink cycle.
589    fn caret_on(&self) -> bool {
590        self.focus
591            .is_some_and(|f| blink_on(f.now.saturating_duration_since(f.updated_at).as_millis()))
592    }
593}
594
595impl Focusable for State {
596    fn is_focused(&self) -> bool {
597        State::is_focused(self)
598    }
599
600    fn focus(&mut self) {
601        State::focus(self);
602    }
603
604    fn unfocus(&mut self) {
605        State::unfocus(self);
606    }
607}
608
609/// The editor widget. Borrows the document to draw; publishes [`Action`]s.
610#[allow(missing_debug_implementations)]
611pub struct Editor<'a, Message> {
612    id: Option<widget::Id>,
613    doc: &'a Document,
614    on_action: Box<dyn Fn(Action) -> Message + 'a>,
615    /// The open completion popup to render over the editor, if any (app-supplied
616    /// from its completion controller). `None` = no popup.
617    popup: Option<&'a PopupList>,
618    /// Whether a snippet tab-stop session is active — then the editor
619    /// captures Tab / Shift+Tab / Escape to drive it (the app owns the session).
620    snippet_active: bool,
621    /// The active signature-help box, if any (app-supplied). Rendered above
622    /// the caret; Escape closes it (after the popup, before the snippet session).
623    signature: Option<&'a SignatureInfo>,
624    /// The open hover popup, if any (app-supplied) — a markdown box anchored
625    /// at the hovered word.
626    hover: Option<&'a HoverInfo>,
627    font: Font,
628    size: f32,
629    line_height: f32,
630}
631
632impl<'a, Message> Editor<'a, Message> {
633    /// An editor rendering `doc`; `on_action(action)` is published for each
634    /// input the widget interprets. Defaults to [`Font::MONOSPACE`] at 14 px.
635    pub fn new(doc: &'a Document, on_action: impl Fn(Action) -> Message + 'a) -> Self {
636        Self {
637            id: None,
638            doc,
639            on_action: Box::new(on_action),
640            popup: None,
641            snippet_active: false,
642            signature: None,
643            hover: None,
644            font: Font::MONOSPACE,
645            size: DEFAULT_SIZE,
646            line_height: default_line_height(DEFAULT_SIZE),
647        }
648    }
649
650    /// Supply the open completion popup to render over the editor. The app
651    /// holds the completion controller and passes its `PopupList` here each
652    /// frame; `None` when no popup is open.
653    #[must_use]
654    pub fn popup(mut self, popup: Option<&'a PopupList>) -> Self {
655        self.popup = popup;
656        self
657    }
658
659    /// Tell the editor a snippet session is active, so it captures
660    /// Tab / Shift+Tab / Escape to drive it (the app owns the session).
661    #[must_use]
662    pub fn snippet_active(mut self, active: bool) -> Self {
663        self.snippet_active = active;
664        self
665    }
666
667    /// Supply the active signature-help box to render above the caret.
668    #[must_use]
669    pub fn signature(mut self, signature: Option<&'a SignatureInfo>) -> Self {
670        self.signature = signature;
671        self
672    }
673
674    /// Supply the open hover popup to render at the hovered word.
675    #[must_use]
676    pub fn hover(mut self, hover: Option<&'a HoverInfo>) -> Self {
677        self.hover = hover;
678        self
679    }
680
681    /// Give the editor a widget [`Id`](widget::Id) so the application can move
682    /// keyboard focus to it through iced's focus operations (`focus`,
683    /// `focus_next`, …) — the addressing half of the focus protocol a multi-pane
684    /// host needs. Without an id the editor can be unfocused by a focus
685    /// elsewhere but never *targeted* by one (a `None` id never matches).
686    #[must_use]
687    pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
688        self.id = Some(id.into());
689        self
690    }
691
692    /// Set the (monospace) font. The cell advance is re-measured for it.
693    #[must_use]
694    pub fn font(mut self, font: Font) -> Self {
695        self.font = font;
696        self
697    }
698
699    /// Set the font size in logical pixels; the row height follows unless later
700    /// overridden with [`Editor::line_height`].
701    #[must_use]
702    pub fn text_size(mut self, size: f32) -> Self {
703        self.size = size;
704        self.line_height = default_line_height(size);
705        self
706    }
707
708    /// Override the row height in logical pixels.
709    #[must_use]
710    pub fn line_height(mut self, line_height: f32) -> Self {
711        self.line_height = line_height;
712        self
713    }
714
715    /// Re-measure metrics into `state` if the configured font/size changed.
716    fn ensure_metrics(&self, state: &mut State) {
717        let stale = state.measured_font != Some(self.font)
718            || state.metrics.size != self.size
719            || state.metrics.line_height != self.line_height;
720        if stale {
721            state.metrics = Metrics::measure(self.font, self.size, self.line_height);
722            state.measured_font = Some(self.font);
723        }
724    }
725
726    /// Width reserved for the gutter: left pad, the line-number digits, a gap, a
727    /// one-cell fold-chevron column, and right pad.
728    ///
729    /// Layout (offsets from the widget's left edge):
730    /// `| GUTTER_PAD | digits | FOLD_GAP | chevron(advance) | GUTTER_PAD |`
731    fn gutter_width(&self, advance: f32) -> f32 {
732        let digits = digit_count(self.doc.buffer().line_count()).max(2);
733        2.0 * GUTTER_PAD + digits as f32 * advance + FOLD_GAP + advance
734    }
735
736    /// The x-offset (from the widget's left) where line numbers right-align — the
737    /// right edge of the digit column, before the fold gap.
738    fn gutter_number_right(&self, advance: f32) -> f32 {
739        let digits = digit_count(self.doc.buffer().line_count()).max(2);
740        GUTTER_PAD + digits as f32 * advance
741    }
742
743    /// The x-offset where the fold chevron is left-drawn — one `FOLD_GAP` past the
744    /// numbers, so it sits in its own padded column just before the code.
745    fn gutter_chevron_x(&self, advance: f32) -> f32 {
746        self.gutter_number_right(advance) + FOLD_GAP
747    }
748
749    /// The screen x of buffer `offset` — the one fold-aware horizontal
750    /// projection. Tab expansion, inline-fold collapse, chip-center clipping,
751    /// and collapsed-tail following all come from the core's one owner
752    /// ([`FoldMap::display_position`]); the pixel affix is [`Geo::cell_x`]'s.
753    /// An offset genuinely hidden inside a fold (callers never pass one —
754    /// carets, selection endpoints on visible rows, and popup anchors are
755    /// visible by construction) falls back to the text origin.
756    fn offset_screen_x(&self, fold_map: &FoldMap, geo: &Geo, offset: u32) -> f32 {
757        let cells = fold_map
758            .display_position(self.doc.buffer(), offset, TAB)
759            .map_or(0.0, |p| p.x.cells());
760        geo.cell_x(cells)
761    }
762
763    /// A floating panel's display-space anchor at buffer `offset`:
764    /// `(x, row_top, row_bottom)` — the one place the completion / hover /
765    /// signature popups (and any future panel) get their anchor. The row is
766    /// display-space by construction, so a panel anchors at the display row and
767    /// cannot sit too low below a fold; each panel keeps its own flip/clamp
768    /// choice on top.
769    fn popup_anchor(&self, fold_map: &FoldMap, geo: &Geo, offset: u32) -> (f32, f32, f32) {
770        let top = match fold_map.display_position(self.doc.buffer(), offset, TAB) {
771            Some(p) => geo.row_y(p.row),
772            // Hidden offset (callers don't pass one): clip to its fold's header row.
773            None => {
774                let row = self.doc.buffer().offset_to_point(offset).row;
775                geo.row_y(fold_map.to_display_row(BufferRow(row)))
776            }
777        };
778        (self.offset_screen_x(fold_map, geo, offset), top, top + geo.line_h())
779    }
780
781    /// The document's memoized fold-aware row converter — O(1) unless the
782    /// folds or buffer changed since the last build (see [`Document::fold_map`]),
783    /// so the render path's many per-frame calls don't each rebuild it.
784    fn fold_map(&self) -> Ref<'_, FoldMap> {
785        self.doc.fold_map()
786    }
787
788    /// Whether any selection's head currently renders inside the vertical
789    /// viewport `[top_rows, top_rows + viewport_rows)` (display rows) — the
790    /// "hold the viewport on a Fit reveal if a cursor is already visible" test.
791    /// Bounded to the on-screen selections via the sorted-set window, so a
792    /// document-scale multi-cursor set costs O(visible + log carets), not
793    /// O(carets), on the reveal path.
794    fn any_caret_on_screen(
795        &self,
796        buffer: &scrive_core::Buffer,
797        fold_map: &FoldMap,
798        top_rows: f64,
799        viewport_rows: f64,
800    ) -> bool {
801        let first_buf = fold_map.to_buffer_row(fold_map.display_row_at(top_rows)).0;
802        let last_buf = fold_map.to_buffer_row(fold_map.display_row_at(top_rows + viewport_rows)).0;
803        let vis_start = buffer.point_to_offset(BufPoint { row: first_buf, col: 0 });
804        let vis_end = buffer.point_to_offset(BufPoint { row: last_buf, col: buffer.line_len(last_buf) });
805        let sels = self.doc.selections().all();
806        // The partially-scrolled top row still counts as visible (floor it).
807        let band = top_rows.floor()..(top_rows + viewport_rows);
808        sels[visible_selection_span(sels, vis_start, vis_end)].iter().any(|s| {
809            fold_map
810                .display_position(buffer, s.head(), TAB)
811                .is_some_and(|p| band.contains(&f64::from(p.row.index())))
812        })
813    }
814
815    /// This pass's pixel projection — built once per
816    /// `draw`/`update`/`mouse_interaction` from live state, like [`Self::fold_map`];
817    /// never stored.
818    fn geo(&self, state: &State, bounds: Rectangle) -> Geo {
819        let Metrics { advance, line_height, .. } = state.metrics;
820        Geo::new(bounds, self.gutter_width(advance), advance, line_height, state.scroll_x, state.scroll)
821    }
822
823    /// The screen rectangle of one *unfolded* collapsible's Ctrl+hover box — the
824    /// single source all three surfaces (render, cursor, Ctrl+Click) measure against,
825    /// so what you see boxed is exactly what the finger and click act on. A block
826    /// bounds its `header … last` extent snug to its own indent and widest line; an
827    /// inline pair bounds its bracket span. `None` for an all-blank block or a pair
828    /// whose header is itself hidden inside an outer collapsed fold. (Collapsed folds
829    /// use [`collapsed_chip_rect`](Self::collapsed_chip_rect) instead.)
830    fn collapsible_box_rect(&self, fold_map: &FoldMap, geo: &Geo, open: u32, close: u32, header: u32, last: u32) -> Option<Rectangle> {
831        let buffer = self.doc.buffer();
832        let code_left = geo.code_left();
833        if fold_map.is_folded(BufferRow(header)) {
834            return None; // the pair's line is hidden inside an outer collapsed fold
835        }
836        let y = geo.row_y(fold_map.to_display_row(BufferRow(header)));
837        if last > header {
838            // Block: the whole `header … last` region, snug to its indent / widest
839            // line — the RENDERED width (fold-aware), not the raw one, so a
840            // collapsed inline fold inside the block doesn't inflate the box.
841            // The width scan is bounded (a block can span the whole document): a
842            // hover box sized by its first `FOLD_BOX_SCAN_ROWS` lines reads the
843            // same, and the scan stays proportional to the viewport, not the
844            // document.
845            let (mut lo, mut hi) = (u32::MAX, 0u32);
846            for r in header..=last.min(header.saturating_add(FOLD_BOX_SCAN_ROWS)) {
847                let l = buffer.line(r);
848                if l.trim().is_empty() {
849                    continue;
850                }
851                lo = lo.min(line_indent_cells(&l));
852                hi = hi.max(fold_map.row_layout(buffer, BufferRow(r), TAB).width());
853            }
854            if lo == u32::MAX {
855                return None;
856            }
857            let x0 = (geo.cell_x(lo as f32) - 3.0).max(code_left + 1.0);
858            let x1 = geo.cell_x(hi as f32) + 3.0;
859            let yl = geo.row_y(fold_map.to_display_row(BufferRow(last)));
860            Some(Rectangle { x: x0, y: y - 1.0, width: (x1 - x0).max(geo.advance()), height: (yl + geo.line_h()) - y + 1.0 })
861        } else {
862            // Inline: the bracket span on the one row (shared cell projection).
863            let xo = self.offset_screen_x(fold_map, geo, open);
864            let xc = self.offset_screen_x(fold_map, geo, close);
865            Some(geo.inline_halo(xo.min(xc), xo.max(xc), y))
866        }
867    }
868
869    /// Every *un*folded collapsible whose Ctrl+hover box actually contains pixel
870    /// `pos`, as `(open, close, rect)` — the Ctrl gesture collapses, so only
871    /// things you can still collapse arm (a collapsed fold expands on a plain click
872    /// instead, [`collapsed_chip_at`](Self::collapsed_chip_at)). Keying on pixel
873    /// containment — not byte range — keeps the finger and click confined to the
874    /// boxes you can see. The caller picks the innermost by `close - open`.
875    fn armed_boxes(&self, geo: &Geo, pos: Point) -> Vec<(u32, u32, Rectangle)> {
876        let fold_map = self.fold_map();
877        let display = fold_map.display_row_at(geo.rows_from_top(pos.y));
878        let pr = fold_map.to_buffer_row(display).0;
879        // Windowed: a pair spanning `pr` is headed at a row <= pr; query only
880        // headers within FOLD_QUERY_SLACK above pr (not a whole-document
881        // `collapsible_pairs()` scan per Ctrl+hover mouse-move). A block taller
882        // than the slack enclosing `pr` is the accepted miss.
883        self.doc
884            .collapsible_pairs_in_rows(pr.saturating_sub(FOLD_QUERY_SLACK)..pr + 1)
885            .into_iter()
886            .filter(|&(_, _, header, last)| pr >= header && pr <= last) // cheap row pre-filter
887            .filter_map(|(open, close, header, last)| {
888                let rect = self.collapsible_box_rect(&fold_map, geo, open, close, header, last)?;
889                rect.contains(pos).then_some((open, close, rect))
890            })
891            .collect()
892    }
893
894
895    /// The largest valid scroll position, in f64 display-row units: total
896    /// display rows (folded interiors removed) minus the rows the viewport
897    /// shows. Row space, not pixels — the [`ScrollAnchor`] model's one clamp.
898    fn max_scroll_rows(&self, viewport_h: f32, line_height: f32) -> f64 {
899        (f64::from(self.fold_map().display_row_count())
900            - f64::from(viewport_h) / f64::from(line_height))
901        .max(0.0)
902    }
903
904    /// The widest **visible** display row's pixel width — the horizontal
905    /// analog of the total row count, fold- and tab-exact through the fold-map
906    /// owners (a collapsed inline fold shrinks its row; a collapsed block header
907    /// spans its full `head … tail` placeholder). Viewport-max (**field-gated**):
908    /// horizontal scroll only ever needs to reach content on rows you can see, so
909    /// the h-scroll range adapts as you scroll vertically — mainstream editors
910    /// behave the same — and no per-frame pass walks the whole document (a scan of
911    /// only the visible rows, never every line, per frame / layout / wheel tick).
912    /// If the adaptive feel fails the field gate, the fallback is a document-owned
913    /// line-width index (exact global max, 4 B/line).
914    fn max_line_px(&self, advance: f32, line_h: f32, bounds: Rectangle, scroll_rows: f64) -> f32 {
915        let buffer = self.doc.buffer();
916        let fold_map = self.fold_map();
917        let window = fold_map
918            .display_window(scroll_rows, scroll_rows + f64::from(bounds.height) / f64::from(line_h));
919        fold_map
920            .visible_rows(window)
921            .map(|vr| match fold_map.header_layout(buffer, vr.buffer_row, TAB) {
922                Some(hl) => hl.width(),
923                None => fold_map.row_layout(buffer, vr.buffer_row, TAB).width(),
924            })
925            .max()
926            .unwrap_or(0) as f32
927            * advance
928    }
929
930    /// The code-area width (between the gutter and the vertical scrollbar) for a
931    /// given viewport — the horizontal viewport that `scroll_x` scrolls within.
932    fn code_area_width(&self, bounds: Rectangle, advance: f32, line_h: f32, scroll_rows: f64) -> f32 {
933        let vsb = if self.scrollbar(bounds, line_h, scroll_rows).is_some() { SCROLLBAR_WIDTH } else { 0.0 };
934        (bounds.width - self.gutter_width(advance) - TEXT_PAD - vsb).max(0.0)
935    }
936
937    /// Max horizontal scroll: how far the widest line overhangs the code area (a
938    /// trailing `TEXT_PAD` so the last glyph isn't flush against the edge).
939    fn max_scroll_x(&self, bounds: Rectangle, advance: f32, line_h: f32, scroll_rows: f64) -> f32 {
940        (self.max_line_px(advance, line_h, bounds, scroll_rows) + TEXT_PAD
941            - self.code_area_width(bounds, advance, line_h, scroll_rows))
942        .max(0.0)
943    }
944
945    /// The last *buffer* row at least partially visible, plus a small margin,
946    /// clamped to the buffer — the tokenize target reported to the app. The
947    /// visible extent is measured in display rows (fold-aware), then mapped back
948    /// to a buffer row.
949    fn last_visible_row(&self, bounds: Rectangle, scroll_rows: f64, line_height: f32) -> u32 {
950        let fold_map = self.fold_map();
951        let rows = (scroll_rows + f64::from(bounds.height) / f64::from(line_height)).ceil()
952            + f64::from(VIEWPORT_TOKENIZE_MARGIN);
953        fold_map.to_buffer_row(fold_map.display_row_at(rows)).0
954    }
955
956    /// Right-edge scrollbar geometry for the current scroll, or `None` when
957    /// the content fits (no overflow → no scrollbar). Shared by `draw` (thumb)
958    /// and `update` (hit-test + drag) so both agree on where the thumb is.
959    /// Row-space throughout (the [`ScrollAnchor`] model): the thumb is a
960    /// ratio of rows, never an absolute pixel accumulation.
961    fn scrollbar(&self, bounds: Rectangle, line_h: f32, scroll_rows: f64) -> Option<Scrollbar> {
962        let total_rows = f64::from(self.fold_map().display_row_count());
963        let viewport_rows = f64::from(bounds.height) / f64::from(line_h);
964        let max_scroll_rows = total_rows - viewport_rows;
965        if max_scroll_rows <= 0.0 {
966            return None;
967        }
968        // The min-thumb clamp can exceed the track when the viewport is under
969        // SCROLLBAR_MIN_THUMB tall; cap at the track height so `travel` (used by
970        // both the thumb_y map and its inverse) never goes negative.
971        // Ratios in f64 (row counts are huge); px results are small.
972        let thumb_h = ((viewport_rows / total_rows) as f32 * bounds.height)
973            .max(SCROLLBAR_MIN_THUMB)
974            .min(bounds.height);
975        let thumb_y =
976            bounds.y + (scroll_rows / max_scroll_rows) as f32 * (bounds.height - thumb_h);
977        Some(Scrollbar {
978            x: bounds.x + bounds.width - SCROLLBAR_WIDTH,
979            track_top: bounds.y,
980            track_h: bounds.height,
981            thumb_y,
982            thumb_h,
983            max_scroll_rows,
984        })
985    }
986
987    /// Bottom-edge horizontal scrollbar geometry, or `None` when lines fit. The
988    /// track spans the code area (gutter edge → vertical-scrollbar edge), so the
989    /// two bars meet at the corner without overlapping. The mirror of
990    /// `scrollbar()` on the X axis.
991    fn hscrollbar(&self, bounds: Rectangle, advance: f32, line_h: f32, scroll_x: f32, scroll_rows: f64) -> Option<HScrollbar> {
992        let max_scroll = self.max_scroll_x(bounds, advance, line_h, scroll_rows);
993        if max_scroll <= 0.0 {
994            return None;
995        }
996        let track_left = bounds.x + self.gutter_width(advance);
997        let content_right = code_area_right(bounds, self.scrollbar(bounds, line_h, scroll_rows).as_ref());
998        let track_w = (content_right - track_left).max(0.0);
999        // thumb / track = viewport / content; clamp to a grabbable min, capped at
1000        // the track so `travel` never goes negative.
1001        let code_area_w = self.code_area_width(bounds, advance, line_h, scroll_rows);
1002        let content_w = code_area_w + max_scroll;
1003        let thumb_w = (track_w * code_area_w / content_w).max(SCROLLBAR_MIN_THUMB).min(track_w);
1004        let thumb_x = track_left + (scroll_x / max_scroll) * (track_w - thumb_w);
1005        Some(HScrollbar {
1006            y: bounds.y + bounds.height - SCROLLBAR_WIDTH,
1007            track_left,
1008            track_w,
1009            thumb_x,
1010            thumb_w,
1011            max_scroll,
1012        })
1013    }
1014}
1015
1016/// The scrollbar thumb's placement within its track. The placement fields
1017/// (`x`, `track_top`, `track_h`, `thumb_y`, `thumb_h`) are pixels in the
1018/// widget's coordinate space; `max_scroll_rows` is the scroll range in f64
1019/// display-row units (the [`ScrollAnchor`] currency). The inverse map
1020/// (thumb top → scroll rows, re-anchored by the caller via
1021/// [`ScrollAnchor::from_rows`]) lives here so `update`'s drag stays in sync
1022/// with `draw`'s thumb.
1023struct Scrollbar {
1024    /// Left edge of the track band (thumb + markers share this x).
1025    x: f32,
1026    track_top: f32,
1027    track_h: f32,
1028    thumb_y: f32,
1029    thumb_h: f32,
1030    /// The scroll range in f64 display-row units (the [`ScrollAnchor`]
1031    /// currency) — a huge document's range doesn't fit `f32` exactly, and the
1032    /// drag inverse multiplies by it.
1033    max_scroll_rows: f64,
1034}
1035
1036impl Scrollbar {
1037    /// Whether `x` falls in the scrollbar's horizontal band.
1038    fn contains_x(&self, x: f32) -> bool {
1039        x >= self.x
1040    }
1041
1042    /// Whether `y` lands on the thumb (vs. the bare track).
1043    fn thumb_contains_y(&self, y: f32) -> bool {
1044        y >= self.thumb_y && y < self.thumb_y + self.thumb_h
1045    }
1046
1047    /// The scroll position (f64 row units) that puts the thumb's top at
1048    /// `top`, clamped to range — the inverse of `scrollbar()`'s `thumb_y`
1049    /// map. The caller re-anchors via [`ScrollAnchor::from_rows`].
1050    fn scroll_rows_for_thumb_top(&self, top: f32) -> f64 {
1051        let travel = self.track_h - self.thumb_h;
1052        if travel <= 0.0 {
1053            return 0.0;
1054        }
1055        (f64::from((top - self.track_top) / travel) * self.max_scroll_rows)
1056            .clamp(0.0, self.max_scroll_rows)
1057    }
1058}
1059
1060/// Bottom-edge horizontal scrollbar thumb placement — the X-axis mirror of
1061/// [`Scrollbar`], with the same inverse-map discipline so drag and draw agree.
1062struct HScrollbar {
1063    /// Top edge of the bottom band.
1064    y: f32,
1065    track_left: f32,
1066    track_w: f32,
1067    thumb_x: f32,
1068    thumb_w: f32,
1069    max_scroll: f32,
1070}
1071
1072impl HScrollbar {
1073    /// Whether `y` falls in the bottom scrollbar band.
1074    fn contains_y(&self, y: f32) -> bool {
1075        y >= self.y
1076    }
1077
1078    /// Whether `x` lands on the thumb (vs. the bare track).
1079    fn thumb_contains_x(&self, x: f32) -> bool {
1080        x >= self.thumb_x && x < self.thumb_x + self.thumb_w
1081    }
1082
1083    /// The `scroll_x` that puts the thumb's left at `left`, clamped to range —
1084    /// the inverse of `hscrollbar()`'s `thumb_x` map.
1085    fn scroll_for_thumb_left(&self, left: f32) -> f32 {
1086        let travel = self.track_w - self.thumb_w;
1087        if travel <= 0.0 {
1088            return 0.0;
1089        }
1090        ((left - self.track_left) / travel * self.max_scroll).clamp(0.0, self.max_scroll)
1091    }
1092}
1093
1094/// Number of decimal digits in `n` (min 1).
1095fn digit_count(n: u32) -> u32 {
1096    let mut n = n.max(1);
1097    let mut d = 0;
1098    while n > 0 {
1099        d += 1;
1100        n /= 10;
1101    }
1102    d
1103}
1104
1105/// Default `Fit` autoscroll margin, in lines: every edit/caret-move reveal keeps
1106/// the caret this many lines clear of the viewport edges — the comfortable
1107/// margin mainstream editors keep so the caret is never flush against an edge.
1108#[must_use]
1109pub fn default_autoscroll_margin() -> u32 {
1110    3
1111}
1112
1113/// The `Fit` reveal: the scroll offset that brings the target band
1114/// `[target_top, target_bottom)` plus a `margin` on each side inside a
1115/// `viewport`-tall/wide window, moving minimally. The margin collapses when the
1116/// banded target nearly fills the window; if both banded edges are outside
1117/// (target taller than the window) it does nothing — never jitter. A
1118/// visible-with-margin target never scrolls, so an edit never moves the user's
1119/// scrollbar unnecessarily.
1120fn reveal_fit(scroll: f64, target_top: f64, target_bottom: f64, viewport: f64, margin: f64) -> f64 {
1121    let m = margin.min(((viewport - (target_bottom - target_top)) / 2.0).max(0.0));
1122    let top = (target_top - m).max(0.0);
1123    let bottom = target_bottom + m;
1124    match (top < scroll, bottom > scroll + viewport) {
1125        (true, false) => top,
1126        (false, true) => bottom - viewport,
1127        _ => scroll, // inside the band, or taller than the viewport
1128    }
1129}
1130
1131/// The code area's right edge in screen px: the vertical scrollbar's left edge,
1132/// or the widget's right edge when lines fit and there is no scrollbar. The ONE
1133/// owner — the `draw` content clip and the h-scrollbar track must end at the
1134/// same x, or the bottom bar and the text would disagree on where the code area
1135/// stops.
1136fn code_area_right(bounds: Rectangle, vscrollbar: Option<&Scrollbar>) -> f32 {
1137    vscrollbar.map_or(bounds.x + bounds.width, |s| s.x)
1138}
1139
1140/// The `[lo, hi)` slice of a sorted, disjoint selection set whose selections
1141/// intersect the visible byte range `[vis_start, vis_end]` — the window every
1142/// per-frame selection wash and caret pass iterates, so a document-scale
1143/// multi-cursor set costs O(visible) per frame, not O(carets). Selections are
1144/// sorted by start and disjoint, so their ends are ascending too; both bounds
1145/// are binary searches. A selection straddling the window (e.g. Ctrl+A) is
1146/// included. Excluded selections lie entirely before or after the range and
1147/// draw nothing, so the slice is exactly the drawing set.
1148fn visible_selection_span(sels: &[scrive_core::Selection], vis_start: u32, vis_end: u32) -> core::ops::Range<usize> {
1149    let lo = sels.partition_point(|s| s.end() < vis_start);
1150    let hi = sels.partition_point(|s| s.start() <= vis_end);
1151    lo..hi
1152}
1153
1154/// Whether one selection in the sorted, non-overlapping `sels` covers the byte
1155/// range `[start, end]`. `O(log n)`: since selections don't overlap, the last
1156/// one starting at/before `start` is the only candidate that can reach `end`.
1157fn range_covered_by(sels: &[scrive_core::Selection], start: u32, end: u32) -> bool {
1158    let i = sels.partition_point(|s| s.start() <= start);
1159    i > 0 && sels[i - 1].end() >= end
1160}
1161
1162impl<Message> Widget<Message, iced::Theme, iced::Renderer> for Editor<'_, Message> {
1163    fn tag(&self) -> widget::tree::Tag {
1164        widget::tree::Tag::of::<State>()
1165    }
1166
1167    fn state(&self) -> widget::tree::State {
1168        widget::tree::State::new(State::default())
1169    }
1170
1171    fn operate(
1172        &mut self,
1173        tree: &mut widget::Tree,
1174        layout: Layout<'_>,
1175        _renderer: &iced::Renderer,
1176        operation: &mut dyn Operation,
1177    ) {
1178        let state = tree.state.downcast_mut::<State>();
1179        operation.focusable(self.id.as_ref(), layout.bounds(), state);
1180    }
1181
1182    fn size(&self) -> Size<Length> {
1183        Size::new(Length::Fill, Length::Fill)
1184    }
1185
1186    fn layout(
1187        &mut self,
1188        tree: &mut widget::Tree,
1189        _renderer: &iced::Renderer,
1190        limits: &layout::Limits,
1191    ) -> layout::Node {
1192        let size = limits.max();
1193        let state = tree.state.downcast_mut::<State>();
1194        self.ensure_metrics(state);
1195        let (advance, line_h) = (state.metrics.advance, state.metrics.line_height);
1196        // Viewport rect for the scroll math (origin-independent — it uses widths
1197        // and the vertical-overflow test only).
1198        let vp = Rectangle { x: 0.0, y: 0.0, width: size.width, height: size.height };
1199        // A jump-class reveal request (find navigation) moved the caret
1200        // outside the widget's input path — pick it up as a one-shot autoscroll.
1201        // Jump-class reveals CENTER the target; the widget's own edits/moves get
1202        // `Fit` with the margin band.
1203        let jump = self.doc.reveal_seq() != state.last_reveal_seq;
1204        if jump {
1205            state.last_reveal_seq = self.doc.reveal_seq();
1206            state.autoscroll = true;
1207        }
1208        if state.autoscroll {
1209            let buffer = self.doc.buffer();
1210            let head = self.doc.selections().newest().head();
1211            let fold_map = self.fold_map();
1212            // Both reveal axes read the one fold-aware projection: the row
1213            // in display space, the cell inline-collapse-aware — a caret past a
1214            // collapsed chip or riding a fold's tail reveals where it renders.
1215            // A HIDDEN caret (inside a fold) reveals nothing — jump verbs
1216            // unfold their targets first; anything else holds the viewport
1217            // rather than scrolling to a fake row 0.
1218            if let Some(p) = fold_map.display_position(buffer, head, TAB) {
1219                // Row units end to end (the ScrollAnchor model): the caret's
1220                // display row is exact, the viewport is a small row count.
1221                let caret_row = f64::from(p.row.index());
1222                let viewport_rows = f64::from(size.height) / f64::from(line_h);
1223                let cur = state.scroll.rows(line_h);
1224                // The reveal mode is meaningful only on a jump (an app reveal
1225                // request). The widget's own autoscroll (typing, click, drag)
1226                // carries none, so it uses `Fit` — hold the viewport if a cursor
1227                // is already on screen.
1228                let mode = if jump { self.doc.reveal_mode() } else { RevealMode::Fit };
1229                let fit = || {
1230                    reveal_fit(
1231                        cur,
1232                        caret_row,
1233                        caret_row + 1.0,
1234                        viewport_rows,
1235                        f64::from(default_autoscroll_margin()),
1236                    )
1237                };
1238                let rows = match mode {
1239                    // Find/diagnostic jumps center the target row.
1240                    RevealMode::Center => caret_row + 0.5 - viewport_rows / 2.0,
1241                    // Ctrl+D: jump to the just-added cursor even if others show.
1242                    RevealMode::FitForce => fit(),
1243                    // Fit: HOLD the viewport if any cursor is already on screen —
1244                    // a multi-cursor op (select-all-occurrences, multi-cursor
1245                    // typing) must not scroll to the off-screen newest caret when
1246                    // the user can already see one (as mainstream editors do). A
1247                    // lone off-screen caret has none on screen and falls through
1248                    // to fit it, so single-cursor typing/moves are unchanged.
1249                    RevealMode::Fit if self.any_caret_on_screen(buffer, &fold_map, cur, viewport_rows) => cur,
1250                    RevealMode::Fit => fit(),
1251                };
1252                state.scroll = ScrollAnchor::from_rows(rows, line_h);
1253                // Horizontal (both reveal classes): keep cells [col−1,
1254                // col+2] inside the code area, minimally; a band wider than
1255                // the viewport is the reveal_fit no-op case.
1256                let cell = p.x.cells();
1257                let code_w = self.code_area_width(vp, advance, line_h, state.scroll.rows(line_h));
1258                state.scroll_x = reveal_fit(
1259                    f64::from(state.scroll_x),
1260                    f64::from((cell - 1.0).max(0.0) * advance),
1261                    f64::from((cell + 2.0) * advance),
1262                    f64::from(code_w),
1263                    0.0,
1264                ) as f32;
1265            }
1266            state.autoscroll = false;
1267        }
1268        // Re-canonicalize + clamp: from_rows floors the row and bounds the
1269        // sub-row offset, so wheel deltas and stale anchors normalize here.
1270        state.scroll = ScrollAnchor::from_rows(
1271            state.scroll.rows(line_h).min(self.max_scroll_rows(size.height, line_h)),
1272            line_h,
1273        );
1274        state.scroll_x =
1275            state.scroll_x.clamp(0.0, self.max_scroll_x(vp, advance, line_h, state.scroll.rows(line_h)));
1276        layout::Node::new(size)
1277    }
1278
1279    fn draw(
1280        &self,
1281        tree: &widget::Tree,
1282        renderer: &mut iced::Renderer,
1283        theme: &iced::Theme,
1284        _style: &renderer::Style,
1285        layout: Layout<'_>,
1286        cursor: mouse::Cursor,
1287        _viewport: &Rectangle,
1288    ) {
1289        use iced::advanced::Renderer as _; // start_layer / end_layer
1290        // Draw-budget gate: zero the per-frame row counter. Every windowed
1291        // wash/squiggle pass bumps it; the assert at the end of the code layer
1292        // trips if any pass walked the document instead of the viewport.
1293        draw_budget::reset();
1294        let state = tree.state.downcast_ref::<State>();
1295        let (advance, line_h) = (state.metrics.advance, state.metrics.line_height);
1296        let bounds = layout.bounds();
1297        let palette = theme.extended_palette();
1298        let geo = self.geo(state, bounds);
1299        let buffer = self.doc.buffer();
1300        // Whether the pointer is over the gutter — the expanded (chevron-down)
1301        // fold controls show only while it is, as mainstream editors do.
1302        let gutter_hover = cursor.position_over(bounds).is_some_and(|p| geo.in_gutter(p.x));
1303        let scroll_x = state.scroll_x;
1304
1305        // The vertical scrollbar (if any), computed once and reused for both the
1306        // content clip and the thumb so they can never disagree. The code area —
1307        // where all horizontally-scrolled content lives — runs from the gutter's
1308        // right edge to the scrollbar's left edge (or the widget edge when there
1309        // is no scrollbar).
1310        let sb = self.scrollbar(bounds, line_h, state.scroll.rows(line_h));
1311        let content_right = code_area_right(bounds, sb.as_ref());
1312        let code_left = geo.code_left();
1313        let code_clip = Rectangle {
1314            x: code_left,
1315            y: bounds.y,
1316            width: (content_right - code_left).max(0.0),
1317            height: bounds.height,
1318        };
1319
1320        // Background (fixed, does not scroll). The gutter shares the editor
1321        // background — no separate strip — so the line-number margin is seamless.
1322        fill(renderer, bounds, palette.background.base.color);
1323
1324        let text_color = palette.background.base.text;
1325        let dim = palette.background.strong.color;
1326        // A neutral gray selection (theme-derived), not the accent — as
1327        // mainstream editors render selection, and it stays legible under
1328        // syntax colors.
1329        let selection_color = palette.background.strong.color;
1330
1331        // Folds: rows hidden inside a fold are not produced. `first`/`last`
1332        // are DISPLAY rows (the visible window); the render iterates display rows
1333        // and maps each back to its buffer row.
1334        let fold_map = self.fold_map();
1335        // The visible display-row window — floor/ceil/clamp policy lives with
1336        // the fold map; the widget only supplies fractional rows from pixels.
1337        let window = fold_map
1338            .display_window(geo.rows_from_top(bounds.y), geo.rows_from_top(bounds.y + bounds.height));
1339        // The display Y of a buffer row, or `None` if it's hidden inside a fold or
1340        // scrolled out of the visible display window — the one place every overlay
1341        // routes its vertical position through.
1342        let visible_y = |row: u32| -> Option<f32> {
1343            let br = BufferRow(row);
1344            if fold_map.is_folded(br) {
1345                return None;
1346            }
1347            let dr = fold_map.to_display_row(br);
1348            window.contains(&dr.index()).then(|| geo.row_y(dr))
1349        };
1350        // Screen (x, top-y) of a buffer offset — the core's one fold-aware
1351        // projection (tab expansion, inline collapse, chip-center clipping,
1352        // collapsed-tail following), culled to the visible display window. `None`
1353        // if the offset is genuinely hidden (in a fold's gap) or scrolled out.
1354        let offset_xy = |off: u32| -> Option<(f32, f32)> {
1355            let p = fold_map.display_position(buffer, off, TAB)?;
1356            window.contains(&p.row.index()).then(|| (geo.cell_x(p.x.cells()), geo.row_y(p.row)))
1357        };
1358
1359        // Current-line highlight: with a single bare cursor, tint the caret's
1360        // row across the *text area only* — not the line-number gutter, matching
1361        // mainstream editors' line highlight. Under selection/find/text.
1362        let selections = self.doc.selections();
1363        if selections.len() == 1 && selections.newest().is_empty() {
1364            let row = buffer.offset_to_point(selections.newest().head()).row;
1365            if let Some(y) = visible_y(row) {
1366                let color = Color { a: LINE_HIGHLIGHT_A, ..text_color };
1367                // Start where a selection does — the text origin (gutter + pad),
1368                // not the raw gutter edge; deliberately UNscrolled (the tint spans
1369                // the whole text area regardless of horizontal scroll).
1370                let x = geo.text_left();
1371                fill(renderer, Rectangle { x, y, width: (content_right - x).max(0.0), height: line_h }, color);
1372            }
1373        }
1374
1375        // Line numbers (fixed — the gutter never scrolls horizontally). The
1376        // caret's row gets the brighter editor foreground while the rest stay
1377        // dim, so the active line's number stands out.
1378        let active_row = buffer.offset_to_point(selections.newest().head()).row;
1379        for vr in fold_map.visible_rows(window.clone()) {
1380            let row = vr.buffer_row.0;
1381            let y = geo.row_y(vr.display_row);
1382            let color = if row == active_row { text_color } else { dim };
1383            // Real buffer line numbers at display Y ⇒ continuous with a gap across
1384            // each fold, matching mainstream editors' gutter.
1385            self.draw_line(renderer, (row + 1).to_string(), Point::new(bounds.x + self.gutter_number_right(advance), y), color, Alignment::Right, bounds);
1386        }
1387
1388        // Fold chevrons in the gutter on foldable header rows (dim), drawn in the
1389        // bundled Codicon font: chevron-right collapsed (always shown),
1390        // chevron-down expanded (only while the pointer is over the gutter, as
1391        // mainstream editors default). Centered in the padded fold column, clear
1392        // of the code. Clicking one toggles the fold (ButtonPressed). The chevron
1393        // on the pointer's own row brightens to the foreground (the active-line-
1394        // number treatment) so adjacent chevrons disambiguate what a click hits.
1395        let hover_chevron_row = cursor
1396            .position_over(bounds)
1397            .filter(|p| geo.in_gutter(p.x))
1398            .map(|p| fold_map.to_buffer_row(fold_map.display_row_at(geo.rows_from_top(p.y))).0);
1399        // Only headers on visible buffer rows can paint a chevron — a windowed
1400        // bracket query, never a whole-document scan per frame. Hidden in-window
1401        // headers still hit the `visible_y` cull below.
1402        let first_buf_row = fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.start))).0;
1403        let last_buf_row = fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.end))).0;
1404        for &(header, _) in &self.doc.foldable_ranges_in_rows(first_buf_row..last_buf_row + 1) {
1405            let Some(y) = visible_y(header.0) else { continue };
1406            let folded = fold_map.fold_at_header(header).is_some();
1407            if !folded && !gutter_hover {
1408                continue;
1409            }
1410            let glyph = if folded { crate::icon::CHEVRON_RIGHT } else { crate::icon::CHEVRON_DOWN };
1411            let color = if hover_chevron_row == Some(header.0) { text_color } else { dim };
1412            let origin = Point::new(bounds.x + self.gutter_chevron_x(advance), y);
1413            self.draw_icon(renderer, glyph, origin, advance, color, bounds);
1414        }
1415
1416        // Everything in the code area scrolls horizontally by `scroll_x` and
1417        // clips to `code_clip`, so glyphs and fills alike stay off the gutter
1418        // (left) and out from under the vertical scrollbar (right). `fill_quad`
1419        // can't self-clip, so the whole block rides one clip layer.
1420        renderer.start_layer(code_clip);
1421
1422        // The visible byte range (fold-aware) bounds every windowed wash below.
1423        // `first`/`last` are display rows; the range spans the visible buffer rows
1424        // (matches inside a fold are drawn by draw_selection, which culls them).
1425        let line_count = buffer.line_count();
1426        let vis_start = buffer
1427            .point_to_offset(BufPoint { row: fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.start))).0, col: 0 });
1428        let vis_end = if window.end >= fold_map.display_row_count() {
1429            buffer.len()
1430        } else {
1431            buffer.point_to_offset(BufPoint { row: fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.end))).0, col: 0 })
1432        };
1433
1434        // Selection highlights (under the text) — only the selections that
1435        // intersect the visible byte range. The set is sorted and disjoint, so
1436        // ends are ascending too: two binary searches bound the visible slice,
1437        // so a document-scale multi-cursor set costs O(visible), not O(carets),
1438        // per frame (the off-screen ones draw nothing anyway). A selection
1439        // straddling the window (e.g. Ctrl+A) still lands in the slice and is drawn.
1440        let sels = self.doc.selections().all();
1441        for sel in &sels[visible_selection_span(sels, vis_start, vis_end)] {
1442            if !sel.is_empty() {
1443                self.draw_selection(renderer, &geo, sel.start(), sel.end(), selection_color);
1444            }
1445        }
1446        // Word-under-caret occurrence wash — highlight every occurrence of the
1447        // word under the caret, under the find washes and only while find is idle.
1448        // A per-frame pure query, like the FoldMap (no tracked state to go stale).
1449        if self.doc.find_query().is_none() {
1450            // The visible window bounds the scan, so it stays O(viewport).
1451            for span in self.doc.caret_word_occurrences(vis_start..vis_end) {
1452                self.draw_selection(renderer, &geo, span.start, span.end, OCCURRENCE_MATCH);
1453            }
1454        }
1455        for (span, is_active) in self.doc.find_matches_in(vis_start..vis_end) {
1456            let color = if is_active { FIND_MATCH_ACTIVE } else { FIND_MATCH };
1457            self.draw_selection(renderer, &geo, span.start, span.end, color);
1458        }
1459
1460        // Indentation guides (on by default): monochrome 1 px vertical lines at
1461        // each indent stop of a line's *own* leading whitespace, spaced by TAB
1462        // display cells — no bracket involvement. A blank line takes its level
1463        // from its nearest non-blank neighbours (below, then above), so guides
1464        // run unbroken through blank gaps. scrive has no separate indent-size
1465        // knob, so TAB serves as both the tab expansion width and the level spacing.
1466        let indent_size = TAB;
1467        // A row's leading-whitespace width in cells, or None for a blank line.
1468        let indent_col = |row: u32| -> Option<u32> {
1469            let l = buffer.line(row);
1470            (!l.trim().is_empty()).then(|| line_indent_cells(&l))
1471        };
1472        // The guide walks (blank-line neighbor interpolation + the active-guide
1473        // extent) clamp to the window ± slack: the guides only paint visibly, and
1474        // a few hundred rows of context gives perceptually identical continuity
1475        // while keeping a blank-heavy file from scanning to the document edges.
1476        let guide_lo = first_buf_row.saturating_sub(INDENT_NEIGHBOR_SLACK);
1477        let guide_hi = (last_buf_row.saturating_add(INDENT_NEIGHBOR_SLACK)).min(line_count);
1478        // Indent level of a row: content = ceil(indent/size); a blank line takes
1479        // the level interpolated from its nearest non-blank neighbours so the
1480        // guides run unbroken through blank rows.
1481        let level_at = |row: u32| -> u32 {
1482            match indent_col(row) {
1483                own @ Some(_) => display_map::indent_guide_level(own, None, None, indent_size),
1484                None => {
1485                    let above = (guide_lo..row).rev().find_map(&indent_col);
1486                    let below = (row + 1..guide_hi).find_map(&indent_col);
1487                    display_map::indent_guide_level(None, above, below, indent_size)
1488                }
1489            }
1490        };
1491        // Active guide: pick one indent level to highlight over a line-range
1492        // around the caret, with scope-aware handling on opening/closing brace
1493        // lines (the child level is highlighted, not the caret line's own). A
1494        // caret far off-screen paints no visible active guide, so the clamp also
1495        // skips the computation entirely (noted for the field gate).
1496        let caret_row = buffer.offset_to_point(self.doc.selections().newest().head()).row;
1497        let active =
1498            display_map::active_indent_guide(caret_row, line_count, guide_lo..guide_hi, level_at);
1499        for vr in fold_map.visible_rows(window.clone()) {
1500            let row = vr.buffer_row.0;
1501            let level = level_at(row);
1502            let y = geo.row_y(vr.display_row);
1503            // Guides at levels 1..=level ⇒ cells 0, TAB, …, (level-1)*TAB. Since
1504            // level = ceil(indent/TAB), the deepest guide sits strictly left of a
1505            // content line's first glyph, so no content-overlap suppression is
1506            // needed.
1507            for lvl in 1..=level {
1508                let cell = (lvl - 1) * indent_size;
1509                let gx = geo.cell_x(cell as f32);
1510                let is_active = active.is_some_and(|(ind, s, e)| lvl == ind && row >= s && row <= e);
1511                let alpha = if is_active { GUIDE_ACTIVE_A } else { GUIDE_IDLE_A };
1512                fill(renderer, Rectangle { x: gx, y, width: GUIDE_WIDTH, height: line_h }, Color { a: alpha, ..text_color });
1513            }
1514        }
1515
1516        // Text (line numbers are drawn fixed, above the layer). Iterates DISPLAY
1517        // rows: a fold's hidden interior is simply not produced, and a folded
1518        // header gets a trailing `…` placeholder chip.
1519        for vr in fold_map.visible_rows(window.clone()) {
1520            let row = vr.buffer_row.0;
1521            let y = geo.row_y(vr.display_row);
1522            let line = buffer.line(row);
1523            let origin = Point::new(geo.cell_x(0.0), y);
1524            let spans = self.doc.highlight_line_spans(row);
1525            // Rows with an inline (single-line) fold draw collapsed — the interior
1526            // between the brackets hides behind a `…` chip, the rest shifts left.
1527            let row_layout = fold_map.row_layout(buffer, BufferRow(row), TAB);
1528            if !row_layout.is_plain() {
1529                self.draw_row_inline(renderer, &line, spans, origin, &geo, &row_layout, dim, text_color, code_clip);
1530            } else {
1531                match spans {
1532                    Some(spans) if !spans.is_empty() => {
1533                        self.draw_spans(renderer, &line, spans, origin, advance, code_clip);
1534                    }
1535                    _ if !line.is_empty() => {
1536                        self.draw_line(renderer, expand_tabs(&line), origin, text_color, Alignment::Left, code_clip);
1537                    }
1538                    _ => {}
1539                }
1540            }
1541            if vr.is_fold_header {
1542                // The collapsed placeholder reads `fn main() { … }` — the `…`
1543                // chip in the gap, then the fold's REAL, cursor-addressable
1544                // closing tail. Every cell comes from the core's one
1545                // `HeaderLayout` owner, so the painted glyphs, caret placement,
1546                // hit-testing, and selection washes agree by construction.
1547                let hl = fold_map
1548                    .header_layout(buffer, BufferRow(row), TAB)
1549                    .expect("is_fold_header rows have a header layout");
1550                let mid = geo.cell_x(hl.gap_center());
1551                // Where the selection runs through the fold, the wash now spans the
1552                // gap (see `draw_wash_row`), so drop the idle pill — else it islands
1553                // as a dark box in the wash, exactly like the inline chip. The `…`
1554                // renders at full glyph color there so it stays visible. The test is
1555                // the same straddle `draw_wash_row` uses: a selection covering the
1556                // header's line end into the folded interior below.
1557                let hdr_end = buffer.point_to_offset(BufPoint { row, col: buffer.line_len(row) });
1558                let dots = if self.range_selected(hdr_end, hdr_end + 1) {
1559                    text_color
1560                } else {
1561                    fill_rounded(renderer, geo.chip_pill(mid, y), POPUP_SELECT, CHIP_PILL_RADIUS);
1562                    dim
1563                };
1564                self.draw_line(renderer, "…".to_string(), Point::new(mid, y), dots, Alignment::Center, code_clip);
1565                // The closing tail in its REAL colors — each bracket in its
1566                // pair-depth color (the closing bracket is on a hidden row, so
1567                // the bracket-colorization pass below never reaches it).
1568                let last_start = buffer.point_to_offset(BufPoint { row: hl.last_row().0, col: 0 });
1569                for g in hl.tail_glyphs() {
1570                    let off = last_start + g.col;
1571                    let color = self.doc.brackets().at(off).map_or(
1572                        text_color,
1573                        |b| {
1574                            if b.partner.is_none() {
1575                                UNMATCHED_BRACKET
1576                            } else {
1577                                BRACKET_DEPTH[b.depth as usize % BRACKET_DEPTH.len()]
1578                            }
1579                        },
1580                    );
1581                    let x = geo.cell_x(g.cell as f32);
1582                    self.draw_line(renderer, g.ch.to_string(), Point::new(x, y), color, Alignment::Left, code_clip);
1583                }
1584            }
1585        }
1586
1587        // Bracket-pair colorization: over-paint each visible bracket in its
1588        // nesting-depth color (unmatched ⇒ red), on top of the syntax spans.
1589        // Per-visible-row windowed queries — never the whole bracket set per
1590        // frame; folds make the visible buffer rows non-contiguous, so per-row
1591        // is simplest-correct.
1592        for vr in fold_map.visible_rows(window.clone()) {
1593            let row = vr.buffer_row.0;
1594            let Some(y) = visible_y(row) else { continue };
1595            let row_start = buffer.point_to_offset(BufPoint { row, col: 0 });
1596            let row_end = if row + 1 < line_count {
1597                buffer.point_to_offset(BufPoint { row: row + 1, col: 0 })
1598            } else {
1599                buffer.len() + 1 // sentinel: a bracket at the final byte is inside
1600            };
1601            let row_layout = fold_map.row_layout(buffer, BufferRow(row), TAB);
1602            for br in self.doc.brackets().in_range_iter(row_start..row_end) {
1603                let col = br.offset - row_start;
1604                let Some(ch) = buffer.char_at(br.offset) else { continue };
1605                // On an inline-folded row, shift the bracket to its display
1606                // cell; one hidden inside a collapsed interior isn't drawn.
1607                if row_layout.glyph_hidden(col) {
1608                    continue;
1609                }
1610                let x = geo.cell_x(row_layout.display_cell(col) as f32);
1611                let color = if br.partner.is_none() {
1612                    UNMATCHED_BRACKET
1613                } else {
1614                    BRACKET_DEPTH[br.depth as usize % BRACKET_DEPTH.len()]
1615                };
1616                self.draw_line(renderer, ch.to_string(), Point::new(x, y), color, Alignment::Left, code_clip);
1617            }
1618        }
1619
1620        // Matching-bracket highlight: when the primary caret is
1621        // adjacent to a matched bracket, box that bracket and its partner. The
1622        // outline is a translucent foreground tint — theme-derived, so it stays
1623        // legible over any depth color.
1624        let match_box = Color { a: 0.45, ..text_color };
1625        if let Some((a, b)) = self.doc.brackets().active_pair(self.doc.selections().newest().head()) {
1626            for off in [a, b] {
1627                // Route through `offset_xy` so a matched bracket on a collapsed
1628                // fold's closing tail (the `}` inline on the header line) gets boxed
1629                // too, not just the visible opening bracket.
1630                let Some((x, y)) = offset_xy(off) else { continue };
1631                fill_border(renderer, Rectangle { x, y, width: advance, height: line_h }, match_box, 1.0);
1632            }
1633        }
1634
1635        // Diagnostic squiggles: a wavy underline per diagnostic, in ascending
1636        // severity so the most severe paints last (Ord on Severity). Row-culled
1637        // to the viewport; each row's run is at least one cell wide; x sits in
1638        // the code area (caret_x never enters the gutter). The store query is
1639        // binary-search-bounded to the visible byte range, so this is
1640        // O(visible diags). Iterate the VISIBLE display rows (O(viewport)) and
1641        // draw the diagnostics covering each — never a diagnostic's raw
1642        // `sp.row..=ep.row` buffer span, which a document-spanning diagnostic
1643        // over a folded file would blow up to O(document) per frame. `diags` is
1644        // already windowed to the visible bytes and sorted by ascending severity,
1645        // so the most severe over-paints last on any shared row. One collect
1646        // (carrying `sev` only to sort by it) derives each span's points + color
1647        // once here rather than in a second `Vec`.
1648        let mut diags: Vec<(u32, u32, u32, u32, Severity, Color)> = self
1649            .doc
1650            .diagnostics_in(vis_start..vis_end)
1651            .map(|(span, sev, _msg)| {
1652                let (sp, ep) = (buffer.offset_to_point(span.start), buffer.offset_to_point(span.end));
1653                (span.start, span.end, sp.row, ep.row, sev, severity_color(sev))
1654            })
1655            .collect();
1656        diags.sort_by_key(|&(.., sev, _)| sev);
1657        for vr in fold_map.visible_rows(window.clone()) {
1658            draw_budget::bump_rows(1);
1659            let row = vr.buffer_row.0;
1660            let Some(row_y) = visible_y(row) else { continue };
1661            for &(start, end, sp_row, ep_row, _sev, color) in &diags {
1662                if row < sp_row || row > ep_row {
1663                    continue;
1664                }
1665                let row_start = if row == sp_row { start } else { buffer.point_to_offset(BufPoint { row, col: 0 }) };
1666                let row_end = if row == ep_row { end } else { buffer.point_to_offset(BufPoint { row, col: buffer.line_len(row) }) };
1667                // A boundary row a multi-line span doesn't actually cover (its
1668                // end landing at column 0, or an empty interior line) has zero
1669                // width here — skip it so the min-one-cell rule below doesn't
1670                // manufacture a phantom squiggle. A genuinely zero-width
1671                // diagnostic (a point) still gets its one cell.
1672                if row_start == row_end && start != end {
1673                    continue;
1674                }
1675                // Fold-aware endpoints: a diagnostic spanning a collapsed
1676                // inline fold underlines the shifted glyphs, not the raw columns.
1677                let x0 = self.offset_screen_x(&fold_map, &geo, row_start);
1678                let x1 = self.offset_screen_x(&fold_map, &geo, row_end).max(x0 + advance);
1679                let baseline = row_y + line_h - SQUIGGLE_AMPLITUDE - 0.5;
1680                squiggle_spans(x0, x1, baseline, |rect| fill(renderer, rect, color));
1681            }
1682        }
1683        // Draw-budget gate: every wash/squiggle pass above is windowed to the
1684        // viewport, so the rows visited must stay proportional to the visible
1685        // window. A pass that walked the whole document would blow past this and
1686        // trip here in tests/dev builds, catching it before it could lock up the
1687        // field build.
1688        #[cfg(debug_assertions)]
1689        {
1690            let vp_rows = u64::from(window.end.saturating_sub(window.start));
1691            debug_assert!(
1692                draw_budget::rows() <= 1024 * (vp_rows + 1),
1693                "draw visited {} rows for a ~{vp_rows}-row viewport — a per-frame path went O(document) (draw budget)",
1694                draw_budget::rows(),
1695            );
1696        }
1697
1698        // Ctrl+hover collapse affordance: while Ctrl is held, bound every
1699        // collapsible under the pointer with a dashed box — dim on the enclosing
1700        // nest, bright + washed on the innermost (the Ctrl+Click target). Inline
1701        // spans and blocks share the one indication; the gutter chevron stays the
1702        // block shortcut. Drawn inside the code-clip layer so it scrolls with the
1703        // text and stops at the gutter / scrollbar edges.
1704        if SHOW_CTRL_COLLAPSE_AFFORDANCE && state.modifiers.command() {
1705            if let Some(pos) = cursor.position_over(bounds) {
1706                // Discovery layer: while Ctrl is held and the pointer is over the
1707                // editor, EVERY visible collapsible shows a dim dashed box ("here's
1708                // what folds"); the innermost box the pointer is actually over is
1709                // bright + washed (the Ctrl+Click target), drawn last so it wins.
1710                let active = self
1711                    .armed_boxes(&geo, pos)
1712                    .into_iter()
1713                    .min_by_key(|&(o, c, _)| c - o)
1714                    .map(|(o, ..)| o);
1715                let first_buf = fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.start))).0;
1716                let last_buf = fold_map.to_buffer_row(fold_map.display_row_at(f64::from(window.end.saturating_sub(1)))).0;
1717                let mut active_rect = None;
1718                // Windowed to the visible rows (± slack for near-enclosing
1719                // blocks) — never a whole-document `collapsible_pairs()` scan per
1720                // frame while Ctrl is held.
1721                let query = first_buf.saturating_sub(FOLD_QUERY_SLACK)..last_buf + 1;
1722                for (open, close, header, last_row) in self.doc.collapsible_pairs_in_rows(query) {
1723                    if last_row < first_buf || header > last_buf {
1724                        continue; // wholly off-screen
1725                    }
1726                    let Some(rect) = self.collapsible_box_rect(&fold_map, &geo, open, close, header, last_row) else {
1727                        continue;
1728                    };
1729                    if active == Some(open) {
1730                        active_rect = Some(rect); // deferred so it paints over the nest
1731                    } else {
1732                        stroke_dashed_rounded_rect(renderer, rect, ARM_DASH, ARM_RADIUS, ARM_DASH_LEN, ARM_DASH_GAP, ARM_STROKE);
1733                    }
1734                }
1735                if let Some(rect) = active_rect {
1736                    fill_rounded(renderer, rect, ARM_FILL, ARM_RADIUS);
1737                    stroke_dashed_rounded_rect(renderer, rect, ARM_DASH_ACTIVE, ARM_RADIUS, ARM_DASH_LEN, ARM_DASH_GAP, ARM_STROKE);
1738                }
1739            }
1740        }
1741
1742        // Plain-hover expand affordance: the `…` pill of the collapsed
1743        // fold under the (non-Ctrl) pointer brightens — quiet feedback that a
1744        // click expands it. Just the pill (the thing that *is* the hidden
1745        // content), no border. The click/finger target stays the whole chip
1746        // span (`collapsed_chip_rect`); only the visual is this tight.
1747        if !state.modifiers.command() {
1748            if let Some(opener) = state.hover_chip {
1749                if let Some(pill) = self.chip_pill_rect(&fold_map, &geo, opener) {
1750                    fill_rounded(renderer, pill, Color { a: CHIP_HOVER_A, ..text_color }, CHIP_PILL_RADIUS);
1751                }
1752            }
1753        }
1754
1755        // Carets (over the text), clipped to the viewport — only in the solid
1756        // half of the blink cycle, and only when focused. Centered on the
1757        // insertion point (x − width/2), text-colored. A caret on a collapsed
1758        // fold's closing row rides the header line via `offset_xy`.
1759        if state.caret_on() {
1760            // Only carets whose selection intersects the visible byte range — the
1761            // rest project off-screen and are culled by offset_xy anyway. Bounds
1762            // the per-frame cost of a document-scale multi-cursor set to O(visible).
1763            let sels = self.doc.selections().all();
1764            for sel in &sels[visible_selection_span(sels, vis_start, vis_end)] {
1765                if let Some((x, y)) = offset_xy(sel.head()) {
1766                    let r = Rectangle { x: x - CARET_WIDTH / 2.0, y: y + 1.0, width: CARET_WIDTH, height: line_h - 2.0 };
1767                    fill(renderer, r, text_color);
1768                }
1769            }
1770        }
1771
1772        // Close the horizontally-scrolled code-area clip layer.
1773        renderer.end_layer();
1774
1775        // Overlays (scrollbars + completion popup) ride their OWN layer, pushed
1776        // *after* the content layer. wgpu renders layers in push order, so a base-
1777        // layer overlay would sit UNDER the content where they overlap (the
1778        // bottom hscrollbar row, the popup over code) and show through
1779        // translucently. (tiny_skia composites in draw order and hides that, so a
1780        // headless capture can't catch it — this must be checked on the wgpu build.)
1781        renderer.start_layer(bounds);
1782
1783        // Scrollbar overlay + overview markers on the right edge (only on
1784        // overflow). Drawn last, over everything (reusing `sb` from the top so
1785        // the clip and the thumb agree). Markers (find matches + diagnostics)
1786        // map line → track position; the translucent thumb shows them through.
1787        if let Some(sb) = sb {
1788            // The track maps DISPLAY rows (fold-aware), so a marker sits where its
1789            // line actually shows; one inside a fold clips to the header.
1790            let total = fold_map.display_row_count().max(1) as f32;
1791            let mark_span = bounds.height - SCROLLBAR_MARK_H;
1792            let mark_y = |row: u32| bounds.y + (fold_map.to_display_row(BufferRow(row)).index() as f32 / total) * mark_span;
1793            // Overview markers ride separate ruler lanes, like mainstream
1794            // editors: the band splits into thirds, find matches take the center
1795            // lane and diagnostics the right lane. Distinct lanes mean a line
1796            // that is both a hit and an error shows both, instead of the
1797            // diagnostic overpainting the find tick.
1798            let lane_w = SCROLLBAR_WIDTH / 3.0;
1799            // Bucket the marks by TRACK PIXEL without scanning the store:
1800            // `overview_marks` folds each lane per pixel in O(P + log M), so no
1801            // per-frame pass walks every diagnostic or find match. The P+1 bucket
1802            // bounds are the INVERSE of the `round(y)` pixel map above — for track
1803            // pixel `p`, its band's lower edge is `y = p - 0.5`; invert that to the
1804            // first display row landing at/below it (`ceil` of the row-space
1805            // threshold), then to a buffer row, then to a byte offset. A mark at
1806            // offset `o` then falls in bucket `p` exactly when
1807            // `round(mark_y(row_of(o))) == p`, so the bucket IS the pixel: the
1808            // reduce picks the per-pixel winner, and the draw recovers its exact
1809            // float-y by re-projecting the winner's own offset.
1810            // `overview_reduce_equals_linear_scan` is the correctness authority; a
1811            // diagnostic-heavy `--capture` is the visual gate (float-quantization
1812            // at a pixel edge can nudge a boundary mark by one pixel — acceptable,
1813            // pending a human judging it on the running app).
1814            let denom = mark_span.max(1.0);
1815            let max_disp = fold_map.max_display_row().index();
1816            let px_to_offset = |p: f32| -> u32 {
1817                let t = ((p - 0.5 - bounds.y) / denom * total).ceil();
1818                if t <= 0.0 {
1819                    return 0; // at/above the first display row
1820                }
1821                let d = t as u32;
1822                if d > max_disp {
1823                    // Below the last row → tail sentinel. `len()+1` (not `len()`) so
1824                    // the last bucket is `[.., len+1)` and a diagnostic clipped to
1825                    // exactly `buffer.len()` (past-EOF, EmptyPolicy::Keep) still falls
1826                    // in a bucket and paints.
1827                    return buffer.len().saturating_add(1);
1828                }
1829                // `display_row_at(d)` reconstructs `DisplayRow(d)` (floor of a whole
1830                // number is itself) — the widget-crate-visible route to a DisplayRow
1831                // (its field is `pub(crate)`), the same idiom the fold-preview uses.
1832                let brow = fold_map.to_buffer_row(fold_map.display_row_at(f64::from(d))).0;
1833                buffer.point_to_offset(BufPoint { row: brow, col: 0 })
1834            };
1835            let p_lo = bounds.y.floor() as i32;
1836            let p_hi = (bounds.y + mark_span).ceil() as i32;
1837            // Pixels p_lo..=p_hi need one extra boundary (p_hi + 1), whose offset is
1838            // the past-tail sentinel (`len+1`) — so the last bucket captures the tail.
1839            let bucket_bounds: Vec<u32> =
1840                (p_lo..=p_hi + 1).map(|p| px_to_offset(p as f32)).collect();
1841            // Retained per-thread scratch — the two lane vectors are P-sized and
1842            // stable frame to frame, so clearing and refilling avoids a fresh,
1843            // growing, reallocating pair every frame the scrollbar exists. `draw`
1844            // takes `&self`, so this can't live in `State`.
1845            thread_local! {
1846                static OVERVIEW_LANES: std::cell::RefCell<OverviewLanes> =
1847                    const { std::cell::RefCell::new((Vec::new(), Vec::new())) };
1848            }
1849            OVERVIEW_LANES.with(|cell| {
1850                let (sev_marks, find_marks) = &mut *cell.borrow_mut();
1851                self.doc.overview_marks(&bucket_bounds, sev_marks, find_marks);
1852                // Find lane (center): the first match in each pixel, at its exact y.
1853                for start in find_marks.iter().flatten() {
1854                    let y = mark_y(buffer.offset_to_point(*start).row);
1855                    fill(renderer, Rectangle { x: sb.x + lane_w, y, width: lane_w, height: SCROLLBAR_MARK_H }, FIND_MATCH_ACTIVE);
1856                }
1857                // Diagnostic lane (right): the severest mark in each pixel, at its
1858                // exact y. Encoded severity `0` (empty) and `1` (Hint, deliberately
1859                // given no overview color) are both skipped.
1860                for &(enc, off) in sev_marks.iter() {
1861                    if enc < 2 {
1862                        continue;
1863                    }
1864                    let sev = match enc {
1865                        2 => Severity::Info,
1866                        3 => Severity::Warning,
1867                        _ => Severity::Error, // encoded 4
1868                    };
1869                    let y = mark_y(buffer.offset_to_point(off).row);
1870                    fill(renderer, Rectangle { x: sb.x + 2.0 * lane_w, y, width: lane_w, height: SCROLLBAR_MARK_H }, severity_color(sev));
1871                }
1872            });
1873            let thumb = if state.scrollbar_grab.is_some() { SCROLLBAR_THUMB_ACTIVE } else { SCROLLBAR_THUMB };
1874            fill(renderer, Rectangle { x: sb.x, y: sb.thumb_y, width: SCROLLBAR_WIDTH, height: sb.thumb_h }, thumb);
1875        }
1876
1877        // Horizontal scrollbar thumb (bottom edge, only on horizontal overflow) —
1878        // the same translucent thumb as the vertical bar, no overview markers. Its
1879        // track stops at the vertical bar, so the two meet at the corner cleanly.
1880        if let Some(hb) = self.hscrollbar(bounds, advance, line_h, scroll_x, state.scroll.rows(line_h)) {
1881            let thumb = if state.hscrollbar_grab.is_some() { SCROLLBAR_THUMB_ACTIVE } else { SCROLLBAR_THUMB };
1882            fill(renderer, Rectangle { x: hb.thumb_x, y: hb.y, width: hb.thumb_w, height: SCROLLBAR_WIDTH }, thumb);
1883        }
1884
1885        // Hover popup — a markdown box at the hovered word.
1886        if let Some(info) = self.hover {
1887            self.draw_hover(renderer, info, &geo, text_color, state.hover_scroll);
1888        }
1889
1890        // Signature-help box — above the caret, under the completion popup.
1891        if let Some(sig) = self.signature {
1892            self.draw_signature(renderer, sig, &geo, text_color);
1893        }
1894
1895        // Fold hover preview — the collapsed fold's hidden content in a
1896        // floating panel, anchored under its `…` chip. In the overlay layer so it
1897        // rides over the code like the other popups.
1898        if let Some(opener) = state.fold_preview {
1899            self.draw_fold_preview(renderer, &geo, opener, text_color);
1900        }
1901
1902        // Completion popup — render it over everything, clipped to
1903        // the widget (it may legitimately cover the gutter).
1904        if let Some(list) = self.popup.filter(|l| !l.filtered.is_empty()) {
1905            self.draw_popup(renderer, list, &geo, text_color);
1906        }
1907
1908        // Close the overlay layer.
1909        renderer.end_layer();
1910    }
1911
1912    fn update(
1913        &mut self,
1914        tree: &mut widget::Tree,
1915        event: &Event,
1916        layout: Layout<'_>,
1917        cursor: mouse::Cursor,
1918        _renderer: &iced::Renderer,
1919        clipboard: &mut dyn Clipboard,
1920        shell: &mut Shell<'_, Message>,
1921        _viewport: &Rectangle,
1922    ) {
1923        let bounds = layout.bounds();
1924        let state = tree.state.downcast_mut::<State>();
1925        self.ensure_metrics(state);
1926        let (advance, line_h) = (state.metrics.advance, state.metrics.line_height);
1927
1928        // Report the visible range to the app — the view reports its viewport to
1929        // the model: highlighting runs only down to what is on screen,
1930        // re-tokenizes newly revealed lines on scroll, and aims the retention
1931        // window at these rows. Checked on every event so scroll, resize, and
1932        // autoscroll are all caught; deduped so it publishes only when the range
1933        // actually moves.
1934        let last_visible = self.last_visible_row(bounds, state.scroll.rows(line_h), line_h);
1935        let first_visible = {
1936            let fm = self.fold_map();
1937            fm.to_buffer_row(fm.display_row_at(state.scroll.rows(line_h))).0
1938        }
1939        .saturating_sub(VIEWPORT_TOKENIZE_MARGIN);
1940        let visible = first_visible..last_visible + 1;
1941        if visible != state.last_reported_viewport {
1942            state.last_reported_viewport = visible.clone();
1943            shell.publish((self.on_action)(Action::ViewportChanged(visible)));
1944        }
1945
1946        match event {
1947            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
1948                let Some(pos) = cursor.position_over(bounds) else {
1949                    state.unfocus();
1950                    return;
1951                };
1952                state.focus();
1953                state.fold_preview = None; // a click dismisses any open fold preview
1954                let geo = self.geo(state, bounds);
1955                // A click inside the open completion popup (on top of everything)
1956                // accepts the row it lands on — never a caret or the scrollbar.
1957                // A click in the popup off the rows is swallowed.
1958                if let Some(list) = self.popup.filter(|l| !l.filtered.is_empty()) {
1959                    let (origin, sz) = self.popup_layout(list, &geo);
1960                    if (Rectangle { x: origin.x, y: origin.y, width: sz.width, height: sz.height }).contains(pos) {
1961                        let n = list.filtered.len();
1962                        let visible = n.min(popup::POPUP_MAX_VISIBLE);
1963                        // The inverse of draw_popup's row map — the same pair in
1964                        // popup.rs, so the click can't drift off the painted rows.
1965                        if let Some(row) = popup::row_at(origin.y, pos.y, line_h, visible) {
1966                            let idx = popup::window_start(list.selected as usize, n) + row;
1967                            shell.publish((self.on_action)(Action::PopupClickAccept(idx as u32)));
1968                        }
1969                        shell.capture_event();
1970                        return;
1971                    }
1972                }
1973                // A press in the scrollbar band drives the scrollbar, never a
1974                // caret: on the thumb it starts a drag from the grab point; on
1975                // the bare track it jumps the thumb to center on the click and
1976                // then drags from there.
1977                if let Some(sb) = self.scrollbar(bounds, line_h, state.scroll.rows(line_h)) {
1978                    if sb.contains_x(pos.x) {
1979                        let grab = if sb.thumb_contains_y(pos.y) {
1980                            pos.y - sb.thumb_y
1981                        } else {
1982                            let half = sb.thumb_h / 2.0;
1983                            state.scroll = ScrollAnchor::from_rows(sb.scroll_rows_for_thumb_top(pos.y - half), line_h);
1984                            half
1985                        };
1986                        state.scrollbar_grab = Some(grab);
1987                        shell.request_redraw();
1988                        shell.capture_event();
1989                        return;
1990                    }
1991                }
1992                // Same for the bottom horizontal bar (checked after the vertical
1993                // one, which owns the corner).
1994                if let Some(hb) = self.hscrollbar(bounds, advance, line_h, state.scroll_x, state.scroll.rows(line_h)) {
1995                    if hb.contains_y(pos.y) {
1996                        let grab = if hb.thumb_contains_x(pos.x) {
1997                            pos.x - hb.thumb_x
1998                        } else {
1999                            let half = hb.thumb_w / 2.0;
2000                            state.scroll_x = hb.scroll_for_thumb_left(pos.x - half);
2001                            half
2002                        };
2003                        state.hscrollbar_grab = Some(grab);
2004                        shell.request_redraw();
2005                        shell.capture_event();
2006                        return;
2007                    }
2008                }
2009                // A click in the gutter on a foldable header row toggles that fold
2010                // — before caret placement; the gutter never places a caret.
2011                if geo.in_gutter(pos.x) {
2012                    let fold_map = self.fold_map();
2013                    let display = fold_map.display_row_at(geo.rows_from_top(pos.y));
2014                    let row = fold_map.to_buffer_row(display).0;
2015                    if let Some(opener) = self.doc.block_opener_on_row(row) {
2016                        shell.publish((self.on_action)(Action::ToggleFold { opener }));
2017                        shell.capture_event();
2018                        return;
2019                    }
2020                }
2021                // A plain click on a collapsed fold's chip expands it — inline `[ … ]`
2022                // or a block header's `{ … }`. Before caret placement so it
2023                // reads as toggling the collapsed span. (Ctrl is the collapse gesture,
2024                // below; a modified click falls through to selection.)
2025                if !state.modifiers.command() && !state.modifiers.shift() && !state.modifiers.alt() {
2026                    if let Some(opener) = self.collapsed_chip_at(&geo, pos) {
2027                        shell.publish((self.on_action)(Action::ToggleFold { opener }));
2028                        shell.capture_event();
2029                        return;
2030                    }
2031                }
2032                // Ctrl+Click a collapsible (the Ctrl+hover affordance): collapse the
2033                // innermost foldable pair under the pointer. No modifier overlap — Alt
2034                // adds carets, Shift extends; plain Ctrl is otherwise just a caret.
2035                if SHOW_CTRL_COLLAPSE_AFFORDANCE && state.modifiers.command() && !state.modifiers.shift() && !state.modifiers.alt() {
2036                    let target = self
2037                        .armed_boxes(&geo, pos)
2038                        .into_iter()
2039                        .min_by_key(|&(o, c, _)| c - o)
2040                        .map(|(o, ..)| o);
2041                    if let Some(opener) = target {
2042                        shell.publish((self.on_action)(Action::ToggleFold { opener }));
2043                        shell.capture_event();
2044                        return;
2045                    }
2046                }
2047                let offset = self.hit_test(&geo, pos);
2048                // Alt+Click adds a caret. Otherwise the click count (iced
2049                // `mouse::Click`) selects a caret / word / line; a single click
2050                // also arms a drag-select anchored here (extended on CursorMoved).
2051                if state.modifiers.alt() && state.modifiers.shift() {
2052                    // Shift+Alt+drag → mouse box (column) selection. The anchor is
2053                    // a virtual cell point; the drag moves the active corner.
2054                    let anchor = self.hit_cell(&geo, pos);
2055                    state.column_drag_anchor = Some(anchor);
2056                    shell.publish((self.on_action)(Action::ColumnDrag { anchor, active: anchor }));
2057                } else if state.modifiers.alt() {
2058                    shell.publish((self.on_action)(Action::AddCaret(offset)));
2059                } else if state.modifiers.shift() {
2060                    // Shift+click extends the primary selection to the click,
2061                    // keeping its far end (`tail`) as the origin; a following drag
2062                    // keeps extending (by character) from there.
2063                    let origin = self.doc.selections().newest().tail();
2064                    state.drag = Some(Drag { granularity: Granularity::Char, origin });
2065                    shell.publish((self.on_action)(Action::DragSelect {
2066                        granularity: Granularity::Char,
2067                        origin,
2068                        head: offset,
2069                    }));
2070                } else {
2071                    // Click count picks the drag granularity: a single click just
2072                    // places a caret, a double/triple selects the word/line.
2073                    let click = mouse::Click::new(pos, mouse::Button::Left, state.last_click);
2074                    state.last_click = Some(click);
2075                    let granularity = match click.kind() {
2076                        mouse::click::Kind::Single => Granularity::Char,
2077                        mouse::click::Kind::Double => Granularity::Word,
2078                        mouse::click::Kind::Triple => Granularity::Line,
2079                    };
2080                    state.drag = Some(Drag { granularity, origin: offset });
2081                    let action = if granularity == Granularity::Char {
2082                        Action::PlaceCaret(offset)
2083                    } else {
2084                        Action::DragSelect { granularity, origin: offset, head: offset }
2085                    };
2086                    shell.publish((self.on_action)(action));
2087                }
2088                shell.capture_event();
2089            }
2090            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
2091                // One frame's geometry for the whole arm, built before any scroll
2092                // mutation below so every gutter check and hit-test shares it.
2093                let geo = self.geo(state, bounds);
2094                // Gutter-hover fold chevrons: repaint once when the pointer
2095                // crosses on/off the gutter so the ▾ controls appear/disappear.
2096                let over_gutter = cursor.position_over(bounds).is_some_and(|p| geo.in_gutter(p.x));
2097                if over_gutter != state.gutter_hover {
2098                    state.gutter_hover = over_gutter;
2099                    shell.request_redraw();
2100                }
2101                // While Ctrl is held the collapse boxes track the pointer — repaint on
2102                // every move (enter / retarget / leave) so the discovery boxes appear,
2103                // the active one follows, and both clear when the pointer leaves.
2104                if SHOW_CTRL_COLLAPSE_AFFORDANCE && state.modifiers.command() {
2105                    shell.request_redraw();
2106                }
2107                // Any pointer move dismisses an open fold preview; it re-arms after
2108                // the idle delay if the pointer is still resting on a chip.
2109                if state.fold_preview.take().is_some() {
2110                    shell.request_redraw();
2111                }
2112                // Track the collapsed chip under the (non-Ctrl) pointer for the
2113                // immediate plain-hover expand highlight — repaint only when it changes.
2114                let chip = (!state.modifiers.command())
2115                    .then(|| cursor.position_over(bounds).and_then(|p| self.collapsed_chip_at(&geo, p)))
2116                    .flatten();
2117                if chip != state.hover_chip {
2118                    state.hover_chip = chip;
2119                    shell.request_redraw();
2120                }
2121                // Track the gutter row under the pointer so the hovered fold
2122                // chevron's brightening follows row-to-row moves WITHIN the
2123                // gutter (crossing the gutter edge repaints above) — repaint
2124                // only when the row changes.
2125                let gutter_row = cursor.position_over(bounds).filter(|p| geo.in_gutter(p.x)).map(|p| {
2126                    let fm = self.fold_map();
2127                    fm.to_buffer_row(fm.display_row_at(geo.rows_from_top(p.y))).0
2128                });
2129                if gutter_row != state.gutter_hover_row {
2130                    state.gutter_hover_row = gutter_row;
2131                    shell.request_redraw();
2132                }
2133                // Extend an armed drag to the cursor. Use the raw position (not
2134                // `position_over`) so a drag past the viewport edge keeps
2135                // selecting — `hit_test` clamps to the buffer.
2136                if let Some(grab) = state.scrollbar_grab {
2137                    // A scrollbar-thumb drag takes precedence over everything:
2138                    // the thumb top follows the cursor, keeping the grab point.
2139                    if let (Some(sb), Some(pos)) =
2140                        (self.scrollbar(bounds, line_h, state.scroll.rows(line_h)), cursor.position())
2141                    {
2142                        state.scroll = ScrollAnchor::from_rows(sb.scroll_rows_for_thumb_top(pos.y - grab), line_h);
2143                        shell.request_redraw();
2144                    }
2145                    shell.capture_event();
2146                } else if let Some(grab) = state.hscrollbar_grab {
2147                    // Bottom-bar thumb drag — the X-axis mirror of the above.
2148                    if let (Some(hb), Some(pos)) = (
2149                        self.hscrollbar(bounds, advance, line_h, state.scroll_x, state.scroll.rows(line_h)),
2150                        cursor.position(),
2151                    ) {
2152                        state.scroll_x = hb.scroll_for_thumb_left(pos.x - grab);
2153                        shell.request_redraw();
2154                    }
2155                    shell.capture_event();
2156                } else if let (Some(anchor), Some(pos)) = (state.column_drag_anchor, cursor.position()) {
2157                    // A box drag takes precedence over a normal drag.
2158                    let active = self.hit_cell(&geo, pos);
2159                    state.autoscroll = true;
2160                    shell.publish((self.on_action)(Action::ColumnDrag { anchor, active }));
2161                    shell.capture_event();
2162                } else if let (Some(drag), Some(pos)) = (state.drag, cursor.position()) {
2163                    let head = self.hit_test(&geo, pos);
2164                    state.autoscroll = true;
2165                    shell.publish((self.on_action)(Action::DragSelect {
2166                        granularity: drag.granularity,
2167                        origin: drag.origin,
2168                        head,
2169                    }));
2170                    shell.capture_event();
2171                } else if let Some(pos) =
2172                    cursor.position_over(bounds).filter(|p| !geo.in_gutter(p.x))
2173                {
2174                    // A plain move (no drag) over the CODE AREA: drive the hover
2175                    // idle timer. Restricted to the code area so a move over
2176                    // the gutter/chevrons never arms a hover (else a column-0 word
2177                    // on an unindented line would pop up). If the pointer is still
2178                    // over the open hover's word, keep it; otherwise dismiss and
2179                    // re-arm the timer for the new position (accurate `now` stamped
2180                    // on the next RedrawRequested).
2181                    let off = self.hit_test(&geo, pos);
2182                    // Keep the hover open while the pointer is over its word OR over
2183                    // the hover box itself — so it can be moved into and scrolled.
2184                    let still_in = self.hover.is_some_and(|h| {
2185                        (off >= h.range.start && off < h.range.end)
2186                            || self.hover_layout(h, &geo).rect.contains(pos)
2187                    });
2188                    if !still_in {
2189                        if self.hover.is_some() {
2190                            shell.publish((self.on_action)(Action::HoverDismiss));
2191                        }
2192                        state.hover_pos = Some(pos);
2193                        state.hover_rearm = true;
2194                        state.hover_scroll = 0.0; // a fresh hover starts un-scrolled
2195                        shell.request_redraw();
2196                    }
2197                } else {
2198                    // Pointer over the gutter or off the widget — cancel any
2199                    // pending / open hover.
2200                    state.hover_pos = None;
2201                    state.hover_at = None;
2202                    state.hover_scroll = 0.0;
2203                    if self.hover.is_some() {
2204                        shell.publish((self.on_action)(Action::HoverDismiss));
2205                    }
2206                }
2207            }
2208            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
2209                // End any in-progress drag; capture the release only if we were
2210                // dragging, so a plain click's release still propagates.
2211                let ended_drag = state.drag.take().is_some();
2212                let ended_box = state.column_drag_anchor.take().is_some();
2213                let ended_sb = state.scrollbar_grab.take().is_some();
2214                let ended_hsb = state.hscrollbar_grab.take().is_some();
2215                if ended_drag || ended_box || ended_sb || ended_hsb {
2216                    shell.capture_event();
2217                }
2218            }
2219            Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
2220                // Wheel over the open popup scrolls the list (moves the
2221                // selection), captured so it never scrolls the editor.
2222                let geo = self.geo(state, bounds);
2223                if let (Some(list), Some(pos)) =
2224                    (self.popup.filter(|l| !l.filtered.is_empty()), cursor.position_over(bounds))
2225                {
2226                    let (origin, sz) = self.popup_layout(list, &geo);
2227                    if (Rectangle { x: origin.x, y: origin.y, width: sz.width, height: sz.height }).contains(pos) {
2228                        let up = matches!(delta, mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } if *y > 0.0);
2229                        shell.publish((self.on_action)(if up { Action::PopupUp } else { Action::PopupDown }));
2230                        shell.capture_event();
2231                        return;
2232                    }
2233                }
2234                // Wheel over an overflowing hover scrolls its content (widget
2235                // state, snapped to whole lines), captured so it never reaches
2236                // the editor beneath.
2237                if let (Some(info), Some(pos)) = (self.hover, cursor.position_over(bounds)) {
2238                    let l = self.hover_layout(info, &geo);
2239                    if l.overflow && l.rect.contains(pos) {
2240                        let dy = match delta {
2241                            mouse::ScrollDelta::Lines { y, .. } => y * line_h,
2242                            mouse::ScrollDelta::Pixels { y, .. } => *y,
2243                        };
2244                        let raw = (state.hover_scroll - dy).clamp(0.0, l.max_scroll);
2245                        state.hover_scroll = (raw / line_h).round() * line_h;
2246                        shell.request_redraw();
2247                        shell.capture_event();
2248                        return;
2249                    }
2250                }
2251                // A thumb drag owns the scroll position; a stray wheel tick
2252                // would jump the thumb only to be snapped back on the next
2253                // CursorMoved. Let the drag win.
2254                if state.scrollbar_grab.is_some()
2255                    || state.hscrollbar_grab.is_some()
2256                    || cursor.position_over(bounds).is_none()
2257                {
2258                    return;
2259                }
2260                let (dx, dy) = match delta {
2261                    mouse::ScrollDelta::Lines { x, y } => (x * advance * WHEEL_COLS, y * line_h * WHEEL_ROWS),
2262                    mouse::ScrollDelta::Pixels { x, y } => (*x, *y),
2263                };
2264                // Shift+wheel scrolls horizontally, as mainstream editors do: a
2265                // vertical mouse wheel becomes an X delta.
2266                let (dx, dy) = if state.modifiers.shift() { (dy, 0.0) } else { (dx, dy) };
2267                // Predominant-axis lock: the larger-magnitude delta wins, so a
2268                // trackpad flick doesn't drift diagonally.
2269                if dx.abs() > dy.abs() {
2270                    let max_x = self.max_scroll_x(bounds, advance, line_h, state.scroll.rows(line_h));
2271                    state.scroll_x = (state.scroll_x - dx).clamp(0.0, max_x);
2272                } else {
2273                    // Row-space wheel step: shift by dy/line_h rows, then
2274                    // re-anchor (from_rows clamps the top; max_scroll_rows the
2275                    // bottom) — no absolute pixel value ever materializes.
2276                    let max_rows = self.max_scroll_rows(bounds.height, line_h);
2277                    state.scroll = ScrollAnchor::from_rows(
2278                        (state.scroll.rows(line_h) - f64::from(dy) / f64::from(line_h))
2279                            .min(max_rows),
2280                        line_h,
2281                    );
2282                }
2283                shell.request_redraw();
2284                shell.capture_event();
2285            }
2286            Event::Keyboard(Keyboard::KeyPressed { key, text, modifiers, physical_key, .. }) => {
2287                if !state.is_focused() {
2288                    return;
2289                }
2290                // Numpad-with-NumLock quirk (iced/winit on Windows): a numpad
2291                // digit reports a *navigation* logical key (Numpad2 → ArrowDown,
2292                // Numpad8 → ArrowUp, NumpadDecimal → Delete, …) but carries the
2293                // real glyph in `text`. Prefer the text so numpad keys type
2294                // instead of moving the caret. NumLock off ⇒ `text` is None ⇒ this
2295                // is skipped and the navigation key stands. Modifier chords fall
2296                // through so Ctrl/Alt+numpad still reach their handlers.
2297                if !modifiers.command() && !modifiers.control() && !modifiers.alt() {
2298                    if let Some(ch) = numpad_text(physical_key, text.as_deref()) {
2299                        state.autoscroll = true;
2300                        state.ping();
2301                        shell.publish((self.on_action)(Action::Type(ch)));
2302                        shell.capture_event();
2303                        return;
2304                    }
2305                }
2306                // While the completion popup is open, its navigation keys are
2307                // captured here and drive the controller (in the app); every
2308                // other key falls through and re-enters as typing.
2309                if self.popup.is_some() {
2310                    // Only PLAIN navigation keys belong to the popup — modified
2311                    // chords (Ctrl+Enter insert-line, Ctrl+Alt add-caret, the
2312                    // Ctrl+Shift+Alt column arms) pass through to their own arms
2313                    // rather than being swallowed by the popup.
2314                    let plain = !modifiers.command() && !modifiers.alt();
2315                    let popup_action = match key {
2316                        Key::Named(Named::ArrowUp) if plain => Some(Action::PopupUp),
2317                        Key::Named(Named::ArrowDown) if plain => Some(Action::PopupDown),
2318                        Key::Named(Named::Enter | Named::Tab) if plain => Some(Action::PopupAccept),
2319                        Key::Named(Named::Escape) => Some(Action::PopupDismiss),
2320                        _ => None,
2321                    };
2322                    if let Some(action) = popup_action {
2323                        state.ping();
2324                        shell.publish((self.on_action)(action));
2325                        shell.capture_event();
2326                        return;
2327                    }
2328                }
2329                // Escape closes the signature box next (after the popup, before
2330                // the snippet session, in dismissal order).
2331                if self.signature.is_some() && matches!(key, Key::Named(Named::Escape)) {
2332                    state.ping();
2333                    shell.publish((self.on_action)(Action::SignatureClose));
2334                    shell.capture_event();
2335                    return;
2336                }
2337                // While a snippet session is active, Tab / Shift+Tab / Escape
2338                // drive it; everything else types/moves normally and the
2339                // app cancels the session when the caret escapes every stop.
2340                if self.snippet_active {
2341                    let snip = match key {
2342                        Key::Named(Named::Tab) if modifiers.shift() => Some(Action::SnippetTabPrev),
2343                        Key::Named(Named::Tab) => Some(Action::SnippetTab),
2344                        Key::Named(Named::Escape) => Some(Action::SnippetCancel),
2345                        _ => None,
2346                    };
2347                    if let Some(action) = snip {
2348                        state.ping();
2349                        shell.publish((self.on_action)(action));
2350                        shell.capture_event();
2351                        return;
2352                    }
2353                }
2354                // Ctrl+C/X/V need the clipboard and the document, which
2355                // `interpret_key` has no access to — handle them here first.
2356                if modifiers.command() && !modifiers.alt() {
2357                    if let Key::Character(c) = key {
2358                        match c.as_str() {
2359                            "c" => {
2360                                self.write_clipboard(clipboard);
2361                                shell.capture_event();
2362                                return;
2363                            }
2364                            "x" => {
2365                                self.write_clipboard(clipboard);
2366                                state.autoscroll = true;
2367                                state.ping();
2368                                shell.publish((self.on_action)(Action::Cut));
2369                                shell.capture_event();
2370                                return;
2371                            }
2372                            "v" => {
2373                                if let Some(pasted) = clipboard.read(ClipboardKind::Standard) {
2374                                    state.autoscroll = true;
2375                                    state.ping();
2376                                    // Whole-line copies paste above the caret's
2377                                    // line — a side table remembers ours.
2378                                    let entire_line = crate::clipboard::is_entire_line(&pasted);
2379                                    shell.publish((self.on_action)(Action::Paste {
2380                                        text: pasted,
2381                                        entire_line,
2382                                    }));
2383                                }
2384                                shell.capture_event();
2385                                return;
2386                            }
2387                            _ => {}
2388                        }
2389                    }
2390                }
2391                // Ctrl+Shift+[ / ] fold / unfold the caret's enclosing block;
2392                // on US layouts Shift makes the logical char `{` / `}`, so accept
2393                // both. Needs the doc + fold state, so it's handled here (like
2394                // Ctrl+C/V), not in the layout-free `interpret_key`.
2395                if modifiers.command() && !modifiers.alt() {
2396                    if let Key::Character(c) = key {
2397                        let unfold = matches!(c.as_str(), "]" | "}");
2398                        if unfold || matches!(c.as_str(), "[" | "{") {
2399                            // Fold/unfold the block at EVERY caret (the core
2400                            // resolves the openers and no-ops if none apply).
2401                            shell.publish((self.on_action)(Action::FoldAtCarets { unfold }));
2402                            shell.capture_event();
2403                            return;
2404                        }
2405                    }
2406                }
2407                // PageUp/PageDown move by a page of *viewport* rows, which
2408                // `interpret_key` (layout-free) can't know — resolve the row count
2409                // from the current bounds here.
2410                if let Key::Named(named @ (Named::PageUp | Named::PageDown)) = key {
2411                    let page = ((bounds.height / line_h).floor() as u32).max(1);
2412                    let motion = if *named == Named::PageUp {
2413                        Motion::PageUp(page)
2414                    } else {
2415                        Motion::PageDown(page)
2416                    };
2417                    state.autoscroll = true;
2418                    state.ping();
2419                    shell.publish((self.on_action)(Action::Move { motion, extend: modifiers.shift() }));
2420                    shell.capture_event();
2421                    return;
2422                }
2423                if let Some(action) = interpret_key(key, text.as_deref(), *modifiers) {
2424                    if action.moves_caret() {
2425                        state.autoscroll = true;
2426                    }
2427                    // Any accepted input restarts the blink so the caret stays
2428                    // solid while the user is working.
2429                    state.ping();
2430                    shell.publish((self.on_action)(action));
2431                    shell.capture_event();
2432                }
2433            }
2434            Event::Keyboard(Keyboard::ModifiersChanged(mods)) => {
2435                // Arming/disarming the collapse affordance (Ctrl held) repaints so the
2436                // dashed box appears/clears without waiting for a mouse move.
2437                if SHOW_CTRL_COLLAPSE_AFFORDANCE && mods.command() != state.modifiers.command() {
2438                    shell.request_redraw();
2439                }
2440                state.modifiers = *mods;
2441            }
2442            // Losing window focus ends every in-progress mouse gesture. If the
2443            // OS steals the pointer mid-drag (a UAC/consent dialog, Win+L, task
2444            // view, a global hotkey) the button-up is delivered elsewhere and
2445            // the ButtonReleased arm never runs — without this, a stale grab
2446            // would drive scroll/selection from a button-less cursor on the
2447            // next move. (CursorLeft is deliberately NOT a trigger: a drag past
2448            // the viewport edge must keep selecting.)
2449            Event::Window(window::Event::Unfocused) => {
2450                state.drag = None;
2451                state.column_drag_anchor = None;
2452                state.scrollbar_grab = None;
2453                state.hscrollbar_grab = None;
2454            }
2455            // Drive the caret blink: advance the clock and schedule the next
2456            // toggle. Mirrors iced `text_input` — the chain self-sustains from
2457            // each rendered frame while focused and idles when unfocused.
2458            Event::Window(window::Event::RedrawRequested(now)) => {
2459                if let Some(focus) = &mut state.focus {
2460                    focus.now = *now;
2461                    let elapsed = now.saturating_duration_since(focus.updated_at).as_millis();
2462                    let until = BLINK_MS - elapsed % BLINK_MS;
2463                    shell.request_redraw_at(*now + Duration::from_millis(until as u64));
2464                }
2465                // Hover idle timer: a move armed a re-arm — stamp the target
2466                // instant here (accurate `now`) and schedule a redraw for it. When
2467                // that redraw arrives with no intervening move, fire the query.
2468                if state.is_focused() {
2469                    if state.hover_rearm {
2470                        state.hover_rearm = false;
2471                        let at = *now + Duration::from_millis(HOVER_IDLE_DELAY_MS);
2472                        state.hover_at = Some(at);
2473                        shell.request_redraw_at(at);
2474                    }
2475                    if state.hover_at.is_some_and(|at| *now >= at) {
2476                        state.hover_at = None;
2477                        if let Some(pos) = state.hover_pos {
2478                            // Resting on a collapsed fold's `…` chip previews its
2479                            // hidden content — a widget-drawn panel. Otherwise
2480                            // the pointer's word drives the app hover query.
2481                            let geo = self.geo(state, bounds);
2482                            if let Some(opener) = self.collapsed_chip_at(&geo, pos) {
2483                                if state.fold_preview != Some(opener) {
2484                                    state.fold_preview = Some(opener);
2485                                    shell.request_redraw();
2486                                }
2487                                if self.hover.is_some() {
2488                                    shell.publish((self.on_action)(Action::HoverDismiss));
2489                                }
2490                            } else {
2491                                let off = self.hit_test(&geo, pos);
2492                                shell.publish((self.on_action)(Action::HoverQuery(off)));
2493                            }
2494                        }
2495                    }
2496                } else {
2497                    state.hover_at = None;
2498                    state.hover_rearm = false;
2499                }
2500            }
2501            _ => {}
2502        }
2503    }
2504
2505    fn mouse_interaction(
2506        &self,
2507        tree: &widget::Tree,
2508        layout: Layout<'_>,
2509        cursor: mouse::Cursor,
2510        _viewport: &Rectangle,
2511        _renderer: &iced::Renderer,
2512    ) -> mouse::Interaction {
2513        let state = tree.state.downcast_ref::<State>();
2514        let advance = state.metrics.advance;
2515        let bounds = layout.bounds();
2516        // A thumb drag (either bar) holds the arrow for its whole duration, even
2517        // as the cursor drifts off the band (the grab tracks the raw cursor).
2518        if state.scrollbar_grab.is_some() || state.hscrollbar_grab.is_some() {
2519            return mouse::Interaction::default();
2520        }
2521        let line_h = state.metrics.line_height;
2522        let geo = self.geo(state, bounds);
2523        // Ctrl held over a collapsible → the finger cursor (Ctrl+Click folds it).
2524        if SHOW_CTRL_COLLAPSE_AFFORDANCE && state.modifiers.command() {
2525            if let Some(p) = cursor.position_over(bounds).filter(|p| !geo.in_gutter(p.x)) {
2526                if !self.armed_boxes(&geo, p).is_empty() {
2527                    return mouse::Interaction::Pointer;
2528                }
2529            }
2530        }
2531        // Plain hover over a collapsed fold's chip → the finger (a plain click
2532        // expands it).
2533        if !state.modifiers.command() {
2534            if let Some(p) = cursor.position_over(bounds).filter(|p| !geo.in_gutter(p.x)) {
2535                if self.collapsed_chip_at(&geo, p).is_some() {
2536                    return mouse::Interaction::Pointer;
2537                }
2538            }
2539        }
2540        match cursor.position_over(bounds) {
2541            // Over the gutter: a pointer (finger) on a foldable header row — the
2542            // clickable fold chevron — otherwise the default arrow.
2543            Some(p) if geo.in_gutter(p.x) => {
2544                let fold_map = self.fold_map();
2545                let display = fold_map.display_row_at(geo.rows_from_top(p.y));
2546                let row = fold_map.to_buffer_row(display).0;
2547                // Windowed to the hovered row — never a whole-document
2548                // `foldable_ranges()` scan per mouse-move, so the gutter cursor
2549                // stays responsive on a large document.
2550                if self.doc.foldable_ranges_in_rows(row..row + 1).iter().any(|(h, _)| h.0 == row) {
2551                    mouse::Interaction::Pointer
2552                } else {
2553                    mouse::Interaction::default()
2554                }
2555            }
2556            // The scrollbar bands (right / bottom, only on overflow) keep the
2557            // arrow; the text area past the gutter shows the I-beam.
2558            Some(p)
2559                if !geo.in_gutter(p.x)
2560                    && !self.scrollbar(bounds, line_h, state.scroll.rows(line_h)).is_some_and(|sb| sb.contains_x(p.x))
2561                    && !self
2562                        .hscrollbar(bounds, advance, line_h, state.scroll_x, state.scroll.rows(line_h))
2563                        .is_some_and(|hb| hb.contains_y(p.y)) =>
2564            {
2565                mouse::Interaction::Text
2566            }
2567            _ => mouse::Interaction::default(),
2568        }
2569    }
2570}
2571
2572/// The per-frame draw-budget gate — the structural guarantee that no per-frame
2573/// draw pass costs more than the viewport. Every windowed draw pass bumps the
2574/// rows it visits; [`draw`](Editor::draw) asserts the frame total stays
2575/// viewport-proportional, so any pass that walks the whole document trips a
2576/// `debug_assert` in tests and dev builds rather than locking up the field build.
2577/// The invariant is not "binary-search your lookups" — it is "**no per-frame work
2578/// proportional to anything but the viewport**." Zero-cost in release.
2579#[cfg(any(test, debug_assertions))]
2580pub(crate) mod draw_budget {
2581    use std::sync::atomic::{AtomicU64, Ordering};
2582    static ROWS: AtomicU64 = AtomicU64::new(0);
2583    /// Zero the counter at the top of a frame.
2584    pub(crate) fn reset() {
2585        ROWS.store(0, Ordering::Relaxed);
2586    }
2587    /// Record `n` rows visited by a windowed draw pass this frame.
2588    pub(crate) fn bump_rows(n: u64) {
2589        ROWS.fetch_add(n, Ordering::Relaxed);
2590    }
2591    /// The rows visited so far this frame.
2592    pub(crate) fn rows() -> u64 {
2593        ROWS.load(Ordering::Relaxed)
2594    }
2595}
2596#[cfg(not(any(test, debug_assertions)))]
2597pub(crate) mod draw_budget {
2598    #[inline(always)]
2599    pub(crate) fn reset() {}
2600    #[inline(always)]
2601    pub(crate) fn bump_rows(_n: u64) {}
2602}
2603
2604impl<Message> Editor<'_, Message> {
2605    /// The completion popup's top-left and size for the current caret/metrics —
2606    /// shared by `draw_popup` and mouse hit-testing so they agree on where it is.
2607    fn popup_layout(&self, list: &PopupList, geo: &Geo) -> (Point, Size) {
2608        let fold_map = self.fold_map();
2609        // The anchor (the completed word's start) shares the caret's row, so the
2610        // shared display-space anchor covers both x and y.
2611        let (anchor_x, row_top, row_bottom) = self.popup_anchor(&fold_map, geo, list.anchor);
2612        let size = popup::extent(list, geo.advance(), geo.line_h());
2613        let origin = popup::place(anchor_x, row_top, row_bottom, size, geo.bounds());
2614        (origin, size)
2615    }
2616
2617    /// Geometry + wrapped content for the hover popup — one source of truth,
2618    /// shared by `draw_hover` and the wheel-scroll handler. Parses the markdown,
2619    /// word-wraps each source line at the box width, caps the visible height at
2620    /// `HOVER_MAX_VISIBLE`, and places the box above the word (flips below when
2621    /// tight).
2622    fn hover_layout(&self, info: &HoverInfo, geo: &Geo) -> HoverLayout {
2623        let (bounds, advance, line_h) = (geo.bounds(), geo.advance(), geo.line_h());
2624        let pad_x = 8.0_f32; // hovers breathe more on the x axis
2625        let pad_y = popup::POPUP_PAD;
2626        let md_lines: Vec<Vec<(String, MdStyle)>> = info.markdown.lines().map(parse_md_runs).collect();
2627        let vis_len = |runs: &[(String, MdStyle)]| runs.iter().map(|(t, _)| t.chars().count()).sum::<usize>();
2628        let cells = md_lines.iter().map(|r| vis_len(r)).max().unwrap_or(0).clamp(popup::POPUP_MIN_CH, popup::POPUP_MAX_CH);
2629        let lines: Vec<Vec<(String, MdStyle)>> = md_lines.iter().flat_map(|r| wrap_runs(r, cells)).collect();
2630        let total = lines.len().max(1);
2631        let visible = total.min(HOVER_MAX_VISIBLE);
2632        let overflow = total > visible;
2633
2634        let inner_w = cells as f32 * advance;
2635        let sb_w = if overflow { HOVER_SB_W } else { 0.0 };
2636        let width = inner_w + 2.0 * pad_x + sb_w;
2637        let height = visible as f32 * line_h + 2.0 * pad_y;
2638
2639        let fold_map = self.fold_map();
2640        // The shared display-space anchor; the hover keeps its own
2641        // above-first flip below.
2642        let (word_x, word_top, word_bottom) = self.popup_anchor(&fold_map, geo, info.range.start);
2643        let y = if word_top - height >= bounds.y { word_top - height } else { word_bottom };
2644        let x = word_x.clamp(bounds.x, (bounds.x + bounds.width - width).max(bounds.x));
2645
2646        HoverLayout {
2647            rect: Rectangle { x, y, width, height },
2648            lines,
2649            visible,
2650            max_scroll: (total - visible) as f32 * line_h,
2651            pad_x,
2652            pad_y,
2653            overflow,
2654        }
2655    }
2656
2657    /// Render the hover popup — a rich-markdown box anchored at the hovered
2658    /// word (above it, flips below when tight). Wraps at the box width; when the
2659    /// content is taller than `HOVER_MAX_VISIBLE` it scrolls (wheel-driven
2660    /// `hover_scroll`, snapped to whole lines) with an auto scrollbar.
2661    fn draw_hover(
2662        &self,
2663        renderer: &mut iced::Renderer,
2664        info: &HoverInfo,
2665        geo: &Geo,
2666        text_color: Color,
2667        hover_scroll: f32,
2668    ) {
2669        let (advance, line_h) = (geo.advance(), geo.line_h());
2670        let l = self.hover_layout(info, geo);
2671        fill_panel(renderer, l.rect, POPUP_SURFACE, POPUP_BORDER, 8.0);
2672
2673        let scroll = hover_scroll.clamp(0.0, l.max_scroll);
2674        let first = (scroll / line_h).round() as usize;
2675        // Each visible line's runs, left-to-right (monospace ⇒ cell = char): bold
2676        // in a bold weight, inline `code` over a rounded pill, plain as-is.
2677        for (vi, line) in l.lines.iter().skip(first).take(l.visible).enumerate() {
2678            let ly = l.rect.y + l.pad_y + vi as f32 * line_h;
2679            let mut col = 0usize;
2680            for (run, st) in line {
2681                let rx = l.rect.x + l.pad_x + col as f32 * advance;
2682                let n = run.chars().count();
2683                if *st == MdStyle::Code {
2684                    let pill = Rectangle { x: rx - 2.0, y: ly + 1.0, width: n as f32 * advance + 4.0, height: line_h - 2.0 };
2685                    fill_rounded(renderer, pill, CODE_PILL_BG, 3.0);
2686                }
2687                let font = if *st == MdStyle::Bold {
2688                    Font { weight: iced::font::Weight::Bold, ..self.font }
2689                } else {
2690                    self.font
2691                };
2692                self.draw_run(renderer, run.clone(), Point::new(rx, ly), text_color, font, l.rect);
2693                col += n;
2694            }
2695        }
2696
2697        // Auto scrollbar on the right edge when the content overflows.
2698        if l.overflow {
2699            let track_h = l.visible as f32 * line_h;
2700            let thumb_h = (l.visible as f32 / l.lines.len() as f32 * track_h).max(HOVER_SB_W);
2701            let frac = if l.max_scroll > 0.0 { scroll / l.max_scroll } else { 0.0 };
2702            let thumb_y = l.rect.y + l.pad_y + frac * (track_h - thumb_h);
2703            let sbx = l.rect.x + l.rect.width - HOVER_SB_W - 1.0;
2704            fill_rounded(renderer, Rectangle { x: sbx, y: thumb_y, width: HOVER_SB_W, height: thumb_h }, SCROLLBAR_THUMB, HOVER_SB_W / 2.0);
2705        }
2706    }
2707
2708    /// Render the signature-help box — a one-line box above the caret
2709    /// showing the signature with the active parameter highlighted, and its doc
2710    /// below. Reuses the popup surface/border; flips below the caret if there is
2711    /// no room above.
2712    #[allow(clippy::too_many_arguments)]
2713    fn draw_signature(
2714        &self,
2715        renderer: &mut iced::Renderer,
2716        sig: &SignatureInfo,
2717        geo: &Geo,
2718        text_color: Color,
2719    ) {
2720        let (bounds, advance, line_h) = (geo.bounds(), geo.advance(), geo.line_h());
2721        let fold_map = self.fold_map();
2722        let head = self.doc.selections().newest().head();
2723        // The shared display-space anchor; the signature keeps its own
2724        // above-first flip below.
2725        let (caret_x, caret_top, caret_bottom) = self.popup_anchor(&fold_map, geo, head);
2726
2727        let dim = Color { a: 0.7, ..text_color };
2728        let active = Color::from_rgb8(0x66, 0xd9, 0xef); // cyan — the active param
2729
2730        let label_cells = sig.label.chars().count();
2731        let doc_cells = sig.doc.as_ref().map_or(0, |d| d.chars().count());
2732        let cells = label_cells.max(doc_cells).clamp(popup::POPUP_MIN_CH, popup::POPUP_MAX_CH);
2733        let width = cells as f32 * advance + 2.0 * popup::POPUP_PAD;
2734        let rows = if sig.doc.is_some() { 2 } else { 1 };
2735        let height = rows as f32 * line_h + 2.0 * popup::POPUP_PAD;
2736        // Above the caret; flip below when there's no room above.
2737        let y = if caret_top - height >= bounds.y { caret_top - height } else { caret_bottom };
2738        let x = caret_x.clamp(bounds.x, (bounds.x + bounds.width - width).max(bounds.x));
2739
2740        let rect = Rectangle { x, y, width, height };
2741        fill_panel(renderer, rect, POPUP_SURFACE, POPUP_BORDER, 8.0);
2742
2743        let tx = x + popup::POPUP_PAD;
2744        let ty = y + popup::POPUP_PAD;
2745        // The label dim, then the active parameter's substring highlighted over it.
2746        self.draw_line(renderer, sig.label.clone(), Point::new(tx, ty), dim, Alignment::Left, rect);
2747        if let Some(range) = sig.active_param() {
2748            let (s, e) = (range.start as usize, range.end as usize);
2749            if let Some(sub) = sig.label.get(s..e) {
2750                let prefix = sig.label[..s].chars().count();
2751                let px = tx + prefix as f32 * advance;
2752                self.draw_line(renderer, sub.to_string(), Point::new(px, ty), active, Alignment::Left, rect);
2753            }
2754        }
2755        if let Some(doc) = &sig.doc {
2756            let dy = ty + line_h;
2757            self.draw_line(renderer, doc.clone(), Point::new(tx, dy), Color { a: 0.5, ..text_color }, Alignment::Left, rect);
2758        }
2759    }
2760
2761    /// Render the completion popup — the box, the windowed rows (label
2762    /// left / detail right, the selected row highlighted), and the selected
2763    /// item's wrapped doc block below. Placement flips above the caret when
2764    /// there's no room below.
2765    fn draw_popup(&self, renderer: &mut iced::Renderer, list: &PopupList, geo: &Geo, text_color: Color) {
2766        let line_h = geo.line_h();
2767        let (origin, size) = self.popup_layout(list, geo);
2768
2769        let dim = Color { a: 0.6, ..text_color };
2770
2771        // Elevated neutral surface (shared with the hover / signature box).
2772        // Opaque, so the editor text can't bleed through; drawn first, the rows +
2773        // selection paint on top.
2774        let rect = Rectangle { x: origin.x, y: origin.y, width: size.width, height: size.height };
2775        fill_panel(renderer, rect, POPUP_SURFACE, POPUP_BORDER, 8.0);
2776
2777        let n = list.filtered.len();
2778        let visible = n.min(popup::POPUP_MAX_VISIBLE);
2779        let start = popup::window_start(list.selected as usize, n);
2780        let row_x = origin.x + popup::POPUP_PAD;
2781        let right = origin.x + size.width - popup::POPUP_PAD;
2782        for (vis, &fi) in list.filtered[start..start + visible].iter().enumerate() {
2783            let item = &list.items[fi as usize];
2784            let y = popup::row_y(origin.y, vis, line_h);
2785            if start + vis == list.selected as usize {
2786                fill(renderer, Rectangle { x: origin.x, y, width: size.width, height: line_h }, POPUP_SELECT);
2787            }
2788            self.draw_line(renderer, item.label.clone(), Point::new(row_x, y), kind_color(item.kind), Alignment::Left, rect);
2789            if let Some(detail) = &item.detail {
2790                self.draw_line(renderer, detail.clone(), Point::new(right, y), dim, Alignment::Right, rect);
2791            }
2792        }
2793
2794        // Doc block below the list (wrapped plain text), if any.
2795        let doc_lines = popup::doc_lines(list, popup::width_cells(list));
2796        if doc_lines > 0 {
2797            if let Some(doc) = popup::selected_doc(list) {
2798                let y = popup::row_y(origin.y, visible, line_h); // one past the last row
2799                fill(renderer, Rectangle { x: origin.x, y, width: size.width, height: 1.0 }, POPUP_BORDER);
2800                renderer.fill_text(
2801                    Text {
2802                        content: doc.to_string(),
2803                        bounds: Size::new(size.width - 2.0 * popup::POPUP_PAD, doc_lines as f32 * line_h),
2804                        size: Pixels(self.size),
2805                        line_height: LineHeight::Absolute(Pixels(line_h)),
2806                        font: self.font,
2807                        align_x: Alignment::Left,
2808                        align_y: Vertical::Top,
2809                        shaping: Shaping::Basic,
2810                        wrapping: Wrapping::Word,
2811                    },
2812                    Point::new(row_x, y + 1.0),
2813                    dim,
2814                    rect,
2815                );
2816            }
2817        }
2818    }
2819
2820    /// Whether one of the current selections covers the byte range `[start, end]`
2821    /// — used to drop a collapsed inline fold's idle chip pill where the selection
2822    /// wash already backs it. `end` is compared in the wash's offset→cell sense
2823    /// (its right edge sits at `offset_screen_x(end)`).
2824    fn range_selected(&self, start: u32, end: u32) -> bool {
2825        range_covered_by(self.doc.selections().all(), start, end)
2826    }
2827
2828    fn draw_selection(&self, renderer: &mut iced::Renderer, geo: &Geo, start: u32, end: u32, color: Color) {
2829        let buffer = self.doc.buffer();
2830        let fold_map = self.fold_map();
2831        let (a, b) = (buffer.offset_to_point(start), buffer.offset_to_point(end));
2832        // Single-row selection — the common case (a bare caret, a word
2833        // occurrence, a find match): wash it directly, no window walk.
2834        if a.row == b.row {
2835            self.draw_wash_row(renderer, &fold_map, geo, a.row, a, b, start, end, color);
2836            return;
2837        }
2838        // Multi-row: iterate the VISIBLE DISPLAY rows, never the selection's
2839        // raw buffer-row span. A document-spanning selection (Ctrl+A) over a
2840        // folded file stays O(viewport): walking every buffer row start→end would
2841        // be O(document) per frame — a huge fold at the viewport top makes even
2842        // the visible *buffer*-row span O(document), so only the display-row
2843        // window is safe. Each visible row washes its own line; a collapsed
2844        // header additionally washes the `}` tail
2845        // riding its display line (reached through the header's `last_folded`, so
2846        // a tail below the window is still drawn without visiting the rows between).
2847        let bounds = geo.bounds();
2848        let window = fold_map
2849            .display_window(geo.rows_from_top(bounds.y), geo.rows_from_top(bounds.y + bounds.height));
2850        let sel = a.row..=b.row;
2851        for vr in fold_map.visible_rows(window) {
2852            if sel.contains(&vr.buffer_row.0) {
2853                self.draw_wash_row(renderer, &fold_map, geo, vr.buffer_row.0, a, b, start, end, color);
2854            }
2855            if let Some(tail) = vr.last_folded {
2856                if sel.contains(&tail.0) {
2857                    self.draw_fold_tail_wash(renderer, &fold_map, geo, tail.0, a, b, color);
2858                }
2859            }
2860        }
2861    }
2862
2863    /// Wash one visible buffer `row` of a selection: a hidden (folded) row defers
2864    /// to [`draw_fold_tail_wash`] (its `}` tail rides a header line); otherwise
2865    /// fill the selected span, endpoints via the one `offset_screen_x` projection.
2866    #[allow(clippy::too_many_arguments)] // a renderer-side wash genuinely needs all of them
2867    fn draw_wash_row(&self, renderer: &mut iced::Renderer, fold_map: &FoldMap, geo: &Geo, row: u32, a: BufPoint, b: BufPoint, start: u32, end: u32, color: Color) {
2868        // The choke point every washed row flows through — count it so the draw
2869        // budget trips if a caller ever washes O(document) rows.
2870        draw_budget::bump_rows(1);
2871        if fold_map.is_folded(BufferRow(row)) {
2872            self.draw_fold_tail_wash(renderer, fold_map, geo, row, a, b, color);
2873            return;
2874        }
2875        let (bounds, advance, line_h) = (geo.bounds(), geo.advance(), geo.line_h());
2876        let buffer = self.doc.buffer();
2877        let y = geo.row_y(fold_map.to_display_row(BufferRow(row)));
2878        if y + line_h < bounds.y || y > bounds.y + bounds.height {
2879            return;
2880        }
2881        let x0 = if row == a.row { self.offset_screen_x(fold_map, geo, start) } else { geo.cell_x(0.0) };
2882        let x1 = if row == b.row {
2883            self.offset_screen_x(fold_map, geo, end)
2884        } else if let Some(hl) = fold_map.header_layout(buffer, BufferRow(row), TAB) {
2885            // A collapsed block header the selection runs THROUGH (it continues into
2886            // the folded interior below): wash straight across the ` … ` placeholder
2887            // gap so the collapsed `head … tail` line has no unwashed hole. The tail
2888            // wash resumes exactly at `tail_cell`, so the two are contiguous.
2889            geo.cell_x(hl.tail_cell() as f32)
2890        } else {
2891            let line_end = buffer.point_to_offset(scrive_core::Point::new(row, buffer.line_len(row)));
2892            self.offset_screen_x(fold_map, geo, line_end) + advance * 0.5
2893        };
2894        fill(renderer, Rectangle { x: x0, y, width: (x1 - x0).max(1.0), height: line_h }, color);
2895    }
2896
2897    /// Wash the selected part of a collapsed fold's closing (tail) row, which
2898    /// renders inline on its header's display line. `tail_row` is the fold's
2899    /// last buffer row; a no-op when it is not a hidden tail or its header is
2900    /// scrolled off. Split out so both the windowed loop and the single-row path
2901    /// reuse the exact same tail projection.
2902    #[allow(clippy::too_many_arguments)] // a renderer-side wash genuinely needs all of them
2903    fn draw_fold_tail_wash(&self, renderer: &mut iced::Renderer, fold_map: &FoldMap, geo: &Geo, tail_row: u32, a: BufPoint, b: BufPoint, color: Color) {
2904        draw_budget::bump_rows(1); // choke point — counted for the draw budget
2905        let (bounds, advance, line_h) = (geo.bounds(), geo.advance(), geo.line_h());
2906        let buffer = self.doc.buffer();
2907        // Hidden interior rows show nothing — only a collapsed fold's closing
2908        // (tail) row rides a header line.
2909        let Some(hdr) = fold_map.header_of_tail(BufferRow(tail_row)) else { return };
2910        let Some(hl) = fold_map.header_layout(buffer, hdr, TAB) else { return };
2911        let lead = hl.tail_start_col();
2912        let sel_a = if tail_row == a.row { a.col } else { 0 };
2913        let sel_b = if tail_row == b.row { b.col } else { buffer.line_len(tail_row) };
2914        let s_col = sel_a.max(lead); // clamp to the visible tail
2915        let y = geo.row_y(fold_map.to_display_row(hdr));
2916        let onscreen = y + line_h >= bounds.y && y <= bounds.y + bounds.height;
2917        if s_col <= sel_b && onscreen {
2918            // Both endpoints through the one tail projection — the wash sits exactly
2919            // under the tail glyphs/caret, including when a collapsed inline fold
2920            // precedes the block on the header.
2921            let (Some(c0), Some(c1)) = (hl.tail_col_cell(s_col), hl.tail_col_cell(sel_b)) else {
2922                return;
2923            };
2924            let x0 = geo.cell_x(c0 as f32);
2925            let x1 = geo.cell_x(c1 as f32) + if tail_row == b.row { 0.0 } else { advance * 0.5 };
2926            fill(renderer, Rectangle { x: x0, y, width: (x1 - x0).max(1.0), height: line_h }, color);
2927        }
2928    }
2929
2930    /// Copy the selections to the system clipboard: the core's one payload
2931    /// rule ([`Document::clipboard_payload`] — non-empty selections joined, or
2932    /// whole lines for empty carets), re-expanded to the OS EOL flavor and
2933    /// recorded in a side table so paste can recognize a whole-line copy.
2934    fn write_clipboard(&self, clipboard: &mut dyn Clipboard) {
2935        let (text, entire_line) = self.doc.clipboard_payload();
2936        if !text.is_empty() {
2937            let exported = crate::clipboard::export_eol(&text);
2938            crate::clipboard::record(&exported, entire_line);
2939            clipboard.write(ClipboardKind::Standard, exported);
2940        }
2941    }
2942
2943    /// The `(visible buffer row, display cell)` under `pos` for box (column)
2944    /// selection: the row via the ONE row inversion, the cell via the ONE
2945    /// virtual-cell rounding rule ([`scrive_core::virtual_cell`]) — UNclamped
2946    /// by the line, so a drag past a short line still reaches the full width
2947    /// of longer ones. The core resolves each spanned row's cells to bytes
2948    /// (`Document::column_drag`).
2949    fn hit_cell(&self, geo: &Geo, pos: Point) -> (u32, u32) {
2950        let fold_map = self.fold_map();
2951        // The click lands on a *display* row; map it back to a buffer row (a
2952        // folded interior is unreachable — clicks resolve to visible rows).
2953        let row = fold_map.to_buffer_row(fold_map.display_row_at(geo.rows_from_top(pos.y))).0;
2954        (row, scrive_core::virtual_cell(geo.x_cell(pos.x)))
2955    }
2956
2957    /// Pixel → buffer offset: the y half resolves through the ONE row-inversion
2958    /// policy ([`FoldMap::display_row_at`]), the x half through the ONE
2959    /// cell-inversion owner ([`FoldMap::hit_row`]: collapsed header gap/tail
2960    /// resolution, chip clicks, tab snapping). The widget's only contribution is
2961    /// px → (fractional row, fractional cell) via [`Geo`].
2962    fn hit_test(&self, geo: &Geo, pos: Point) -> u32 {
2963        let fold_map = self.fold_map();
2964        let row = fold_map.to_buffer_row(fold_map.display_row_at(geo.rows_from_top(pos.y)));
2965        fold_map.hit_row(self.doc.buffer(), row, geo.x_cell(pos.x), scrive_core::Bias::Left, TAB)
2966    }
2967
2968    /// Draw a row that has inline (single-line) folds: its highlight spans with a
2969    /// per-fold horizontal collapse — the bytes between each pair's brackets hide,
2970    /// later cells shift left, and a `…` chip fills the gap.
2971    /// The delimiters keep their span color; the closer stays a real position.
2972    /// All cell math comes from the core's one [`RowLayout`] owner.
2973    #[allow(clippy::too_many_arguments)]
2974    fn draw_row_inline(
2975        &self,
2976        renderer: &mut iced::Renderer,
2977        line: &str,
2978        spans: Option<&[HighlightSpan]>,
2979        origin: Point,
2980        geo: &Geo,
2981        row_layout: &RowLayout<'_>,
2982        dim: Color,
2983        text_color: Color,
2984        clip: Rectangle,
2985    ) {
2986        let advance = geo.advance();
2987        let seg = |renderer: &mut iced::Renderer, text: &str, start_col: u32, color: Color| {
2988            if text.is_empty() || row_layout.glyph_hidden(start_col) {
2989                return;
2990            }
2991            let x = origin.x + row_layout.display_cell(start_col) as f32 * advance;
2992            self.draw_line(renderer, expand_tabs(text), Point::new(x, origin.y), color, Alignment::Left, clip);
2993        };
2994        match spans {
2995            Some(spans) if !spans.is_empty() => {
2996                for s in spans {
2997                    let fg = s.style.fg;
2998                    let text = &line[s.range.start as usize..s.range.end as usize];
2999                    seg(renderer, text, s.range.start, Color::from_rgb8(fg.r, fg.g, fg.b));
3000                }
3001            }
3002            _ => {
3003                let mut cursor = 0u32;
3004                for chip in row_layout.chips() {
3005                    seg(renderer, &line[cursor as usize..=chip.open_col as usize], cursor, text_color);
3006                    cursor = chip.close_col;
3007                }
3008                seg(renderer, &line[cursor as usize..], cursor, text_color);
3009            }
3010        }
3011        // The `…` chip between each pair's brackets, centered on the collapsed gap.
3012        // Where the selection wash already backs the chip, the idle pill is
3013        // suppressed: it is `POPUP_SELECT` (darker than the wash), so on a selected
3014        // row it would island as a dark box ringed by a lighter-wash margin — the
3015        // "space to the left of the `…`" artifact. The `…` still renders on the
3016        // wash, exactly like any other selected glyph.
3017        let row_start = row_layout.row_start();
3018        for chip in row_layout.chips() {
3019            let mid = origin.x + chip.center * advance;
3020            // On a selected row the wash already backs the chip, so the idle pill
3021            // (`POPUP_SELECT`, darker than the wash) is dropped — else it islands as
3022            // a dark box ringed by a lighter-wash margin (the "space to the left of
3023            // the `…`" artifact). Without the pill's contrast the `dim` dots would
3024            // vanish into the wash, so they render at full glyph color there.
3025            let dots = if self.range_selected(row_start + chip.open_col + 1, row_start + chip.close_col) {
3026                text_color
3027            } else {
3028                fill_rounded(renderer, geo.chip_pill(mid, origin.y), POPUP_SELECT, CHIP_PILL_RADIUS);
3029                dim
3030            };
3031            self.draw_line(renderer, "…".to_string(), Point::new(mid, origin.y), dots, Alignment::Center, clip);
3032        }
3033    }
3034
3035    /// The screen rect of a collapsed fold's `…` chip — an inline `[ … ]` or a block
3036    /// header's `{ … }`. The single source for the plain-hover expand affordance:
3037    /// the highlight, the finger, the click, and the hover preview all measure against
3038    /// it, so they agree. `None` if `opener` isn't collapsed or its line is itself
3039    /// hidden inside an outer collapsed fold.
3040    fn collapsed_chip_rect(&self, fold_map: &FoldMap, geo: &Geo, opener: u32) -> Option<Rectangle> {
3041        if !self.doc.folds().is_folded(opener) {
3042            return None;
3043        }
3044        let buffer = self.doc.buffer();
3045        let b = self.doc.brackets().at(opener).filter(|b| b.open)?;
3046        let close = b.partner?;
3047        let header = buffer.offset_to_point(opener).row;
3048        let last = buffer.offset_to_point(close).row;
3049        if fold_map.is_folded(BufferRow(header)) {
3050            return None;
3051        }
3052        let code_left = geo.code_left();
3053        let y = geo.row_y(fold_map.to_display_row(BufferRow(header)));
3054        if last > header {
3055            // Block: the `{ … }` placeholder — the header's `{` through the closing
3056            // `}`. End at the CLOSER's cell, NOT `hl.width()` (the whole tail line):
3057            // text typed after the `}` on the last line is outside the collapsed
3058            // region and must not join the fold's hover / expand target.
3059            let hl = fold_map.header_layout(buffer, BufferRow(header), TAB)?;
3060            let x0 = geo.cell_x((hl.head_cells() as f32 - 1.0).max(0.0)) - 2.0;
3061            let last_start = buffer.point_to_offset(BufPoint { row: last, col: 0 });
3062            let close_cell = hl.tail_col_cell(close - last_start).unwrap_or_else(|| hl.tail_cell());
3063            let x1 = geo.cell_x(close_cell as f32 + 1.0) + 2.0;
3064            Some(Rectangle {
3065                x: x0.max(code_left + 1.0),
3066                y: y + 1.0,
3067                width: (x1 - x0).max(geo.advance()),
3068                height: geo.line_h() - 2.0,
3069            })
3070        } else {
3071            // Inline: the `[ … ]` bracket span. Only a *root* inline fold has a chip;
3072            // one nested inside another collapsed inline pair is hidden in that pair's
3073            // chip (reduced away by `FoldMap`), so it has no rect of its own.
3074            // `inline_fold_at` is an O(log F) offset-keyed descent, so a hover /
3075            // preview frame costs O(log F), never an O(F) decode of every inline fold.
3076            fold_map.inline_fold_at(opener)?;
3077            let xo = self.offset_screen_x(fold_map, geo, opener);
3078            let xc = self.offset_screen_x(fold_map, geo, close);
3079            Some(geo.inline_halo(xo.min(xc), xo.max(xc), y))
3080        }
3081    }
3082
3083    /// The screen rect of a collapsed fold's `…` pill — the exact box the text
3084    /// pass paints (a block's, centered in the placeholder gap; an inline
3085    /// chip's, at its center), for the plain-hover highlight. All cells come
3086    /// from the same owners the painter reads (`gap_center` / `chips` /
3087    /// [`Geo::chip_pill`]). `None` when the fold has no on-screen pill (not
3088    /// collapsed, hidden inside an outer fold, or nested away).
3089    fn chip_pill_rect(&self, fold_map: &FoldMap, geo: &Geo, opener: u32) -> Option<Rectangle> {
3090        if !self.doc.folds().is_folded(opener) {
3091            return None;
3092        }
3093        let buffer = self.doc.buffer();
3094        let b = self.doc.brackets().at(opener).filter(|b| b.open)?;
3095        let close = b.partner?;
3096        let header = buffer.offset_to_point(opener).row;
3097        let last = buffer.offset_to_point(close).row;
3098        if fold_map.is_folded(BufferRow(header)) {
3099            return None;
3100        }
3101        let y = geo.row_y(fold_map.to_display_row(BufferRow(header)));
3102        let mid = if last > header {
3103            geo.cell_x(fold_map.header_layout(buffer, BufferRow(header), TAB)?.gap_center())
3104        } else {
3105            let row_start = buffer.point_to_offset(BufPoint { row: header, col: 0 });
3106            let layout = fold_map.row_layout(buffer, BufferRow(header), TAB);
3107            let chip = layout.chips().find(|c| c.open_col == opener - row_start)?;
3108            geo.cell_x(chip.center)
3109        };
3110        Some(geo.chip_pill(mid, y))
3111    }
3112
3113    /// The collapsed fold whose `…` chip is under `pos` — the plain-hover expand
3114    /// target. Chips are disjoint on screen, so the first match wins.
3115    fn collapsed_chip_at(&self, geo: &Geo, pos: Point) -> Option<u32> {
3116        let fold_map = self.fold_map();
3117        // A `…` chip renders only on its fold's header (opener) row, so only a
3118        // pair headed on the pointer's display row can sit under `pos`. Resolve
3119        // that one buffer row and test just the pairs headed there
3120        // (O(brackets on the row)) — never a scan of every fold in the document,
3121        // which would build one `HeaderLayout` (rope reads) per fold on every
3122        // `mouse_interaction` frame and stall a collapsed large file. Chips are
3123        // disjoint on screen, so the first hit wins regardless of order.
3124        let row = fold_map.to_buffer_row(fold_map.display_row_at(geo.rows_from_top(pos.y))).0;
3125        self.doc
3126            .foldable_pairs_in_rows(row..row + 1)
3127            .into_iter()
3128            .map(|(open, ..)| open)
3129            .find(|&o| self.collapsed_chip_rect(&fold_map, geo, o).is_some_and(|r| r.contains(pos)))
3130    }
3131
3132    /// Draw the hover preview of a collapsed fold's hidden content: a floating
3133    /// panel, anchored under the collapsed line's chip, showing the folded interior
3134    /// (a block's hidden rows, or an inline pair's own expanded line) in the
3135    /// editor's own syntax colors — a folded-region hover like mainstream editors'.
3136    /// `opener` is the
3137    /// collapsed pair's opening-bracket offset; a no-op if it is not a live
3138    /// collapsed fold (e.g. expanded since the preview armed) or its header is
3139    /// scrolled/folded out of view.
3140    fn draw_fold_preview(&self, renderer: &mut iced::Renderer, geo: &Geo, opener: u32, text_color: Color) {
3141        let (bounds, advance, line_h) = (geo.bounds(), geo.advance(), geo.line_h());
3142        if !self.doc.folds().is_folded(opener) {
3143            return; // expanded since the preview armed — nothing hidden
3144        }
3145        let buffer = self.doc.buffer();
3146        let Some(b) = self.doc.brackets().at(opener).filter(|b| b.open) else { return };
3147        let Some(close) = b.partner else { return };
3148        let header = buffer.offset_to_point(opener).row;
3149        let last = buffer.offset_to_point(close).row;
3150        let fold_map = self.fold_map();
3151        if fold_map.is_folded(BufferRow(header)) {
3152            return; // the collapsed line is itself hidden inside an outer fold
3153        }
3154        // Rows to show: a block's hidden interior (`header+1..=last`), or the inline
3155        // pair's own row expanded. Only the first `MAX_ROWS` are ever read, so
3156        // `take` them — the interior of a document-scale collapsed block must not
3157        // materialize an O(hidden-rows) Vec on every repaint of the preview.
3158        const MAX_ROWS: usize = 18;
3159        let total = if last > header { (last - header) as usize } else { 1 };
3160        let rows: Vec<u32> = if last > header {
3161            (header + 1..=last).take(MAX_ROWS).collect()
3162        } else {
3163            vec![header]
3164        };
3165        let shown = rows.len(); // == total.min(MAX_ROWS)
3166        let more = total - shown;
3167        // Strip the shared leading indent so the preview reads compact.
3168        let mut min_cells = u32::MAX;
3169        let mut max_cells = 0u32;
3170        for &r in &rows[..shown] {
3171            let l = buffer.line(r);
3172            if l.trim().is_empty() {
3173                continue;
3174            }
3175            min_cells = min_cells.min(line_indent_cells(&l));
3176            max_cells = max_cells.max(display_map::expand(&l, l.len() as u32, TAB));
3177        }
3178        let min_cells = if min_cells == u32::MAX { 0 } else { min_cells };
3179        let content_cells = max_cells.saturating_sub(min_cells);
3180        let pad = 8.0_f32;
3181        let code_w = (bounds.width - geo.gutter() - SCROLLBAR_WIDTH).max(advance * 8.0);
3182        let width = (content_cells as f32 * advance + 2.0 * pad).clamp(advance * 8.0, code_w);
3183        let height = (shown + usize::from(more > 0)) as f32 * line_h + 2.0 * pad;
3184        // Anchor at the `…` chip being previewed — the SAME rect the hover
3185        // wash, finger cursor, and expand click measure against, so the panel
3186        // opens under the thing the pointer is on (a mid-line inline chip
3187        // included, not the row's end). Flip above when it would overflow the
3188        // bottom, and clamp horizontally.
3189        let Some(chip) = self.collapsed_chip_rect(&fold_map, geo, opener) else {
3190            return; // no chip on screen (nested away) ⇒ nothing to anchor to
3191        };
3192        let code_left = geo.code_left();
3193        let x = chip.x.clamp(code_left, (bounds.x + bounds.width - width - 4.0).max(code_left));
3194        let line_top = geo.row_y(fold_map.to_display_row(BufferRow(header)));
3195        let below = line_top + line_h + 2.0;
3196        let y = if below + height <= bounds.y + bounds.height { below } else { (line_top - height - 2.0).max(bounds.y) };
3197        let rect = Rectangle { x, y, width, height };
3198        fill_panel(renderer, rect, POPUP_SURFACE, POPUP_BORDER, 4.0);
3199        // Content lines, shifted left by the stripped indent, in their syntax colors.
3200        let sx = rect.x + pad - min_cells as f32 * advance;
3201        for (i, &r) in rows[..shown].iter().enumerate() {
3202            let ry = rect.y + pad + i as f32 * line_h;
3203            match self.doc.highlight_line_spans(r) {
3204                Some(spans) if !spans.is_empty() => self.draw_spans(renderer, &buffer.line(r), spans, Point::new(sx, ry), advance, rect),
3205                _ => {
3206                    let line = buffer.line(r);
3207                    let l = line.trim_start();
3208                    if !l.is_empty() {
3209                        self.draw_line(renderer, expand_tabs(l), Point::new(rect.x + pad, ry), text_color, Alignment::Left, rect);
3210                    }
3211                }
3212            }
3213        }
3214        if more > 0 {
3215            let ry = rect.y + pad + shown as f32 * line_h;
3216            let msg = format!("… {more} more line{}", if more == 1 { "" } else { "s" });
3217            self.draw_line(renderer, msg, Point::new(rect.x + pad, ry), Color { a: 0.6, ..text_color }, Alignment::Left, rect);
3218        }
3219    }
3220
3221    /// Draw one line as its colored highlight spans (each a byte range within the
3222    /// line, positioned by its start cell). Bold/italic deferred; fg only.
3223    fn draw_spans(
3224        &self,
3225        renderer: &mut iced::Renderer,
3226        line: &str,
3227        spans: &[HighlightSpan],
3228        origin: Point,
3229        advance: f32,
3230        clip: Rectangle,
3231    ) {
3232        for span in spans {
3233            let text = &line[span.range.start as usize..span.range.end as usize];
3234            if text.is_empty() {
3235                continue;
3236            }
3237            let cell = display_map::expand(line, span.range.start, TAB);
3238            let x = origin.x + cell as f32 * advance;
3239            let fg = span.style.fg;
3240            self.draw_line(
3241                renderer,
3242                expand_tabs(text),
3243                Point::new(x, origin.y),
3244                Color::from_rgb8(fg.r, fg.g, fg.b),
3245                Alignment::Left,
3246                clip,
3247            );
3248        }
3249    }
3250
3251    /// Draw one line of text at `position` using the configured font/size.
3252    fn draw_line(
3253        &self,
3254        renderer: &mut iced::Renderer,
3255        content: String,
3256        position: Point,
3257        color: Color,
3258        align: Alignment,
3259        clip: Rectangle,
3260    ) {
3261        renderer.fill_text(
3262            Text {
3263                content,
3264                bounds: Size::new(f32::INFINITY, self.line_height),
3265                size: Pixels(self.size),
3266                line_height: LineHeight::Absolute(Pixels(self.line_height)),
3267                font: self.font,
3268                align_x: align,
3269                align_y: Vertical::Top,
3270                shaping: Shaping::Basic,
3271                wrapping: Wrapping::None,
3272            },
3273            position,
3274            color,
3275            clip,
3276        );
3277    }
3278
3279    /// Draw a single [`CODICON`](crate::CODICON) glyph (a fold chevron),
3280    /// horizontally centered in the `width`-wide column whose top-left is `origin`
3281    /// and vertically centered in the row (same vertical rhythm as
3282    /// [`Self::draw_line`]). The host must have loaded
3283    /// [`CODICON_FONT`](crate::CODICON_FONT); if it didn't, iced renders a
3284    /// missing-glyph box rather than the chevron.
3285    fn draw_icon(
3286        &self,
3287        renderer: &mut iced::Renderer,
3288        glyph: char,
3289        origin: Point,
3290        width: f32,
3291        color: Color,
3292        clip: Rectangle,
3293    ) {
3294        renderer.fill_text(
3295            Text {
3296                content: glyph.to_string(),
3297                bounds: Size::new(f32::INFINITY, self.line_height),
3298                size: Pixels(self.size),
3299                line_height: LineHeight::Absolute(Pixels(self.line_height)),
3300                font: crate::CODICON,
3301                align_x: Alignment::Center,
3302                align_y: Vertical::Top,
3303                shaping: Shaping::Basic,
3304                wrapping: Wrapping::None,
3305            },
3306            Point::new(origin.x + width / 2.0, origin.y),
3307            color,
3308            clip,
3309        );
3310    }
3311
3312    /// Left-aligned, unwrapped run in an explicit `font` — the hover's bold /
3313    /// code / plain markdown segments (`draw_line` fixed to `self.font`).
3314    fn draw_run(
3315        &self,
3316        renderer: &mut iced::Renderer,
3317        content: String,
3318        position: Point,
3319        color: Color,
3320        font: Font,
3321        clip: Rectangle,
3322    ) {
3323        renderer.fill_text(
3324            Text {
3325                content,
3326                bounds: Size::new(f32::INFINITY, self.line_height),
3327                size: Pixels(self.size),
3328                line_height: LineHeight::Absolute(Pixels(self.line_height)),
3329                font,
3330                align_x: Alignment::Left,
3331                align_y: Vertical::Top,
3332                shaping: Shaping::Basic,
3333                wrapping: Wrapping::None,
3334            },
3335            position,
3336            color,
3337            clip,
3338        );
3339    }
3340}
3341
3342/// Expand tabs to spaces for display (the caret math uses the display map, so
3343/// they agree).
3344fn expand_tabs(line: &str) -> String {
3345    if !line.contains('\t') {
3346        return line.to_owned();
3347    }
3348    let mut out = String::with_capacity(line.len());
3349    let mut cell = 0u32;
3350    for ch in line.chars() {
3351        if ch == '\t' {
3352            let w = display_map::tab_width(cell, TAB);
3353            for _ in 0..w {
3354                out.push(' ');
3355            }
3356            cell += w;
3357        } else {
3358            out.push(ch);
3359            cell += 1;
3360        }
3361    }
3362    out
3363}
3364
3365/// Map a key event to an [`Action`], or `None` if it isn't an editor input.
3366/// The glyph a numpad *character* key carries in `text`, or `None` for a
3367/// non-numpad key, a numpad key with no text (NumLock off), or control text
3368/// (NumpadEnter → `\r`, left for the `Named::Enter` path). Works around the
3369/// NumLock-on quirk where numpad keys report a navigation logical key.
3370fn numpad_text(physical: &iced::keyboard::key::Physical, text: Option<&str>) -> Option<char> {
3371    use iced::keyboard::key::{Code, Physical};
3372    let is_numpad = matches!(
3373        physical,
3374        Physical::Code(
3375            Code::Numpad0
3376                | Code::Numpad1
3377                | Code::Numpad2
3378                | Code::Numpad3
3379                | Code::Numpad4
3380                | Code::Numpad5
3381                | Code::Numpad6
3382                | Code::Numpad7
3383                | Code::Numpad8
3384                | Code::Numpad9
3385                | Code::NumpadDecimal
3386                | Code::NumpadComma
3387                | Code::NumpadAdd
3388                | Code::NumpadSubtract
3389                | Code::NumpadMultiply
3390                | Code::NumpadDivide
3391                | Code::NumpadEqual
3392        )
3393    );
3394    if !is_numpad {
3395        return None;
3396    }
3397    let ch = text?.chars().next()?;
3398    (!ch.is_control()).then_some(ch)
3399}
3400
3401fn interpret_key(key: &Key, text: Option<&str>, mods: Modifiers) -> Option<Action> {
3402    let extend = mods.shift();
3403    let mv = |motion| Some(Action::Move { motion, extend });
3404    // Column (box) selection needs all three of Ctrl+Shift+Alt — checked before
3405    // the Ctrl+Arrow word-motions below, which Ctrl+Shift+Alt+Left would else
3406    // match on `control()` alone.
3407    let column = mods.control() && mods.shift() && mods.alt();
3408    match key {
3409        Key::Named(Named::ArrowUp) if column => Some(Action::ColumnSelect(ColumnDir::Up)),
3410        Key::Named(Named::ArrowDown) if column => Some(Action::ColumnSelect(ColumnDir::Down)),
3411        Key::Named(Named::ArrowLeft) if column => Some(Action::ColumnSelect(ColumnDir::Left)),
3412        Key::Named(Named::ArrowRight) if column => Some(Action::ColumnSelect(ColumnDir::Right)),
3413        // Ctrl+Alt+↑/↓ add a caret above/below every caret — after the
3414        // three-modifier column arms, before the plain arrows.
3415        Key::Named(Named::ArrowUp) if mods.control() && mods.alt() && !mods.shift() => {
3416            Some(Action::AddCaretVertical { down: false })
3417        }
3418        Key::Named(Named::ArrowDown) if mods.control() && mods.alt() && !mods.shift() => {
3419            Some(Action::AddCaretVertical { down: true })
3420        }
3421        // Shift+Alt+←/→ shrink/expand the selection structurally — the
3422        // standard bindings; without Ctrl, which is column-select above.
3423        Key::Named(Named::ArrowRight) if mods.alt() && !mods.control() && mods.shift() => {
3424            Some(Action::ExpandSelection)
3425        }
3426        Key::Named(Named::ArrowLeft) if mods.alt() && !mods.control() && mods.shift() => {
3427            Some(Action::ShrinkSelection)
3428        }
3429        // Alt+↑/↓ move the line, Shift+Alt+↑/↓ copy it (both without Ctrl, which
3430        // is column-select above). Checked before the plain arrow motions.
3431        Key::Named(Named::ArrowUp) if mods.alt() && !mods.control() && !mods.shift() => {
3432            Some(Action::MoveLine { down: false })
3433        }
3434        Key::Named(Named::ArrowDown) if mods.alt() && !mods.control() && !mods.shift() => {
3435            Some(Action::MoveLine { down: true })
3436        }
3437        Key::Named(Named::ArrowUp) if mods.alt() && !mods.control() && mods.shift() => {
3438            Some(Action::CopyLine { down: false })
3439        }
3440        Key::Named(Named::ArrowDown) if mods.alt() && !mods.control() && mods.shift() => {
3441            Some(Action::CopyLine { down: true })
3442        }
3443        Key::Named(Named::ArrowLeft) if mods.control() => mv(Motion::WordLeft),
3444        Key::Named(Named::ArrowRight) if mods.control() => mv(Motion::WordRight),
3445        Key::Named(Named::ArrowLeft) => mv(Motion::Left),
3446        Key::Named(Named::ArrowRight) => mv(Motion::Right),
3447        Key::Named(Named::ArrowUp) => mv(Motion::Up),
3448        Key::Named(Named::ArrowDown) => mv(Motion::Down),
3449        // Ctrl+Home/End jump to the document ends (Shift extends via `extend`);
3450        // checked before the plain Home/End smart-line motions below.
3451        Key::Named(Named::Home) if mods.control() => mv(Motion::DocStart),
3452        Key::Named(Named::End) if mods.control() => mv(Motion::DocEnd),
3453        Key::Named(Named::Home) => mv(Motion::LineStart),
3454        Key::Named(Named::End) => mv(Motion::LineEnd),
3455        // Ctrl+Backspace/Delete delete by word; before the plain arms.
3456        Key::Named(Named::Backspace) if mods.control() => Some(Action::DeleteWordBack),
3457        Key::Named(Named::Delete) if mods.control() => Some(Action::DeleteWordForward),
3458        Key::Named(Named::Backspace) => Some(Action::Backspace),
3459        Key::Named(Named::Delete) => Some(Action::Delete),
3460        // Ctrl+Enter opens a line below, Ctrl+Shift+Enter above — without
3461        // splitting the current line; before the plain Enter arm. Alt+Enter
3462        // is the app's find-bar chord (select all matches), so the editor
3463        // deliberately ignores it — no newline alongside the app gesture.
3464        Key::Named(Named::Enter) if mods.control() && !mods.alt() => {
3465            Some(Action::InsertLine { down: !mods.shift() })
3466        }
3467        Key::Named(Named::Enter) if !mods.alt() => Some(Action::Enter),
3468        Key::Named(Named::Enter) => None,
3469        Key::Named(Named::Tab) if mods.shift() => Some(Action::Outdent),
3470        Key::Named(Named::Tab) => Some(Action::Tab),
3471        Key::Named(Named::Space) => Some(Action::Type(' ')),
3472        Key::Named(Named::Escape) => Some(Action::Collapse),
3473        // F8 / Shift+F8 jump to the next/previous diagnostic.
3474        Key::Named(Named::F8) => Some(Action::NextDiagnostic { forward: !mods.shift() }),
3475        // Ctrl+D add-next-occurrence. The `!alt` guard keeps Ctrl+Alt
3476        // (AltGr) from ever triggering the gesture.
3477        Key::Character(c) if mods.control() && !mods.alt() && c.as_str() == "d" => {
3478            Some(Action::AddNextOccurrence)
3479        }
3480        // Ctrl+Shift+L select-all-occurrences (Shift makes the logical "L").
3481        Key::Character(c)
3482            if mods.control() && mods.shift() && !mods.alt() && c.as_str().eq_ignore_ascii_case("l") =>
3483        {
3484            Some(Action::SelectAllOccurrences)
3485        }
3486        // Ctrl+A select-all (same `!alt` guard).
3487        Key::Character(c) if mods.control() && !mods.alt() && c.as_str() == "a" => {
3488            Some(Action::SelectAll)
3489        }
3490        // Ctrl+/ toggle line comment (same `!alt` AltGr guard).
3491        Key::Character(c) if mods.control() && !mods.alt() && c.as_str() == "/" => {
3492            Some(Action::ToggleComment)
3493        }
3494        // Ctrl+Shift+K delete line (Shift makes the logical char "K").
3495        Key::Character(c)
3496            if mods.control() && mods.shift() && !mods.alt() && c.as_str().eq_ignore_ascii_case("k") =>
3497        {
3498            Some(Action::DeleteLine)
3499        }
3500        // Ctrl+Shift+\ jump to matching bracket — on US layouts Shift makes
3501        // the logical char `|`, so accept both (like the fold chords).
3502        Key::Character(c) if mods.control() && !mods.alt() && matches!(c.as_str(), "\\" | "|") => {
3503            Some(Action::JumpToBracket)
3504        }
3505        // Undo / redo: Ctrl+Z undoes, Ctrl+Shift+Z and Ctrl+Y redo. Shift makes
3506        // the logical char "Z", so fold case on the z arm.
3507        Key::Character(c) if mods.control() && !mods.alt() && c.as_str().eq_ignore_ascii_case("z") => {
3508            Some(if mods.shift() { Action::Redo } else { Action::Undo })
3509        }
3510        Key::Character(c) if mods.control() && !mods.alt() && c.as_str().eq_ignore_ascii_case("y") => {
3511            Some(Action::Redo)
3512        }
3513        _ => {
3514            if (mods.control() && !mods.alt()) || mods.command() {
3515                return None;
3516            }
3517            let ch = text?.chars().next()?;
3518            (!ch.is_control()).then_some(Action::Type(ch))
3519        }
3520    }
3521}
3522
3523/// The display-cell width of a line's leading whitespace (its indentation),
3524/// with tabs expanded — the column an indent guide one level in sits at.
3525fn line_indent_cells(line: &str) -> u32 {
3526    let ws = (line.len() - line.trim_start().len()) as u32;
3527    display_map::expand(line, ws, TAB)
3528}
3529
3530/// Completion-item label color by kind — one accent per kind, so the
3531/// popup's labels are colored by what they complete to (a theme may override).
3532fn kind_color(kind: CompletionKind) -> Color {
3533    match kind {
3534        CompletionKind::Keyword | CompletionKind::Construct => Color::from_rgb8(0xff, 0x61, 0x8d), // pink
3535        CompletionKind::Type => Color::from_rgb8(0x66, 0xd9, 0xef), // cyan
3536        CompletionKind::Param | CompletionKind::Field => Color::from_rgb8(0xfd, 0x97, 0x1f), // orange
3537        CompletionKind::Value | CompletionKind::Event => Color::from_rgb8(0xa6, 0xe2, 0x2e), // green
3538        CompletionKind::Symbol | CompletionKind::Method => Color::from_rgb8(0xae, 0x81, 0xff), // purple
3539    }
3540}
3541
3542fn severity_color(sev: Severity) -> Color {
3543    match sev {
3544        Severity::Error => Color::from_rgb8(0xff, 0x61, 0x69),   // red
3545        Severity::Warning => Color::from_rgb8(0xfc, 0xe5, 0x66), // yellow
3546        Severity::Info => Color::from_rgb8(0x5a, 0xd4, 0xe6),    // blue
3547        Severity::Hint => Color::from_rgb8(0x94, 0x8a, 0xe3),    // muted purple
3548    }
3549}
3550
3551/// Rasterize a diagnostic squiggle from `x0` to `x1` at vertical `baseline` into
3552/// 1-px vertical spans. The wave is a sine anchored at `x0`
3553/// (half-period [`SQUIGGLE_HALF_PERIOD`], amplitude [`SQUIGGLE_AMPLITUDE`]); each
3554/// span covers one horizontal pixel and is tall enough to bridge the wave's rise
3555/// to the next sample, so the [`SQUIGGLE_STROKE`]-thick line stays continuous
3556/// through the steep zero-crossings. Pure — the geometry is unit-tested without
3557/// a renderer, then the widget paints the emitted spans with `fill`.
3558fn squiggle_spans(x0: f32, x1: f32, baseline: f32, mut emit: impl FnMut(Rectangle)) {
3559    use std::f32::consts::PI;
3560    let wave = |x: f32| SQUIGGLE_AMPLITUDE * (PI * (x - x0) / SQUIGGLE_HALF_PERIOD).sin();
3561    let mut x = x0;
3562    while x < x1 {
3563        let next = (x + 1.0).min(x1);
3564        let (ya, yb) = (baseline + wave(x), baseline + wave(next));
3565        let top = ya.min(yb) - SQUIGGLE_STROKE / 2.0;
3566        let height = (ya - yb).abs() + SQUIGGLE_STROKE;
3567        emit(Rectangle { x, y: top, width: next - x, height });
3568        x = next;
3569    }
3570}
3571
3572/// Fill a rectangle with a flat color.
3573fn fill(renderer: &mut iced::Renderer, bounds: Rectangle, color: Color) {
3574    use iced::advanced::Renderer as _;
3575    renderer.fill_quad(
3576        renderer::Quad { bounds, border: border::rounded(0), shadow: Shadow::default(), snap: true },
3577        color,
3578    );
3579}
3580
3581/// Inline markdown styling for a hover run.
3582#[derive(Clone, Copy, PartialEq, Eq, Debug)]
3583enum MdStyle {
3584    Plain,
3585    Bold,
3586    Code,
3587}
3588
3589/// Split one markdown line into styled runs, consuming the `**bold**` and
3590/// `` `code` `` markers. A minimal inline subset (no nesting) — enough for the
3591/// hover's spec-derived docs; unmatched markers just toggle back at line end.
3592fn parse_md_runs(line: &str) -> Vec<(String, MdStyle)> {
3593    let mut runs = Vec::new();
3594    let mut cur = String::new();
3595    let mut style = MdStyle::Plain;
3596    let mut push = |cur: &mut String, style: MdStyle| {
3597        if !cur.is_empty() {
3598            runs.push((std::mem::take(cur), style));
3599        }
3600    };
3601    let mut chars = line.chars().peekable();
3602    while let Some(c) = chars.next() {
3603        match c {
3604            '*' if chars.peek() == Some(&'*') => {
3605                chars.next(); // second '*'
3606                push(&mut cur, style);
3607                style = if style == MdStyle::Bold { MdStyle::Plain } else { MdStyle::Bold };
3608            }
3609            '`' => {
3610                push(&mut cur, style);
3611                style = if style == MdStyle::Code { MdStyle::Plain } else { MdStyle::Code };
3612            }
3613            _ => cur.push(c),
3614        }
3615    }
3616    if !cur.is_empty() {
3617        runs.push((cur, style));
3618    }
3619    runs
3620}
3621
3622/// Coalesce a per-char styled sequence into `(String, style)` segments.
3623fn coalesce_chars(chars: Vec<(char, MdStyle)>) -> Vec<(String, MdStyle)> {
3624    let mut segs: Vec<(String, MdStyle)> = Vec::new();
3625    for (c, s) in chars {
3626        match segs.last_mut() {
3627            Some((t, st)) if *st == s => t.push(c),
3628            _ => segs.push((c.to_string(), s)),
3629        }
3630    }
3631    segs
3632}
3633
3634/// Word-wrap one source line's styled runs into visual lines that each fit in
3635/// `max_cells` (monospace ⇒ 1 cell = 1 char). Breaks at spaces (dropped at the
3636/// break); a word longer than the width hard-breaks. Per-char style rides along.
3637fn wrap_runs(runs: &[(String, MdStyle)], max_cells: usize) -> Vec<Vec<(String, MdStyle)>> {
3638    let max = max_cells.max(1);
3639    let flat: Vec<(char, MdStyle)> =
3640        runs.iter().flat_map(|(t, s)| t.chars().map(move |c| (c, *s))).collect();
3641    let mut lines: Vec<Vec<(char, MdStyle)>> = Vec::new();
3642    let mut cur: Vec<(char, MdStyle)> = Vec::new();
3643    let trim = |line: &mut Vec<(char, MdStyle)>| {
3644        while line.last().map(|&(c, _)| c) == Some(' ') {
3645            line.pop();
3646        }
3647    };
3648    let mut i = 0;
3649    while i < flat.len() {
3650        if flat[i].0 == ' ' {
3651            if cur.len() < max {
3652                cur.push(flat[i]); // spaces only count while the line has room
3653            }
3654            i += 1;
3655            continue;
3656        }
3657        let start = i;
3658        while i < flat.len() && flat[i].0 != ' ' {
3659            i += 1;
3660        }
3661        let word = &flat[start..i];
3662        if word.len() > max {
3663            // Hard-break an over-long word across lines.
3664            for &ch in word {
3665                if cur.len() >= max {
3666                    lines.push(std::mem::take(&mut cur));
3667                }
3668                cur.push(ch);
3669            }
3670        } else {
3671            if cur.len() + word.len() > max {
3672                trim(&mut cur);
3673                lines.push(std::mem::take(&mut cur));
3674            }
3675            cur.extend_from_slice(word);
3676        }
3677    }
3678    trim(&mut cur);
3679    lines.push(cur);
3680    lines.into_iter().map(coalesce_chars).collect()
3681}
3682
3683/// Geometry + wrapped content for the hover popup — computed once and shared by
3684/// `draw_hover` and the wheel-scroll handler (one source of truth for the box).
3685struct HoverLayout {
3686    /// The whole popup box.
3687    rect: Rectangle,
3688    /// Word-wrapped visual lines (styled runs).
3689    lines: Vec<Vec<(String, MdStyle)>>,
3690    /// How many lines are visible at once (≤ `HOVER_MAX_VISIBLE`).
3691    visible: usize,
3692    /// Max vertical scroll offset in px (`(total − visible) · line_h`), 0 if it fits.
3693    max_scroll: f32,
3694    /// Inner padding (x, y).
3695    pad_x: f32,
3696    pad_y: f32,
3697    /// Whether the content is taller than the box (⇒ scrollbar + wheel scroll).
3698    overflow: bool,
3699}
3700
3701/// A filled rounded rect with no border or shadow — the inline-code pill.
3702fn fill_rounded(renderer: &mut iced::Renderer, bounds: Rectangle, color: Color, radius: f32) {
3703    use iced::advanced::Renderer as _;
3704    renderer.fill_quad(
3705        renderer::Quad { bounds, border: border::rounded(radius), shadow: Shadow::default(), snap: true },
3706        color,
3707    );
3708}
3709
3710/// Draw a floating panel: a rounded quad with a colored 1-px border and a soft
3711/// drop shadow — the shared hover/popup surface.
3712fn fill_panel(renderer: &mut iced::Renderer, bounds: Rectangle, fill: Color, border_color: Color, radius: f32) {
3713    use iced::advanced::Renderer as _;
3714    renderer.fill_quad(
3715        renderer::Quad {
3716            bounds,
3717            border: border::rounded(radius).color(border_color).width(1.0),
3718            shadow: Shadow {
3719                color: Color::from_rgba8(0, 0, 0, 0.36),
3720                offset: Vector::new(0.0, 2.0),
3721                blur_radius: 8.0,
3722            },
3723            snap: true,
3724        },
3725        fill,
3726    );
3727}
3728
3729/// Draw a rectangle outline: a transparent-fill quad with a `width`-px colored
3730/// border (the matching-bracket box).
3731fn fill_border(renderer: &mut iced::Renderer, bounds: Rectangle, color: Color, width: f32) {
3732    use iced::advanced::Renderer as _;
3733    renderer.fill_quad(
3734        renderer::Quad {
3735            bounds,
3736            border: border::rounded(2).color(color).width(width),
3737            shadow: Shadow::default(),
3738            snap: true,
3739        },
3740        Color::TRANSPARENT,
3741    );
3742}
3743
3744/// Stroke a rounded rectangle's perimeter with a dashed line — the Ctrl+hover
3745/// collapse box. iced quad borders are solid-only, so the straight edges are
3746/// laid down as short filled spans (inset by `radius` at each end) and the four
3747/// corners are traced as short arcs of 1-px dots. `dash`/`gap`/`w` are the run,
3748/// the gap, and the stroke width; `radius` the corner radius.
3749#[allow(clippy::too_many_arguments)] // a dashed rounded stroke needs all of them
3750fn stroke_dashed_rounded_rect(renderer: &mut iced::Renderer, rect: Rectangle, color: Color, radius: f32, dash: f32, gap: f32, w: f32) {
3751    if rect.width <= 0.0 || rect.height <= 0.0 {
3752        return;
3753    }
3754    let (x, y, ww, hh) = (rect.x, rect.y, rect.width, rect.height);
3755    let r = radius.min(ww * 0.5).min(hh * 0.5).max(0.0);
3756    let step = (dash + gap).max(0.1);
3757    // Top & bottom edges — horizontal dashes, inset by `r` so the corners are free.
3758    let mut sx = x + r;
3759    while sx < x + ww - r {
3760        let dw = dash.min(x + ww - r - sx);
3761        fill(renderer, Rectangle { x: sx, y, width: dw, height: w }, color);
3762        fill(renderer, Rectangle { x: sx, y: y + hh - w, width: dw, height: w }, color);
3763        sx += step;
3764    }
3765    // Left & right edges — vertical dashes, likewise inset.
3766    let mut sy = y + r;
3767    while sy < y + hh - r {
3768        let dh = dash.min(y + hh - r - sy);
3769        fill(renderer, Rectangle { x, y: sy, width: w, height: dh }, color);
3770        fill(renderer, Rectangle { x: x + ww - w, y: sy, width: w, height: dh }, color);
3771        sy += step;
3772    }
3773    // Rounded corners — trace each quarter-arc with 1-px dots (centre, start→end).
3774    if r > 0.5 {
3775        use core::f32::consts::{FRAC_PI_2, PI};
3776        let half = w * 0.5;
3777        let arcs = [
3778            (x + ww - r, y + r, -FRAC_PI_2, 0.0),    // top-right
3779            (x + ww - r, y + hh - r, 0.0, FRAC_PI_2), // bottom-right
3780            (x + r, y + hh - r, FRAC_PI_2, PI),       // bottom-left
3781            (x + r, y + r, PI, PI + FRAC_PI_2),       // top-left
3782        ];
3783        for (cx, cy, a0, a1) in arcs {
3784            let steps = (r * (a1 - a0).abs()).ceil().max(1.0) as u32;
3785            for k in 0..=steps {
3786                let a = a0 + (a1 - a0) * (k as f32 / steps as f32);
3787                fill(renderer, Rectangle { x: cx + r * a.cos() - half, y: cy + r * a.sin() - half, width: w, height: w }, color);
3788            }
3789        }
3790    }
3791}
3792
3793impl<'a, Message: 'a> From<Editor<'a, Message>>
3794    for Element<'a, Message, iced::Theme, iced::Renderer>
3795{
3796    fn from(editor: Editor<'a, Message>) -> Self {
3797        Element::new(editor)
3798    }
3799}
3800
3801#[cfg(test)]
3802mod tests {
3803    use super::*;
3804    use iced::advanced::widget::operation;
3805
3806    #[test]
3807    fn digit_count_is_right() {
3808        assert_eq!(digit_count(0), 1);
3809        assert_eq!(digit_count(9), 1);
3810        assert_eq!(digit_count(10), 2);
3811        assert_eq!(digit_count(1000), 4);
3812    }
3813
3814    #[test]
3815    fn visible_selection_span_matches_a_brute_force_filter() {
3816        use scrive_core::SelectionSet;
3817        // A sorted, disjoint set: carets and ranges. from_ranges normalizes.
3818        let ranges = [(0u32, 3u32), (10, 10), (20, 25), (40, 40), (55, 60), (80, 80)];
3819        let set = SelectionSet::from_ranges(&ranges, 0);
3820        let sels = set.all();
3821        for vis_start in 0..90u32 {
3822            for vis_end in vis_start..90u32 {
3823                let span = visible_selection_span(sels, vis_start, vis_end);
3824                // A selection intersects the window iff end ≥ start_of_window and
3825                // start ≤ end_of_window; the intersecting set is contiguous.
3826                let brute: Vec<usize> = (0..sels.len())
3827                    .filter(|&i| sels[i].end() >= vis_start && sels[i].start() <= vis_end)
3828                    .collect();
3829                let got: Vec<usize> = span.clone().collect();
3830                assert_eq!(got, brute, "window [{vis_start}, {vis_end}]");
3831            }
3832        }
3833    }
3834
3835    #[test]
3836    fn range_covered_by_matches_a_brute_force_scan() {
3837        use scrive_core::SelectionSet;
3838        // Carets and ranges, sorted + disjoint (from_ranges normalizes).
3839        let ranges = [(0u32, 3u32), (10, 10), (20, 25), (40, 40), (55, 60)];
3840        let set = SelectionSet::from_ranges(&ranges, 0);
3841        let sels = set.all();
3842        for start in 0..65u32 {
3843            for end in start..65u32 {
3844                let got = range_covered_by(sels, start, end);
3845                // Covered iff some single selection spans the whole range.
3846                let brute = sels.iter().any(|s| s.start() <= start && s.end() >= end);
3847                assert_eq!(got, brute, "range [{start}, {end}]");
3848            }
3849        }
3850    }
3851
3852    #[test]
3853    fn numpad_text_prefers_glyph_over_nav() {
3854        use iced::keyboard::key::{Code, Physical};
3855        // NumLock on: a numpad key carries its glyph in `text` → type it, even
3856        // though its logical key is a navigation key.
3857        assert_eq!(numpad_text(&Physical::Code(Code::Numpad2), Some("2")), Some('2'));
3858        assert_eq!(numpad_text(&Physical::Code(Code::NumpadDecimal), Some(".")), Some('.'));
3859        assert_eq!(numpad_text(&Physical::Code(Code::NumpadAdd), Some("+")), Some('+'));
3860        // NumLock off: no text ⇒ None ⇒ the navigation key stands.
3861        assert_eq!(numpad_text(&Physical::Code(Code::Numpad2), None), None);
3862        // A main-row digit is not a numpad key ⇒ never intercepted here.
3863        assert_eq!(numpad_text(&Physical::Code(Code::Digit2), Some("2")), None);
3864        // NumpadEnter is excluded ⇒ left for the Named::Enter → newline path.
3865        assert_eq!(numpad_text(&Physical::Code(Code::NumpadEnter), Some("\r")), None);
3866    }
3867
3868    #[test]
3869    fn wrap_runs_wraps_at_word_boundaries() {
3870        use MdStyle::*;
3871        let text = |line: &Vec<(String, MdStyle)>| line.iter().map(|(t, _)| t.as_str()).collect::<String>();
3872        // Greedy word wrap at width 8.
3873        let w = wrap_runs(&[("hello world foo".to_string(), Plain)], 8);
3874        assert_eq!(w.iter().map(text).collect::<Vec<_>>(), vec!["hello", "world", "foo"]);
3875        // A word longer than the width hard-breaks.
3876        let h = wrap_runs(&[("verylongword".to_string(), Plain)], 4);
3877        assert_eq!(h.iter().map(text).collect::<Vec<_>>(), vec!["very", "long", "word"]);
3878        // Short content stays one line and keeps its styles.
3879        let s = wrap_runs(&[("a".to_string(), Bold), (" b".to_string(), Plain)], 40);
3880        assert_eq!(s.len(), 1);
3881        assert_eq!(s[0], vec![("a".to_string(), Bold), (" b".to_string(), Plain)]);
3882    }
3883
3884    #[test]
3885    fn parse_md_runs_splits_bold_and_code() {
3886        use MdStyle::*;
3887        let runs = parse_md_runs("**name** `id { … }` — handle it.");
3888        assert_eq!(
3889            runs,
3890            vec![
3891                ("name".to_string(), Bold),
3892                (" ".to_string(), Plain),
3893                ("id { … }".to_string(), Code),
3894                (" — handle it.".to_string(), Plain),
3895            ]
3896        );
3897        // Plain text with no markers is a single Plain run.
3898        assert_eq!(parse_md_runs("just words"), vec![("just words".to_string(), Plain)]);
3899        // A single '*' is literal (only '**' toggles bold).
3900        assert_eq!(parse_md_runs("a * b"), vec![("a * b".to_string(), Plain)]);
3901    }
3902
3903    #[test]
3904    fn reveal_fit_scrolls_with_margin_and_holds() {
3905        // Margin 0 is plain minimal-reveal: scroll just enough to bring the target in.
3906        assert_eq!(reveal_fit(100.0, 40.0, 60.0, 300.0, 0.0), 40.0); // above → up
3907        assert_eq!(reveal_fit(0.0, 600.0, 620.0, 300.0, 0.0), 320.0); // below → down
3908        assert_eq!(reveal_fit(100.0, 200.0, 220.0, 300.0, 0.0), 100.0); // visible → hold
3909        // Margin band (3 lines × 20 px = 60): a caret 2 lines from the
3910        // bottom edge scrolls until 3 clear lines separate it from the edge…
3911        assert_eq!(reveal_fit(0.0, 240.0, 260.0, 300.0, 60.0), 20.0);
3912        // …a caret already ≥ margin clear of both edges holds…
3913        assert_eq!(reveal_fit(0.0, 100.0, 120.0, 300.0, 60.0), 0.0);
3914        // …and the band clamps at the document top (no negative scroll).
3915        assert_eq!(reveal_fit(100.0, 40.0, 60.0, 300.0, 60.0), 0.0);
3916        // The margin collapses when the target nearly fills the window:
3917        // target 80 tall in a 100 window → m = 10, not 60.
3918        assert_eq!(reveal_fit(0.0, 200.0, 280.0, 100.0, 60.0), 190.0);
3919        // Target (with band) taller than the window: never jitter — hold.
3920        assert_eq!(reveal_fit(50.0, 0.0, 400.0, 300.0, 0.0), 50.0);
3921    }
3922
3923    #[test]
3924    fn squiggle_spans_trace_the_wave_within_amplitude() {
3925        let collect = |x0, x1, baseline| {
3926            let mut v = Vec::new();
3927            squiggle_spans(x0, x1, baseline, |r| v.push(r));
3928            v
3929        };
3930        let spans = collect(0.0, 8.0, 10.0);
3931        assert!(!spans.is_empty());
3932        // Contiguous 1-px columns covering [0, 8).
3933        assert!(spans.iter().all(|r| (r.width - 1.0).abs() < 1e-3));
3934        assert!((spans[0].x - 0.0).abs() < 1e-3);
3935        // The stroke stays within the amplitude band [-amp-stroke/2, +amp+stroke/2].
3936        let top = spans.iter().map(|r| r.y).fold(f32::MAX, f32::min);
3937        let bot = spans.iter().map(|r| r.y + r.height).fold(f32::MIN, f32::max);
3938        assert!((top - (10.0 - SQUIGGLE_AMPLITUDE - SQUIGGLE_STROKE / 2.0)).abs() < 0.2, "reaches −amp");
3939        assert!((bot - (10.0 + SQUIGGLE_AMPLITUDE + SQUIGGLE_STROKE / 2.0)).abs() < 0.2, "reaches +amp");
3940        // Half-period 2 px: the wave peaks (+amp) one half-period-quarter in, at x≈1.
3941        assert!(collect(5.0, 5.0, 10.0).is_empty(), "zero width → no spans");
3942    }
3943
3944    #[test]
3945    fn scrollbar_thumb_map_round_trips_and_clamps() {
3946        // track [0,300), 100-px thumb, 700 rows of scroll → thumb travels 200 px.
3947        let sb = Scrollbar { x: 288.0, track_top: 0.0, track_h: 300.0, thumb_h: 100.0, thumb_y: 100.0, max_scroll_rows: 700.0 };
3948        // The forward map (draw) and inverse (drag) agree at the midpoint.
3949        assert_eq!(sb.scroll_rows_for_thumb_top(100.0), 350.0);
3950        // Dragging past either end clamps to the scroll range, not past it.
3951        assert_eq!(sb.scroll_rows_for_thumb_top(-50.0), 0.0);
3952        assert_eq!(sb.scroll_rows_for_thumb_top(500.0), 700.0);
3953        // Hit-testing: the band is [x, ∞); the thumb is [thumb_y, +thumb_h).
3954        assert!(sb.contains_x(290.0) && !sb.contains_x(287.0));
3955        assert!(sb.thumb_contains_y(150.0) && !sb.thumb_contains_y(200.0) && !sb.thumb_contains_y(99.0));
3956    }
3957
3958    #[test]
3959    fn hscrollbar_thumb_map_round_trips_and_clamps() {
3960        // The X-axis mirror: track [0,300), 100-px thumb, 700 px of scroll.
3961        let hb = HScrollbar { y: 288.0, track_left: 0.0, track_w: 300.0, thumb_w: 100.0, thumb_x: 100.0, max_scroll: 700.0 };
3962        assert_eq!(hb.scroll_for_thumb_left(100.0), 350.0);
3963        assert_eq!(hb.scroll_for_thumb_left(-50.0), 0.0);
3964        assert_eq!(hb.scroll_for_thumb_left(500.0), 700.0);
3965        // Band is [y, ∞) (bottom edge); thumb is [thumb_x, +thumb_w).
3966        assert!(hb.contains_y(290.0) && !hb.contains_y(287.0));
3967        assert!(hb.thumb_contains_x(150.0) && !hb.thumb_contains_x(200.0) && !hb.thumb_contains_x(99.0));
3968    }
3969
3970    #[test]
3971    fn severity_order_is_ascending_so_error_paints_last() {
3972        // The draw loop sorts by this Ord; Error must sort highest (paint last).
3973        assert!(Severity::Hint < Severity::Info);
3974        assert!(Severity::Info < Severity::Warning);
3975        assert!(Severity::Warning < Severity::Error);
3976    }
3977
3978    #[test]
3979    fn interpret_typing_and_chords() {
3980        let none = Modifiers::default();
3981        assert_eq!(interpret_key(&Key::Character("a".into()), Some("a"), none), Some(Action::Type('a')));
3982        // An unbound Ctrl chord is swallowed (no Type), unlike Ctrl+A/D below.
3983        assert_eq!(interpret_key(&Key::Character("q".into()), Some("q"), Modifiers::CTRL), None);
3984        assert_eq!(
3985            interpret_key(&Key::Character("a".into()), Some("a"), Modifiers::CTRL),
3986            Some(Action::SelectAll)
3987        );
3988        assert_eq!(
3989            interpret_key(&Key::Named(Named::ArrowRight), None, Modifiers::SHIFT),
3990            Some(Action::Move { motion: Motion::Right, extend: true })
3991        );
3992        assert_eq!(
3993            interpret_key(&Key::Named(Named::ArrowLeft), None, Modifiers::CTRL),
3994            Some(Action::Move { motion: Motion::WordLeft, extend: false })
3995        );
3996    }
3997
3998    #[test]
3999    fn ctrl_backspace_delete_are_word_deletes() {
4000        assert_eq!(
4001            interpret_key(&Key::Named(Named::Backspace), None, Modifiers::CTRL),
4002            Some(Action::DeleteWordBack)
4003        );
4004        assert_eq!(
4005            interpret_key(&Key::Named(Named::Delete), None, Modifiers::CTRL),
4006            Some(Action::DeleteWordForward)
4007        );
4008        // Plain Backspace/Delete are unchanged.
4009        assert_eq!(
4010            interpret_key(&Key::Named(Named::Backspace), None, Modifiers::default()),
4011            Some(Action::Backspace)
4012        );
4013    }
4014
4015    #[test]
4016    fn ctrl_home_end_jump_to_document_ends() {
4017        assert_eq!(
4018            interpret_key(&Key::Named(Named::Home), None, Modifiers::CTRL),
4019            Some(Action::Move { motion: Motion::DocStart, extend: false })
4020        );
4021        assert_eq!(
4022            interpret_key(&Key::Named(Named::End), None, Modifiers::CTRL | Modifiers::SHIFT),
4023            Some(Action::Move { motion: Motion::DocEnd, extend: true })
4024        );
4025        // Plain Home is still the smart line start.
4026        assert_eq!(
4027            interpret_key(&Key::Named(Named::Home), None, Modifiers::default()),
4028            Some(Action::Move { motion: Motion::LineStart, extend: false })
4029        );
4030    }
4031
4032    #[test]
4033    fn undo_redo_chords_interpret() {
4034        assert_eq!(
4035            interpret_key(&Key::Character("z".into()), Some("z"), Modifiers::CTRL),
4036            Some(Action::Undo)
4037        );
4038        assert_eq!(
4039            interpret_key(&Key::Character("Z".into()), Some("Z"), Modifiers::CTRL | Modifiers::SHIFT),
4040            Some(Action::Redo) // Ctrl+Shift+Z, case-folded z
4041        );
4042        assert_eq!(
4043            interpret_key(&Key::Character("y".into()), Some("y"), Modifiers::CTRL),
4044            Some(Action::Redo)
4045        );
4046    }
4047
4048    #[test]
4049    fn ctrl_d_and_escape_interpret() {
4050        assert_eq!(
4051            interpret_key(&Key::Character("d".into()), Some("d"), Modifiers::CTRL),
4052            Some(Action::AddNextOccurrence)
4053        );
4054        // Ctrl+Alt (AltGr) must NOT trigger the gesture — the `!alt` guard. (What
4055        // it resolves to instead is platform-dependent, since `command()` folds
4056        // onto Ctrl off macOS, so assert only that it isn't the gesture.)
4057        assert_ne!(
4058            interpret_key(&Key::Character("d".into()), Some("d"), Modifiers::CTRL | Modifiers::ALT),
4059            Some(Action::AddNextOccurrence)
4060        );
4061        assert_eq!(
4062            interpret_key(&Key::Named(Named::Escape), None, Modifiers::default()),
4063            Some(Action::Collapse)
4064        );
4065        // Tab indents; Shift+Tab outdents.
4066        assert_eq!(interpret_key(&Key::Named(Named::Tab), None, Modifiers::default()), Some(Action::Tab));
4067        assert_eq!(
4068            interpret_key(&Key::Named(Named::Tab), None, Modifiers::SHIFT),
4069            Some(Action::Outdent)
4070        );
4071    }
4072
4073    #[test]
4074    fn ctrl_slash_is_toggle_comment() {
4075        let ctrl = Modifiers::CTRL;
4076        assert_eq!(
4077            interpret_key(&Key::Character("/".into()), Some("/"), ctrl),
4078            Some(Action::ToggleComment)
4079        );
4080        // Plain "/" stays typing; Ctrl+Alt (AltGr) never triggers it.
4081        assert_eq!(
4082            interpret_key(&Key::Character("/".into()), Some("/"), Modifiers::empty()),
4083            Some(Action::Type('/'))
4084        );
4085        assert_eq!(interpret_key(&Key::Character("/".into()), Some("/"), ctrl | Modifiers::ALT), None);
4086    }
4087
4088    #[test]
4089    fn ctrl_shift_alt_arrow_is_column_select() {
4090        let csa = Modifiers::CTRL | Modifiers::SHIFT | Modifiers::ALT;
4091        assert_eq!(
4092            interpret_key(&Key::Named(Named::ArrowDown), None, csa),
4093            Some(Action::ColumnSelect(ColumnDir::Down))
4094        );
4095        assert_eq!(
4096            interpret_key(&Key::Named(Named::ArrowRight), None, csa),
4097            Some(Action::ColumnSelect(ColumnDir::Right))
4098        );
4099        // Without Alt it stays word-motion extend, not column select.
4100        assert_eq!(
4101            interpret_key(&Key::Named(Named::ArrowLeft), None, Modifiers::CTRL | Modifiers::SHIFT),
4102            Some(Action::Move { motion: Motion::WordLeft, extend: true })
4103        );
4104    }
4105
4106    #[test]
4107    fn alt_arrow_moves_and_copies_lines() {
4108        assert_eq!(
4109            interpret_key(&Key::Named(Named::ArrowDown), None, Modifiers::ALT),
4110            Some(Action::MoveLine { down: true })
4111        );
4112        assert_eq!(
4113            interpret_key(&Key::Named(Named::ArrowUp), None, Modifiers::ALT),
4114            Some(Action::MoveLine { down: false })
4115        );
4116        assert_eq!(
4117            interpret_key(&Key::Named(Named::ArrowDown), None, Modifiers::ALT | Modifiers::SHIFT),
4118            Some(Action::CopyLine { down: true })
4119        );
4120        // Ctrl+Shift+Alt+Down stays column-select, not copy-line.
4121        assert_eq!(
4122            interpret_key(
4123                &Key::Named(Named::ArrowDown),
4124                None,
4125                Modifiers::CTRL | Modifiers::SHIFT | Modifiers::ALT
4126            ),
4127            Some(Action::ColumnSelect(ColumnDir::Down))
4128        );
4129    }
4130
4131    #[test]
4132    fn clipboard_payload_feeds_write_clipboard() {
4133        // The payload rule itself lives (and is tested) in the core; here we
4134        // pin that the widget consults it — mixed sets keep the joined form.
4135        use scrive_core::{Document, Selection, SelectionId, SelectionSet};
4136        let mut doc = Document::new("foo\nbar\nbaz").unwrap();
4137        let mut set = SelectionSet::new(0);
4138        set.set_single(Selection::from_anchor(SelectionId(0), 0, 3)); // "foo"
4139        set.add_selection(4, 7); // "bar"
4140        set.add_caret(9); // an empty caret contributes nothing when mixed
4141        doc.set_selections(set);
4142        assert_eq!(doc.clipboard_payload(), ("foo\nbar".to_string(), false));
4143    }
4144
4145    #[test]
4146    fn gesture_autoscroll_policy() {
4147        assert!(!Action::AddNextOccurrence.moves_caret()); // reveals via request_reveal (FitForce)
4148        assert!(!Action::AddCaret(0).moves_caret()); // click lands in view
4149        assert!(!Action::Collapse.moves_caret()); // keeps an existing caret
4150        assert!(!Action::PlaceCaret(0).moves_caret());
4151        assert!(Action::Type('x').moves_caret());
4152        // Core-revealed verbs (request_reveal on success only): the widget
4153        // never blind-Fits for them, so a no-op press cannot move the view.
4154        assert!(!Action::NextDiagnostic { forward: true }.moves_caret());
4155        assert!(!Action::JumpToBracket.moves_caret());
4156        assert!(!Action::ExpandSelection.moves_caret());
4157        assert!(!Action::AddCaretVertical { down: true }.moves_caret());
4158        assert!(!Action::SelectAllOccurrences.moves_caret());
4159        // A drag reveals its head — a drag past the viewport edge must scroll.
4160        assert!(Action::DragSelect { granularity: Granularity::Char, origin: 0, head: 5 }
4161            .moves_caret());
4162        // A viewport report is not an edit and must never autoscroll (that would
4163        // feed back: report → scroll → report).
4164        assert!(!Action::ViewportChanged(0..42).moves_caret());
4165    }
4166
4167    #[test]
4168    fn last_visible_row_tracks_scroll_and_clamps() {
4169        use scrive_core::Document;
4170        // 100 lines; line height 10 px; 200 px viewport (20 rows); margin 8.
4171        let text = vec!["x"; 100].join("\n");
4172        let doc = Document::new(&text).unwrap();
4173        let ed = Editor::new(&doc, |_: Action| ());
4174        let bounds = Rectangle { x: 0.0, y: 0.0, width: 300.0, height: 200.0 };
4175        // scroll 0: last px 200 → row 20 + margin 8 = 28.
4176        assert_eq!(ed.last_visible_row(bounds, 0.0, 10.0), 28);
4177        // scrolled 50 rows (500 px): last row 70 + 8 = 78 (row units — the
4178        // ScrollAnchor currency).
4179        assert_eq!(ed.last_visible_row(bounds, 50.0, 10.0), 78);
4180        // scrolled past the end: clamps to the last row (line_count - 1 = 99).
4181        assert_eq!(ed.last_visible_row(bounds, 500.0, 10.0), 99);
4182    }
4183
4184    #[test]
4185    fn blink_phase_toggles_each_interval() {
4186        assert!(blink_on(0)); // just focused → solid
4187        assert!(blink_on(499)); // end of the on-half → still solid
4188        assert!(!blink_on(500)); // off-half begins
4189        assert!(!blink_on(999));
4190        assert!(blink_on(1000)); // back on
4191        assert!(blink_on(1499));
4192        assert!(!blink_on(1500));
4193    }
4194
4195    #[test]
4196    fn focus_shows_solid_caret_unfocus_hides_it() {
4197        let mut st = State::default(); // starts focused
4198        assert!(st.is_focused());
4199        assert!(st.caret_on()); // elapsed ≈ 0 → solid
4200        st.unfocus();
4201        assert!(!st.is_focused());
4202        assert!(!st.caret_on()); // no caret when unfocused
4203        st.focus();
4204        assert!(st.caret_on()); // refocus → solid again
4205    }
4206
4207    #[test]
4208    fn focus_operation_targets_by_id() {
4209        // The editor participates in iced's focus protocol via `State:
4210        // Focusable`; drive the real `focus` operation against it.
4211        let id = widget::Id::from("editor");
4212        let other = widget::Id::from("other");
4213        let bounds = Rectangle::new(Point::ORIGIN, Size::new(1.0, 1.0));
4214        let mut st = State::default();
4215
4216        // A focus aimed elsewhere unfocuses us…
4217        operation::focusable::focus::<()>(other).focusable(Some(&id), bounds, &mut st);
4218        assert!(!st.is_focused());
4219        // …one aimed at our id focuses us…
4220        operation::focusable::focus::<()>(id.clone()).focusable(Some(&id), bounds, &mut st);
4221        assert!(st.is_focused());
4222        // …and an id-less editor can never be targeted (None ≠ Some(target)).
4223        operation::focusable::focus::<()>(id).focusable(None, bounds, &mut st);
4224        assert!(!st.is_focused());
4225    }
4226
4227    // ── Fold-geometry tests: the display projections stay correct at the widget
4228    //    level, now that `Geo` makes the layout paths renderer-free ──
4229
4230    /// Rows `x / a { / b / c / } / word here`, block collapsed: buffer rows
4231    /// 2..=4 hide, so buffer row 5 renders at display row 2.
4232    fn folded_doc() -> Document {
4233        let text = "x\na {\nb\nc\n}\nword here\n";
4234        let mut doc = Document::new(text).unwrap();
4235        let opener = text.find('{').unwrap() as u32;
4236        assert!(doc.toggle_fold_opener(opener));
4237        doc
4238    }
4239
4240    /// Round-number frame: gutter 50, advance 10, line_h 10, unscrolled.
4241    fn test_geo() -> Geo {
4242        Geo::new(
4243            Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 },
4244            50.0,
4245            10.0,
4246            10.0,
4247            0.0,
4248            ScrollAnchor::TOP,
4249        )
4250    }
4251
4252    #[test]
4253    fn collapsed_chip_at_windows_to_the_hovered_row() {
4254        // The plain-hover chip hit-test resolves the pointer's display row
4255        // and tests only the pairs headed there — never a scan of every fold in
4256        // the document (which would build a `HeaderLayout` per fold each frame
4257        // and stall a collapsed large file). A point over the collapsed `a { … }`
4258        // header's chip returns its opener; a point on the fold-less row above
4259        // returns None (that row's windowed query is empty).
4260        let doc = folded_doc(); // block collapsed; header at buffer row 1
4261        let ed = Editor::new(&doc, |_: Action| ());
4262        let geo = test_geo();
4263        let fm = ed.fold_map();
4264        let opener = doc.buffer().text().find('{').unwrap() as u32;
4265        // Hover the exact chip rect the painter uses.
4266        let rect = ed.collapsed_chip_rect(&fm, &geo, opener).expect("collapsed header has a chip");
4267        let center = Point::new(rect.x + rect.width / 2.0, rect.y + rect.height / 2.0);
4268        assert_eq!(ed.collapsed_chip_at(&geo, center), Some(opener), "hover over the chip finds it");
4269        // A point far right of the chip on the same row is off it.
4270        assert_eq!(ed.collapsed_chip_at(&geo, Point::new(rect.x + rect.width + 200.0, center.y)), None);
4271        // A point on display row 0 ("x", no fold) resolves a fold-less row.
4272        assert_eq!(ed.collapsed_chip_at(&geo, Point::new(center.x, 5.0)), None);
4273    }
4274
4275    #[test]
4276    fn block_fold_hover_target_excludes_text_after_the_closer() {
4277        // Text typed after a folded block's closing `}` (on the tail line) must
4278        // not join its hover / expand hit region — the block rect ends at the
4279        // closer, not the whole tail line's width. The target covers only `{ … }`.
4280        let text = "x\na {\nb\nc\n} trailing text after brace\n";
4281        let mut doc = Document::new(text).unwrap();
4282        let opener = text.find('{').unwrap() as u32;
4283        assert!(doc.toggle_fold_opener(opener)); // block: `{` row 1 … `}` row 4
4284        let ed = Editor::new(&doc, |_: Action| ());
4285        let geo = test_geo();
4286        let fm = ed.fold_map();
4287        let rect = ed.collapsed_chip_rect(&fm, &geo, opener).expect("collapsed header has a chip");
4288        let cy = rect.y + rect.height / 2.0;
4289        // The `…` chip is still the expand target.
4290        assert_eq!(
4291            ed.collapsed_chip_at(&geo, Point::new(rect.x + rect.width / 2.0, cy)),
4292            Some(opener),
4293        );
4294        // The box ends at the closer: `a {`(3) + ` … `(4) + `}`(1) ≈ 8 cells, far
4295        // short of the ~34-cell full line — so a point out over the trailing text
4296        // must not hit the fold.
4297        assert!(
4298            rect.width < 12.0 * geo.advance(),
4299            "hover box stops at the closer, not the trailing text ({}px)",
4300            rect.width
4301        );
4302        assert_eq!(
4303            ed.collapsed_chip_at(&geo, Point::new(geo.cell_x(16.0), cy)),
4304            None,
4305            "text after the closing brace is not part of the fold's hover area"
4306        );
4307    }
4308
4309    #[test]
4310    fn draw_budget_ctrl_a_over_folded_doc_stays_windowed() {
4311        // A document-spanning selection over a fully-folded large file must wash
4312        // only the VISIBLE display rows, never every buffer row. Fold a big doc,
4313        // select all, render one headless frame, and assert the wash visited
4314        // O(viewport) rows — washing `for row in a.row..=b.row` would visit all N
4315        // rows (thousands here) and freeze; the display-window walk visits a few
4316        // dozen. A change that washes buffer rows through `draw_wash_row` trips
4317        // this and the in-`draw` debug assert.
4318        use iced::advanced::{clipboard, mouse, renderer};
4319        use iced::{Font, Pixels, Point as IPoint, Size};
4320        use iced_runtime::user_interface::{Cache, UserInterface};
4321
4322        const N: usize = 3000;
4323        let mut text = String::with_capacity(N * 20);
4324        for i in 0..N {
4325            text.push_str("fn f");
4326            text.push_str(&i.to_string());
4327            text.push_str("() {\n    body\n}\n");
4328        }
4329        let mut doc = Document::new(&text).expect("doc fits");
4330        let opens: Vec<u32> = doc.collapsible_pairs().into_iter().map(|(o, ..)| o).collect();
4331        assert!(opens.len() >= N, "every block is foldable ({} of {N})", opens.len());
4332        doc.set_selections(scrive_core::SelectionSet::from_offsets(&opens));
4333        doc.fold_at_carets(false); // collapse every block
4334        doc.select_all(); // the doc-spanning Ctrl+A selection
4335
4336        // Headless one-frame render (mirrors examples/shared/capture.rs): build →
4337        // update(RedrawRequested) → draw. `draw` resets the budget, every wash
4338        // bumps it, so afterwards it holds exactly this frame's visited rows.
4339        iced_tiny_skia::graphics::text::font_system()
4340            .write()
4341            .expect("font system lock")
4342            .load_font(std::borrow::Cow::Borrowed(crate::CODICON_FONT));
4343        let mut r = iced_renderer::fallback::Renderer::Secondary(
4344            iced_tiny_skia::Renderer::new(Font::default(), Pixels(14.0)),
4345        );
4346        let (w, h) = (500.0_f32, 320.0_f32);
4347        let cursor = mouse::Cursor::Available(IPoint::new(w / 2.0, h / 2.0));
4348        let element: iced::Element<'_, (), iced::Theme, iced::Renderer> =
4349            Editor::new(&doc, |_: Action| ()).into();
4350        let mut ui = UserInterface::build(element, Size::new(w, h), Cache::new(), &mut r);
4351        let mut msgs: Vec<()> = Vec::new();
4352        ui.update(
4353            &[iced::Event::Window(iced::window::Event::RedrawRequested(std::time::Instant::now()))],
4354            cursor,
4355            &mut r,
4356            &mut clipboard::Null,
4357            &mut msgs,
4358        );
4359        ui.draw(&mut r, &iced::Theme::Dark, &renderer::Style::default(), cursor);
4360
4361        // ~320px / ~19px line ≈ 17 visible rows; the wash bumps ~2 per row. A few
4362        // hundred is the generous ceiling; O(document) would be ~2·N = 6000.
4363        // ~48 in practice (a ~17-row viewport, ~2 bumps/row). The lower bound
4364        // catches a vacuous pass (nothing drawn); the upper catches O(document)
4365        // (~2·N = 6000).
4366        let rows = draw_budget::rows();
4367        assert!((8..400).contains(&rows), "Ctrl+A wash over a folded {N}-block doc visited {rows} rows — expected O(viewport), not O(document)");
4368    }
4369
4370    #[test]
4371    fn popup_anchors_are_display_space_below_a_fold() {
4372        use scrive_core::{HoverInfo, PopupList, Selection, SelectionId, SelectionSet};
4373        let mut doc = folded_doc();
4374        // Caret on "word here" (buffer row 5, col 0) — display row 2.
4375        let head = doc.buffer().point_to_offset(BufPoint::new(5, 0));
4376        let mut set = SelectionSet::new(0);
4377        set.set_single(Selection::from_anchor(SelectionId(0), head, head));
4378        doc.set_selections(set);
4379        let ed = Editor::new(&doc, |_: Action| ());
4380        let geo = test_geo();
4381        let fm = ed.fold_map();
4382
4383        // The one shared anchor must be display-space: row 2 × 10 px, NOT buffer
4384        // row 5 × 10 px — so a popup below a fold sits at the right height.
4385        let (x, top, bottom) = ed.popup_anchor(&fm, &geo, head);
4386        assert_eq!((top, bottom), (20.0, 30.0), "display row 2, not buffer row 5");
4387        assert_eq!(x, 56.0, "gutter 50 + TEXT_PAD 6 + col 0");
4388
4389        // Both testable panels consume it: the completion flips below-first…
4390        let list = PopupList { items: vec![], filtered: vec![], selected: 0, anchor: head };
4391        let (origin, _) = ed.popup_layout(&list, &geo);
4392        assert_eq!(origin.y, 30.0, "completion sits below the DISPLAY row");
4393        // …the hover above-first. (The signature box calls the same
4394        // popup_anchor by construction.)
4395        let info = HoverInfo { markdown: "hi".into(), range: head..head + 4 };
4396        let l = ed.hover_layout(&info, &geo);
4397        assert_eq!(l.rect.y, top - l.rect.height, "hover sits above the DISPLAY row");
4398    }
4399
4400    #[test]
4401    fn hit_test_inverts_offset_screen_x_across_fold_geometry() {
4402        // An inline fold AND a block fold on one header — the hardest case for
4403        // the projection, exercising every path at once: `f([ … ]) { … }`.
4404        let text = "f([a, b]) {\ninner\n}\nafter\n";
4405        let mut doc = Document::new(text).unwrap();
4406        let inline_open = text.find('[').unwrap() as u32;
4407        let close = text.find(']').unwrap() as u32;
4408        let block_open = text.find('{').unwrap() as u32;
4409        assert!(doc.toggle_fold_opener(inline_open));
4410        assert!(doc.toggle_fold_opener(block_open));
4411        let ed = Editor::new(&doc, |_: Action| ());
4412        let geo = test_geo();
4413        let fm = ed.fold_map();
4414        let buffer = doc.buffer();
4415        let tail = buffer.point_to_offset(BufPoint::new(2, 0)); // the real `}`
4416        let after = buffer.point_to_offset(BufPoint::new(3, 2)); // below the fold
4417        // Every landable offset round-trips: forward-project to screen, then
4418        // hit-test the same pixel back. Fails against either a fold-blind x
4419        // or a buffer-space chip compare.
4420        for off in [0, inline_open + 1, close, block_open, tail, after] {
4421            let p = fm.display_position(buffer, off, TAB).expect("landable offset");
4422            let pos = Point::new(ed.offset_screen_x(&fm, &geo, off), geo.row_y(p.row) + 5.0);
4423            assert_eq!(ed.hit_test(&geo, pos), off, "offset {off} round-trips");
4424        }
4425    }
4426
4427    /// Deep-scroll row positions stay exact. At ~5.9M rows scrolled to the bottom,
4428    /// content y-space is ~112M px — past that scale a flat `f32` pixel scroll
4429    /// would quantize to 8-px representable steps, rendering consecutive rows 16
4430    /// or 24 px apart instead of 19 and losing half-row precision in the y→row
4431    /// inverse (wrong-line clicks). The [`ScrollAnchor`] model (row-space integers
4432    /// plus a bounded sub-row offset) is exact for any u32-addressable document —
4433    /// this pins both the uniform step and the hit round-trip at depth.
4434    #[test]
4435    fn deep_scroll_row_positions_stay_exact() {
4436        let rows: u32 = 5_900_000;
4437        let doc = Document::new(&"x\n".repeat(rows as usize)).unwrap();
4438        let ed = Editor::new(&doc, |_: Action| ());
4439        let fm = ed.fold_map();
4440        let line_h = 19.0_f32;
4441        let bounds = Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
4442        // Scrolled to the very bottom, exactly as layout()'s clamp computes it
4443        // (a fractional row position, so offset_px is non-zero too).
4444        let max_rows = f64::from(rows) + 1.0 - f64::from(bounds.height) / f64::from(line_h);
4445        let anchor = ScrollAnchor::from_rows(max_rows, line_h);
4446        let geo = Geo::new(bounds, 50.0, 10.0, line_h, 0.0, anchor);
4447        let first = fm.display_row_at(geo.rows_from_top(bounds.y));
4448        for i in 0..30 {
4449            let row = fm.to_display_row(BufferRow(first.index() + i));
4450            let next = fm.to_display_row(BufferRow(first.index() + i + 1));
4451            let step = geo.row_y(next) - geo.row_y(row);
4452            assert!((step - line_h).abs() < 0.01, "row {i}: step {step}, want {line_h}");
4453            // The y→row inverse hits the row it was projected from.
4454            let hit = fm.display_row_at(geo.rows_from_top(geo.row_y(row) + 1.0));
4455            assert_eq!(hit, row, "row {i} round-trips");
4456        }
4457        // And the anchor round-trips its own row-unit projection exactly.
4458        assert!((anchor.rows(line_h) - max_rows).abs() < 1e-6);
4459    }
4460
4461    #[test]
4462    fn max_line_px_shrinks_when_the_widest_lines_fold() {
4463        // Viewport-max semantics: with the whole small doc in the window, the
4464        // widest visible row is the global max — hidden rows contribute nothing,
4465        // the collapsed header spans its whole `f() { … }` placeholder, from the
4466        // one HeaderLayout owner.
4467        let vp = Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 };
4468        let text = "f() {\n    a_very_long_interior_line_wwwwwwwwwwwwwww\n}\nshort\n";
4469        let mut doc = Document::new(text).unwrap();
4470        let unfolded = Editor::new(&doc, |_: Action| ()).max_line_px(10.0, 20.0, vp, 0.0);
4471        let opener = text.find('{').unwrap() as u32;
4472        assert!(doc.toggle_fold_opener(opener));
4473        let ed = Editor::new(&doc, |_: Action| ());
4474        let folded = ed.max_line_px(10.0, 20.0, vp, 0.0);
4475        let fm = ed.fold_map();
4476        let hl = fm.header_layout(doc.buffer(), BufferRow(0), TAB).unwrap();
4477        assert_eq!(folded, hl.width() as f32 * 10.0);
4478        assert!(folded < unfolded, "collapsing the widest line shrinks the h-range");
4479    }
4480
4481    #[test]
4482    fn max_line_px_is_viewport_scoped() {
4483        // The widest line OFF-screen does not widen the h-range: a 2-row
4484        // viewport over the short head of a doc whose widest line is far
4485        // below reports the head's width — the adaptive h-scrollbar
4486        // (field-gated; a document-owned line-width index would restore a
4487        // global max if the adaptive feel fails).
4488        let mut text = String::from("ab\ncd\n");
4489        text.push_str(&"x".repeat(200));
4490        text.push('\n');
4491        let doc = Document::new(&text).unwrap();
4492        let ed = Editor::new(&doc, |_: Action| ());
4493        let two_rows = Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 40.0 };
4494        assert_eq!(ed.max_line_px(10.0, 20.0, two_rows, 0.0), 2.0 * 10.0, "only visible rows count");
4495        // Scrolled down 2 rows to the wide line, the range adapts up.
4496        assert_eq!(ed.max_line_px(10.0, 20.0, two_rows, 2.0), 200.0 * 10.0);
4497    }
4498}