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}