Skip to main content

guise/markdown/
editor.rs

1//! `MarkdownEditor` — an Obsidian-style live-preview markdown editor
2//! (gpui entity).
3//!
4//! Every line renders formatted — headings sized, emphasis styled, list
5//! markers replaced by bullets/checkboxes, fenced code highlighted — while
6//! the cursor line (and any line the selection touches) *reveals* its
7//! markdown syntax for editing. Text soft-wraps; list items keep a hanging
8//! indent; checkboxes toggle on click; links open on Cmd+click (plain click
9//! when read-only) via [`MarkdownEditorEvent::LinkClick`].
10//!
11//! ```ignore
12//! let editor = cx.new(|cx| {
13//!     MarkdownEditor::new(cx).value("# Notes\n\n- [ ] try **guise**")
14//! });
15//! cx.subscribe(&editor, |_this, _editor, event: &MarkdownEditorEvent, _cx| {
16//!     match event {
17//!         MarkdownEditorEvent::Change(text) => { /* persist */ }
18//!         MarkdownEditorEvent::LinkClick(target) => { /* open */ }
19//!     }
20//! })
21//! .detach();
22//! ```
23
24use gpui::prelude::*;
25use gpui::{
26    canvas, div, point, px, App, Bounds, ClipboardItem, Context, Div, DragMoveEvent, Empty, Entity,
27    EntityId, EventEmitter, FocusHandle, Font, FontStyle, FontWeight, Hsla, IntoElement,
28    KeyDownEvent, MouseButton, MouseDownEvent, Pixels, ScrollHandle, SharedString,
29    StrikethroughStyle, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine,
30};
31
32use super::block::{classify, Block, DocState};
33use super::layout::{
34    byte_for_col, col_for_byte, metrics, plan, src_for_vis, vis_for_src, RowKind, RowPlan,
35};
36use crate::editor::{token_color, EditorModel, Highlighter, Language, LineState, Pos, TokenKind};
37use crate::reactive::Signal;
38use crate::theme::theme;
39use crate::{Glyph, IconName};
40
41use crate::devtools::Probed;
42/// The monospace family for code spans and code blocks.
43use crate::style::MONO_FAMILY;
44/// Horizontal padding around the document, in px.
45const PAD_X: f32 = 16.0;
46/// Vertical padding above and below the document, in px.
47const PAD_Y: f32 = 12.0;
48/// Wrap width used before the first layout pass has measured the content.
49const DEFAULT_WRAP: f32 = 640.0;
50
51/// Emitted as the user edits or activates a link.
52#[derive(Debug, Clone)]
53pub enum MarkdownEditorEvent {
54    /// The document changed. Carries the full new text.
55    Change(String),
56    /// A link was activated (Cmd+click, or plain click when read-only).
57    /// Carries the target — a url or a wikilink page name.
58    LinkClick(String),
59}
60
61/// The drag payload for selection-by-mouse; tagged with the owning entity so
62/// two editors in one window never react to each other's drags.
63struct MarkdownDrag(EntityId);
64
65/// Per-editor visual overrides. Unset fields fall back to the theme-derived
66/// defaults, so an empty style changes nothing.
67#[derive(Clone, Copy, Default)]
68pub struct MarkdownStyle {
69    /// Paint no frame border and no corner radius (an embedded surface).
70    pub bare: bool,
71    pub bg: Option<Hsla>,
72    pub text: Option<Hsla>,
73    pub caret: Option<Hsla>,
74    pub selection: Option<Hsla>,
75    /// Links, bullets, checked boxes, quote bars.
76    pub accent: Option<Hsla>,
77    pub code_bg: Option<Hsla>,
78    pub placeholder: Option<Hsla>,
79}
80
81/// One laid-out source line: its plan, shaped text, and geometry. Rebuilt
82/// every render; mouse and caret math read the copy from the last frame.
83struct Row {
84    plan: RowPlan,
85    /// `Rc` so the paint closure can share the shaped line with the row.
86    text: Option<std::rc::Rc<WrappedLine>>,
87    line_h: f32,
88    pad_top: f32,
89    inset: f32,
90    height: f32,
91    y: f32,
92}
93
94impl Row {
95    fn visual_rows(&self) -> usize {
96        self.text
97            .as_ref()
98            .map_or(1, |t| t.wrap_boundaries().len() + 1)
99    }
100
101    /// Byte offsets where the shaped line wraps, ascending.
102    fn boundaries(&self) -> Vec<usize> {
103        let Some(text) = &self.text else {
104            return Vec::new();
105        };
106        text.wrap_boundaries()
107            .iter()
108            .map(|b| text.runs()[b.run_ix].glyphs[b.glyph_ix].index)
109            .collect()
110    }
111
112    /// End-semantics position for a visible byte: a wrap-boundary byte maps
113    /// to the end of the earlier visual row.
114    fn pos_end(&self, vis: usize) -> (f32, f32) {
115        let Some(text) = &self.text else {
116            return (0.0, 0.0);
117        };
118        match text.position_for_index(vis.min(text.len()), px(self.line_h)) {
119            Some(p) => (f32::from(p.x), f32::from(p.y)),
120            None => (0.0, 0.0),
121        }
122    }
123
124    /// Caret position for a visible byte: (x, visual row). Start semantics —
125    /// a wrap-boundary byte belongs to the row it starts.
126    fn caret(&self, vis: usize) -> (f32, usize) {
127        let bounds = self.boundaries();
128        let row = bounds.iter().filter(|&&b| b <= vis).count();
129        if bounds.contains(&vis) {
130            return (0.0, row);
131        }
132        let (x, y) = self.pos_end(vis);
133        (
134            x,
135            if self.line_h > 0.0 {
136                (y / self.line_h).round() as usize
137            } else {
138                0
139            },
140        )
141    }
142
143    /// Selection rectangles for the visible byte range: (x, visual row,
144    /// width). `newline` extends the last rect to show a selected line end.
145    fn sel_rects(&self, vs: usize, ve: usize, newline: bool, cell: f32) -> Vec<(f32, usize, f32)> {
146        let bounds = self.boundaries();
147        let (sr, er) = split_visual(&bounds, vs, ve);
148        let (sx, _) = if bounds.contains(&vs) {
149            (0.0, 0.0)
150        } else {
151            self.pos_end(vs)
152        };
153        let (ex, _) = self.pos_end(ve);
154        let row_end = |r: usize| -> f32 {
155            match bounds.get(r) {
156                Some(&b) => self.pos_end(b).0,
157                None => self.pos_end(usize::MAX).0,
158            }
159        };
160        let mut rects = Vec::new();
161        if sr == er {
162            rects.push((sx, sr, (ex - sx).max(0.0)));
163        } else {
164            rects.push((sx, sr, (row_end(sr) - sx).max(0.0)));
165            for r in sr + 1..er {
166                rects.push((0.0, r, row_end(r).max(0.0)));
167            }
168            rects.push((0.0, er, ex.max(0.0)));
169        }
170        if newline {
171            if let Some(last) = rects.last_mut() {
172                last.2 += cell;
173            }
174        }
175        rects.retain(|r| r.2 > 0.0);
176        rects
177    }
178}
179
180/// An Obsidian-style live-preview markdown editor. Create with
181/// `cx.new(|cx| MarkdownEditor::new(cx))`.
182///
183/// The text model is the unit-tested [`EditorModel`] (char-index cursor,
184/// anchor selection, coalesced undo); markdown structure comes from the pure
185/// [`block`](super::block)/[`inline`](super::inline)/[`layout`](super::layout)
186/// passes. Read-only editors render pure preview — selection, copy, and
187/// plain-click links still work.
188pub struct MarkdownEditor {
189    model: EditorModel,
190    placeholder: SharedString,
191    read_only: bool,
192    font_size: f32,
193    rows: Option<usize>,
194    style: MarkdownStyle,
195    focus: FocusHandle,
196    scroll: ScrollHandle,
197    /// Window-space bounds of the document content, captured at prepaint.
198    text_bounds: Bounds<Pixels>,
199    /// Content width from the last frame — the wrap width for this one.
200    wrap_w: f32,
201    /// Measured prose cell advance, for indent units and newline cells.
202    cell_w: f32,
203    /// Last frame's per-line layout, for mouse/caret/scroll math.
204    layout: Vec<Row>,
205    /// Bring the caret into view on the next render.
206    scroll_to_cursor: bool,
207    /// Sticky x (content space) for visual-row vertical movement.
208    goal_x: Option<f32>,
209}
210
211impl EventEmitter<MarkdownEditorEvent> for MarkdownEditor {}
212
213impl MarkdownEditor {
214    pub fn new(cx: &mut Context<Self>) -> Self {
215        MarkdownEditor {
216            model: EditorModel::new(""),
217            placeholder: SharedString::default(),
218            read_only: false,
219            font_size: 15.0,
220            rows: None,
221            style: MarkdownStyle::default(),
222            focus: cx.focus_handle(),
223            scroll: ScrollHandle::new(),
224            text_bounds: Bounds::default(),
225            wrap_w: DEFAULT_WRAP,
226            cell_w: 15.0 * 0.55,
227            layout: Vec::new(),
228            scroll_to_cursor: false,
229            goal_x: None,
230        }
231    }
232
233    // ---- builders ----
234
235    /// Initial markdown text (`text()` is the getter).
236    pub fn value(mut self, text: &str) -> Self {
237        self.model.set_text(text);
238        self
239    }
240
241    /// Dimmed hint shown while the document is empty and unfocused.
242    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
243        self.placeholder = placeholder.into();
244        self
245    }
246
247    /// Render pure preview: no caret, no edits, no syntax reveal. Selection,
248    /// copy, and plain-click link activation still work.
249    pub fn read_only(mut self, read_only: bool) -> Self {
250        self.read_only = read_only;
251        self
252    }
253
254    /// Base font size in px (default 15.0). Headings scale from it.
255    pub fn font_size(mut self, size: f32) -> Self {
256        self.font_size = size;
257        self
258    }
259
260    /// Minimum height, as a number of paragraph lines.
261    pub fn rows(mut self, rows: usize) -> Self {
262        self.rows = Some(rows);
263        self
264    }
265
266    /// Spaces inserted per list indent level (default 4).
267    pub fn tab_size(mut self, n: usize) -> Self {
268        self.model.set_tab_size(n);
269        self
270    }
271
272    /// Per-editor visual overrides (see [`MarkdownStyle`]).
273    pub fn style(mut self, style: MarkdownStyle) -> Self {
274        self.style = style;
275        self
276    }
277
278    /// Replace the style at runtime (theme switches).
279    pub fn set_style(&mut self, style: MarkdownStyle, cx: &mut Context<Self>) {
280        self.style = style;
281        cx.notify();
282    }
283
284    // ---- runtime API ----
285
286    /// The current markdown text.
287    pub fn text(&self) -> String {
288        self.model.text()
289    }
290
291    /// Replace the document, resetting cursor, selection, and history.
292    pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
293        self.model.set_text(value);
294        cx.notify();
295    }
296
297    /// The editor's focus handle, so a host can focus it on open.
298    pub fn focus_handle(&self) -> FocusHandle {
299        self.focus.clone()
300    }
301
302    /// Read access to the underlying [`EditorModel`] — cursor, selection,
303    /// lines — for hosts building features over the buffer.
304    pub fn model(&self) -> &EditorModel {
305        &self.model
306    }
307
308    /// Mutate the [`EditorModel`] directly. Emits
309    /// [`MarkdownEditorEvent::Change`] when the text changed, keeps the
310    /// caret visible, and repaints.
311    pub fn edit<R>(&mut self, cx: &mut Context<Self>, f: impl FnOnce(&mut EditorModel) -> R) -> R {
312        let before = self.model.text();
313        let result = f(&mut self.model);
314        let after = self.model.text();
315        if after != before {
316            cx.emit(MarkdownEditorEvent::Change(after));
317        }
318        self.scroll_to_cursor = true;
319        cx.notify();
320        result
321    }
322
323    /// Two-way bind this editor's text to a `Signal<String>`. The signal is
324    /// the source of truth; equality guards on both directions prevent
325    /// update loops.
326    pub fn bind(entity: &Entity<MarkdownEditor>, signal: &Signal<String>, cx: &mut App) {
327        let initial = signal.get(cx);
328        entity.update(cx, |this, cx| {
329            if this.text() != initial {
330                this.set_text(&initial, cx);
331            }
332        });
333        let sink = signal.clone();
334        cx.subscribe(entity, move |_editor, event: &MarkdownEditorEvent, cx| {
335            if let MarkdownEditorEvent::Change(text) = event {
336                sink.set_if_changed(cx, text.clone());
337            }
338        })
339        .detach();
340        // Weak handle: a strong clone would form a retain cycle with the
341        // subscription above and leak both the editor and the signal.
342        let editor = entity.downgrade();
343        cx.observe(signal.entity(), move |observed, cx| {
344            let value = observed.read(cx).clone();
345            editor
346                .update(cx, |this, cx| {
347                    if this.text() != value {
348                        this.set_text(&value, cx);
349                    }
350                })
351                .ok();
352        })
353        .detach();
354    }
355
356    // ---- markdown editing commands ----
357
358    /// Toggle the checkbox on `line` if it is a task item, preserving the
359    /// cursor. Returns whether the line was a task.
360    pub fn toggle_task(&mut self, line: usize, cx: &mut Context<Self>) -> bool {
361        let Some(text) = self.model.line(line) else {
362            return false;
363        };
364        let Block::Task { checked, state, .. } = classify_alone(text) else {
365            return false;
366        };
367        let cursor = self.model.cursor();
368        self.edit(cx, |m| {
369            // The marker prefix is ASCII, so `state` is also a char column.
370            m.move_to(line, state, false);
371            m.move_to(line, state + 1, true);
372            m.insert(if checked { " " } else { "x" });
373            m.move_to(cursor.line, cursor.col, false);
374        });
375        true
376    }
377
378    /// Wrap the selection (or the word at the cursor) in `marker`, or unwrap
379    /// it when already wrapped. Single-line selections only.
380    fn toggle_wrap(&mut self, marker: &str, cx: &mut Context<Self>) {
381        if self.read_only {
382            return;
383        }
384        let chars = marker.chars().count();
385        if self.model.selection().is_none() {
386            self.model.select_word();
387        }
388        let Some((start, end)) = self.model.selection() else {
389            // Empty spot: insert a pair and park the cursor inside it.
390            self.edit(cx, |m| {
391                m.insert(&format!("{marker}{marker}"));
392                for _ in 0..chars {
393                    m.move_left(false);
394                }
395            });
396            return;
397        };
398        if start.line != end.line {
399            return;
400        }
401        // Selection just inside an existing pair: widen to include it.
402        let line = self.model.line(start.line).unwrap_or("");
403        let (sb, eb) = (byte_for_col(line, start.col), byte_for_col(line, end.col));
404        if line[..sb].ends_with(marker) && line[eb..].starts_with(marker) {
405            self.model.move_to(start.line, start.col - chars, false);
406            self.model.move_to(end.line, end.col + chars, true);
407        }
408        let Some(sel) = self.model.selected_text() else {
409            return;
410        };
411        let unwrap =
412            sel.starts_with(marker) && sel.ends_with(marker) && sel.len() >= 2 * marker.len();
413        let replacement = if unwrap {
414            sel[marker.len()..sel.len() - marker.len()].to_string()
415        } else {
416            format!("{marker}{sel}{marker}")
417        };
418        self.edit(cx, |m| m.insert(&replacement));
419    }
420
421    /// Wrap the selection as a markdown link and park the cursor where the
422    /// missing half goes (url for a selection, text otherwise).
423    fn insert_link(&mut self, cx: &mut Context<Self>) {
424        if self.read_only {
425            return;
426        }
427        let single_line = matches!(self.model.selection(), Some((s, e)) if s.line == e.line);
428        self.edit(cx, |m| {
429            if single_line {
430                let sel = m.selected_text().unwrap_or_default();
431                m.insert(&format!("[{sel}]()"));
432                m.move_left(false);
433            } else {
434                m.insert("[]()");
435                for _ in 0..3 {
436                    m.move_left(false);
437                }
438            }
439        });
440    }
441
442    /// Enter: continue lists and quotes; an empty item exits the list.
443    fn on_enter(&mut self, cx: &mut Context<Self>) {
444        let cursor = self.model.cursor();
445        let line = self.model.line(cursor.line).unwrap_or("").to_string();
446        let in_code = matches!(
447            self.layout.get(cursor.line).map(|r| &r.plan.kind),
448            Some(RowKind::Code { .. } | RowKind::Fence { .. } | RowKind::FrontMatter)
449        );
450        let marker = if in_code || self.model.selection().is_some() {
451            None
452        } else {
453            continuation(&line)
454        };
455        match marker {
456            Some(_) if line[prefix_end(&line)..].trim().is_empty() => {
457                // Enter on an empty item clears the marker instead of
458                // continuing the list.
459                let cols = line.chars().count();
460                self.edit(cx, |m| {
461                    m.move_to(cursor.line, 0, false);
462                    m.move_to(cursor.line, cols, true);
463                    m.delete_selection();
464                });
465            }
466            Some(marker) => self.edit(cx, |m| {
467                m.newline();
468                m.insert(&marker);
469            }),
470            None => self.edit(cx, |m| m.newline()),
471        }
472    }
473
474    /// Tab / Shift+Tab: indent or outdent list items; plain Tab elsewhere.
475    fn on_tab(&mut self, outdent: bool, cx: &mut Context<Self>) {
476        let cursor = self.model.cursor();
477        let line = self.model.line(cursor.line).unwrap_or("").to_string();
478        let is_item = matches!(
479            classify_alone(&line),
480            Block::Bullet { .. } | Block::Ordered { .. } | Block::Task { .. }
481        );
482        if !is_item {
483            if !outdent {
484                self.edit(cx, |m| m.tab());
485            }
486            return;
487        }
488        let n = self.model.tab_size();
489        if outdent {
490            let lead = line.chars().take_while(|&c| c == ' ').count().min(n);
491            if lead == 0 {
492                return;
493            }
494            self.edit(cx, |m| {
495                m.move_to(cursor.line, 0, false);
496                m.move_to(cursor.line, lead, true);
497                m.delete_selection();
498                m.move_to(cursor.line, cursor.col.saturating_sub(lead), false);
499            });
500        } else {
501            self.edit(cx, |m| {
502                m.move_to(cursor.line, 0, false);
503                m.insert(&" ".repeat(n));
504                m.move_to(cursor.line, cursor.col + n, false);
505            });
506        }
507    }
508
509    /// Backspace at the start of an item's content removes the whole marker
510    /// (list or quote) instead of eating into it invisibly.
511    fn backspace_marker(&mut self, cx: &mut Context<Self>) -> bool {
512        let cursor = self.model.cursor();
513        let line = self.model.line(cursor.line).unwrap_or("").to_string();
514        let content = match classify_alone(&line) {
515            Block::Bullet { content, .. }
516            | Block::Ordered { content, .. }
517            | Block::Task { content, .. }
518            | Block::Quote { content, .. } => content,
519            _ => return false,
520        };
521        // Marker prefixes are ASCII, so `content` is also a char column.
522        if content == 0 || cursor.col != content {
523            return false;
524        }
525        self.edit(cx, |m| {
526            m.move_to(cursor.line, 0, false);
527            m.move_to(cursor.line, content, true);
528            m.delete_selection();
529        });
530        true
531    }
532
533    // ---- input handling ----
534
535    fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
536        let ks = &event.keystroke;
537        let m = ks.modifiers;
538        let shift = m.shift;
539        if !matches!(ks.key.as_str(), "up" | "down") {
540            self.goal_x = None;
541        }
542        match ks.key.as_str() {
543            "left" => {
544                if m.platform {
545                    self.model.home(shift);
546                } else if m.alt {
547                    self.model.word_left(shift);
548                } else {
549                    self.model.move_left(shift);
550                }
551                self.after_move(cx);
552            }
553            "right" => {
554                if m.platform {
555                    self.model.end(shift);
556                } else if m.alt {
557                    self.model.word_right(shift);
558                } else {
559                    self.model.move_right(shift);
560                }
561                self.after_move(cx);
562            }
563            "up" => {
564                if m.platform {
565                    self.model.doc_start(shift);
566                    self.after_move(cx);
567                } else {
568                    self.move_visual(false, shift, cx);
569                }
570            }
571            "down" => {
572                if m.platform {
573                    self.model.doc_end(shift);
574                    self.after_move(cx);
575                } else {
576                    self.move_visual(true, shift, cx);
577                }
578            }
579            "home" => {
580                if m.platform {
581                    self.model.doc_start(shift);
582                } else {
583                    self.model.home(shift);
584                }
585                self.after_move(cx);
586            }
587            "end" => {
588                if m.platform {
589                    self.model.doc_end(shift);
590                } else {
591                    self.model.end(shift);
592                }
593                self.after_move(cx);
594            }
595            "backspace" => {
596                if self.read_only {
597                    return;
598                }
599                if self.model.selection().is_none() && !m.platform && !m.alt {
600                    if self.backspace_marker(cx) {
601                        cx.stop_propagation();
602                        return;
603                    }
604                }
605                let changed = if self.model.selection().is_some() {
606                    self.model.delete_selection()
607                } else if m.platform {
608                    self.model.home(true);
609                    self.model.delete_selection()
610                } else if m.alt {
611                    self.model.word_left(true);
612                    self.model.delete_selection()
613                } else {
614                    self.model.backspace()
615                };
616                if changed {
617                    self.after_edit(cx);
618                } else {
619                    cx.stop_propagation();
620                }
621            }
622            "delete" => {
623                if self.read_only {
624                    return;
625                }
626                let changed = if self.model.selection().is_some() {
627                    self.model.delete_selection()
628                } else if m.platform {
629                    self.model.end(true);
630                    self.model.delete_selection()
631                } else if m.alt {
632                    self.model.word_right(true);
633                    self.model.delete_selection()
634                } else {
635                    self.model.delete()
636                };
637                if changed {
638                    self.after_edit(cx);
639                } else {
640                    cx.stop_propagation();
641                }
642            }
643            "enter" if m.platform => {
644                if !self.read_only && self.toggle_task(self.model.cursor().line, cx) {
645                    cx.stop_propagation();
646                }
647            }
648            "enter" => {
649                if self.read_only {
650                    return;
651                }
652                self.on_enter(cx);
653                cx.stop_propagation();
654            }
655            "tab" => {
656                if m.platform || self.read_only {
657                    return;
658                }
659                self.on_tab(shift, cx);
660                cx.stop_propagation();
661            }
662            // Escape bubbles (dialogs close on it) but drops the selection.
663            "escape" => {
664                if self.model.selection().is_some() {
665                    self.model.clear_selection();
666                    cx.notify();
667                }
668            }
669            "a" if m.platform => {
670                self.model.select_all();
671                cx.notify();
672                cx.stop_propagation();
673            }
674            "b" if m.platform => {
675                self.toggle_wrap("**", cx);
676                cx.stop_propagation();
677            }
678            "i" if m.platform => {
679                self.toggle_wrap("*", cx);
680                cx.stop_propagation();
681            }
682            "k" if m.platform => {
683                self.insert_link(cx);
684                cx.stop_propagation();
685            }
686            "c" if m.platform => {
687                if let Some(text) = self.model.copy() {
688                    cx.write_to_clipboard(ClipboardItem::new_string(text));
689                }
690                cx.stop_propagation();
691            }
692            "x" if m.platform => {
693                if self.read_only {
694                    // Selection stays; degrade cut to copy.
695                    if let Some(text) = self.model.copy() {
696                        cx.write_to_clipboard(ClipboardItem::new_string(text));
697                    }
698                } else if let Some(text) = self.model.cut() {
699                    cx.write_to_clipboard(ClipboardItem::new_string(text));
700                    self.after_edit(cx);
701                    return;
702                }
703                cx.stop_propagation();
704            }
705            "v" if m.platform => {
706                if !self.read_only {
707                    if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
708                        if !text.is_empty() {
709                            self.model.insert(&text);
710                            self.after_edit(cx);
711                            return;
712                        }
713                    }
714                }
715                cx.stop_propagation();
716            }
717            "z" if m.platform => {
718                if !self.read_only {
719                    let changed = if m.shift {
720                        self.model.redo()
721                    } else {
722                        self.model.undo()
723                    };
724                    if changed {
725                        self.after_edit(cx);
726                        return;
727                    }
728                }
729                cx.stop_propagation();
730            }
731            _ => {
732                // Printable input: never on Cmd/Ctrl chords; Option+key is
733                // allowed so composed glyphs land.
734                if !self.read_only && !m.platform && !m.control {
735                    if let Some(text) = ks.key_char.as_deref().filter(|t| !t.is_empty()) {
736                        self.model.insert(text);
737                        self.after_edit(cx);
738                    }
739                }
740                // Everything else bubbles to the host.
741            }
742        }
743        let _ = window;
744    }
745
746    fn on_mouse_down(&mut self, ev: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
747        window.focus(&self.focus);
748        self.goal_x = None;
749        let x = f32::from(ev.position.x) - f32::from(self.text_bounds.origin.x);
750        let y = f32::from(ev.position.y) - f32::from(self.text_bounds.origin.y);
751        let (line, col) = self.hit(x, y);
752
753        // A click on a task's checkbox toggles it in place.
754        if !self.read_only && ev.click_count == 1 {
755            if let Some(row) = self.layout.get(line) {
756                if let RowKind::Task { .. } = row.plan.kind {
757                    let in_slot = x < row.inset && x >= 0.0;
758                    let in_first_row = y >= row.y && y < row.y + row.pad_top + row.line_h;
759                    if in_slot && in_first_row && self.toggle_task(line, cx) {
760                        return;
761                    }
762                }
763            }
764        }
765        // Cmd+click (plain click when read-only) follows links.
766        if ev.modifiers.platform || self.read_only {
767            if let (Some(row), Some(text)) = (self.layout.get(line), self.model.line(line)) {
768                if let Some(target) = row.plan.link_at(byte_for_col(text, col)) {
769                    cx.emit(MarkdownEditorEvent::LinkClick(target.to_string()));
770                    return;
771                }
772            }
773        }
774        match ev.click_count {
775            2 => {
776                self.model.move_to(line, col, false);
777                self.model.select_word();
778            }
779            n if n > 2 => {
780                self.model.move_to(line, col, false);
781                self.model.select_line();
782            }
783            _ => self.model.move_to(line, col, ev.modifiers.shift),
784        }
785        cx.notify();
786    }
787
788    fn on_drag_move(
789        &mut self,
790        ev: &DragMoveEvent<MarkdownDrag>,
791        _window: &mut Window,
792        cx: &mut Context<Self>,
793    ) {
794        if ev.drag(cx).0 != cx.entity_id() {
795            return;
796        }
797        let x = f32::from(ev.event.position.x) - f32::from(self.text_bounds.origin.x);
798        let y = f32::from(ev.event.position.y) - f32::from(self.text_bounds.origin.y);
799        let (line, col) = self.hit(x, y);
800        self.model.move_to(line, col, true);
801        self.scroll_to_cursor = true;
802        cx.notify();
803    }
804
805    /// Content-space position → (line, col), through the last frame's
806    /// layout and each row's source↔visible mapping.
807    fn hit(&self, x: f32, y: f32) -> (usize, usize) {
808        if self.layout.is_empty() {
809            return (0, 0);
810        }
811        let mut line = self.layout.len() - 1;
812        for (i, row) in self.layout.iter().enumerate() {
813            if y < row.y + row.height {
814                line = i;
815                break;
816            }
817        }
818        line = line.min(self.model.line_count().saturating_sub(1));
819        let row = &self.layout[line];
820        let Some(text) = self.model.line(line) else {
821            return (line, 0);
822        };
823        let text_h = (row.visual_rows() as f32 * row.line_h).max(row.line_h);
824        let local = point(
825            px((x - row.inset).max(0.0)),
826            px((y - row.y - row.pad_top).clamp(0.0, text_h - 1.0)),
827        );
828        let vis = match &row.text {
829            Some(shaped) => shaped
830                .closest_index_for_position(local, px(row.line_h))
831                .unwrap_or_else(|near| near),
832            None => 0,
833        };
834        let src = src_for_vis(&row.plan.segs, vis);
835        (line, col_for_byte(text, src))
836    }
837
838    /// Arrow up/down across *visual* rows, holding a sticky goal x through
839    /// shorter rows — soft wrap means source-line movement would skip rows.
840    fn move_visual(&mut self, down: bool, extend: bool, cx: &mut Context<Self>) {
841        let cursor = self.model.cursor();
842        if self.layout.len() != self.model.line_count() {
843            // Stale layout (edit since last frame): plain line movement.
844            if down {
845                self.model.move_down(extend);
846            } else {
847                self.model.move_up(extend);
848            }
849            self.after_move(cx);
850            return;
851        }
852        let row = &self.layout[cursor.line];
853        let text = self.model.line(cursor.line).unwrap_or("");
854        let vis = vis_for_src(&row.plan.segs, byte_for_col(text, cursor.col));
855        let (x, vrow) = row.caret(vis);
856        let goal = self.goal_x.unwrap_or(row.inset + x);
857        self.goal_x = Some(goal);
858
859        let target = if down {
860            if vrow + 1 < row.visual_rows() {
861                Some((cursor.line, vrow + 1))
862            } else if cursor.line + 1 < self.layout.len() {
863                Some((cursor.line + 1, 0))
864            } else {
865                None
866            }
867        } else if vrow > 0 {
868            Some((cursor.line, vrow - 1))
869        } else if cursor.line > 0 {
870            Some((
871                cursor.line - 1,
872                self.layout[cursor.line - 1].visual_rows() - 1,
873            ))
874        } else {
875            None
876        };
877        let Some((tline, tvrow)) = target else {
878            if down {
879                self.model.doc_end(extend);
880            } else {
881                self.model.doc_start(extend);
882            }
883            self.after_move(cx);
884            return;
885        };
886        let trow = &self.layout[tline];
887        let local = point(
888            px((goal - trow.inset).max(0.0)),
889            px((tvrow as f32 + 0.5) * trow.line_h),
890        );
891        let vis = match &trow.text {
892            Some(shaped) => shaped
893                .closest_index_for_position(local, px(trow.line_h))
894                .unwrap_or_else(|near| near),
895            None => 0,
896        };
897        let src = src_for_vis(&trow.plan.segs, vis);
898        let col = col_for_byte(self.model.line(tline).unwrap_or(""), src);
899        self.model.move_to(tline, col, extend);
900        self.after_move(cx);
901    }
902
903    fn after_edit(&mut self, cx: &mut Context<Self>) {
904        cx.emit(MarkdownEditorEvent::Change(self.model.text()));
905        self.scroll_to_cursor = true;
906        cx.notify();
907        cx.stop_propagation();
908    }
909
910    fn after_move(&mut self, cx: &mut Context<Self>) {
911        self.scroll_to_cursor = true;
912        cx.notify();
913        cx.stop_propagation();
914    }
915
916    /// Nudge the scroll offset so the caret's visual row is inside the
917    /// viewport. Runs during render, right after the fresh layout is built.
918    fn ensure_cursor_visible(&mut self) {
919        let cursor = self.model.cursor();
920        let Some(row) = self.layout.get(cursor.line) else {
921            return;
922        };
923        let view_h = f32::from(self.scroll.bounds().size.height);
924        if view_h <= 0.0 {
925            return;
926        }
927        let text = self.model.line(cursor.line).unwrap_or("");
928        let vis = vis_for_src(&row.plan.segs, byte_for_col(text, cursor.col));
929        let (_, vrow) = row.caret(vis);
930        let top = row.y + row.pad_top + vrow as f32 * row.line_h;
931        let bottom = top + row.line_h + 2.0 * PAD_Y;
932        let offset = self.scroll.offset();
933        let y = scroll_adjust(f32::from(offset.y), view_h, top, bottom);
934        if y != f32::from(offset.y) {
935            self.scroll.set_offset(point(offset.x, px(y)));
936        }
937    }
938}
939
940impl Render for MarkdownEditor {
941    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
942        let focused = self.focus.is_focused(window);
943        let base = self.font_size;
944
945        let t = theme(cx);
946        let style = self.style;
947        let is_dark = t.scheme.is_dark();
948        let frame_border = if focused {
949            t.primary().hsla()
950        } else {
951            t.border().hsla()
952        };
953        let bg = style.bg.unwrap_or_else(|| t.surface().hsla());
954        let text_color = style.text.unwrap_or_else(|| t.text().hsla());
955        let dimmed = t.dimmed().hsla();
956        let marker_color = t.dimmed().alpha(0.75);
957        let accent = style.accent.unwrap_or_else(|| t.primary().hsla());
958        let caret_color = style.caret.unwrap_or(accent);
959        let selection_bg = style.selection.unwrap_or_else(|| t.primary().alpha(0.25));
960        let code_bg = style
961            .code_bg
962            .unwrap_or_else(|| t.surface_hover().alpha(if is_dark { 0.45 } else { 0.6 }));
963        let highlight_bg = t
964            .color(crate::theme::ColorName::Yellow, if is_dark { 7 } else { 2 })
965            .alpha(if is_dark { 0.45 } else { 0.7 });
966        let placeholder_color = style.placeholder.unwrap_or_else(|| t.dimmed().hsla());
967        let rule_color = t.border().hsla();
968        let quote_bar = t.primary().alpha(0.55);
969        let radius = t.radius(t.default_radius);
970        let token_colors: [Hsla; 8] = TokenKind::ALL.map(|kind| token_color(kind, t));
971
972        let prose = window.text_style().font();
973        let mono = Font {
974            family: MONO_FAMILY.into(),
975            ..prose.clone()
976        };
977        let cell_w = {
978            let ts = window.text_system();
979            let font_id = ts.resolve_font(&prose);
980            ts.ch_advance(font_id, px(base))
981                .map(f32::from)
982                .unwrap_or(base * 0.55)
983        };
984        self.cell_w = cell_w;
985        let base_line_h = (base * 1.6).round();
986
987        let cursor = self.model.cursor();
988        let selection = self.model.selection();
989        let reveal_range = match selection {
990            _ if !focused || self.read_only => None,
991            Some((s, e)) => Some((s.line, e.line)),
992            None => Some((cursor.line, cursor.line)),
993        };
994        let show_caret = focused && !self.read_only;
995        let show_placeholder = self.model.is_empty() && !focused && !self.placeholder.is_empty();
996
997        // ---- build rows: classify, plan, shape ----
998        let wrap_total = self.wrap_w.max(120.0);
999        let mut rows: Vec<Row> = Vec::with_capacity(self.model.line_count());
1000        let mut doc_state = DocState::default();
1001        let mut hl_state = LineState::default();
1002        let mut y = 0.0;
1003        for (i, line) in self.model.lines().iter().enumerate() {
1004            let lang = doc_state.fence_lang().map(str::to_string);
1005            let block = classify(line, &mut doc_state);
1006            if matches!(block, Block::Fence { open: true, .. }) {
1007                hl_state = LineState::default();
1008            }
1009            let reveal = reveal_range.is_some_and(|(s, e)| i >= s && i <= e);
1010            let plan = plan(line, &block, lang.as_deref(), reveal);
1011
1012            let m = metrics(&plan.kind);
1013            let (scale, lh, pt, pb) = (m.scale, m.line_height, m.pad_top, m.pad_bottom);
1014            let size = (base * scale).round();
1015            let line_h = (size * lh).round();
1016            let pad_top = (base * pt).round();
1017            let pad_bottom = (base * pb).round();
1018
1019            let inset = match &plan.kind {
1020                RowKind::Bullet { cols } | RowKind::Task { cols, .. } => {
1021                    *cols as f32 * cell_w + (base * 1.6).round()
1022                }
1023                RowKind::Ordered { cols, number } => {
1024                    let shaped = window.text_system().shape_line(
1025                        SharedString::from(format!("{number}.")),
1026                        px(size),
1027                        &[TextRun {
1028                            len: format!("{number}.").len(),
1029                            font: prose.clone(),
1030                            color: Hsla::default(),
1031                            background_color: None,
1032                            underline: None,
1033                            strikethrough: None,
1034                        }],
1035                        None,
1036                    );
1037                    *cols as f32 * cell_w + f32::from(shaped.width) + (base * 0.55).round()
1038                }
1039                RowKind::Quote { depth } => *depth as f32 * (base * 1.1).round(),
1040                RowKind::Code { .. } | RowKind::Fence { .. } => (base * 0.75).round(),
1041                _ => 0.0,
1042            };
1043            let right_pad = match &plan.kind {
1044                RowKind::Code { .. } | RowKind::Fence { .. } => inset,
1045                _ => 0.0,
1046            };
1047            let wrap = (wrap_total - inset - right_pad).max(60.0);
1048
1049            let runs = if let RowKind::Code { lang } = &plan.kind {
1050                let language = fence_language(lang.as_deref());
1051                let tokens = language.line(&plan.visible, &mut hl_state);
1052                cover(plan.visible.len(), &tokens)
1053                    .into_iter()
1054                    .map(|(len, kind)| TextRun {
1055                        len,
1056                        font: mono.clone(),
1057                        color: kind.map_or(text_color, |k| token_colors[k.index()]),
1058                        background_color: None,
1059                        underline: None,
1060                        strikethrough: None,
1061                    })
1062                    .collect::<Vec<_>>()
1063            } else {
1064                let heading = matches!(plan.kind, RowKind::Heading(_));
1065                plan.runs
1066                    .iter()
1067                    .map(|run| {
1068                        let s = run.style;
1069                        let font = Font {
1070                            family: if s.code {
1071                                MONO_FAMILY.into()
1072                            } else {
1073                                prose.family.clone()
1074                            },
1075                            weight: if heading || s.bold {
1076                                FontWeight::BOLD
1077                            } else {
1078                                prose.weight
1079                            },
1080                            style: if s.italic {
1081                                FontStyle::Italic
1082                            } else {
1083                                prose.style
1084                            },
1085                            ..prose.clone()
1086                        };
1087                        let color = if run.marker {
1088                            marker_color
1089                        } else if run.dim {
1090                            dimmed
1091                        } else if s.link {
1092                            accent
1093                        } else {
1094                            text_color
1095                        };
1096                        TextRun {
1097                            len: run.len,
1098                            font,
1099                            color,
1100                            background_color: if s.highlight {
1101                                Some(highlight_bg)
1102                            } else if s.code {
1103                                Some(code_bg)
1104                            } else {
1105                                None
1106                            },
1107                            underline: (s.link && !run.marker).then(|| UnderlineStyle {
1108                                thickness: px(1.0),
1109                                color: Some(accent),
1110                                wavy: false,
1111                            }),
1112                            strikethrough: s.strike.then(|| StrikethroughStyle {
1113                                thickness: px(1.0),
1114                                color: Some(dimmed),
1115                            }),
1116                        }
1117                    })
1118                    .collect::<Vec<_>>()
1119            };
1120
1121            let shaped = window
1122                .text_system()
1123                .shape_text(
1124                    SharedString::from(plan.visible.clone()),
1125                    px(size),
1126                    &runs,
1127                    Some(px(wrap)),
1128                    None,
1129                )
1130                .ok()
1131                .and_then(|mut lines| {
1132                    if lines.is_empty() {
1133                        None
1134                    } else {
1135                        Some(std::rc::Rc::new(lines.swap_remove(0)))
1136                    }
1137                });
1138            let visual_rows = shaped
1139                .as_ref()
1140                .map_or(1, |s| s.wrap_boundaries().len() + 1)
1141                .max(1);
1142            let height = pad_top + visual_rows as f32 * line_h + pad_bottom;
1143            rows.push(Row {
1144                plan,
1145                text: shaped,
1146                line_h,
1147                pad_top,
1148                inset,
1149                height,
1150                y,
1151            });
1152            y += height;
1153        }
1154        self.layout = rows;
1155        if self.scroll_to_cursor {
1156            self.scroll_to_cursor = false;
1157            self.ensure_cursor_visible();
1158        }
1159
1160        // ---- build elements ----
1161        let mut row_divs: Vec<Div> = Vec::with_capacity(self.layout.len());
1162        for (i, row) in self.layout.iter().enumerate() {
1163            let size_of_row = row
1164                .text
1165                .as_ref()
1166                .map(|t| f32::from(t.font_size()))
1167                .unwrap_or(base);
1168            let mut el = div().relative().w_full().h(px(row.height));
1169
1170            match &row.plan.kind {
1171                RowKind::Code { .. } => el = el.bg(code_bg),
1172                RowKind::Fence { open } => {
1173                    el = el.bg(code_bg);
1174                    el = if *open {
1175                        el.rounded_t(px(radius))
1176                    } else {
1177                        el.rounded_b(px(radius))
1178                    };
1179                }
1180                RowKind::Rule if !row.plan.revealed => {
1181                    el = el.child(
1182                        div()
1183                            .absolute()
1184                            .left_0()
1185                            .right_0()
1186                            .top(px((row.height / 2.0 - 1.0).max(0.0)))
1187                            .h(px(2.0))
1188                            .rounded(px(1.0))
1189                            .bg(rule_color),
1190                    );
1191                }
1192                RowKind::Quote { depth } => {
1193                    let step = (base * 1.1).round();
1194                    for k in 0..*depth {
1195                        el = el.child(
1196                            div()
1197                                .absolute()
1198                                .left(px(k as f32 * step + 1.0))
1199                                .top_0()
1200                                .bottom_0()
1201                                .w(px(3.0))
1202                                .rounded(px(1.5))
1203                                .bg(quote_bar),
1204                        );
1205                    }
1206                }
1207                RowKind::Bullet { .. } => {
1208                    let dot = (base * 0.36).round().max(4.0);
1209                    el = el.child(
1210                        div()
1211                            .absolute()
1212                            .left(px(row.inset - dot - (base * 0.6).round()))
1213                            .top(px(row.pad_top + (row.line_h - dot) / 2.0))
1214                            .size(px(dot))
1215                            .rounded_full()
1216                            .bg(marker_color),
1217                    );
1218                }
1219                RowKind::Ordered { number, .. } => {
1220                    el = el.child(
1221                        div()
1222                            .absolute()
1223                            .left_0()
1224                            .top(px(row.pad_top))
1225                            .w(px((row.inset - (base * 0.35)).max(0.0)))
1226                            .h(px(row.line_h))
1227                            .flex()
1228                            .items_center()
1229                            .justify_end()
1230                            .text_size(px(size_of_row))
1231                            .text_color(marker_color)
1232                            .child(SharedString::from(format!("{number}."))),
1233                    );
1234                }
1235                RowKind::Task { checked, .. } => {
1236                    let box_s = (size_of_row * 1.05).round();
1237                    let boxed = div()
1238                        .absolute()
1239                        .left(px(row.inset - box_s - (base * 0.45).round()))
1240                        .top(px(row.pad_top + (row.line_h - box_s) / 2.0))
1241                        .size(px(box_s))
1242                        .rounded(px(4.0))
1243                        .flex()
1244                        .items_center()
1245                        .justify_center();
1246                    el = el.child(if *checked {
1247                        boxed
1248                            .bg(accent)
1249                            .text_size(px(box_s * 0.8))
1250                            .text_color(gpui::white())
1251                            .child(Glyph::Lucide(IconName::Check))
1252                    } else {
1253                        boxed.border_1().border_color(dimmed)
1254                    });
1255                }
1256                _ => {}
1257            }
1258
1259            // Selection rectangles.
1260            if let Some((start, end)) = selection {
1261                if let Some(text) = self.model.line(i) {
1262                    if let Some((s_col, e_col, newline)) =
1263                        line_selection(start, end, i, text.chars().count())
1264                    {
1265                        let vs = vis_for_src(&row.plan.segs, byte_for_col(text, s_col));
1266                        let ve = vis_for_src(&row.plan.segs, byte_for_col(text, e_col));
1267                        for (x, vrow, w) in row.sel_rects(vs, ve, newline, cell_w) {
1268                            el = el.child(
1269                                div()
1270                                    .absolute()
1271                                    .left(px(row.inset + x))
1272                                    .top(px(row.pad_top + vrow as f32 * row.line_h))
1273                                    .w(px(w.max(2.0)))
1274                                    .h(px(row.line_h))
1275                                    .bg(selection_bg),
1276                            );
1277                        }
1278                    }
1279                }
1280            }
1281
1282            // The shaped text itself, painted directly so caret/selection
1283            // math and the glyphs always agree.
1284            if let Some(shaped) = row.text.clone() {
1285                if !row.plan.visible.is_empty() {
1286                    let line_h = px(row.line_h);
1287                    let text_h = row.visual_rows() as f32 * row.line_h;
1288                    el = el.child(
1289                        div()
1290                            .absolute()
1291                            .left(px(row.inset))
1292                            .top(px(row.pad_top))
1293                            .w(px((wrap_total - row.inset).max(60.0)))
1294                            .h(px(text_h))
1295                            .child(canvas(
1296                                |_, _, _| (),
1297                                move |bounds, _, window, cx| {
1298                                    let origin = bounds.origin;
1299                                    shaped
1300                                        .paint_background(
1301                                            origin,
1302                                            line_h,
1303                                            TextAlign::Left,
1304                                            None,
1305                                            window,
1306                                            cx,
1307                                        )
1308                                        .ok();
1309                                    shaped
1310                                        .paint(origin, line_h, TextAlign::Left, None, window, cx)
1311                                        .ok();
1312                                },
1313                            )),
1314                    );
1315                }
1316            }
1317
1318            // Caret.
1319            if show_caret && i == cursor.line {
1320                if let Some(text) = self.model.line(i) {
1321                    let vis = vis_for_src(&row.plan.segs, byte_for_col(text, cursor.col));
1322                    let (x, vrow) = row.caret(vis);
1323                    el = el.child(
1324                        div()
1325                            .absolute()
1326                            .left(px((row.inset + x - 1.0).max(0.0)))
1327                            .top(px(row.pad_top + vrow as f32 * row.line_h))
1328                            .w(px(2.0))
1329                            .h(px(row.line_h))
1330                            .bg(caret_color),
1331                    );
1332                }
1333            }
1334
1335            row_divs.push(el);
1336        }
1337
1338        // Invisible bounds probe: its painted origin is content cell (0, 0)
1339        // and its width is next frame's wrap width.
1340        let entity = cx.entity();
1341        let probe = canvas(
1342            move |bounds, _window, cx| {
1343                entity.update(cx, |this, cx| {
1344                    this.text_bounds = bounds;
1345                    let w = f32::from(bounds.size.width);
1346                    if (w - this.wrap_w).abs() > 0.5 {
1347                        this.wrap_w = w;
1348                        cx.notify();
1349                    }
1350                });
1351            },
1352            |_, _, _, _| {},
1353        )
1354        .absolute()
1355        .size_full();
1356
1357        let mut lines_col = div()
1358            .relative()
1359            .flex()
1360            .flex_col()
1361            .w_full()
1362            .child(probe)
1363            .children(row_divs);
1364        if show_placeholder {
1365            lines_col = lines_col.child(
1366                div()
1367                    .absolute()
1368                    .top_0()
1369                    .left_0()
1370                    .text_color(placeholder_color)
1371                    .child(self.placeholder.clone()),
1372            );
1373        }
1374
1375        let content = div().w_full().py(px(PAD_Y)).px(px(PAD_X)).child(lines_col);
1376
1377        let mut body = div()
1378            .id("guise-markdown-body")
1379            .track_focus(&self.focus)
1380            .on_key_down(cx.listener(Self::on_key))
1381            .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
1382            .on_drag(MarkdownDrag(cx.entity_id()), |_, _, _, cx| {
1383                cx.new(|_| Empty)
1384            })
1385            .on_drag_move(cx.listener(Self::on_drag_move))
1386            .overflow_y_scroll()
1387            .track_scroll(&self.scroll)
1388            .w_full()
1389            .max_h_full()
1390            .cursor_text()
1391            .child(content);
1392        if let Some(rows) = self.rows {
1393            body = body.min_h(px(rows as f32 * base_line_h + 2.0 * PAD_Y));
1394        }
1395
1396        let mut frame = div().flex().flex_col().w_full().h_full();
1397        if !style.bare {
1398            frame = frame
1399                .rounded(px(radius))
1400                .border_1()
1401                .border_color(frame_border);
1402        }
1403        frame
1404            .bg(bg)
1405            .overflow_hidden()
1406            .text_size(px(base))
1407            .line_height(px(base_line_h))
1408            .text_color(text_color)
1409            .child(body)
1410            .probe("MarkdownEditor")
1411    }
1412}
1413
1414// ---- pure helpers (unit-tested) ---------------------------------------------
1415
1416/// Map a fence info string onto a highlighter language.
1417fn fence_language(lang: Option<&str>) -> Language {
1418    match lang {
1419        Some("rust" | "rs") => Language::Rust,
1420        Some("sql") => Language::Sql,
1421        Some("json" | "jsonc") => Language::Json,
1422        _ => Language::None,
1423    }
1424}
1425
1426/// Classify a single line out of document context (fences and frontmatter
1427/// need the document pass; lists, quotes, and headings don't).
1428fn classify_alone(line: &str) -> Block {
1429    let mut state = DocState::default();
1430    classify("", &mut state);
1431    classify(line, &mut state)
1432}
1433
1434/// The list/quote marker that continues `line` onto the next one, ready to
1435/// insert after the auto-copied indent — `None` when the line isn't a
1436/// continuable item.
1437fn continuation(line: &str) -> Option<String> {
1438    match classify_alone(line) {
1439        Block::Task { indent, .. } => {
1440            let ch = line.as_bytes()[indent] as char;
1441            Some(format!("{ch} [ ] "))
1442        }
1443        Block::Bullet { indent, .. } => {
1444            let ch = line.as_bytes()[indent] as char;
1445            Some(format!("{ch} "))
1446        }
1447        Block::Ordered { indent, number, .. } => {
1448            let delim = line[indent..]
1449                .bytes()
1450                .find(|b| *b == b'.' || *b == b')')
1451                .unwrap_or(b'.') as char;
1452            Some(format!("{}{delim} ", number + 1))
1453        }
1454        Block::Quote { depth, .. } => Some("> ".repeat(depth as usize)),
1455        _ => None,
1456    }
1457}
1458
1459/// Where a continuable item's marker prefix ends (its content offset).
1460fn prefix_end(line: &str) -> usize {
1461    match classify_alone(line) {
1462        Block::Task { content, .. }
1463        | Block::Bullet { content, .. }
1464        | Block::Ordered { content, .. }
1465        | Block::Quote { content, .. } => content,
1466        _ => 0,
1467    }
1468}
1469
1470/// The visual rows a visible byte range spans, given the wrap-boundary
1471/// bytes: (start row, end row). Range starts at a boundary belong to the
1472/// later row; range ends at a boundary belong to the earlier one.
1473fn split_visual(bounds: &[usize], vs: usize, ve: usize) -> (usize, usize) {
1474    let sr = bounds.iter().filter(|&&b| b <= vs).count();
1475    let er = bounds.iter().filter(|&&b| b < ve).count();
1476    (sr, er.max(sr))
1477}
1478
1479/// Cover `len` bytes with contiguous span lengths: token ranges keep their
1480/// kind, gaps get `None`. Clamps overlap/overflow so lengths sum to `len`.
1481fn cover(
1482    len: usize,
1483    tokens: &[(std::ops::Range<usize>, TokenKind)],
1484) -> Vec<(usize, Option<TokenKind>)> {
1485    let mut out = Vec::new();
1486    let mut at = 0;
1487    for (range, kind) in tokens {
1488        let start = range.start.max(at).min(len);
1489        let end = range.end.max(start).min(len);
1490        if start > at {
1491            out.push((start - at, None));
1492        }
1493        if end > start {
1494            out.push((end - start, Some(*kind)));
1495        }
1496        at = end.max(at);
1497    }
1498    if at < len {
1499        out.push((len - at, None));
1500    }
1501    out
1502}
1503
1504/// The selected char-column range on `line` for a normalized selection
1505/// `(start, end)`, or `None` when the selection misses the line. The `bool`
1506/// is whether the selection continues past this line's end.
1507fn line_selection(
1508    start: Pos,
1509    end: Pos,
1510    line: usize,
1511    line_len: usize,
1512) -> Option<(usize, usize, bool)> {
1513    if line < start.line || line > end.line {
1514        return None;
1515    }
1516    let s = if line == start.line {
1517        start.col.min(line_len)
1518    } else {
1519        0
1520    };
1521    let e = if line == end.line {
1522        end.col.min(line_len)
1523    } else {
1524        line_len
1525    };
1526    let e = e.max(s);
1527    let newline = line < end.line;
1528    if e == s && !newline {
1529        return None;
1530    }
1531    Some((s, e, newline))
1532}
1533
1534/// Adjust a scroll offset (0 or negative, more negative = scrolled further)
1535/// so the content range `top..bottom` is inside a `view`-long viewport.
1536fn scroll_adjust(offset: f32, view: f32, top: f32, bottom: f32) -> f32 {
1537    let mut adjusted = offset;
1538    if bottom + adjusted > view {
1539        adjusted = view - bottom;
1540    }
1541    if top + adjusted < 0.0 {
1542        adjusted = -top;
1543    }
1544    adjusted
1545}
1546
1547#[cfg(test)]
1548mod tests {
1549    use super::*;
1550
1551    #[test]
1552    fn continuation_markers() {
1553        assert_eq!(continuation("- item"), Some("- ".into()));
1554        assert_eq!(continuation("* item"), Some("* ".into()));
1555        assert_eq!(continuation("  - item"), Some("- ".into()));
1556        assert_eq!(continuation("- [x] done"), Some("- [ ] ".into()));
1557        assert_eq!(continuation("3. third"), Some("4. ".into()));
1558        assert_eq!(continuation("3) third"), Some("4) ".into()));
1559        assert_eq!(continuation("> quoted"), Some("> ".into()));
1560        assert_eq!(continuation("> > deep"), Some("> > ".into()));
1561        assert_eq!(continuation("plain"), None);
1562        assert_eq!(continuation("# heading"), None);
1563    }
1564
1565    #[test]
1566    fn prefix_end_finds_content() {
1567        assert_eq!(prefix_end("- item"), 2);
1568        assert_eq!(prefix_end("  - [ ] x"), 8);
1569        assert_eq!(prefix_end("> q"), 2);
1570        assert_eq!(prefix_end("plain"), 0);
1571    }
1572
1573    #[test]
1574    fn split_visual_rows() {
1575        // No wrapping: everything is row 0.
1576        assert_eq!(split_visual(&[], 0, 10), (0, 0));
1577        // One boundary at 10: [0..10) is row 0, [10..) is row 1.
1578        assert_eq!(split_visual(&[10], 2, 8), (0, 0));
1579        assert_eq!(split_visual(&[10], 2, 15), (0, 1));
1580        assert_eq!(split_visual(&[10], 12, 15), (1, 1));
1581        // Starts at the boundary → later row; ends at it → earlier row.
1582        assert_eq!(split_visual(&[10], 10, 15), (1, 1));
1583        assert_eq!(split_visual(&[10], 2, 10), (0, 0));
1584        // Degenerate empty range never inverts.
1585        assert_eq!(split_visual(&[10], 10, 10), (1, 1));
1586    }
1587
1588    #[test]
1589    fn cover_spans_exactly() {
1590        let tokens = vec![(2..5, TokenKind::Keyword)];
1591        let s = cover(10, &tokens);
1592        let total: usize = s.iter().map(|(len, _)| len).sum();
1593        assert_eq!(total, 10);
1594        assert_eq!(s[1], (3, Some(TokenKind::Keyword)));
1595        assert_eq!(cover(4, &[]), vec![(4, None)]);
1596    }
1597
1598    #[test]
1599    fn fence_language_mapping() {
1600        assert_eq!(fence_language(Some("rust")), Language::Rust);
1601        assert_eq!(fence_language(Some("rs")), Language::Rust);
1602        assert_eq!(fence_language(Some("json")), Language::Json);
1603        assert_eq!(fence_language(Some("python")), Language::None);
1604        assert_eq!(fence_language(None), Language::None);
1605    }
1606
1607    #[test]
1608    fn scroll_adjust_reveals_target() {
1609        assert_eq!(scroll_adjust(-10.0, 100.0, 20.0, 40.0), -10.0);
1610        assert_eq!(scroll_adjust(-50.0, 100.0, 20.0, 40.0), -20.0);
1611        assert_eq!(scroll_adjust(0.0, 100.0, 150.0, 170.0), -70.0);
1612    }
1613
1614    fn at(line: usize, col: usize) -> Pos {
1615        Pos::new(line, col)
1616    }
1617
1618    #[test]
1619    fn line_selection_matches_editor_semantics() {
1620        assert_eq!(
1621            line_selection(at(1, 2), at(1, 5), 1, 8),
1622            Some((2, 5, false))
1623        );
1624        assert_eq!(line_selection(at(0, 3), at(2, 2), 1, 4), Some((0, 4, true)));
1625        assert_eq!(line_selection(at(0, 3), at(2, 2), 3, 4), None);
1626        assert_eq!(line_selection(at(0, 0), at(2, 1), 1, 0), Some((0, 0, true)));
1627    }
1628}