longcipher_leptos_components/components/editor/
selection.rs

1//! Selection handling for the editor
2//!
3//! Manages text selection, selection ranges, and selection operations.
4
5use serde::{Deserialize, Serialize};
6
7use super::cursor::CursorPosition;
8
9/// A text selection range in the document.
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
11pub struct Selection {
12    /// Start position of the selection
13    pub start: CursorPosition,
14    /// End position of the selection
15    pub end: CursorPosition,
16}
17
18impl Selection {
19    /// Create a new selection range.
20    #[must_use]
21    pub const fn new(start: CursorPosition, end: CursorPosition) -> Self {
22        Self { start, end }
23    }
24
25    /// Create an empty selection at a position.
26    #[must_use]
27    pub const fn empty(position: CursorPosition) -> Self {
28        Self {
29            start: position,
30            end: position,
31        }
32    }
33
34    /// Check if the selection is empty (no text selected).
35    #[must_use]
36    pub fn is_empty(&self) -> bool {
37        self.start == self.end
38    }
39
40    /// Check if a position is within this selection.
41    #[must_use]
42    pub fn contains(&self, position: CursorPosition) -> bool {
43        let (min, max) = self.normalized();
44        position >= min && position < max
45    }
46
47    /// Get normalized start and end (start is always before end).
48    #[must_use]
49    pub fn normalized(&self) -> (CursorPosition, CursorPosition) {
50        if self.start.is_before(&self.end) {
51            (self.start, self.end)
52        } else {
53            (self.end, self.start)
54        }
55    }
56
57    /// Check if this selection overlaps with another.
58    #[must_use]
59    pub fn overlaps(&self, other: &Self) -> bool {
60        let (self_start, self_end) = self.normalized();
61        let (other_start, other_end) = other.normalized();
62
63        !(self_end <= other_start || other_end <= self_start)
64    }
65
66    /// Merge this selection with another (if they overlap or are adjacent).
67    #[must_use]
68    pub fn merge(&self, other: &Self) -> Option<Self> {
69        let (self_start, self_end) = self.normalized();
70        let (other_start, other_end) = other.normalized();
71
72        // Check if they overlap or are adjacent
73        if self_end >= other_start && other_end >= self_start {
74            Some(Self {
75                start: self_start.min(other_start),
76                end: self_end.max(other_end),
77            })
78        } else {
79            None
80        }
81    }
82}
83
84/// The type of selection being made.
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
86pub enum SelectionMode {
87    /// Normal character-by-character selection
88    #[default]
89    Character,
90    /// Select whole words
91    Word,
92    /// Select whole lines
93    Line,
94    /// Block/column selection
95    Block,
96}
97
98/// Selection direction for keyboard navigation.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100#[allow(dead_code)]
101pub enum SelectionDirection {
102    /// Selection is moving forward (right/down)
103    Forward,
104    /// Selection is moving backward (left/up)
105    Backward,
106}
107
108/// Get the word boundaries around a position in text.
109#[must_use]
110#[allow(dead_code)]
111pub fn word_at_position(text: &str, line: usize, column: usize) -> Option<(usize, usize)> {
112    let lines: Vec<&str> = text.lines().collect();
113    let line_text = lines.get(line)?;
114
115    if column > line_text.len() {
116        return None;
117    }
118
119    // Find word start
120    let mut start = column;
121    for (i, c) in line_text[..column].char_indices().rev() {
122        if !is_word_char(c) {
123            start = i + c.len_utf8();
124            break;
125        }
126        if i == 0 {
127            start = 0;
128        }
129    }
130
131    // Find word end
132    let mut end = column;
133    for (i, c) in line_text[column..].char_indices() {
134        if !is_word_char(c) {
135            end = column + i;
136            break;
137        }
138        end = column + i + c.len_utf8();
139    }
140
141    if start == end {
142        None
143    } else {
144        Some((start, end))
145    }
146}
147
148/// Check if a character is part of a word.
149#[allow(dead_code)]
150fn is_word_char(c: char) -> bool {
151    c.is_alphanumeric() || c == '_'
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_selection_normalized() {
160        let sel = Selection::new(CursorPosition::new(1, 5), CursorPosition::new(0, 3));
161
162        let (start, end) = sel.normalized();
163        assert_eq!(start, CursorPosition::new(0, 3));
164        assert_eq!(end, CursorPosition::new(1, 5));
165    }
166
167    #[test]
168    fn test_selection_overlaps() {
169        let a = Selection::new(CursorPosition::new(0, 0), CursorPosition::new(0, 5));
170        let b = Selection::new(CursorPosition::new(0, 3), CursorPosition::new(0, 10));
171        let c = Selection::new(CursorPosition::new(0, 6), CursorPosition::new(0, 10));
172
173        assert!(a.overlaps(&b));
174        assert!(!a.overlaps(&c));
175    }
176
177    #[test]
178    fn test_word_at_position() {
179        let text = "hello world foo_bar";
180
181        assert_eq!(word_at_position(text, 0, 2), Some((0, 5)));
182        assert_eq!(word_at_position(text, 0, 8), Some((6, 11)));
183        assert_eq!(word_at_position(text, 0, 15), Some((12, 19)));
184    }
185}