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}