Skip to main content

twrite_gpui/editor/
mod.rs

1mod clipboard;
2mod context_menu;
3mod geometry;
4mod keyboard;
5mod mouse;
6mod render;
7
8use std::ops::Range;
9use std::sync::Arc;
10
11use gpui::{Bounds, Context, FocusHandle, Font, Pixels, Point, SharedString, Task};
12use twrite_core::{
13    ContextMenuState, CursorStyle, EditorBuffer, EditorHook, HookEffect, PromptState,
14    SearchSnapshot, Selection, SyntaxHighlighter,
15};
16
17use crate::{config::EditorConfig, fps::FrameStats, layout_cache::LayoutCache, theme::EditorTheme};
18
19/// The main GPUI text editor view and controller.
20pub struct Editor {
21    /// The underlying text buffer managing document contents and undo/redo history.
22    pub buffer: EditorBuffer,
23    /// Visual color palette for canvas background, text, cursor, and syntax tokens.
24    pub theme: EditorTheme,
25    /// Display and layout configurations (font size, line height, line numbers, wrapping).
26    pub config: EditorConfig,
27    /// Extensible hooks chain intercepting keystrokes, edits, and selection changes.
28    pub hooks: Vec<Box<dyn EditorHook>>,
29    /// Active syntax highlighter computing semantic and direct style spans.
30    pub highlighter: Option<Arc<dyn SyntaxHighlighter>>,
31    /// Revision bumped on every highlighter swap so [`LayoutCache`] epochs bust
32    /// even when the buffer version is unchanged (e.g. conceal-mode cycling).
33    pub highlighter_rev: u64,
34    /// Per-version cache of highlight/conceal/link inputs, shared by prepaint
35    /// and hit-testing so each row is parsed once per epoch, not per frame.
36    pub layout_cache: LayoutCache,
37    /// Bold/italic face availability from the last prepaint probe (`None` before first paint).
38    pub face_availability: Option<FaceAvailability>,
39    /// Base family picked by candidate auto-select (`None` before first paint,
40    /// or when no candidate has emphasis faces).
41    pub selected_font_family: Option<SharedString>,
42    /// Inputs the face probe last ran against: explicit families + host font.
43    /// Re-probed on change only.
44    pub face_probe_key: Option<(Option<SharedString>, Option<SharedString>, Font)>,
45    /// Focus handle for keyboard input tracking within GPUI.
46    pub focus_handle: FocusHandle,
47    /// First visible row index in the viewport.
48    pub scroll_row: usize,
49    /// Active selection range, if any.
50    pub selection: Option<Selection>,
51    /// Active visual cursor style (Bar, Block, Underline, Hidden).
52    pub cursor_style: CursorStyle,
53    /// Whether the visual cursor is currently visible in its blink cycle.
54    pub cursor_visible: bool,
55    /// Background timer task driving cursor blinking.
56    blink_task: Option<Task<()>>,
57    /// Whether the user is currently mouse-drag selecting text.
58    pub is_selecting: bool,
59    /// Active selection granularity for drag selection.
60    pub selection_granularity: SelectionGranularity,
61    /// Anchor range for multi-click drag selection expansion.
62    pub drag_initial_range: Option<Range<usize>>,
63    /// Whether the mouse cursor is currently hovering over an interactive task checkbox.
64    pub is_hovering_task: bool,
65    /// Target URL if the mouse cursor is currently hovering over a hyperlink.
66    pub hovered_link: Option<String>,
67    /// Last rendered bounds in window pixel coordinates.
68    pub last_bounds: Option<Bounds<Pixels>>,
69    /// Last rendered cursor position in window pixel coordinates, computed during canvas prepaint.
70    pub last_cursor_pixel: Option<Point<Pixels>>,
71    /// Layout metrics and screen coordinates of currently visible lines, cached during prepaint.
72    pub visible_lines: Vec<VisibleLineLayout>,
73    /// Rolling frame-rate samples, recorded once per canvas prepaint.
74    ///
75    /// Powers the [`crate::fps_badge`] testing HUD: it updates whenever the
76    /// editor repaints (typing, selection drags, scrolling) and freezes when
77    /// idle, with no forced repaints of its own.
78    pub frame_stats: FrameStats,
79    /// Shared headless prompt / input-box state, passed to hooks via
80    /// [`twrite_core::HookContext`] and rendered by [`crate::prompt_bar::PromptBar`] when open.
81    pub prompt: PromptState,
82    /// Headless right-click menu state (items merged from defaults + hooks).
83    pub context_menu: ContextMenuState,
84    /// Window-pixel anchor where the menu was opened; clamped at render time.
85    pub context_menu_anchor: Option<Point<Pixels>>,
86    /// Keyboard-selected row inside the open menu, if any.
87    pub context_menu_selected: Option<usize>,
88    /// App-level requests queued by hooks; [`Self::flush_effects`] executes
89    /// file effects inline, hosts drain the rest via [`Self::take_effects`].
90    pub pending_effects: Vec<HookEffect>,
91    /// File path for `:w`-style saves (`Save { path: None }`); set by
92    /// [`Self::load_file`].
93    pub file_path: Option<std::path::PathBuf>,
94    /// Last synced search matches for the highlight-all wash.
95    pub search_matches: Vec<Range<usize>>,
96    /// Whether the highlight-all wash is enabled (from the search snapshot).
97    pub search_highlight_all: bool,
98}
99
100/// Selection granularity when mouse-drag selecting text.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
102pub enum SelectionGranularity {
103    /// Character-by-character selection.
104    #[default]
105    Character,
106    /// Word-by-word selection (initiated by double-click).
107    Word,
108    /// Line-by-line selection (initiated by triple-click).
109    Line,
110}
111
112/// A hyperlink visible on screen with its screen pixel bounds and target URL.
113#[derive(Debug, Clone)]
114pub struct VisibleLink {
115    /// Screen pixel bounds of the clickable link label.
116    pub bounds: Bounds<Pixels>,
117    /// The destination URL.
118    pub url: String,
119}
120
121/// Availability of bold/italic font faces for the editor's base font.
122///
123/// Computed during prepaint by comparing resolved `FontId`s: a missing face
124/// silently falls back to the regular face, which would make emphasis
125/// invisible. Host apps can surface this (e.g. in a status bar) and point
126/// users at `EditorConfig::font_family`.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
128pub struct FaceAvailability {
129    /// Whether bold text resolves to a distinct face.
130    pub bold: bool,
131    /// Whether italic text resolves to a distinct face.
132    pub italic: bool,
133}
134
135/// Layout metrics and screen coordinates for a visible line, cached during prepaint for instant hit testing.
136#[derive(Debug, Clone)]
137pub struct VisibleLineLayout {
138    /// Zero-based buffer row index.
139    pub row: usize,
140    /// Top Y position in window coordinates.
141    pub top: Pixels,
142    /// Bottom Y position in window coordinates.
143    pub bottom: Pixels,
144    /// Starting byte offset in the buffer.
145    pub line_start_byte: usize,
146    /// Length of the line text in bytes.
147    pub line_len_bytes: usize,
148    /// Left X position where text starts.
149    pub text_origin_x: Pixels,
150    /// Vertical line height.
151    pub line_height: Pixels,
152    /// Whether this is a task list line with a rendered checkbox.
153    pub is_task_checkbox: bool,
154    /// Left X position of the checkbox box.
155    pub checkbox_box_x: Pixels,
156    /// Task state for this line, if the highlighter reports one.
157    /// `Some(false)` = unchecked, `Some(true)` = checked.
158    pub task_state: Option<bool>,
159    /// Hyperlinks located on this line.
160    pub links: Vec<VisibleLink>,
161}
162
163impl Editor {
164    /// Creates a new editor entity with `initial_text`.
165    pub fn new(initial_text: &str, cx: &mut Context<Self>) -> Self {
166        let config = EditorConfig::default();
167        let cursor_style = if config.block_cursor {
168            CursorStyle::Block
169        } else {
170            CursorStyle::Bar
171        };
172        let mut ed = Self {
173            buffer: EditorBuffer::new(initial_text),
174            theme: EditorTheme::default(),
175            config,
176            hooks: Vec::new(),
177            highlighter: None,
178            highlighter_rev: 0,
179            layout_cache: LayoutCache::new(),
180            face_availability: None,
181            selected_font_family: None,
182            face_probe_key: None,
183            focus_handle: cx.focus_handle(),
184            scroll_row: 0,
185            selection: None,
186            cursor_style,
187            cursor_visible: true,
188            blink_task: None,
189            is_selecting: false,
190            selection_granularity: SelectionGranularity::Character,
191            drag_initial_range: None,
192            is_hovering_task: false,
193            hovered_link: None,
194            last_bounds: None,
195            last_cursor_pixel: None,
196            visible_lines: Vec::new(),
197            frame_stats: FrameStats::new(),
198            prompt: PromptState::new(),
199            context_menu: ContextMenuState::new(),
200            context_menu_anchor: None,
201            context_menu_selected: None,
202            pending_effects: Vec::new(),
203            file_path: None,
204            search_matches: Vec::new(),
205            search_highlight_all: false,
206        };
207        ed.reset_blink_cursor(cx);
208        ed
209    }
210
211    /// Resets the cursor blink cycle to visible and schedules periodic toggling.
212    pub fn reset_blink_cursor(&mut self, cx: &mut Context<Self>) {
213        self.cursor_visible = true;
214        drop(self.blink_task.take());
215        if !self.config.cursor_blink {
216            return;
217        }
218
219        self.blink_task = Some(cx.spawn(async move |this, cx| {
220            loop {
221                cx.background_executor()
222                    .timer(std::time::Duration::from_millis(500))
223                    .await;
224                let res = this.update(cx, |editor, cx| {
225                    if !editor.config.cursor_blink {
226                        editor.cursor_visible = true;
227                        false
228                    } else {
229                        editor.cursor_visible = !editor.cursor_visible;
230                        cx.notify();
231                        true
232                    }
233                });
234                match res {
235                    Ok(true) => {}
236                    _ => break,
237                }
238            }
239        }));
240    }
241
242    /// Sets whether cursor blinking is enabled and resets the blink cycle.
243    pub fn set_cursor_blink(&mut self, enabled: bool, cx: &mut Context<Self>) {
244        self.config.cursor_blink = enabled;
245        self.reset_blink_cursor(cx);
246        cx.notify();
247    }
248
249    /// Adds an editor hook to the execution chain.
250    pub fn add_hook(&mut self, hook: impl EditorHook) {
251        self.hooks.push(Box::new(hook));
252    }
253
254    /// Clears all registered editor hooks.
255    pub fn clear_hooks(&mut self) {
256        self.hooks.clear();
257    }
258
259    /// Returns the active status or mode text reported by registered hooks.
260    pub fn status_text(&self) -> Option<&str> {
261        self.hooks.iter().find_map(|h| h.status_text())
262    }
263
264    /// Returns true if the cursor is currently in block mode.
265    pub fn is_block_cursor(&self) -> bool {
266        self.cursor_style == CursorStyle::Block || self.config.block_cursor
267    }
268
269    /// Resolves the base font: explicit family, else auto-selected family, else host.
270    pub fn resolved_base_font(&self, host: &Font) -> Font {
271        self.config
272            .base_font(host, self.selected_font_family.as_ref())
273    }
274
275    /// Resolves the `Code`-span font (follows auto-select unless overridden).
276    pub fn resolved_code_font(&self, host: &Font) -> Font {
277        self.config
278            .code_font(host, self.selected_font_family.as_ref())
279    }
280
281    /// Sets the active syntax highlighter.
282    pub fn set_highlighter(&mut self, highlighter: impl SyntaxHighlighter) {
283        self.highlighter = Some(Arc::new(highlighter));
284        self.highlighter_rev = self.highlighter_rev.wrapping_add(1);
285        self.layout_cache.clear();
286    }
287
288    /// Clears the active syntax highlighter, reverting to plain text.
289    pub fn clear_highlighter(&mut self) {
290        self.highlighter = None;
291        self.highlighter_rev = self.highlighter_rev.wrapping_add(1);
292        self.layout_cache.clear();
293    }
294
295    /// Enables out-of-the-box CommonMark and GFM editing using `self.config.markdown`.
296    ///
297    /// Automatically configures [`twrite_core::MarkdownHighlighter`], [`twrite_core::AutoPairsHook`],
298    /// and [`twrite_core::MarkdownHook`].
299    #[cfg(feature = "markdown")]
300    pub fn enable_markdown(&mut self) {
301        self.enable_markdown_with_config(self.config.markdown);
302    }
303
304    /// Enables out-of-the-box CommonMark and GFM editing with custom Markdown configuration.
305    #[cfg(feature = "markdown")]
306    pub fn enable_markdown_with_config(&mut self, config: twrite_core::markdown::MarkdownConfig) {
307        use twrite_core::{AutoPairsHook, MarkdownHighlighter, MarkdownHook};
308        self.config.markdown = config;
309        self.set_highlighter(MarkdownHighlighter::with_config(config));
310        self.add_hook(AutoPairsHook::new());
311        self.add_hook(MarkdownHook::with_config(config));
312    }
313
314    /// Loads document text from a file into the editor, resetting cursor and undo history.
315    pub fn load_file<P: AsRef<std::path::Path>>(
316        &mut self,
317        path: P,
318    ) -> Result<(), twrite_core::EditorError> {
319        let new_buffer = EditorBuffer::from_file(path.as_ref())?;
320        self.buffer = new_buffer;
321        self.scroll_row = 0;
322        self.selection = None;
323        self.file_path = Some(path.as_ref().to_path_buf());
324        self.layout_cache.clear();
325        Ok(())
326    }
327
328    /// Executes queued file effects (`Save` / `Load`) inline, keeping
329    /// app-level effects (`Quit` / `Message`) queued for the host to drain
330    /// via [`Self::take_effects`]. Runs automatically after input events.
331    pub fn flush_effects(&mut self) {
332        let queued = std::mem::take(&mut self.pending_effects);
333        let mut unhandled = Vec::new();
334        for effect in queued {
335            match effect {
336                HookEffect::Save { path } => {
337                    let target = path
338                        .map(std::path::PathBuf::from)
339                        .or_else(|| self.file_path.clone());
340                    match target {
341                        Some(p) => {
342                            if let Err(e) = self.save_file(&p) {
343                                unhandled.push(HookEffect::Message(format!("save failed: {e}")));
344                            }
345                        }
346                        None => {
347                            unhandled.push(HookEffect::Message("E32: No file name".to_string()));
348                        }
349                    }
350                }
351                HookEffect::Load { path } => match self.load_file(&path) {
352                    Ok(()) => {}
353                    Err(e) => unhandled.push(HookEffect::Message(format!("load failed: {e}"))),
354                },
355                other => unhandled.push(other),
356            }
357        }
358        self.pending_effects = unhandled;
359    }
360
361    /// Takes app-level effects (`Quit` / `Message`) left by [`Self::flush_effects`].
362    pub fn take_effects(&mut self) -> Vec<HookEffect> {
363        std::mem::take(&mut self.pending_effects)
364    }
365
366    /// Returns the live search-panel snapshot from the first hook that
367    /// provides one ([`twrite_core::SearchHook`]; composite hooks forward their own).
368    pub fn search_snapshot(&self) -> Option<SearchSnapshot> {
369        self.hooks.iter().find_map(|h| h.search_snapshot())
370    }
371
372    /// Refreshes [`Self::search_matches`] / [`Self::search_highlight_all`]
373    /// from the hook snapshot; clears both when no hook is active.
374    /// Runs automatically after key input (see [`Self::dispatch_key`]).
375    pub fn sync_search_state(&mut self) {
376        match self.search_snapshot() {
377            Some(snapshot) => {
378                self.search_matches = snapshot.matches;
379                self.search_highlight_all = snapshot.highlight_all;
380            }
381            None => {
382                self.search_matches.clear();
383                self.search_highlight_all = false;
384            }
385        }
386    }
387
388    /// Saves the current editor document contents to a file.
389    pub fn save_file<P: AsRef<std::path::Path>>(
390        &self,
391        path: P,
392    ) -> Result<(), twrite_core::EditorError> {
393        self.buffer.save_to_file(path)
394    }
395}