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