Skip to main content

aetna_core/
selection.rs

1//! Library-level text selection model.
2//!
3//! Selection is a single, application-owned [`Selection`] value that
4//! identifies *which* keyed text-bearing element holds the active
5//! selection and *where* in that element's text the anchor and head
6//! sit. The library enforces the single-selection invariant by
7//! emitting `SelectionChanged` events; the app folds them into its
8//! `Selection` field the same way it folds `apply_event` results into
9//! a [`crate::widgets::text_input::TextSelection`] today.
10//!
11//! # Model
12//!
13//! - [`Selection`] — the slot, holds an `Option<SelectionRange>`.
14//! - [`SelectionRange`] — anchor + head, both [`SelectionPoint`].
15//! - [`SelectionPoint`] — `(key, byte)`. The key references the same
16//!   widget-key form that `focus_order` already uses; the byte indexes
17//!   into that element's text content.
18//!
19//! When `anchor.key == head.key` the selection lives entirely inside
20//! one leaf — equivalent to a [`crate::widgets::text_input::TextSelection`]
21//! over that leaf's text. When they differ, the selection spans
22//! multiple leaves in document order.
23//!
24//! # Key requirement
25//!
26//! Selectable leaves must carry an explicit `.key(...)` — same
27//! convention as focusable widgets. Without a key the leaf is silently
28//! excluded from `selection_order` because nothing could survive a
29//! tree rebuild as a stable identity. See [`crate::tree::El::selectable`].
30
31use crate::tree::{El, Kind};
32use crate::widgets::text_input::TextSelection;
33
34/// The application's single selection slot. `None` means nothing is
35/// selected. The library emits `SelectionChanged` events that fold
36/// into this; widgets read it back to draw highlight bands and route
37/// editing operations.
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub struct Selection {
40    pub range: Option<SelectionRange>,
41}
42
43/// A non-empty selection range. `anchor` is where the user started
44/// (pointer-down); `head` is where they ended up (pointer current /
45/// last move). The pair may be in tree order or reversed.
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct SelectionRange {
48    pub anchor: SelectionPoint,
49    pub head: SelectionPoint,
50}
51
52/// A point inside a selectable leaf's text content. `key` is the
53/// widget key that owns the leaf; `byte` is a byte offset into that
54/// leaf's text (clamped to a UTF-8 char boundary by anything that
55/// reads or writes it).
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct SelectionPoint {
58    pub key: String,
59    pub byte: usize,
60}
61
62impl SelectionPoint {
63    pub fn new(key: impl Into<String>, byte: usize) -> Self {
64        Self {
65            key: key.into(),
66            byte,
67        }
68    }
69}
70
71impl Selection {
72    /// A collapsed caret at `(key, byte)`. Convenience for tests and
73    /// app-side initialization.
74    pub fn caret(key: impl Into<String>, byte: usize) -> Self {
75        let pt = SelectionPoint::new(key, byte);
76        Self {
77            range: Some(SelectionRange {
78                anchor: pt.clone(),
79                head: pt,
80            }),
81        }
82    }
83
84    /// True when there is no active selection.
85    pub fn is_empty(&self) -> bool {
86        self.range.is_none()
87    }
88
89    /// True when the selection lives entirely inside `key` — both
90    /// anchor and head reference it. False for cross-element
91    /// selections and for the empty selection.
92    pub fn is_within(&self, key: &str) -> bool {
93        match &self.range {
94            Some(r) => r.anchor.key == key && r.head.key == key,
95            None => false,
96        }
97    }
98
99    /// True when `key` is the anchor's key (the originating leaf).
100    pub fn anchored_at(&self, key: &str) -> bool {
101        self.range.as_ref().is_some_and(|r| r.anchor.key == key)
102    }
103
104    /// View the selection through one leaf's lens: returns
105    /// `Some(TextSelection)` only when the selection lives entirely
106    /// inside `key`. Cross-element selections return `None` here —
107    /// callers that need a per-leaf slice for a spanned leaf should
108    /// instead consult the document-order range.
109    pub fn within(&self, key: &str) -> Option<TextSelection> {
110        let r = self.range.as_ref()?;
111        if r.anchor.key == key && r.head.key == key {
112            Some(TextSelection {
113                anchor: r.anchor.byte,
114                head: r.head.byte,
115            })
116        } else {
117            None
118        }
119    }
120
121    /// Replace this selection's slice for `key` from a freshly
122    /// produced [`TextSelection`]. Used by editable widgets after
123    /// folding an event: take the slice via [`Self::within`], let the
124    /// widget mutate it, and write it back. No-op when the selection
125    /// isn't currently within `key`.
126    pub fn set_within(&mut self, key: &str, sel: TextSelection) {
127        let Some(r) = self.range.as_mut() else { return };
128        if r.anchor.key == key && r.head.key == key {
129            r.anchor.byte = sel.anchor;
130            r.head.byte = sel.head;
131        }
132    }
133
134    /// Clear the selection.
135    pub fn clear(&mut self) {
136        self.range = None;
137    }
138}
139
140/// Compute the byte range within `key`'s text that should be
141/// highlighted, given the current `selection` and the document-order
142/// list of selectable leaves. Returns `None` when `key` isn't part
143/// of the selection range.
144///
145/// The painter calls this for each selectable leaf to decide whether
146/// (and where) to draw a highlight band:
147///
148/// - Single-leaf selection: returns the `(lo, hi)` byte range when
149///   `key` matches both endpoints.
150/// - Anchor leaf (in cross-leaf): returns `(anchor.byte, text_len)`
151///   for the leaf where the drag started.
152/// - Head leaf (in cross-leaf): returns `(0, head.byte)` for the
153///   leaf where the drag currently ends.
154/// - Middle leaf: returns `(0, text_len)` — fully selected.
155///
156/// Anchor / head are normalized to document order using
157/// `order` (keys in tree order, e.g. `selection_order` from
158/// [`crate::state::UiState::selection_order`]).
159pub fn slice_for_leaf(
160    selection: &Selection,
161    order: &[crate::event::UiTarget],
162    key: &str,
163    text_len: usize,
164) -> Option<(usize, usize)> {
165    let r = selection.range.as_ref()?;
166    if r.anchor.key == r.head.key {
167        if r.anchor.key != key {
168            return None;
169        }
170        let (lo, hi) = (
171            r.anchor.byte.min(r.head.byte).min(text_len),
172            r.anchor.byte.max(r.head.byte).min(text_len),
173        );
174        return (lo < hi).then_some((lo, hi));
175    }
176    let pos = |k: &str| order.iter().position(|t| t.key == k);
177    let (a_idx, h_idx, key_idx) = (pos(&r.anchor.key)?, pos(&r.head.key)?, pos(key)?);
178    let (lo_idx, lo_byte, hi_idx, hi_byte) = if a_idx <= h_idx {
179        (a_idx, r.anchor.byte, h_idx, r.head.byte)
180    } else {
181        (h_idx, r.head.byte, a_idx, r.anchor.byte)
182    };
183    if key_idx < lo_idx || key_idx > hi_idx {
184        return None;
185    }
186    let lo = if key_idx == lo_idx {
187        lo_byte.min(text_len)
188    } else {
189        0
190    };
191    let hi = if key_idx == hi_idx {
192        hi_byte.min(text_len)
193    } else {
194        text_len
195    };
196    (lo < hi).then_some((lo, hi))
197}
198
199/// Walk `tree` and return the substring covered by `selection`.
200/// Returns `None` for an empty selection or when the selection
201/// references a key with no matching keyed text leaf in the tree.
202///
203/// For single-leaf selections (the only kind P1a renders) the
204/// returned string is `value[lo..hi]` for that leaf. Cross-leaf
205/// selections walk in tree order: anchor leaf from anchor.byte to
206/// end, every leaf strictly between anchor and head fully, head leaf
207/// up to head.byte. Joined with `\n` between leaves.
208pub fn selected_text(tree: &El, selection: &Selection) -> Option<String> {
209    let r = selection.range.as_ref()?;
210    if r.anchor.key == r.head.key {
211        let value = find_keyed_text(tree, &r.anchor.key)?;
212        let lo = r.anchor.byte.min(r.head.byte).min(value.len());
213        let hi = r.anchor.byte.max(r.head.byte).min(value.len());
214        if lo >= hi {
215            return None;
216        }
217        return Some(value[lo..hi].to_string());
218    }
219    // Cross-leaf walk in tree order.
220    let mut leaves: Vec<(String, String)> = Vec::new();
221    collect_keyed_text_leaves(tree, &mut leaves);
222    let anchor_idx = leaves.iter().position(|(k, _)| *k == r.anchor.key)?;
223    let head_idx = leaves.iter().position(|(k, _)| *k == r.head.key)?;
224    let (lo_idx, lo_byte, hi_idx, hi_byte) = if anchor_idx <= head_idx {
225        (anchor_idx, r.anchor.byte, head_idx, r.head.byte)
226    } else {
227        (head_idx, r.head.byte, anchor_idx, r.anchor.byte)
228    };
229    let mut out = String::new();
230    for (i, (_, value)) in leaves
231        .iter()
232        .enumerate()
233        .skip(lo_idx)
234        .take(hi_idx - lo_idx + 1)
235    {
236        let start = if i == lo_idx {
237            lo_byte.min(value.len())
238        } else {
239            0
240        };
241        let end = if i == hi_idx {
242            hi_byte.min(value.len())
243        } else {
244            value.len()
245        };
246        if start >= end {
247            continue;
248        }
249        if !out.is_empty() {
250            out.push('\n');
251        }
252        out.push_str(&value[start..end]);
253    }
254    if out.is_empty() { None } else { Some(out) }
255}
256
257pub(crate) fn find_keyed_text(node: &El, key: &str) -> Option<String> {
258    if matches!(node.kind, Kind::Text | Kind::Heading)
259        && node.key.as_deref() == Some(key)
260        && let Some(t) = &node.text
261    {
262        return Some(t.clone());
263    }
264    node.children.iter().find_map(|c| find_keyed_text(c, key))
265}
266
267fn collect_keyed_text_leaves(node: &El, out: &mut Vec<(String, String)>) {
268    if matches!(node.kind, Kind::Text | Kind::Heading)
269        && let (Some(k), Some(t)) = (&node.key, &node.text)
270    {
271        out.push((k.clone(), t.clone()));
272    }
273    for c in &node.children {
274        collect_keyed_text_leaves(c, out);
275    }
276}
277
278/// Word range containing `byte`, returned as `(lo, hi)` byte offsets
279/// into `text`. A *word* is a maximal run of `is_word_char` scalars
280/// (alphanumeric, `_`, or `'`); when `byte` lands on a non-word
281/// character the result is just that single codepoint, matching the
282/// browser convention where double-clicking a punctuation mark
283/// selects only that mark rather than the surrounding whitespace.
284/// Used for double-click word selection.
285///
286/// `byte` is clamped to a UTF-8 char boundary; positions inside a
287/// multi-byte codepoint snap to the previous boundary. An empty
288/// `text` returns `(0, 0)`.
289pub fn word_range_at(text: &str, byte: usize) -> (usize, usize) {
290    if text.is_empty() {
291        return (0, 0);
292    }
293    let byte = clamp_to_char_boundary(text, byte.min(text.len()));
294    // At the very end of the text, point at the previous codepoint so
295    // double-click after the last word still selects that word rather
296    // than collapsing to (len, len).
297    let probe = if byte == text.len() {
298        prev_char_boundary(text, byte)
299    } else {
300        byte
301    };
302    let probe_char = text[probe..].chars().next().unwrap_or(' ');
303    if !is_word_char(probe_char) {
304        // Non-word char → select just this codepoint. Avoids the
305        // awkward "comma + space" double-select that grouping would
306        // produce.
307        return (probe, probe + probe_char.len_utf8());
308    }
309
310    // Word char → expand left and right through the run.
311    let mut lo = probe;
312    while lo > 0 {
313        let p = prev_char_boundary(text, lo);
314        let ch = text[p..].chars().next().unwrap();
315        if !is_word_char(ch) {
316            break;
317        }
318        lo = p;
319    }
320    let mut hi = probe;
321    while hi < text.len() {
322        let ch = text[hi..].chars().next().unwrap();
323        if !is_word_char(ch) {
324            break;
325        }
326        hi += ch.len_utf8();
327    }
328    (lo, hi)
329}
330
331/// Line range containing `byte`, returned as `(lo, hi)` byte offsets
332/// into `text`. The range excludes the trailing `\n` so the matching
333/// substring renders the visible line. An empty text returns `(0, 0)`.
334/// Used for triple-click line selection.
335pub fn line_range_at(text: &str, byte: usize) -> (usize, usize) {
336    let byte = byte.min(text.len());
337    let lo = text[..byte].rfind('\n').map(|i| i + 1).unwrap_or(0);
338    let hi = text[byte..]
339        .find('\n')
340        .map(|i| byte + i)
341        .unwrap_or(text.len());
342    (lo, hi)
343}
344
345fn is_word_char(c: char) -> bool {
346    c.is_alphanumeric() || c == '_' || c == '\''
347}
348
349fn clamp_to_char_boundary(text: &str, byte: usize) -> usize {
350    let mut b = byte;
351    while b > 0 && !text.is_char_boundary(b) {
352        b -= 1;
353    }
354    b
355}
356
357fn prev_char_boundary(text: &str, byte: usize) -> usize {
358    let mut b = byte.saturating_sub(1);
359    while b > 0 && !text.is_char_boundary(b) {
360        b -= 1;
361    }
362    b
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn empty_selection_has_no_views() {
371        let sel = Selection::default();
372        assert!(sel.is_empty());
373        assert!(!sel.is_within("name"));
374        assert!(sel.within("name").is_none());
375    }
376
377    #[test]
378    fn caret_constructor_is_within_its_key() {
379        let sel = Selection::caret("name", 3);
380        assert!(!sel.is_empty());
381        assert!(sel.is_within("name"));
382        assert!(!sel.is_within("email"));
383        let view = sel.within("name").expect("within name");
384        assert_eq!(view, TextSelection::caret(3));
385    }
386
387    #[test]
388    fn within_returns_none_for_cross_element_selection() {
389        let sel = Selection {
390            range: Some(SelectionRange {
391                anchor: SelectionPoint::new("para_a", 0),
392                head: SelectionPoint::new("para_b", 5),
393            }),
394        };
395        // Cross-element: neither lens reveals the full selection.
396        assert!(sel.within("para_a").is_none());
397        assert!(sel.within("para_b").is_none());
398        // But the originating-leaf check still works.
399        assert!(sel.anchored_at("para_a"));
400        assert!(!sel.anchored_at("para_b"));
401    }
402
403    #[test]
404    fn set_within_writes_back_a_modified_slice() {
405        let mut sel = Selection::caret("name", 0);
406        let mut view = sel.within("name").expect("caret");
407        view.head = 5; // simulate widget editing the slice
408        sel.set_within("name", view);
409        let view_back = sel.within("name").expect("still within name");
410        assert_eq!(view_back, TextSelection::range(0, 5));
411    }
412
413    #[test]
414    fn set_within_is_a_noop_when_selection_is_not_in_key() {
415        let mut sel = Selection::caret("name", 0);
416        sel.set_within("email", TextSelection::range(0, 9));
417        // Selection unchanged.
418        assert_eq!(sel.within("name"), Some(TextSelection::caret(0)));
419        assert!(sel.within("email").is_none());
420    }
421
422    #[test]
423    fn selected_text_returns_single_leaf_substring() {
424        let tree = crate::widgets::text::text("Hello, world!").key("p");
425        let sel = Selection {
426            range: Some(SelectionRange {
427                anchor: SelectionPoint::new("p", 7),
428                head: SelectionPoint::new("p", 12),
429            }),
430        };
431        assert_eq!(selected_text(&tree, &sel).as_deref(), Some("world"));
432    }
433
434    #[test]
435    fn selected_text_walks_tree_order_for_cross_leaf_selection() {
436        let tree = crate::column([
437            crate::widgets::text::text("alpha").key("a"),
438            crate::widgets::text::text("bravo").key("b"),
439            crate::widgets::text::text("charlie").key("c"),
440        ]);
441        // Anchor inside "alpha" at byte 2, head inside "charlie" at
442        // byte 4 — should yield "pha\nbravo\nchar" (joined by newline
443        // between leaves; full middle leaf included).
444        let sel = Selection {
445            range: Some(SelectionRange {
446                anchor: SelectionPoint::new("a", 2),
447                head: SelectionPoint::new("c", 4),
448            }),
449        };
450        assert_eq!(
451            selected_text(&tree, &sel).as_deref(),
452            Some("pha\nbravo\nchar")
453        );
454    }
455
456    #[test]
457    fn slice_for_leaf_single_leaf() {
458        let order = order_for(&["a", "b", "c"]);
459        let sel = Selection {
460            range: Some(SelectionRange {
461                anchor: SelectionPoint::new("b", 2),
462                head: SelectionPoint::new("b", 5),
463            }),
464        };
465        assert_eq!(slice_for_leaf(&sel, &order, "b", 10), Some((2, 5)));
466        assert_eq!(slice_for_leaf(&sel, &order, "a", 10), None);
467        assert_eq!(slice_for_leaf(&sel, &order, "c", 10), None);
468    }
469
470    #[test]
471    fn slice_for_leaf_cross_leaf_anchor_to_head_in_doc_order() {
472        // anchor = a@2, head = c@4: spans a, b, c.
473        let order = order_for(&["a", "b", "c"]);
474        let sel = Selection {
475            range: Some(SelectionRange {
476                anchor: SelectionPoint::new("a", 2),
477                head: SelectionPoint::new("c", 4),
478            }),
479        };
480        assert_eq!(
481            slice_for_leaf(&sel, &order, "a", 10),
482            Some((2, 10)),
483            "anchor leaf: from anchor.byte to text_len"
484        );
485        assert_eq!(
486            slice_for_leaf(&sel, &order, "b", 8),
487            Some((0, 8)),
488            "middle leaf: fully selected"
489        );
490        assert_eq!(
491            slice_for_leaf(&sel, &order, "c", 10),
492            Some((0, 4)),
493            "head leaf: from 0 to head.byte"
494        );
495    }
496
497    #[test]
498    fn slice_for_leaf_cross_leaf_reversed_drag() {
499        // anchor in c (later), head in a (earlier) — order shouldn't
500        // matter; the slice is the same as forward drag.
501        let order = order_for(&["a", "b", "c"]);
502        let sel = Selection {
503            range: Some(SelectionRange {
504                anchor: SelectionPoint::new("c", 3),
505                head: SelectionPoint::new("a", 1),
506            }),
507        };
508        // Forward in doc order: a@1..end, b full, c 0..3.
509        assert_eq!(slice_for_leaf(&sel, &order, "a", 5), Some((1, 5)));
510        assert_eq!(slice_for_leaf(&sel, &order, "b", 6), Some((0, 6)));
511        assert_eq!(slice_for_leaf(&sel, &order, "c", 9), Some((0, 3)));
512    }
513
514    #[test]
515    fn slice_for_leaf_returns_none_for_leaves_outside_range() {
516        // 5-leaf order; selection covers only b..d.
517        let order = order_for(&["a", "b", "c", "d", "e"]);
518        let sel = Selection {
519            range: Some(SelectionRange {
520                anchor: SelectionPoint::new("b", 0),
521                head: SelectionPoint::new("d", 0),
522            }),
523        };
524        assert_eq!(slice_for_leaf(&sel, &order, "a", 10), None);
525        assert_eq!(slice_for_leaf(&sel, &order, "e", 10), None);
526        // Boundary leaves with collapsed endpoints: anchor at b@0
527        // means b's slice is (0, len). head at d@0 means d's slice is
528        // (0, 0) which collapses → None.
529        assert_eq!(slice_for_leaf(&sel, &order, "b", 4), Some((0, 4)));
530        assert_eq!(slice_for_leaf(&sel, &order, "c", 7), Some((0, 7)));
531        assert_eq!(slice_for_leaf(&sel, &order, "d", 5), None);
532    }
533
534    fn order_for(keys: &[&str]) -> Vec<crate::event::UiTarget> {
535        keys.iter()
536            .map(|k| crate::event::UiTarget {
537                key: (*k).to_string(),
538                node_id: format!("root.{k}"),
539                rect: crate::tree::Rect::new(0.0, 0.0, 0.0, 0.0),
540                tooltip: None,
541                scroll_offset_y: 0.0,
542            })
543            .collect()
544    }
545
546    #[test]
547    fn selected_text_returns_none_for_empty_or_unknown_keys() {
548        let tree = crate::widgets::text::text("hi").key("p");
549        assert!(selected_text(&tree, &Selection::default()).is_none());
550        let unknown = Selection::caret("missing", 0);
551        assert!(selected_text(&tree, &unknown).is_none());
552    }
553
554    #[test]
555    fn word_range_at_picks_run_around_byte() {
556        let text = "Hello, world!";
557        // Byte 0 in "Hello" → whole word.
558        assert_eq!(word_range_at(text, 0), (0, 5));
559        // Byte 3 (inside "Hello") → whole word.
560        assert_eq!(word_range_at(text, 3), (0, 5));
561        // Byte 5 (the comma) → run of non-word chars (just ",").
562        assert_eq!(word_range_at(text, 5), (5, 6));
563        // Byte 6 (the space) → run of non-word chars (just " ").
564        assert_eq!(word_range_at(text, 6), (6, 7));
565        // Byte 7 (start of "world") → "world".
566        assert_eq!(word_range_at(text, 7), (7, 12));
567        // Byte 12 ("!") → "!".
568        assert_eq!(word_range_at(text, 12), (12, 13));
569    }
570
571    #[test]
572    fn word_range_at_treats_apostrophe_and_underscore_as_word_chars() {
573        // Contractions stay one word.
574        assert_eq!(word_range_at("don't stop", 2), (0, 5));
575        // Identifier-style.
576        assert_eq!(word_range_at("foo_bar baz", 4), (0, 7));
577    }
578
579    #[test]
580    fn word_range_at_handles_end_of_text_and_empty() {
581        let text = "hello";
582        // Byte at len → snaps back into the trailing word.
583        assert_eq!(word_range_at(text, 5), (0, 5));
584        // Empty text → (0, 0).
585        assert_eq!(word_range_at("", 0), (0, 0));
586    }
587
588    #[test]
589    fn word_range_at_clamps_off_utf8_boundary() {
590        // 'é' is two bytes; byte=1 sits inside the codepoint and snaps
591        // back to byte 0, then expands into the run of non-ASCII word chars.
592        let text = "café";
593        let (lo, hi) = word_range_at(text, 1);
594        assert_eq!((lo, hi), (0, text.len()));
595    }
596
597    #[test]
598    fn line_range_at_returns_line_around_byte() {
599        let text = "first\nsecond line\nthird";
600        // First line: bytes 0..5 ("first"), \n at byte 5.
601        assert_eq!(line_range_at(text, 0), (0, 5));
602        assert_eq!(line_range_at(text, 3), (0, 5));
603        assert_eq!(line_range_at(text, 5), (0, 5));
604        // Second line: bytes 6..17 ("second line"), \n at byte 17.
605        assert_eq!(line_range_at(text, 6), (6, 17));
606        assert_eq!(line_range_at(text, 12), (6, 17));
607        assert_eq!(line_range_at(text, 17), (6, 17));
608        // Third (final, no trailing \n) line: bytes 18..23.
609        assert_eq!(line_range_at(text, 18), (18, 23));
610        assert_eq!(line_range_at(text, 23), (18, 23));
611    }
612
613    #[test]
614    fn line_range_at_handles_empty_and_single_line() {
615        assert_eq!(line_range_at("", 0), (0, 0));
616        assert_eq!(line_range_at("just one line", 4), (0, 13));
617    }
618}