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