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}