Skip to main content

guise/editor/
editor.rs

1//! `Editor` — a multiline code editor (gpui entity).
2//!
3//! Renders an [`EditorModel`] with a line-number gutter, syntax highlighting
4//! (a built-in [`Language`] tokenizer, or a whole-document
5//! [`DocumentHighlighter`] such as the `treesitter` feature's adapter),
6//! selection, caret, and the full macOS-convention keyboard map. Emits
7//! [`EditorEvent::Change`] on every edit and [`EditorEvent::Run`] on
8//! Cmd+Enter, so a host can execute the buffer (a query console, a REPL).
9//!
10//! ```ignore
11//! let editor = cx.new(|cx| {
12//!     Editor::new(cx)
13//!         .language(Language::Rust)
14//!         .rows(8)
15//!         .value("fn main() {\n    println!(\"hi\");\n}")
16//! });
17//! cx.subscribe(&editor, |_this, _editor, event: &EditorEvent, _cx| {
18//!     if let EditorEvent::Run(source) = event {
19//!         // Cmd+Enter — run `source`
20//!     }
21//! })
22//! .detach();
23//! ```
24
25use std::ops::Range;
26
27use gpui::prelude::*;
28use gpui::{
29  canvas, div, point, px, App, Bounds, ClipboardItem, Context, Div, DragMoveEvent, Empty, Entity,
30  EntityId, EventEmitter, FocusHandle, Font, Hsla, IntoElement, KeyDownEvent, MouseButton,
31  MouseDownEvent, Pixels, Point, ScrollHandle, ShapedLine, SharedString, StyledText, TextRun,
32  Window,
33};
34
35use super::cache::HighlightCache;
36use super::diagnostic::{line_message, line_severity, Diagnostic};
37use super::highlight::{token_color, DocumentHighlighter, Language, TokenKind};
38use super::model::{EditorModel, Pos};
39use crate::reactive::Signal;
40use crate::theme::theme;
41
42use crate::devtools::Probed;
43/// The monospace family used for the buffer, gutter, and placeholder.
44use crate::style::MONO_FAMILY;
45/// Horizontal padding around the text content, in px.
46const PAD_X: f32 = 10.0;
47/// Vertical padding above and below the lines, in px.
48const PAD_Y: f32 = 8.0;
49/// Padding inside the gutter on each side of the line numbers, in px.
50const GUTTER_PAD: f32 = 10.0;
51
52/// Emitted as the user edits or asks to run the buffer.
53#[derive(Debug, Clone)]
54pub enum EditorEvent {
55  /// The document changed. Carries the full new text.
56  Change(String),
57  /// Cmd+Enter. Carries the current text, for hosts that execute it.
58  Run(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 EditorDrag(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 EditorStyle {
69  /// Paint no frame border and no corner radius (an embedded strip).
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  pub active_line: Option<Hsla>,
76  pub gutter_fg: Option<Hsla>,
77  pub gutter_fg_active: Option<Hsla>,
78  pub placeholder: Option<Hsla>,
79}
80
81/// A multiline code editor. Create with `cx.new(|cx| Editor::new(cx))`.
82///
83/// The text model is the unit-tested [`EditorModel`] (char-index cursor,
84/// anchor selection, coalesced undo). Read-only editors still support
85/// selection and copy — only mutations are blocked.
86pub struct Editor {
87  model: EditorModel,
88  language: Language,
89  /// Whole-document highlighter; overrides `language` while set.
90  doc_highlighter: Option<Box<dyn DocumentHighlighter>>,
91  /// Whether `doc_highlighter` must reparse before the next paint.
92  doc_dirty: bool,
93  /// Per-line tokens for the `language` path, revalidated each frame and
94  /// re-tokenized only for lines that changed.
95  hl_cache: HighlightCache,
96  placeholder: SharedString,
97  read_only: bool,
98  line_numbers: bool,
99  font_size: f32,
100  rows: Option<usize>,
101  token_palette: Option<[Hsla; 8]>,
102  style: EditorStyle,
103  highlights: Vec<(Pos, Pos, Hsla)>,
104  diagnostics: Vec<Diagnostic>,
105  focus: FocusHandle,
106  scroll: ScrollHandle,
107  hscroll: ScrollHandle,
108  /// Window-space bounds of the text content, captured at prepaint. Mouse
109  /// positions minus this origin give content coordinates directly (the
110  /// captured origin already moves with both scroll axes).
111  text_bounds: Bounds<Pixels>,
112  /// Measured monospace cell advance ('0'), refreshed every render. Only a
113  /// rough unit (gutter width, scroll margins) — never per-glyph math.
114  cell_w: f32,
115  /// Line height in px, refreshed every render.
116  line_h: f32,
117  /// The resolved mono font, refreshed every render so shaping for mouse
118  /// math uses exactly the font the glyphs are painted with.
119  mono_font: Font,
120}
121
122impl EventEmitter<EditorEvent> for Editor {}
123
124impl Editor {
125  pub fn new(cx: &mut Context<Self>) -> Self {
126    Editor {
127      model: EditorModel::new(""),
128      language: Language::None,
129      doc_highlighter: None,
130      doc_dirty: true,
131      hl_cache: HighlightCache::new(),
132      placeholder: SharedString::default(),
133      read_only: false,
134      line_numbers: true,
135      font_size: 13.0,
136      rows: None,
137      token_palette: None,
138      style: EditorStyle::default(),
139      highlights: Vec::new(),
140      diagnostics: Vec::new(),
141      focus: cx.focus_handle(),
142      scroll: ScrollHandle::new(),
143      hscroll: ScrollHandle::new(),
144      text_bounds: Bounds::default(),
145      cell_w: 13.0 * 0.6,
146      line_h: 20.0,
147      mono_font: gpui::font(MONO_FAMILY),
148    }
149  }
150
151  // ---- builders ----
152
153  /// Initial text (named like [`TextInput::value`](crate::input::TextInput::value);
154  /// `text()` is the getter).
155  pub fn value(mut self, text: &str) -> Self {
156    self.model.set_text(text);
157    self
158  }
159
160  /// Syntax highlighting language (default [`Language::None`]).
161  pub fn language(mut self, language: Language) -> Self {
162    self.language = language;
163    self.hl_cache.clear();
164    self
165  }
166
167  /// Highlight with a whole-document backend (a tree-sitter adapter)
168  /// instead of the line-based `language` tokenizer. Takes precedence over
169  /// [`language`](Self::language) while set.
170  pub fn highlighter(mut self, highlighter: impl DocumentHighlighter + 'static) -> Self {
171    self.doc_highlighter = Some(Box::new(highlighter));
172    self.doc_dirty = true;
173    self
174  }
175
176  /// Dimmed hint shown while the buffer is empty and unfocused.
177  pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
178    self.placeholder = placeholder.into();
179    self
180  }
181
182  /// Block edits. Selection, copy, and Cmd+Enter still work.
183  pub fn read_only(mut self, read_only: bool) -> Self {
184    self.read_only = read_only;
185    self
186  }
187
188  /// Show the line-number gutter (default true).
189  pub fn line_numbers(mut self, show: bool) -> Self {
190    self.line_numbers = show;
191    self
192  }
193
194  /// Spaces per tab stop (default 4).
195  pub fn tab_size(mut self, n: usize) -> Self {
196    self.model.set_tab_size(n);
197    self
198  }
199
200  /// Buffer font size in px (default 13.0).
201  pub fn font_size(mut self, size: f32) -> Self {
202    self.font_size = size;
203    self
204  }
205
206  /// Minimum height, as a number of visible lines.
207  pub fn rows(mut self, rows: usize) -> Self {
208    self.rows = Some(rows);
209    self
210  }
211
212  /// Override the syntax palette — one color per [`TokenKind`], in
213  /// [`TokenKind::ALL`] order. Defaults to the theme mapping
214  /// ([`token_color`]).
215  pub fn token_colors(mut self, colors: [Hsla; 8]) -> Self {
216    self.token_palette = Some(colors);
217    self
218  }
219
220  /// Per-editor visual overrides (see [`EditorStyle`]).
221  pub fn style(mut self, style: EditorStyle) -> Self {
222    self.style = style;
223    self
224  }
225
226  /// Replace the style at runtime (theme switches).
227  pub fn set_style(&mut self, style: EditorStyle, cx: &mut Context<Self>) {
228    self.style = style;
229    cx.notify();
230  }
231
232  /// Switch the highlighting language at runtime (a file-type change).
233  /// Ignored while a document highlighter is set.
234  pub fn set_language(&mut self, language: Language, cx: &mut Context<Self>) {
235    self.language = language;
236    self.hl_cache.clear();
237    cx.notify();
238  }
239
240  /// Install or replace the document highlighter at runtime; `None` falls
241  /// back to the line-based `language` tokenizer.
242  pub fn set_highlighter(
243    &mut self,
244    highlighter: Option<Box<dyn DocumentHighlighter>>,
245    cx: &mut Context<Self>,
246  ) {
247    self.doc_highlighter = highlighter;
248    self.doc_dirty = true;
249    cx.notify();
250  }
251
252  /// Background rectangles painted under the text — search matches,
253  /// occurrence highlights. Document-position ranges; multi-line ranges
254  /// paint like selections.
255  pub fn set_highlights(&mut self, highlights: Vec<(Pos, Pos, Hsla)>, cx: &mut Context<Self>) {
256    self.highlights = highlights;
257    cx.notify();
258  }
259
260  /// Attach diagnostics (compiler/linter/LSP output). Affected lines get a
261  /// severity-colored gutter dot and range underline, and the active
262  /// line's message shows in a strip under the buffer.
263  pub fn set_diagnostics(&mut self, diagnostics: Vec<Diagnostic>, cx: &mut Context<Self>) {
264    self.diagnostics = diagnostics;
265    cx.notify();
266  }
267
268  pub fn clear_diagnostics(&mut self, cx: &mut Context<Self>) {
269    if !self.diagnostics.is_empty() {
270      self.diagnostics.clear();
271      cx.notify();
272    }
273  }
274
275  pub fn diagnostics(&self) -> &[Diagnostic] {
276    &self.diagnostics
277  }
278
279  // ---- runtime API ----
280
281  /// The current document text.
282  pub fn text(&self) -> String {
283    self.model.text()
284  }
285
286  /// Replace the document, resetting cursor, selection, and history.
287  pub fn set_text(&mut self, value: &str, cx: &mut Context<Self>) {
288    self.model.set_text(value);
289    self.doc_dirty = true;
290    cx.notify();
291  }
292
293  /// The editor's focus handle, so a host can focus it on open.
294  pub fn focus_handle(&self) -> FocusHandle {
295    self.focus.clone()
296  }
297
298  /// Read access to the underlying [`EditorModel`] — cursor, selection,
299  /// lines — for hosts that build features over the buffer (completion,
300  /// modal keymaps, search).
301  pub fn model(&self) -> &EditorModel {
302    &self.model
303  }
304
305  /// Mutate the [`EditorModel`] directly. Emits [`EditorEvent::Change`]
306  /// when the text changed, keeps the caret visible, and repaints — the
307  /// same bookkeeping every built-in edit goes through.
308  pub fn edit<R>(
309    &mut self,
310    window: &mut Window,
311    cx: &mut Context<Self>,
312    f: impl FnOnce(&mut EditorModel) -> R,
313  ) -> 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      self.doc_dirty = true;
319      cx.emit(EditorEvent::Change(after));
320    }
321    self.ensure_cursor_visible(window);
322    cx.notify();
323    result
324  }
325
326  /// Window-space origin of the caret's cell — where a completion popup or
327  /// search bar anchors. Tracks both scroll axes; meaningless before the
328  /// first paint (returns the content origin).
329  pub fn caret_origin(&self, window: &Window) -> Point<Pixels> {
330    let cursor = self.model.cursor();
331    let x = match self.model.line(cursor.line) {
332      Some(line) => self.caret_x(line, cursor.col, window),
333      None => 0.0,
334    };
335    point(
336      self.text_bounds.origin.x + px(x),
337      self.text_bounds.origin.y + px(cursor.line as f32 * self.line_h),
338    )
339  }
340
341  /// The pixel height of one buffer line, as painted last frame.
342  pub fn line_height(&self) -> f32 {
343    self.line_h
344  }
345
346  /// Two-way bind this editor's text to a `Signal<String>`. The signal is
347  /// the source of truth: the editor adopts its value now, edits write back
348  /// through [`Signal::set_if_changed`], and signal writes replace the text.
349  /// Equality guards on both directions prevent update loops.
350  pub fn bind(entity: &Entity<Editor>, signal: &Signal<String>, cx: &mut App) {
351    let initial = signal.get(cx);
352    entity.update(cx, |this, cx| {
353      if this.text() != initial {
354        this.set_text(&initial, cx);
355      }
356    });
357    let sink = signal.clone();
358    cx.subscribe(entity, move |_editor, event: &EditorEvent, cx| {
359      if let EditorEvent::Change(text) = event {
360        sink.set_if_changed(cx, text.clone());
361      }
362    })
363    .detach();
364    // Weak handle: a strong clone would form a retain cycle with the
365    // subscription above and leak both the editor and the signal.
366    let editor = entity.downgrade();
367    cx.observe(signal.entity(), move |observed, cx| {
368      let value = observed.read(cx).clone();
369      editor
370        .update(cx, |this, cx| {
371          if this.text() != value {
372            this.set_text(&value, cx);
373          }
374        })
375        .ok();
376    })
377    .detach();
378  }
379
380  // ---- input handling ----
381
382  fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
383    let ks = &event.keystroke;
384    let m = ks.modifiers;
385    let shift = m.shift;
386    match ks.key.as_str() {
387      "left" => {
388        if m.platform {
389          self.model.home(shift);
390        } else if m.alt {
391          self.model.word_left(shift);
392        } else {
393          self.model.move_left(shift);
394        }
395        self.after_move(window, cx);
396      }
397      "right" => {
398        if m.platform {
399          self.model.end(shift);
400        } else if m.alt {
401          self.model.word_right(shift);
402        } else {
403          self.model.move_right(shift);
404        }
405        self.after_move(window, cx);
406      }
407      "up" => {
408        if m.platform {
409          self.model.doc_start(shift);
410        } else {
411          self.model.move_up(shift);
412        }
413        self.after_move(window, cx);
414      }
415      "down" => {
416        if m.platform {
417          self.model.doc_end(shift);
418        } else {
419          self.model.move_down(shift);
420        }
421        self.after_move(window, cx);
422      }
423      "home" => {
424        if m.platform {
425          self.model.doc_start(shift);
426        } else {
427          self.model.home(shift);
428        }
429        self.after_move(window, cx);
430      }
431      "end" => {
432        if m.platform {
433          self.model.doc_end(shift);
434        } else {
435          self.model.end(shift);
436        }
437        self.after_move(window, cx);
438      }
439      "backspace" => {
440        if self.read_only {
441          return;
442        }
443        let changed = if self.model.selection().is_some() {
444          self.model.delete_selection()
445        } else if m.platform {
446          self.model.home(true);
447          self.model.delete_selection()
448        } else if m.alt {
449          self.model.word_left(true);
450          self.model.delete_selection()
451        } else {
452          self.model.backspace()
453        };
454        if changed {
455          self.after_edit(window, cx);
456        } else {
457          cx.stop_propagation();
458        }
459      }
460      "delete" => {
461        if self.read_only {
462          return;
463        }
464        let changed = if self.model.selection().is_some() {
465          self.model.delete_selection()
466        } else if m.platform {
467          self.model.end(true);
468          self.model.delete_selection()
469        } else if m.alt {
470          self.model.word_right(true);
471          self.model.delete_selection()
472        } else {
473          self.model.delete()
474        };
475        if changed {
476          self.after_edit(window, cx);
477        } else {
478          cx.stop_propagation();
479        }
480      }
481      "enter" if m.platform => {
482        cx.emit(EditorEvent::Run(self.model.text()));
483        cx.stop_propagation();
484      }
485      "enter" => {
486        if self.read_only {
487          return;
488        }
489        self.model.newline();
490        self.after_edit(window, cx);
491      }
492      "tab" => {
493        // Cmd+Tab (and read-only Tab) bubbles so hosts keep focus moves.
494        if m.platform || self.read_only {
495          return;
496        }
497        self.model.tab();
498        self.after_edit(window, cx);
499      }
500      // Escape bubbles (dialogs close on it) but still drops the selection.
501      "escape" => {
502        if self.model.selection().is_some() {
503          self.model.clear_selection();
504          cx.notify();
505        }
506      }
507      "a" if m.platform => {
508        self.model.select_all();
509        cx.notify();
510        cx.stop_propagation();
511      }
512      "c" if m.platform => {
513        if let Some(text) = self.model.copy() {
514          cx.write_to_clipboard(ClipboardItem::new_string(text));
515        }
516        cx.stop_propagation();
517      }
518      "x" if m.platform => {
519        if self.read_only {
520          // Selection stays; degrade cut to copy.
521          if let Some(text) = self.model.copy() {
522            cx.write_to_clipboard(ClipboardItem::new_string(text));
523          }
524        } else if let Some(text) = self.model.cut() {
525          cx.write_to_clipboard(ClipboardItem::new_string(text));
526          self.after_edit(window, cx);
527          return;
528        }
529        cx.stop_propagation();
530      }
531      "v" if m.platform => {
532        if !self.read_only {
533          if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
534            if !text.is_empty() {
535              self.model.insert(&text);
536              self.after_edit(window, cx);
537              return;
538            }
539          }
540        }
541        cx.stop_propagation();
542      }
543      "z" if m.platform => {
544        if !self.read_only {
545          let changed = if m.shift {
546            self.model.redo()
547          } else {
548            self.model.undo()
549          };
550          if changed {
551            self.after_edit(window, cx);
552            return;
553          }
554        }
555        cx.stop_propagation();
556      }
557      _ => {
558        // Printable input: never on Cmd/Ctrl chords; Option+key is
559        // allowed so composed glyphs land (matches `input::apply_key`).
560        if !self.read_only && !m.platform && !m.control {
561          if let Some(text) = ks.key_char.as_deref().filter(|t| !t.is_empty()) {
562            self.model.insert(text);
563            self.after_edit(window, cx);
564          }
565        }
566        // Everything else bubbles to the host.
567      }
568    }
569  }
570
571  fn on_mouse_down(&mut self, ev: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
572    window.focus(&self.focus);
573    let (line, col) = self.hit(ev.position, window);
574    match ev.click_count {
575      2 => {
576        self.model.move_to(line, col, false);
577        self.model.select_word();
578      }
579      n if n > 2 => {
580        self.model.move_to(line, col, false);
581        self.model.select_line();
582      }
583      _ => self.model.move_to(line, col, ev.modifiers.shift),
584    }
585    cx.notify();
586  }
587
588  fn on_drag_move(
589    &mut self,
590    ev: &DragMoveEvent<EditorDrag>,
591    window: &mut Window,
592    cx: &mut Context<Self>,
593  ) {
594    if ev.drag(cx).0 != cx.entity_id() {
595      return;
596    }
597    let (line, col) = self.hit(ev.event.position, window);
598    self.model.move_to(line, col, true);
599    self.ensure_cursor_visible(window);
600    cx.notify();
601  }
602
603  /// Shape one line with the editor's mono font. The resulting layout maps
604  /// char boundaries to painted x positions (and back), so mouse math, the
605  /// caret, and selection agree with the glyphs `StyledText` actually paints
606  /// — including double-width CJK/emoji fallback glyphs and literal tabs.
607  fn shape(&self, line: &str, window: &Window) -> ShapedLine {
608    let text = SharedString::from(line.to_string());
609    let run = TextRun {
610      len: text.len(),
611      font: self.mono_font.clone(),
612      color: Hsla::default(),
613      background_color: None,
614      underline: None,
615      strikethrough: None,
616    };
617    window
618      .text_system()
619      .shape_line(text, px(self.font_size), &[run], None)
620  }
621
622  /// Window position -> (line, col): the line from the fixed row height,
623  /// the column from the shaped line's closest char boundary. The model
624  /// clamps out-of-range values.
625  fn hit(&self, position: Point<Pixels>, window: &Window) -> (usize, usize) {
626    let x = f32::from(position.x) - f32::from(self.text_bounds.origin.x);
627    let y = f32::from(position.y) - f32::from(self.text_bounds.origin.y);
628    let line = hit_line(y, self.line_h).min(self.model.line_count().saturating_sub(1));
629    let Some(text) = self.model.line(line) else {
630      return (line, 0);
631    };
632    let byte = self.shape(text, window).closest_index_for_x(px(x.max(0.0)));
633    (line, col_for_byte(text, byte))
634  }
635
636  /// Painted x of the caret at char column `col` on `line`.
637  fn caret_x(&self, line: &str, col: usize, window: &Window) -> f32 {
638    f32::from(
639      self
640        .shape(line, window)
641        .x_for_index(byte_for_col(line, col)),
642    )
643  }
644
645  fn after_edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
646    self.doc_dirty = true;
647    cx.emit(EditorEvent::Change(self.model.text()));
648    self.ensure_cursor_visible(window);
649    cx.notify();
650    cx.stop_propagation();
651  }
652
653  fn after_move(&mut self, window: &mut Window, cx: &mut Context<Self>) {
654    self.ensure_cursor_visible(window);
655    cx.notify();
656    cx.stop_propagation();
657  }
658
659  /// Nudge both scroll axes so the caret (plus a padding margin) is inside
660  /// the viewport. No-op before the first paint.
661  fn ensure_cursor_visible(&mut self, window: &Window) {
662    let cursor = self.model.cursor();
663    let view_h = f32::from(self.scroll.bounds().size.height);
664    if view_h > 0.0 {
665      let top = cursor.line as f32 * self.line_h;
666      let bottom = top + self.line_h + 2.0 * PAD_Y;
667      let offset = self.scroll.offset();
668      let y = scroll_adjust(f32::from(offset.y), view_h, top, bottom);
669      if y != f32::from(offset.y) {
670        self.scroll.set_offset(point(offset.x, px(y)));
671      }
672    }
673    let view_w = f32::from(self.hscroll.bounds().size.width);
674    if view_w > 0.0 {
675      let left = match self.model.line(cursor.line) {
676        Some(line) => self.caret_x(line, cursor.col, window),
677        None => cursor.col as f32 * self.cell_w,
678      };
679      let right = left + self.cell_w + 2.0 * PAD_X;
680      let offset = self.hscroll.offset();
681      let x = scroll_adjust(f32::from(offset.x), view_w, left, right);
682      if x != f32::from(offset.x) {
683        self.hscroll.set_offset(point(px(x), offset.y));
684      }
685    }
686  }
687}
688
689impl Render for Editor {
690  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
691    let focused = self.focus.is_focused(window);
692
693    let t = theme(cx);
694    let style = self.style;
695    let frame_border = if focused {
696      t.primary().hsla()
697    } else {
698      t.border().hsla()
699    };
700    let edge = t.border().hsla();
701    let bg = style.bg.unwrap_or_else(|| t.surface().hsla());
702    let text_color = style.text.unwrap_or_else(|| t.text().hsla());
703    let dimmed = style.placeholder.unwrap_or_else(|| t.dimmed().hsla());
704    let caret_color = style.caret.unwrap_or_else(|| t.primary().hsla());
705    let selection_bg = style.selection.unwrap_or_else(|| t.primary().alpha(0.25));
706    let active_bg = style
707      .active_line
708      .unwrap_or_else(|| t.surface_hover().alpha(0.55));
709    let gutter_fg = style.gutter_fg.unwrap_or_else(|| t.dimmed().alpha(0.7));
710    let gutter_fg_active = style.gutter_fg_active.unwrap_or(text_color);
711    let radius = t.radius(t.default_radius);
712    let token_colors: [Hsla; 8] = self
713      .token_palette
714      .unwrap_or_else(|| TokenKind::ALL.map(|kind| token_color(kind, t)));
715
716    // Resolve the mono font once per render and keep it on self: mouse
717    // math shapes lines with the same font the glyphs are painted with.
718    let font_size = self.font_size;
719    let line_h = (font_size * 1.5).round();
720    let font = Font {
721      family: MONO_FAMILY.into(),
722      ..window.text_style().font()
723    };
724    let cell_w = {
725      let ts = window.text_system();
726      let font_id = ts.resolve_font(&font);
727      ts.ch_advance(font_id, px(font_size))
728        .map(f32::from)
729        .unwrap_or(font_size * 0.6)
730    };
731    self.line_h = line_h;
732    self.cell_w = cell_w;
733    self.mono_font = font.clone();
734
735    let cursor = self.model.cursor();
736    let selection = self.model.selection();
737    let line_count = self.model.line_count();
738    let show_placeholder = self.model.is_empty() && !focused && !self.placeholder.is_empty();
739    let show_caret = focused && !self.read_only;
740
741    let digits = line_count.to_string().len().max(2);
742    let gutter_w = digits as f32 * cell_w + 2.0 * GUTTER_PAD;
743    let mut max_line_w: f32 = 0.0;
744
745    // Re-highlight only what changed, never per frame: the document
746    // highlighter reparses on its dirty flag; the line cache revalidates
747    // against the lines and re-tokenizes only the ones that moved.
748    match &mut self.doc_highlighter {
749      Some(doc) => {
750        if self.doc_dirty {
751          let text = self.model.text();
752          doc.update(&text);
753          self.doc_dirty = false;
754        }
755      }
756      None => {
757        self.hl_cache.sync(&self.language, self.model.lines());
758      }
759    }
760
761    let mut gutter_rows: Vec<Div> = Vec::with_capacity(line_count);
762    let mut text_rows: Vec<Div> = Vec::with_capacity(line_count);
763    for (i, line) in self.model.lines().iter().enumerate() {
764      let is_active = i == cursor.line;
765
766      if self.line_numbers {
767        let mut gutter_row = div()
768          .relative()
769          .h(px(line_h))
770          .flex()
771          .items_center()
772          .justify_end()
773          .text_color(if is_active && focused {
774            gutter_fg_active
775          } else {
776            gutter_fg
777          })
778          .child(SharedString::from((i + 1).to_string()));
779        if let Some(severity) = line_severity(&self.diagnostics, i) {
780          gutter_row = gutter_row.child(
781            div()
782              .absolute()
783              .left(px(1.0))
784              .top(px((line_h - 6.0) / 2.0))
785              .w(px(6.0))
786              .h(px(6.0))
787              .rounded_full()
788              .bg(severity.color(t)),
789          );
790        }
791        gutter_rows.push(gutter_row);
792      }
793
794      // Shaped once per line (cached across frames by gpui): painted
795      // width, selection rects, and the caret all read real glyph
796      // positions instead of assuming one uniform cell per char.
797      let shaped = self.shape(line, window);
798      max_line_w = max_line_w.max(f32::from(shaped.width));
799
800      let mut row = div().relative().h(px(line_h)).w_full();
801      if focused && is_active {
802        row = row.bg(active_bg);
803      }
804      for (start, end, color) in &self.highlights {
805        if let Some((s, e, newline)) = line_selection(*start, *end, i, line.chars().count()) {
806          let sx = f32::from(shaped.x_for_index(byte_for_col(line, s)));
807          let ex = f32::from(shaped.x_for_index(byte_for_col(line, e)));
808          let w = (ex - sx) + if newline { cell_w } else { 0.0 };
809          row = row.child(
810            div()
811              .absolute()
812              .top_0()
813              .bottom_0()
814              .left(px(sx))
815              .w(px(w))
816              .bg(*color),
817          );
818        }
819      }
820      if let Some((start, end)) = selection {
821        if let Some((s, e, newline)) = line_selection(start, end, i, line.chars().count()) {
822          let sx = f32::from(shaped.x_for_index(byte_for_col(line, s)));
823          let ex = f32::from(shaped.x_for_index(byte_for_col(line, e)));
824          let w = (ex - sx) + if newline { cell_w } else { 0.0 };
825          row = row.child(
826            div()
827              .absolute()
828              .top_0()
829              .bottom_0()
830              .left(px(sx))
831              .w(px(w))
832              .bg(selection_bg),
833          );
834        }
835      }
836      // Diagnostic underlines: a 2px severity-colored bar under the
837      // char range (empty range = the whole line).
838      for diag in self.diagnostics.iter().filter(|d| d.line == i) {
839        let chars = line.chars().count();
840        let (s, e) = if diag.cols.is_empty() || diag.cols.start >= chars {
841          (0, chars)
842        } else {
843          (diag.cols.start, diag.cols.end.min(chars))
844        };
845        let sx = f32::from(shaped.x_for_index(byte_for_col(line, s)));
846        let ex = f32::from(shaped.x_for_index(byte_for_col(line, e)));
847        row = row.child(
848          div()
849            .absolute()
850            .bottom(px(1.0))
851            .left(px(sx))
852            .w(px((ex - sx).max(cell_w * 0.75)))
853            .h(px(2.0))
854            .rounded(px(1.0))
855            .bg(diag.severity.color(t)),
856        );
857      }
858      let tokens = match &self.doc_highlighter {
859        Some(doc) => doc.tokens(i),
860        None => self.hl_cache.tokens(i),
861      };
862      if !line.is_empty() {
863        let runs: Vec<TextRun> = spans(line.len(), tokens)
864          .into_iter()
865          .map(|(len, kind)| TextRun {
866            len,
867            font: font.clone(),
868            color: kind.map_or(text_color, |k| token_colors[k.index()]),
869            background_color: None,
870            underline: None,
871            strikethrough: None,
872          })
873          .collect();
874        row = row.child(StyledText::new(SharedString::from(line.clone())).with_runs(runs));
875      } else if i == 0 && show_placeholder {
876        row = row.child(div().text_color(dimmed).child(self.placeholder.clone()));
877      }
878      if show_caret && is_active {
879        let x = f32::from(shaped.x_for_index(byte_for_col(line, cursor.col)));
880        row = row.child(
881          div()
882            .absolute()
883            .top_0()
884            .h(px(line_h))
885            .left(px((x - 1.0).max(0.0)))
886            .w(px(2.0))
887            .bg(caret_color),
888        );
889      }
890      text_rows.push(row);
891    }
892    let content_w = max_line_w + cell_w;
893
894    // Invisible bounds probe: its painted origin (which moves with both
895    // scroll axes) is exactly where content cell (0, 0) is, so mouse
896    // hit-testing is a subtraction.
897    let entity = cx.entity();
898    let probe = canvas(
899      move |bounds, _window, cx| {
900        entity.update(cx, |this, _| this.text_bounds = bounds);
901      },
902      |_, _, _, _| {},
903    )
904    .absolute()
905    .size_full();
906
907    let lines_col = div()
908      .relative()
909      .flex()
910      .flex_col()
911      .flex_grow()
912      .min_w(px(content_w))
913      .whitespace_nowrap()
914      .child(probe)
915      .children(text_rows);
916
917    let text_area = div()
918      .id("guise-editor-text")
919      .flex_1()
920      .overflow_x_scroll()
921      .track_scroll(&self.hscroll)
922      .px(px(PAD_X))
923      .child(lines_col);
924
925    let mut content_row = div().flex().items_start().w_full().py(px(PAD_Y));
926    if self.line_numbers {
927      content_row = content_row.child(
928        div()
929          .flex()
930          .flex_col()
931          .flex_none()
932          .w(px(gutter_w))
933          .pr(px(GUTTER_PAD))
934          .border_r_1()
935          .border_color(edge)
936          .children(gutter_rows),
937      );
938    }
939    content_row = content_row.child(text_area);
940
941    let mut body = div()
942      .id("guise-editor-body")
943      .track_focus(&self.focus)
944      .on_key_down(cx.listener(Self::on_key))
945      .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
946      .on_drag(EditorDrag(cx.entity_id()), |_, _, _, cx| cx.new(|_| Empty))
947      .on_drag_move(cx.listener(Self::on_drag_move))
948      .overflow_y_scroll()
949      .track_scroll(&self.scroll)
950      .w_full()
951      .max_h_full()
952      .cursor_text()
953      .child(content_row);
954    if let Some(rows) = self.rows {
955      body = body.min_h(px(rows as f32 * line_h + 2.0 * PAD_Y));
956    }
957
958    let mut frame = div().flex().flex_col().w_full().h_full();
959    if !style.bare {
960      frame = frame
961        .rounded(px(radius))
962        .border_1()
963        .border_color(frame_border);
964    }
965    frame = frame
966      .bg(bg)
967      .overflow_hidden()
968      .font_family(MONO_FAMILY)
969      .text_size(px(font_size))
970      .line_height(px(line_h))
971      .text_color(text_color)
972      .child(body);
973
974    // The active line's diagnostic, in a strip under the buffer (no
975    // hover needed, no hit-test interference with text selection).
976    if let Some(diag) = line_message(&self.diagnostics, cursor.line) {
977      let accent = diag.severity.color(t);
978      frame = frame.child(
979        div()
980          .flex()
981          .items_center()
982          .gap(px(6.0))
983          .px(px(PAD_X))
984          .py(px(3.0))
985          .border_t_1()
986          .border_color(edge)
987          .bg(Hsla { a: 0.06, ..accent })
988          .text_size(px(font_size - 2.0))
989          .text_color(dimmed)
990          .child(div().w(px(6.0)).h(px(6.0)).rounded_full().bg(accent))
991          .child(diag.message.clone()),
992      );
993    }
994    frame.probe("Editor")
995  }
996}
997
998// ---- pure geometry helpers (unit-tested) -----------------------------------
999
1000/// Cover `len` bytes with contiguous span lengths: token ranges keep their
1001/// kind, gaps get `None`. Clamps overlapping or out-of-range tokens so the
1002/// lengths always sum to exactly `len` (a gpui `StyledText` requirement).
1003fn spans(len: usize, tokens: &[(Range<usize>, TokenKind)]) -> Vec<(usize, Option<TokenKind>)> {
1004  let mut out = Vec::new();
1005  let mut at = 0;
1006  for (range, kind) in tokens {
1007    let start = range.start.max(at).min(len);
1008    let end = range.end.max(start).min(len);
1009    if start > at {
1010      out.push((start - at, None));
1011    }
1012    if end > start {
1013      out.push((end - start, Some(*kind)));
1014    }
1015    at = end.max(at);
1016  }
1017  if at < len {
1018    out.push((len - at, None));
1019  }
1020  out
1021}
1022
1023/// The selected char-column range on `line` for a normalized selection
1024/// `(start, end)`, or `None` when the selection misses the line entirely.
1025/// The `bool` is whether the selection continues past this line's end (draw
1026/// the newline as one extra cell).
1027fn line_selection(
1028  start: Pos,
1029  end: Pos,
1030  line: usize,
1031  line_len: usize,
1032) -> Option<(usize, usize, bool)> {
1033  if line < start.line || line > end.line {
1034    return None;
1035  }
1036  let s = if line == start.line {
1037    start.col.min(line_len)
1038  } else {
1039    0
1040  };
1041  let e = if line == end.line {
1042    end.col.min(line_len)
1043  } else {
1044    line_len
1045  };
1046  let e = e.max(s);
1047  let newline = line < end.line;
1048  if e == s && !newline {
1049    return None;
1050  }
1051  Some((s, e, newline))
1052}
1053
1054/// Content-space y -> line row, clamping negatives to zero. Rows share one
1055/// fixed height, so this stays uniform math; columns go through shaping.
1056fn hit_line(y: f32, line_h: f32) -> usize {
1057  (y / line_h).floor().max(0.0) as usize
1058}
1059
1060/// Byte offset of char column `col` in `line`, clamped to the line end.
1061fn byte_for_col(line: &str, col: usize) -> usize {
1062  line
1063    .char_indices()
1064    .nth(col)
1065    .map(|(i, _)| i)
1066    .unwrap_or(line.len())
1067}
1068
1069/// Char column of byte offset `byte` in `line`. Boundary-safe: offsets inside
1070/// a multi-byte char count as the column of that char.
1071fn col_for_byte(line: &str, byte: usize) -> usize {
1072  line.char_indices().take_while(|&(i, _)| i < byte).count()
1073}
1074
1075/// Adjust a scroll offset (0 or negative, more negative = scrolled further)
1076/// so the content range `top..bottom` is inside a `view`-long viewport.
1077fn scroll_adjust(offset: f32, view: f32, top: f32, bottom: f32) -> f32 {
1078  let mut adjusted = offset;
1079  if bottom + adjusted > view {
1080    adjusted = view - bottom;
1081  }
1082  if top + adjusted < 0.0 {
1083    adjusted = -top;
1084  }
1085  adjusted
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090  use super::*;
1091
1092  fn total(spans: &[(usize, Option<TokenKind>)]) -> usize {
1093    spans.iter().map(|(len, _)| len).sum()
1094  }
1095
1096  #[test]
1097  fn spans_cover_the_line_exactly() {
1098    let tokens = vec![(2..5, TokenKind::Keyword), (7..9, TokenKind::Number)];
1099    let s = spans(10, &tokens);
1100    assert_eq!(
1101      s,
1102      vec![
1103        (2, None),
1104        (3, Some(TokenKind::Keyword)),
1105        (2, None),
1106        (2, Some(TokenKind::Number)),
1107        (1, None),
1108      ]
1109    );
1110    assert_eq!(total(&s), 10);
1111  }
1112
1113  #[test]
1114  fn spans_with_no_tokens_is_one_gap() {
1115    assert_eq!(spans(4, &[]), vec![(4, None)]);
1116    assert!(spans(0, &[]).is_empty());
1117  }
1118
1119  #[test]
1120  fn spans_clamp_overlap_and_overflow() {
1121    // Overlapping ranges never double-cover bytes...
1122    let tokens = vec![(0..6, TokenKind::Keyword), (4..8, TokenKind::Number)];
1123    let s = spans(10, &tokens);
1124    assert_eq!(total(&s), 10);
1125    // ...and ranges past the end clamp to it.
1126    let tokens = vec![(8..20, TokenKind::Comment)];
1127    let s = spans(10, &tokens);
1128    assert_eq!(s, vec![(8, None), (2, Some(TokenKind::Comment))]);
1129  }
1130
1131  #[test]
1132  fn spans_token_flush_to_both_edges() {
1133    let tokens = vec![(0..10, TokenKind::Comment)];
1134    assert_eq!(spans(10, &tokens), vec![(10, Some(TokenKind::Comment))]);
1135  }
1136
1137  fn at(line: usize, col: usize) -> Pos {
1138    Pos::new(line, col)
1139  }
1140
1141  #[test]
1142  fn selection_on_a_single_line() {
1143    let sel = line_selection(at(1, 2), at(1, 5), 1, 8);
1144    assert_eq!(sel, Some((2, 5, false)));
1145    assert_eq!(line_selection(at(1, 2), at(1, 5), 0, 8), None);
1146    assert_eq!(line_selection(at(1, 2), at(1, 5), 2, 8), None);
1147  }
1148
1149  #[test]
1150  fn selection_across_lines() {
1151    // First line: from start.col to the end, plus the newline cell.
1152    assert_eq!(line_selection(at(0, 3), at(2, 2), 0, 6), Some((3, 6, true)));
1153    // Middle line: everything, plus the newline cell.
1154    assert_eq!(line_selection(at(0, 3), at(2, 2), 1, 4), Some((0, 4, true)));
1155    // Last line: from col 0 to end.col.
1156    assert_eq!(
1157      line_selection(at(0, 3), at(2, 2), 2, 6),
1158      Some((0, 2, false))
1159    );
1160  }
1161
1162  #[test]
1163  fn selection_on_an_empty_middle_line_shows_the_newline() {
1164    assert_eq!(line_selection(at(0, 0), at(2, 1), 1, 0), Some((0, 0, true)));
1165  }
1166
1167  #[test]
1168  fn selection_cols_clamp_to_line_len() {
1169    assert_eq!(line_selection(at(0, 10), at(0, 20), 0, 5), None); // both past end
1170    assert_eq!(
1171      line_selection(at(0, 2), at(0, 20), 0, 5),
1172      Some((2, 5, false))
1173    );
1174  }
1175
1176  #[test]
1177  fn hit_line_maps_and_clamps() {
1178    assert_eq!(hit_line(0.0, 20.0), 0);
1179    assert_eq!(hit_line(45.0, 20.0), 2);
1180    assert_eq!(hit_line(-10.0, 20.0), 0); // padding clicks above the text
1181  }
1182
1183  #[test]
1184  fn byte_for_col_handles_multibyte_chars() {
1185    assert_eq!(byte_for_col("abc", 0), 0);
1186    assert_eq!(byte_for_col("abc", 2), 2);
1187    assert_eq!(byte_for_col("abc", 9), 3); // clamps to the line end
1188                                           // "日本語abc": each CJK char is 3 bytes.
1189    assert_eq!(byte_for_col("日本語abc", 1), 3);
1190    assert_eq!(byte_for_col("日本語abc", 3), 9);
1191    assert_eq!(byte_for_col("日本語abc", 4), 10);
1192  }
1193
1194  #[test]
1195  fn col_for_byte_inverts_byte_for_col() {
1196    let line = "日本語abc";
1197    for col in 0..=6 {
1198      assert_eq!(col_for_byte(line, byte_for_col(line, col)), col);
1199    }
1200    assert_eq!(col_for_byte(line, 999), 6); // past the end
1201    assert_eq!(col_for_byte("", 0), 0);
1202  }
1203
1204  #[test]
1205  fn scroll_adjust_reveals_the_target() {
1206    // Already visible: unchanged.
1207    assert_eq!(scroll_adjust(-10.0, 100.0, 20.0, 40.0), -10.0);
1208    // Above the viewport: scroll up to the top edge.
1209    assert_eq!(scroll_adjust(-50.0, 100.0, 20.0, 40.0), -20.0);
1210    // Below the viewport: scroll down to the bottom edge.
1211    assert_eq!(scroll_adjust(0.0, 100.0, 150.0, 170.0), -70.0);
1212    // Taller than the viewport: the top wins.
1213    assert_eq!(scroll_adjust(0.0, 50.0, 100.0, 200.0), -100.0);
1214  }
1215}