ailloli_ui_editor/input/
selection.rs

1//! Syntax-aware selection ranges and visual selection rectangles.
2
3use std::ops::Range;
4
5use ailloli_ui_core::Rect;
6
7use crate::code::{EditorLanguage, SyntaxKind, SyntaxToken};
8use crate::layout::{first_layout_baseline, EditorTextRun};
9use crate::EditorStyle;
10
11/// Unit chosen for a pointer-driven selection gesture.
12///
13/// # Examples
14///
15/// ```
16/// use ailloli_ui_editor::SelectionGranularity;
17///
18/// assert_ne!(SelectionGranularity::Character, SelectionGranularity::Word);
19/// ```
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum SelectionGranularity {
22    /// Extend to an exact UTF-8 caret position.
23    Character,
24    /// Select a syntax token or lexical word around the pointer.
25    Word,
26    /// Select one logical line without its newline delimiter.
27    Line,
28    /// Select a syntax token when one is available.
29    Token,
30}
31
32/// Builds viewport-space rectangles for a run-local byte selection.
33///
34/// `lo_local..hi_local` is clamped to the run layout's UTF-8 byte length. One
35/// rectangle is returned per intersected visual line, with rounded `x`/`y`, a
36/// minimum width of one logical pixel, and at least `px_size + 2` logical pixels
37/// of height. Empty or reversed selections return an empty vector.
38///
39/// # Examples
40///
41/// ```
42/// use ailloli_ui_core::{Color, FontId, TextStyle};
43/// use ailloli_ui_editor::{input::selection::selection_rects_for_run, layout::EditorTextRun, EditorStyle};
44/// use ailloli_ui_text::{TextLayoutParams, TextSystem};
45///
46/// let mut text_system = TextSystem::new();
47/// let layout = text_system.layout_cached(TextLayoutParams::new(
48///     "abc",
49///     TextStyle::new(FontId::Mono, 13, Color::WHITE),
50/// ));
51/// let run = EditorTextRun { index: 0, byte_range: 0..3, baseline_y: 10.0, layout };
52/// let rects = selection_rects_for_run(4.0, 6.0, &run, 0, 2, EditorStyle::default());
53/// assert_eq!(rects.len(), 1);
54/// assert!(rects[0].x >= 4.0 && rects[0].w >= 1.0);
55/// ```
56pub fn selection_rects_for_run(
57    content_x: f32,
58    content_y: f32,
59    run: &EditorTextRun,
60    lo_local: usize,
61    hi_local: usize,
62    style: EditorStyle,
63) -> Vec<Rect> {
64    let text_len = run.layout.text().len();
65    let lo_local = lo_local.min(text_len);
66    let hi_local = hi_local.min(text_len);
67    if hi_local <= lo_local {
68        return Vec::new();
69    }
70
71    let first_baseline = first_layout_baseline(&run.layout);
72    let text_origin_y = run.baseline_y - first_baseline;
73    let mut out = Vec::new();
74    for line in &run.layout.lines {
75        let line_start = line.text_range.start.min(text_len);
76        let line_end = line.text_range.end.min(text_len);
77        let start = lo_local.max(line_start);
78        let end = hi_local.min(line_end);
79        if end <= start {
80            continue;
81        }
82
83        let x0 = if start <= line_start {
84            0.0
85        } else {
86            run.layout.caret_rect_at(start, 0.0).x
87        };
88        let x1 = if end >= line_end {
89            line.width
90        } else {
91            run.layout.caret_rect_at(end, 0.0).x
92        };
93        let x_left = x0.min(x1);
94        let width = (x1 - x0).abs().max(1.0);
95        let caret = run.layout.caret_rect_at(line_start, 0.0);
96        let fallback_h = style.px_size as f32 + 2.0;
97        out.push(Rect::new(
98            (content_x + x_left).round(),
99            (content_y + text_origin_y + caret.y).round(),
100            width,
101            caret.h.max(fallback_h),
102        ));
103    }
104    out
105}
106
107/// Selects a syntax token or ASCII lexical word around a UTF-8 byte offset.
108///
109/// A selectable token wins when supplied. The fallback recognizes ASCII Rust
110/// raw identifiers and lifetimes, ASCII identifiers in every language, and a
111/// fixed set of one-byte punctuation. At or beyond EOF, it examines the final
112/// character; empty text and unsupported characters return `None`.
113///
114/// # Examples
115///
116/// ```
117/// use ailloli_ui_editor::{input::selection::select_word_at, EditorLanguage};
118///
119/// assert_eq!(select_word_at("let r#type = 1;", 7, None, EditorLanguage::Rust), Some(4..10));
120/// assert_eq!(select_word_at("abc", usize::MAX, None, EditorLanguage::PlainText), Some(0..3));
121/// assert_eq!(select_word_at(" ", 0, None, EditorLanguage::PlainText), None);
122/// ```
123pub fn select_word_at(
124    text: &str,
125    byte: usize,
126    syntax_tokens: Option<&[SyntaxToken]>,
127    language: EditorLanguage,
128) -> Option<Range<usize>> {
129    if let Some(range) = syntax_tokens.and_then(|tokens| select_token_at(byte, tokens)) {
130        return Some(range);
131    }
132    lexical_word_range_at(text, byte, language)
133}
134
135/// Returns the first selectable syntax-token range containing `byte`.
136///
137/// Containment is half-open. Keyword, type, function, number, operator,
138/// punctuation, and identifier tokens are selectable; string and comment
139/// tokens are skipped. Token ranges are returned as supplied without clamping
140/// or overlap resolution.
141///
142/// # Examples
143///
144/// ```
145/// use ailloli_ui_editor::{input::selection::select_token_at, code::{SyntaxKind, SyntaxToken}};
146///
147/// let tokens = [
148///     SyntaxToken { range: 0..3, kind: SyntaxKind::Comment },
149///     SyntaxToken { range: 4..7, kind: SyntaxKind::Identifier },
150/// ];
151/// assert_eq!(select_token_at(5, &tokens), Some(4..7));
152/// assert_eq!(select_token_at(3, &tokens), None);
153/// ```
154pub fn select_token_at(byte: usize, syntax_tokens: &[SyntaxToken]) -> Option<Range<usize>> {
155    syntax_tokens
156        .iter()
157        .filter(|token| selectable_syntax_kind(token.kind))
158        .find(|token| byte_in_range(byte, &token.range))
159        .map(|token| token.range.clone())
160}
161
162/// Returns the logical line containing a UTF-8 byte offset, excluding `\n`.
163///
164/// Out-of-range offsets clamp to EOF and offsets inside a multi-byte character
165/// clamp backward to its start. For a trailing newline, EOF selects the empty
166/// final line.
167///
168/// # Examples
169///
170/// ```
171/// use ailloli_ui_editor::input::selection::select_line_at;
172///
173/// assert_eq!(select_line_at("first\nsecond\n", 8), 6..12);
174/// assert_eq!(select_line_at("first\nsecond\n", usize::MAX), 13..13);
175/// ```
176pub fn select_line_at(text: &str, byte: usize) -> Range<usize> {
177    let byte = clamp_boundary(text, byte.min(text.len()));
178    let start = text[..byte].rfind('\n').map(|idx| idx + 1).unwrap_or(0);
179    let end = text[byte..]
180        .find('\n')
181        .map(|idx| byte + idx)
182        .unwrap_or(text.len());
183    start..end
184}
185
186/// Reports whether syntax selection accepts a token category.
187fn selectable_syntax_kind(kind: SyntaxKind) -> bool {
188    matches!(
189        kind,
190        SyntaxKind::Keyword
191            | SyntaxKind::Type
192            | SyntaxKind::Function
193            | SyntaxKind::Number
194            | SyntaxKind::Operator
195            | SyntaxKind::Punctuation
196            | SyntaxKind::Identifier
197    )
198}
199
200/// Tests non-empty half-open byte-range containment.
201fn byte_in_range(byte: usize, range: &Range<usize>) -> bool {
202    range.start < range.end && range.start <= byte && byte < range.end
203}
204
205/// Finds the language-aware ASCII lexical unit around a byte position.
206fn lexical_word_range_at(
207    text: &str,
208    byte: usize,
209    language: EditorLanguage,
210) -> Option<Range<usize>> {
211    if text.is_empty() {
212        return None;
213    }
214    let mut at = clamp_boundary(text, byte.min(text.len()));
215    if at == text.len() {
216        at = previous_boundary(text, at);
217    }
218    if at >= text.len() {
219        return None;
220    }
221    let bytes = text.as_bytes();
222    if matches!(language, EditorLanguage::Rust) {
223        if let Some(range) = rust_lifetime_range_at(text, at) {
224            return Some(range);
225        }
226        if bytes.get(at) == Some(&b'#') && at > 0 && bytes.get(at - 1) == Some(&b'r') {
227            at -= 1;
228        }
229        if let Some(range) = rust_raw_identifier_range_at(text, at) {
230            return Some(range);
231        }
232    }
233    if is_ident_byte(bytes[at]) {
234        return Some(identifier_range_at(text, at));
235    }
236    if is_selectable_punctuation(bytes[at]) {
237        return Some(at..at + 1);
238    }
239    None
240}
241
242/// Finds a Rust `r#identifier` containing a byte position.
243fn rust_raw_identifier_range_at(text: &str, at: usize) -> Option<Range<usize>> {
244    let bytes = text.as_bytes();
245    let mut start = at;
246    while start > 0 && is_ident_byte(bytes[start - 1]) {
247        start -= 1;
248    }
249    if start >= 2 && &bytes[start - 2..start] == b"r#" {
250        start -= 2;
251    }
252    if &bytes.get(start..start + 2)? != b"r#" {
253        return None;
254    }
255    let mut end = start + 2;
256    if !bytes.get(end).is_some_and(|byte| is_ident_start(*byte)) {
257        return None;
258    }
259    end += 1;
260    while end < bytes.len() && is_ident_byte(bytes[end]) {
261        end += 1;
262    }
263    (start <= at && at < end).then_some(start..end)
264}
265
266/// Finds a Rust lifetime containing a byte position, excluding char literals.
267fn rust_lifetime_range_at(text: &str, at: usize) -> Option<Range<usize>> {
268    let bytes = text.as_bytes();
269    let start = if bytes.get(at) == Some(&b'\'') {
270        at
271    } else if at > 0 && is_ident_byte(bytes[at]) && bytes.get(at - 1) == Some(&b'\'') {
272        at - 1
273    } else {
274        return None;
275    };
276    let first = *bytes.get(start + 1)?;
277    if !is_ident_start(first) {
278        return None;
279    }
280    let mut end = start + 2;
281    while end < bytes.len() && is_ident_byte(bytes[end]) {
282        end += 1;
283    }
284    if bytes.get(end) == Some(&b'\'') {
285        return None;
286    }
287    Some(start..end)
288}
289
290/// Expands an ASCII identifier byte to its complete range.
291fn identifier_range_at(text: &str, at: usize) -> Range<usize> {
292    let bytes = text.as_bytes();
293    let mut start = at;
294    while start > 0 && is_ident_byte(bytes[start - 1]) {
295        start -= 1;
296    }
297    let mut end = at + 1;
298    while end < bytes.len() && is_ident_byte(bytes[end]) {
299        end += 1;
300    }
301    start..end
302}
303
304/// Reports whether a byte starts an ASCII identifier.
305fn is_ident_start(byte: u8) -> bool {
306    byte == b'_' || byte.is_ascii_alphabetic()
307}
308
309/// Reports whether a byte continues an ASCII identifier.
310fn is_ident_byte(byte: u8) -> bool {
311    is_ident_start(byte) || byte.is_ascii_digit()
312}
313
314/// Reports whether a byte is one of the selectable punctuation characters.
315fn is_selectable_punctuation(byte: u8) -> bool {
316    matches!(
317        byte,
318        b'!' | b'%'
319            | b'&'
320            | b'*'
321            | b'+'
322            | b'-'
323            | b'.'
324            | b'/'
325            | b':'
326            | b';'
327            | b'<'
328            | b'='
329            | b'>'
330            | b'?'
331            | b'|'
332            | b'('
333            | b')'
334            | b'['
335            | b']'
336            | b'{'
337            | b'}'
338            | b','
339            | b'#'
340    )
341}
342
343/// Clamps an offset to EOF and then backward to a UTF-8 boundary.
344fn clamp_boundary(text: &str, mut byte: usize) -> usize {
345    byte = byte.min(text.len());
346    while byte > 0 && !text.is_char_boundary(byte) {
347        byte -= 1;
348    }
349    byte
350}
351
352/// Returns the UTF-8 boundary immediately before an offset, or zero.
353fn previous_boundary(text: &str, byte: usize) -> usize {
354    let mut byte = clamp_boundary(text, byte);
355    if byte == 0 {
356        return 0;
357    }
358    byte -= 1;
359    clamp_boundary(text, byte)
360}