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}