1
//! Text selection helper functions
2
//!
3
//! Provides word and paragraph selection algorithms.
4

            
5
use azul_core::selection::{CursorAffinity, GraphemeClusterId, SelectionRange, TextCursor};
6

            
7
use crate::text3::cache::{PositionedItem, ShapedCluster, ShapedItem, UnifiedLayout};
8

            
9
/// Select the word at the given cursor position
10
///
11
/// Uses a simple word character heuristic (alphanumeric and underscore)
12
/// to determine word start/end. Returns a SelectionRange covering the entire word.
13
pub 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.
54
pub 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
101
fn 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
116
fn extract_line_text_at_item(item_idx: usize, layout: &UnifiedLayout) -> String {
117
    let line_index = layout.items[item_idx].line_index;
118

            
119
    layout.items.iter()
120
        .filter(|item| item.line_index == line_index)
121
        .filter_map(|item| item.item.as_cluster())
122
        .map(|c| c.text.as_str())
123
        .collect()
124
}
125

            
126
/// Find word boundaries around the given byte offset
127
///
128
/// Uses a simple algorithm: word characters are alphanumeric or underscore,
129
/// everything else is a boundary.
130
fn find_word_boundaries(text: &str, cursor_offset: usize) -> (usize, usize) {
131
    // Clamp cursor offset to text length
132
    let cursor_offset = cursor_offset.min(text.len());
133

            
134
    // Find word start (scan backwards)
135
    let mut word_start = 0;
136
    let char_indices: Vec<(usize, char)> = text.char_indices().collect();
137

            
138
    for (i, (byte_idx, ch)) in char_indices.iter().enumerate().rev() {
139
        if *byte_idx >= cursor_offset {
140
            continue;
141
        }
142

            
143
        if !is_word_char(*ch) {
144
            // Found boundary, word starts after this char
145
            word_start = if i + 1 < char_indices.len() {
146
                char_indices[i + 1].0
147
            } else {
148
                text.len()
149
            };
150
            break;
151
        }
152
    }
153

            
154
    // Find word end (scan forwards)
155
    let mut word_end = text.len();
156
    for (byte_idx, ch) in char_indices.iter() {
157
        if *byte_idx <= cursor_offset {
158
            continue;
159
        }
160

            
161
        if !is_word_char(*ch) {
162
            // Found boundary, word ends before this char
163
            word_end = *byte_idx;
164
            break;
165
        }
166
    }
167

            
168
    // If cursor is on whitespace, select just that whitespace
169
    if let Some((_, ch)) = char_indices.iter().find(|(idx, _)| *idx == cursor_offset) {
170
        if !is_word_char(*ch) {
171
            // Find span of consecutive whitespace/punctuation
172
            let start = char_indices
173
                .iter()
174
                .rev()
175
                .find(|(idx, c)| *idx < cursor_offset && is_word_char(*c))
176
                .map(|(idx, c)| idx + c.len_utf8())
177
                .unwrap_or(0);
178

            
179
            let end = char_indices
180
                .iter()
181
                .find(|(idx, c)| *idx > cursor_offset && is_word_char(*c))
182
                .map(|(idx, _)| *idx)
183
                .unwrap_or(text.len());
184

            
185
            return (start, end);
186
        }
187
    }
188

            
189
    (word_start, word_end)
190
}
191

            
192
/// Check if a character is part of a word
193
#[inline]
194
fn is_word_char(ch: char) -> bool {
195
    ch.is_alphanumeric() || ch == '_'
196
}