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