Skip to main content

azul_layout/text3/
selection.rs

1//! Text selection helper functions
2//!
3//! Provides word and paragraph selection algorithms.
4
5use azul_core::selection::{CursorAffinity, GraphemeClusterId, SelectionRange, TextCursor};
6
7use crate::text3::cache::{PositionedItem, ShapedCluster, ShapedItem, UnifiedLayout};
8
9/// Select the word at the given cursor position
10///
11/// Uses Unicode word boundaries to determine word start/end.
12/// Returns a SelectionRange covering the entire word.
13pub fn select_word_at_cursor(
14    cursor: &TextCursor,
15    layout: &UnifiedLayout,
16) -> Option<SelectionRange> {
17    // Find the item containing this cursor
18    let (item_idx, cluster) = find_cluster_at_cursor(cursor, layout)?;
19
20    // Get the text from this cluster and surrounding clusters on the same line
21    let line_text = extract_line_text_at_item(item_idx, layout);
22    let cursor_byte_offset = cursor.cluster_id.start_byte_in_run as usize;
23
24    // Find word boundaries
25    let (word_start, word_end) = find_word_boundaries(&line_text, cursor_byte_offset);
26
27    // Convert byte offsets to cursors
28    let start_cursor = TextCursor {
29        cluster_id: GraphemeClusterId {
30            source_run: cursor.cluster_id.source_run,
31            start_byte_in_run: word_start as u32,
32        },
33        affinity: CursorAffinity::Leading,
34    };
35
36    let end_cursor = TextCursor {
37        cluster_id: GraphemeClusterId {
38            source_run: cursor.cluster_id.source_run,
39            start_byte_in_run: word_end as u32,
40        },
41        affinity: CursorAffinity::Trailing,
42    };
43
44    Some(SelectionRange {
45        start: start_cursor,
46        end: end_cursor,
47    })
48}
49
50/// Select the paragraph/line at the given cursor position
51///
52/// Returns a SelectionRange covering the entire line from the first
53/// to the last cluster on that line.
54pub fn select_paragraph_at_cursor(
55    cursor: &TextCursor,
56    layout: &UnifiedLayout,
57) -> Option<SelectionRange> {
58    // Find the item containing this cursor
59    let (item_idx, _) = find_cluster_at_cursor(cursor, layout)?;
60    let item = &layout.items[item_idx];
61    let line_index = item.line_index;
62
63    // Find all items on this line
64    let line_items: Vec<(usize, &PositionedItem)> = layout
65        .items
66        .iter()
67        .enumerate()
68        .filter(|(_, item)| item.line_index == line_index)
69        .collect();
70
71    if line_items.is_empty() {
72        return None;
73    }
74
75    // Get first and last cluster on line
76    let first_cluster = line_items
77        .iter()
78        .find_map(|(_, item)| item.item.as_cluster())?;
79
80    let last_cluster = line_items
81        .iter()
82        .rev()
83        .find_map(|(_, item)| item.item.as_cluster())?;
84
85    // Create selection spanning entire line
86    Some(SelectionRange {
87        start: TextCursor {
88            cluster_id: first_cluster.source_cluster_id,
89            affinity: CursorAffinity::Leading,
90        },
91        end: TextCursor {
92            cluster_id: last_cluster.source_cluster_id,
93            affinity: CursorAffinity::Trailing,
94        },
95    })
96}
97
98// Helper Functions
99
100/// Find the cluster containing the given cursor
101fn find_cluster_at_cursor<'a>(
102    cursor: &TextCursor,
103    layout: &'a UnifiedLayout,
104) -> Option<(usize, &'a ShapedCluster)> {
105    layout.items.iter().enumerate().find_map(|(idx, item)| {
106        if let ShapedItem::Cluster(cluster) = &item.item {
107            if cluster.source_cluster_id == cursor.cluster_id {
108                return Some((idx, cluster));
109            }
110        }
111        None
112    })
113}
114
115/// Extract text from all clusters on the same line as the given item
116fn extract_line_text_at_item(item_idx: usize, layout: &UnifiedLayout) -> String {
117    let line_index = layout.items[item_idx].line_index;
118
119    let mut text = String::new();
120    for item in &layout.items {
121        if item.line_index != line_index {
122            continue;
123        }
124
125        if let ShapedItem::Cluster(cluster) = &item.item {
126            text.push_str(&cluster.text);
127        }
128    }
129
130    text
131}
132
133/// Find word boundaries around the given byte offset
134///
135/// Uses a simple algorithm: word characters are alphanumeric or underscore,
136/// everything else is a boundary.
137fn find_word_boundaries(text: &str, cursor_offset: usize) -> (usize, usize) {
138    // Clamp cursor offset to text length
139    let cursor_offset = cursor_offset.min(text.len());
140
141    // Find word start (scan backwards)
142    let mut word_start = 0;
143    let mut char_indices: Vec<(usize, char)> = text.char_indices().collect();
144
145    for (i, (byte_idx, ch)) in char_indices.iter().enumerate().rev() {
146        if *byte_idx >= cursor_offset {
147            continue;
148        }
149
150        if !is_word_char(*ch) {
151            // Found boundary, word starts after this char
152            word_start = if i + 1 < char_indices.len() {
153                char_indices[i + 1].0
154            } else {
155                text.len()
156            };
157            break;
158        }
159    }
160
161    // Find word end (scan forwards)
162    let mut word_end = text.len();
163    for (byte_idx, ch) in char_indices.iter() {
164        if *byte_idx <= cursor_offset {
165            continue;
166        }
167
168        if !is_word_char(*ch) {
169            // Found boundary, word ends before this char
170            word_end = *byte_idx;
171            break;
172        }
173    }
174
175    // If cursor is on whitespace, select just that whitespace
176    if let Some((_, ch)) = char_indices.iter().find(|(idx, _)| *idx == cursor_offset) {
177        if !is_word_char(*ch) {
178            // Find span of consecutive whitespace/punctuation
179            let start = char_indices
180                .iter()
181                .rev()
182                .find(|(idx, c)| *idx < cursor_offset && is_word_char(*c))
183                .map(|(idx, c)| idx + c.len_utf8())
184                .unwrap_or(0);
185
186            let end = char_indices
187                .iter()
188                .find(|(idx, c)| *idx > cursor_offset && is_word_char(*c))
189                .map(|(idx, _)| *idx)
190                .unwrap_or(text.len());
191
192            return (start, end);
193        }
194    }
195
196    (word_start, word_end)
197}
198
199/// Check if a character is part of a word
200#[inline]
201fn is_word_char(ch: char) -> bool {
202    ch.is_alphanumeric() || ch == '_'
203}