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