iced_code_editor/canvas_editor/mod.rs
1//! Canvas-based text editor widget for maximum performance.
2//!
3//! This module provides a custom Canvas widget that handles all text rendering
4//! and input directly, bypassing Iced's higher-level widgets for optimal speed.
5
6use iced::widget::operation::{RelativeOffset, snap_to};
7use iced::widget::{Id, canvas};
8use std::time::Instant;
9
10use crate::i18n::Translations;
11use crate::text_buffer::TextBuffer;
12use crate::theme::Style;
13pub use history::CommandHistory;
14
15// Re-export submodules
16mod canvas_impl;
17mod clipboard;
18pub mod command;
19mod cursor;
20pub mod history;
21mod search;
22mod search_dialog;
23mod selection;
24mod update;
25mod view;
26mod wrapping;
27
28/// Canvas-based text editor constants
29pub(crate) const FONT_SIZE: f32 = 14.0;
30pub(crate) const LINE_HEIGHT: f32 = 20.0;
31pub(crate) const CHAR_WIDTH: f32 = 8.4; // Monospace character width
32pub(crate) const GUTTER_WIDTH: f32 = 60.0;
33pub(crate) const CURSOR_BLINK_INTERVAL: std::time::Duration =
34 std::time::Duration::from_millis(530);
35
36/// Canvas-based high-performance text editor.
37pub struct CodeEditor {
38 /// Text buffer
39 pub(crate) buffer: TextBuffer,
40 /// Cursor position (line, column)
41 pub(crate) cursor: (usize, usize),
42 /// Scroll offset in pixels
43 pub(crate) scroll_offset: f32,
44 /// Editor theme style
45 pub(crate) style: Style,
46 /// Syntax highlighting language
47 pub(crate) syntax: String,
48 /// Last cursor blink time
49 pub(crate) last_blink: Instant,
50 /// Cursor visible state
51 pub(crate) cursor_visible: bool,
52 /// Selection start (if any)
53 pub(crate) selection_start: Option<(usize, usize)>,
54 /// Selection end (if any) - cursor position during selection
55 pub(crate) selection_end: Option<(usize, usize)>,
56 /// Mouse is currently dragging for selection
57 pub(crate) is_dragging: bool,
58 /// Cache for canvas rendering
59 pub(crate) cache: canvas::Cache,
60 /// Scrollable ID for programmatic scrolling
61 pub(crate) scrollable_id: Id,
62 /// Current viewport scroll position (Y offset)
63 pub(crate) viewport_scroll: f32,
64 /// Viewport height (visible area)
65 pub(crate) viewport_height: f32,
66 /// Viewport width (visible area)
67 pub(crate) viewport_width: f32,
68 /// Command history for undo/redo
69 pub(crate) history: CommandHistory,
70 /// Whether we're currently grouping commands (for smart undo)
71 pub(crate) is_grouping: bool,
72 /// Line wrapping enabled
73 pub(crate) wrap_enabled: bool,
74 /// Wrap column (None = wrap at viewport width)
75 pub(crate) wrap_column: Option<usize>,
76 /// Search state
77 pub(crate) search_state: search::SearchState,
78 /// Translations for UI text
79 pub(crate) translations: Translations,
80 /// Whether search/replace functionality is enabled
81 pub(crate) search_replace_enabled: bool,
82}
83
84/// Messages emitted by the code editor
85#[derive(Debug, Clone)]
86pub enum Message {
87 /// Character typed
88 CharacterInput(char),
89 /// Backspace pressed
90 Backspace,
91 /// Delete pressed
92 Delete,
93 /// Enter pressed
94 Enter,
95 /// Tab pressed (inserts 4 spaces)
96 Tab,
97 /// Arrow key pressed (direction, shift_pressed)
98 ArrowKey(ArrowDirection, bool),
99 /// Mouse clicked at position
100 MouseClick(iced::Point),
101 /// Mouse drag for selection
102 MouseDrag(iced::Point),
103 /// Mouse released
104 MouseRelease,
105 /// Copy selected text (Ctrl+C)
106 Copy,
107 /// Paste text from clipboard (Ctrl+V)
108 Paste(String),
109 /// Delete selected text (Shift+Delete)
110 DeleteSelection,
111 /// Request redraw for cursor blink
112 Tick,
113 /// Page Up pressed
114 PageUp,
115 /// Page Down pressed
116 PageDown,
117 /// Home key pressed (move to start of line, shift_pressed)
118 Home(bool),
119 /// End key pressed (move to end of line, shift_pressed)
120 End(bool),
121 /// Ctrl+Home pressed (move to start of document)
122 CtrlHome,
123 /// Ctrl+End pressed (move to end of document)
124 CtrlEnd,
125 /// Viewport scrolled - track scroll position
126 Scrolled(iced::widget::scrollable::Viewport),
127 /// Undo last operation (Ctrl+Z)
128 Undo,
129 /// Redo last undone operation (Ctrl+Y)
130 Redo,
131 /// Open search dialog (Ctrl+F)
132 OpenSearch,
133 /// Open search and replace dialog (Ctrl+H)
134 OpenSearchReplace,
135 /// Close search dialog (Escape)
136 CloseSearch,
137 /// Search query text changed
138 SearchQueryChanged(String),
139 /// Replace text changed
140 ReplaceQueryChanged(String),
141 /// Toggle case sensitivity
142 ToggleCaseSensitive,
143 /// Find next match (F3)
144 FindNext,
145 /// Find previous match (Shift+F3)
146 FindPrevious,
147 /// Replace current match
148 ReplaceNext,
149 /// Replace all matches
150 ReplaceAll,
151 /// Tab pressed in search dialog (cycle forward)
152 SearchDialogTab,
153 /// Shift+Tab pressed in search dialog (cycle backward)
154 SearchDialogShiftTab,
155}
156
157/// Arrow key directions
158#[derive(Debug, Clone, Copy)]
159pub enum ArrowDirection {
160 Up,
161 Down,
162 Left,
163 Right,
164}
165
166impl CodeEditor {
167 /// Creates a new canvas-based text editor.
168 ///
169 /// # Arguments
170 ///
171 /// * `content` - Initial text content
172 /// * `syntax` - Syntax highlighting language (e.g., "py", "lua", "rs")
173 ///
174 /// # Returns
175 ///
176 /// A new `CodeEditor` instance
177 pub fn new(content: &str, syntax: &str) -> Self {
178 Self {
179 buffer: TextBuffer::new(content),
180 cursor: (0, 0),
181 scroll_offset: 0.0,
182 style: crate::theme::from_iced_theme(&iced::Theme::TokyoNightStorm),
183 syntax: syntax.to_string(),
184 last_blink: Instant::now(),
185 cursor_visible: true,
186 selection_start: None,
187 selection_end: None,
188 is_dragging: false,
189 cache: canvas::Cache::default(),
190 scrollable_id: Id::unique(),
191 viewport_scroll: 0.0,
192 viewport_height: 600.0, // Default, will be updated
193 viewport_width: 800.0, // Default, will be updated
194 history: CommandHistory::new(100),
195 is_grouping: false,
196 wrap_enabled: true,
197 wrap_column: None,
198 search_state: search::SearchState::new(),
199 translations: Translations::default(),
200 search_replace_enabled: true,
201 }
202 }
203
204 /// Returns the current text content as a string.
205 ///
206 /// # Returns
207 ///
208 /// The complete text content of the editor
209 pub fn content(&self) -> String {
210 self.buffer.to_string()
211 }
212
213 /// Sets the viewport height for the editor.
214 ///
215 /// This determines the minimum height of the canvas, ensuring proper
216 /// background rendering even when content is smaller than the viewport.
217 ///
218 /// # Arguments
219 ///
220 /// * `height` - The viewport height in pixels
221 ///
222 /// # Returns
223 ///
224 /// Self for method chaining
225 ///
226 /// # Example
227 ///
228 /// ```
229 /// use iced_code_editor::CodeEditor;
230 ///
231 /// let editor = CodeEditor::new("fn main() {}", "rs")
232 /// .with_viewport_height(500.0);
233 /// ```
234 #[must_use]
235 pub fn with_viewport_height(mut self, height: f32) -> Self {
236 self.viewport_height = height;
237 self
238 }
239
240 /// Sets the theme style for the editor.
241 ///
242 /// # Arguments
243 ///
244 /// * `style` - The style to apply to the editor
245 ///
246 /// # Example
247 ///
248 /// ```
249 /// use iced_code_editor::{CodeEditor, theme};
250 ///
251 /// let mut editor = CodeEditor::new("fn main() {}", "rs");
252 /// editor.set_theme(theme::from_iced_theme(&iced::Theme::TokyoNightStorm));
253 /// ```
254 pub fn set_theme(&mut self, style: Style) {
255 self.style = style;
256 self.cache.clear(); // Force redraw with new theme
257 }
258
259 /// Sets the language for UI translations.
260 ///
261 /// This changes the language used for all UI text elements in the editor,
262 /// including search dialog tooltips, placeholders, and labels.
263 ///
264 /// # Arguments
265 ///
266 /// * `language` - The language to use for UI text
267 ///
268 /// # Example
269 ///
270 /// ```
271 /// use iced_code_editor::{CodeEditor, Language};
272 ///
273 /// let mut editor = CodeEditor::new("fn main() {}", "rs");
274 /// editor.set_language(Language::French);
275 /// ```
276 pub fn set_language(&mut self, language: crate::i18n::Language) {
277 self.translations.set_language(language);
278 self.cache.clear(); // Force UI redraw
279 }
280
281 /// Returns the current UI language.
282 ///
283 /// # Returns
284 ///
285 /// The current language setting
286 ///
287 /// # Example
288 ///
289 /// ```
290 /// use iced_code_editor::{CodeEditor, Language};
291 ///
292 /// let editor = CodeEditor::new("fn main() {}", "rs");
293 /// assert_eq!(editor.language(), Language::English);
294 /// ```
295 #[must_use]
296 pub const fn language(&self) -> crate::i18n::Language {
297 self.translations.language()
298 }
299
300 /// Resets the editor with new content.
301 ///
302 /// This method replaces the buffer content and resets all editor state
303 /// (cursor position, selection, scroll, history) to initial values.
304 /// Use this instead of creating a new `CodeEditor` instance to ensure
305 /// proper widget tree updates in iced.
306 ///
307 /// Returns a `Task` that scrolls the editor to the top, which also
308 /// forces a redraw of the canvas.
309 ///
310 /// # Arguments
311 ///
312 /// * `content` - The new text content
313 ///
314 /// # Returns
315 ///
316 /// A `Task<Message>` that should be returned from your update function
317 ///
318 /// # Example
319 ///
320 /// ```ignore
321 /// use iced_code_editor::CodeEditor;
322 ///
323 /// let mut editor = CodeEditor::new("initial content", "lua");
324 /// // Later, reset with new content and get the task
325 /// let task = editor.reset("new content");
326 /// // Return task.map(YourMessage::Editor) from your update function
327 /// ```
328 pub fn reset(&mut self, content: &str) -> iced::Task<Message> {
329 self.buffer = TextBuffer::new(content);
330 self.cursor = (0, 0);
331 self.scroll_offset = 0.0;
332 self.selection_start = None;
333 self.selection_end = None;
334 self.is_dragging = false;
335 self.viewport_scroll = 0.0;
336 self.history = CommandHistory::new(100);
337 self.is_grouping = false;
338 self.last_blink = Instant::now();
339 self.cursor_visible = true;
340 // Create a new cache to ensure complete redraw (clear() is not sufficient
341 // when new content is smaller than previous content)
342 self.cache = canvas::Cache::default();
343
344 // Scroll to top to force a redraw
345 snap_to(self.scrollable_id.clone(), RelativeOffset::START)
346 }
347
348 /// Resets the cursor blink animation.
349 pub(crate) fn reset_cursor_blink(&mut self) {
350 self.last_blink = Instant::now();
351 self.cursor_visible = true;
352 }
353
354 /// Refreshes search matches after buffer modification.
355 ///
356 /// Should be called after any operation that modifies the buffer.
357 /// If search is active, recalculates matches and selects the one
358 /// closest to the current cursor position.
359 pub(crate) fn refresh_search_matches_if_needed(&mut self) {
360 if self.search_state.is_open && !self.search_state.query.is_empty() {
361 // Recalculate matches with current query
362 self.search_state.update_matches(&self.buffer);
363
364 // Select match closest to cursor to maintain context
365 self.search_state.select_match_near_cursor(self.cursor);
366 }
367 }
368
369 /// Returns whether the editor has unsaved changes.
370 ///
371 /// # Returns
372 ///
373 /// `true` if there are unsaved modifications, `false` otherwise
374 pub fn is_modified(&self) -> bool {
375 self.history.is_modified()
376 }
377
378 /// Marks the current state as saved.
379 ///
380 /// Call this after successfully saving the file to reset the modified state.
381 pub fn mark_saved(&mut self) {
382 self.history.mark_saved();
383 }
384
385 /// Returns whether undo is available.
386 pub fn can_undo(&self) -> bool {
387 self.history.can_undo()
388 }
389
390 /// Returns whether redo is available.
391 pub fn can_redo(&self) -> bool {
392 self.history.can_redo()
393 }
394
395 /// Sets whether line wrapping is enabled.
396 ///
397 /// When enabled, long lines will wrap at the viewport width or at a
398 /// configured column width.
399 ///
400 /// # Arguments
401 ///
402 /// * `enabled` - Whether to enable line wrapping
403 ///
404 /// # Example
405 ///
406 /// ```
407 /// use iced_code_editor::CodeEditor;
408 ///
409 /// let mut editor = CodeEditor::new("fn main() {}", "rs");
410 /// editor.set_wrap_enabled(false); // Disable wrapping
411 /// ```
412 pub fn set_wrap_enabled(&mut self, enabled: bool) {
413 if self.wrap_enabled != enabled {
414 self.wrap_enabled = enabled;
415 self.cache.clear(); // Force redraw
416 }
417 }
418
419 /// Returns whether line wrapping is enabled.
420 ///
421 /// # Returns
422 ///
423 /// `true` if line wrapping is enabled, `false` otherwise
424 pub fn wrap_enabled(&self) -> bool {
425 self.wrap_enabled
426 }
427
428 /// Enables or disables the search/replace functionality.
429 ///
430 /// When disabled, search/replace keyboard shortcuts (Ctrl+F, Ctrl+H, F3)
431 /// will be ignored. If the search dialog is currently open, it will be closed.
432 ///
433 /// # Arguments
434 ///
435 /// * `enabled` - Whether to enable search/replace functionality
436 ///
437 /// # Example
438 ///
439 /// ```
440 /// use iced_code_editor::CodeEditor;
441 ///
442 /// let mut editor = CodeEditor::new("fn main() {}", "rs");
443 /// editor.set_search_replace_enabled(false); // Disable search/replace
444 /// ```
445 pub fn set_search_replace_enabled(&mut self, enabled: bool) {
446 self.search_replace_enabled = enabled;
447 if !enabled && self.search_state.is_open {
448 self.search_state.close();
449 }
450 }
451
452 /// Returns whether search/replace functionality is enabled.
453 ///
454 /// # Returns
455 ///
456 /// `true` if search/replace is enabled, `false` otherwise
457 pub fn search_replace_enabled(&self) -> bool {
458 self.search_replace_enabled
459 }
460
461 /// Sets the line wrapping with builder pattern.
462 ///
463 /// # Arguments
464 ///
465 /// * `enabled` - Whether to enable line wrapping
466 ///
467 /// # Returns
468 ///
469 /// Self for method chaining
470 ///
471 /// # Example
472 ///
473 /// ```
474 /// use iced_code_editor::CodeEditor;
475 ///
476 /// let editor = CodeEditor::new("fn main() {}", "rs")
477 /// .with_wrap_enabled(false);
478 /// ```
479 #[must_use]
480 pub fn with_wrap_enabled(mut self, enabled: bool) -> Self {
481 self.wrap_enabled = enabled;
482 self
483 }
484
485 /// Sets the wrap column (fixed width wrapping).
486 ///
487 /// When set to `Some(n)`, lines will wrap at column `n`.
488 /// When set to `None`, lines will wrap at the viewport width.
489 ///
490 /// # Arguments
491 ///
492 /// * `column` - The column to wrap at, or None for viewport-based wrapping
493 ///
494 /// # Example
495 ///
496 /// ```
497 /// use iced_code_editor::CodeEditor;
498 ///
499 /// let editor = CodeEditor::new("fn main() {}", "rs")
500 /// .with_wrap_column(Some(80)); // Wrap at 80 characters
501 /// ```
502 #[must_use]
503 pub fn with_wrap_column(mut self, column: Option<usize>) -> Self {
504 self.wrap_column = column;
505 self
506 }
507}