longcipher_leptos_components/components/editor/
state.rs

1//! Editor state management
2//!
3//! Centralized state for the editor component.
4
5use serde::{Deserialize, Serialize};
6
7use super::{
8    cursor::{Cursor, CursorPosition, CursorSet},
9    history::History,
10};
11
12/// Editor configuration options.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[allow(clippy::struct_excessive_bools)]
15pub struct EditorConfig {
16    /// Tab size in spaces
17    pub tab_size: usize,
18    /// Whether to insert spaces instead of tabs
19    pub insert_spaces: bool,
20    /// Whether word wrap is enabled
21    pub word_wrap: bool,
22    /// Whether to show line numbers
23    pub show_line_numbers: bool,
24    /// Whether to highlight the current line
25    pub highlight_current_line: bool,
26    /// Whether to show whitespace characters
27    pub show_whitespace: bool,
28    /// Whether bracket matching is enabled
29    pub match_brackets: bool,
30    /// Whether auto-indent is enabled
31    pub auto_indent: bool,
32    /// Whether auto-close brackets is enabled
33    pub auto_close_brackets: bool,
34    /// Font size in pixels
35    pub font_size: f32,
36    /// Line height multiplier (1.0 = same as font size)
37    pub line_height: f32,
38    /// Maximum line width (0 = no limit)
39    pub max_line_width: usize,
40    /// Whether the editor is read-only
41    pub read_only: bool,
42}
43
44impl Default for EditorConfig {
45    fn default() -> Self {
46        Self {
47            tab_size: 4,
48            insert_spaces: true,
49            word_wrap: true,
50            show_line_numbers: true,
51            highlight_current_line: true,
52            show_whitespace: false,
53            match_brackets: true,
54            auto_indent: true,
55            auto_close_brackets: true,
56            font_size: 14.0,
57            line_height: 1.5,
58            max_line_width: 0,
59            read_only: false,
60        }
61    }
62}
63
64/// The complete state of an editor instance.
65#[derive(Debug, Clone)]
66pub struct EditorState {
67    /// The document content
68    pub content: String,
69    /// Cursor positions (supports multi-cursor)
70    pub cursors: CursorSet,
71    /// Edit history for undo/redo
72    pub history: History,
73    /// Configuration
74    pub config: EditorConfig,
75    /// Content version (incremented on each change)
76    pub version: u64,
77    /// Whether the content has been modified since last save
78    pub is_modified: bool,
79    /// Current scroll position (line number)
80    pub scroll_line: usize,
81    /// Current scroll offset (pixels)
82    pub scroll_offset: f32,
83    /// Detected or explicitly set language
84    pub language: Option<String>,
85}
86
87impl Default for EditorState {
88    fn default() -> Self {
89        Self {
90            content: String::new(),
91            cursors: CursorSet::new(Cursor::zero()),
92            history: History::new(),
93            config: EditorConfig::default(),
94            version: 0,
95            is_modified: false,
96            scroll_line: 0,
97            scroll_offset: 0.0,
98            language: None,
99        }
100    }
101}
102
103impl EditorState {
104    /// Create a new editor state with the given content.
105    #[must_use]
106    pub fn new(content: impl Into<String>) -> Self {
107        Self {
108            content: content.into(),
109            ..Default::default()
110        }
111    }
112
113    /// Create with custom configuration.
114    #[must_use]
115    pub fn with_config(content: impl Into<String>, config: EditorConfig) -> Self {
116        Self {
117            content: content.into(),
118            config,
119            ..Default::default()
120        }
121    }
122
123    /// Get the current content.
124    #[must_use]
125    pub fn content(&self) -> &str {
126        &self.content
127    }
128
129    /// Set new content.
130    pub fn set_content(&mut self, content: impl Into<String>) {
131        let new_content = content.into();
132        if new_content != self.content {
133            // Save to history before modifying
134            self.history
135                .push(self.content.clone(), self.cursors.clone());
136            self.content = new_content;
137            self.version += 1;
138            self.is_modified = true;
139        }
140    }
141
142    /// Replace content without adding to history (for external updates).
143    pub fn replace_content(&mut self, content: impl Into<String>) {
144        self.content = content.into();
145        self.version += 1;
146    }
147
148    /// Get the primary cursor position.
149    #[must_use]
150    pub fn cursor_position(&self) -> CursorPosition {
151        self.cursors.primary().head
152    }
153
154    /// Set the primary cursor position.
155    pub fn set_cursor(&mut self, position: CursorPosition) {
156        self.cursors.primary_mut().move_to(position, false);
157    }
158
159    /// Set the cursor with selection.
160    pub fn set_cursor_with_selection(&mut self, head: CursorPosition, anchor: CursorPosition) {
161        let cursor = self.cursors.primary_mut();
162        cursor.head = head;
163        cursor.anchor = anchor;
164    }
165
166    /// Get the line count.
167    #[must_use]
168    pub fn line_count(&self) -> usize {
169        if self.content.is_empty() {
170            1
171        } else {
172            self.content.chars().filter(|&c| c == '\n').count() + 1
173        }
174    }
175
176    /// Get a specific line (0-indexed).
177    #[must_use]
178    pub fn get_line(&self, index: usize) -> Option<&str> {
179        self.content.lines().nth(index)
180    }
181
182    /// Insert text at the current cursor position.
183    pub fn insert(&mut self, text: &str) {
184        if self.config.read_only {
185            return;
186        }
187
188        let position = self.cursor_position();
189        if let Some(offset) = self.position_to_offset(position) {
190            self.history
191                .push(self.content.clone(), self.cursors.clone());
192
193            // Handle selection - delete selected text first
194            let cursor = self.cursors.primary();
195            if cursor.has_selection() {
196                let (start, end) = (
197                    self.position_to_offset(cursor.selection_start()),
198                    self.position_to_offset(cursor.selection_end()),
199                );
200                if let (Some(start), Some(end)) = (start, end) {
201                    self.content =
202                        format!("{}{}{}", &self.content[..start], text, &self.content[end..]);
203                    // Move cursor to end of inserted text
204                    let new_offset = start + text.len();
205                    if let Some(new_pos) = self.offset_to_position(new_offset) {
206                        self.set_cursor(new_pos);
207                    }
208                }
209            } else {
210                // No selection - just insert
211                self.content.insert_str(offset, text);
212                let new_offset = offset + text.len();
213                if let Some(new_pos) = self.offset_to_position(new_offset) {
214                    self.set_cursor(new_pos);
215                }
216            }
217
218            self.version += 1;
219            self.is_modified = true;
220        }
221    }
222
223    /// Delete the character before the cursor (backspace).
224    pub fn delete_backward(&mut self) {
225        if self.config.read_only {
226            return;
227        }
228
229        let cursor = self.cursors.primary();
230        if cursor.has_selection() {
231            self.delete_selection();
232            return;
233        }
234
235        let position = cursor.head;
236        if let Some(offset) = self.position_to_offset(position) {
237            if offset == 0 {
238                return;
239            }
240
241            self.history
242                .push(self.content.clone(), self.cursors.clone());
243
244            // Find the previous character boundary
245            let prev_offset = self.content[..offset]
246                .char_indices()
247                .last()
248                .map_or(0, |(i, _)| i);
249
250            self.content = format!(
251                "{}{}",
252                &self.content[..prev_offset],
253                &self.content[offset..]
254            );
255
256            if let Some(new_pos) = self.offset_to_position(prev_offset) {
257                self.set_cursor(new_pos);
258            }
259
260            self.version += 1;
261            self.is_modified = true;
262        }
263    }
264
265    /// Delete the character after the cursor (delete).
266    pub fn delete_forward(&mut self) {
267        if self.config.read_only {
268            return;
269        }
270
271        let cursor = self.cursors.primary();
272        if cursor.has_selection() {
273            self.delete_selection();
274            return;
275        }
276
277        let position = cursor.head;
278        if let Some(offset) = self.position_to_offset(position) {
279            if offset >= self.content.len() {
280                return;
281            }
282
283            self.history
284                .push(self.content.clone(), self.cursors.clone());
285
286            // Find the next character boundary
287            let next_offset = self.content[offset..]
288                .char_indices()
289                .nth(1)
290                .map_or(self.content.len(), |(i, _)| offset + i);
291
292            self.content = format!(
293                "{}{}",
294                &self.content[..offset],
295                &self.content[next_offset..]
296            );
297
298            self.version += 1;
299            self.is_modified = true;
300        }
301    }
302
303    /// Delete the current selection.
304    fn delete_selection(&mut self) {
305        let cursor = self.cursors.primary();
306        if !cursor.has_selection() {
307            return;
308        }
309
310        let start_pos = cursor.selection_start();
311        let end_pos = cursor.selection_end();
312
313        if let (Some(start), Some(end)) = (
314            self.position_to_offset(start_pos),
315            self.position_to_offset(end_pos),
316        ) {
317            self.history
318                .push(self.content.clone(), self.cursors.clone());
319
320            self.content = format!("{}{}", &self.content[..start], &self.content[end..]);
321            self.set_cursor(start_pos);
322
323            self.version += 1;
324            self.is_modified = true;
325        }
326    }
327
328    /// Undo the last change.
329    pub fn undo(&mut self) -> bool {
330        if let Some(entry) = self.history.undo(&self.content, &self.cursors) {
331            self.content = entry.content;
332            self.cursors = entry.cursors;
333            self.version += 1;
334            true
335        } else {
336            false
337        }
338    }
339
340    /// Redo the last undone change.
341    pub fn redo(&mut self) -> bool {
342        if let Some(entry) = self.history.redo(&self.content, &self.cursors) {
343            self.content = entry.content;
344            self.cursors = entry.cursors;
345            self.version += 1;
346            true
347        } else {
348            false
349        }
350    }
351
352    /// Check if undo is available.
353    #[must_use]
354    pub fn can_undo(&self) -> bool {
355        self.history.can_undo()
356    }
357
358    /// Check if redo is available.
359    #[must_use]
360    pub fn can_redo(&self) -> bool {
361        self.history.can_redo()
362    }
363
364    /// Mark the content as saved (clears modified flag).
365    pub fn mark_saved(&mut self) {
366        self.is_modified = false;
367    }
368
369    /// Convert a cursor position to a byte offset.
370    #[must_use]
371    pub fn position_to_offset(&self, position: CursorPosition) -> Option<usize> {
372        let mut current_line = 0;
373        let mut offset = 0;
374
375        for (i, ch) in self.content.char_indices() {
376            if current_line == position.line {
377                let line_start = i;
378                let mut col = 0;
379                for (j, c) in self.content[line_start..].char_indices() {
380                    if col == position.column {
381                        return Some(line_start + j);
382                    }
383                    if c == '\n' {
384                        break;
385                    }
386                    col += 1;
387                }
388                // Position at end of line
389                if col == position.column {
390                    return Some(
391                        line_start
392                            + self.content[line_start..]
393                                .find('\n')
394                                .unwrap_or(self.content.len() - line_start),
395                    );
396                }
397                return None;
398            }
399            if ch == '\n' {
400                current_line += 1;
401            }
402            offset = i + ch.len_utf8();
403        }
404
405        // Handle position at end of last line
406        if current_line == position.line && position.column == 0 {
407            return Some(offset);
408        }
409
410        None
411    }
412
413    /// Convert a byte offset to a cursor position.
414    #[must_use]
415    pub fn offset_to_position(&self, offset: usize) -> Option<CursorPosition> {
416        if offset > self.content.len() {
417            return None;
418        }
419
420        let mut line = 0;
421        let mut col = 0;
422
423        for (i, ch) in self.content.char_indices() {
424            if i >= offset {
425                return Some(CursorPosition::new(line, col));
426            }
427            if ch == '\n' {
428                line += 1;
429                col = 0;
430            } else {
431                col += 1;
432            }
433        }
434
435        // Position at end of content
436        Some(CursorPosition::new(line, col))
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_editor_state_new() {
446        let state = EditorState::new("Hello, World!");
447        assert_eq!(state.content(), "Hello, World!");
448        assert_eq!(state.line_count(), 1);
449        assert!(!state.is_modified);
450    }
451
452    #[test]
453    fn test_insert() {
454        let mut state = EditorState::new("");
455        state.insert("Hello");
456        assert_eq!(state.content(), "Hello");
457        assert!(state.is_modified);
458    }
459
460    #[test]
461    fn test_undo_redo() {
462        let mut state = EditorState::new("initial");
463        state.set_content("modified");
464
465        assert!(state.undo());
466        assert_eq!(state.content(), "initial");
467
468        assert!(state.redo());
469        assert_eq!(state.content(), "modified");
470    }
471
472    #[test]
473    fn test_position_offset_conversion() {
474        let state = EditorState::new("hello\nworld\nfoo");
475
476        assert_eq!(state.position_to_offset(CursorPosition::new(0, 0)), Some(0));
477        assert_eq!(state.position_to_offset(CursorPosition::new(1, 0)), Some(6));
478        assert_eq!(
479            state.position_to_offset(CursorPosition::new(2, 0)),
480            Some(12)
481        );
482
483        assert_eq!(state.offset_to_position(0), Some(CursorPosition::new(0, 0)));
484        assert_eq!(state.offset_to_position(6), Some(CursorPosition::new(1, 0)));
485    }
486}