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 std::ops::Range;
32
33use crate::tree::{El, Kind};
34use crate::widgets::text_input::TextSelection;
35
36/// The application's single selection slot. `None` means nothing is
37/// selected. The library emits `SelectionChanged` events that fold
38/// into this; widgets read it back to draw highlight bands and route
39/// editing operations.
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct Selection {
42    pub range: Option<SelectionRange>,
43}
44
45/// A non-empty selection range. `anchor` is where the user started
46/// (pointer-down); `head` is where they ended up (pointer current /
47/// last move). The pair may be in tree order or reversed.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct SelectionRange {
50    pub anchor: SelectionPoint,
51    pub head: SelectionPoint,
52}
53
54/// A point inside a selectable leaf's text content. `key` is the
55/// widget key that owns the leaf; `byte` is a byte offset into that
56/// leaf's text (clamped to a UTF-8 char boundary by anything that
57/// reads or writes it).
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct SelectionPoint {
60    pub key: String,
61    pub byte: usize,
62}
63
64impl SelectionPoint {
65    pub fn new(key: impl Into<String>, byte: usize) -> Self {
66        Self {
67            key: key.into(),
68            byte,
69        }
70    }
71}
72
73/// Source-backed copy/hit-test payload for a selectable rich-text
74/// node.
75///
76/// `visible` is the logical text users point at while selecting;
77/// `source` is what copy should return. `spans` maps byte ranges in
78/// `visible` to byte ranges in `source`. A plain text leaf has one
79/// identity span. Markdown can instead map rendered words to their
80/// original markdown source, and atomic embeds such as math can map a
81/// one-byte object slot to the full `$...$` / `$$...$$` source.
82#[derive(Clone, Debug, Default, PartialEq, Eq)]
83pub struct SelectionSource {
84    pub source: String,
85    pub visible: String,
86    pub spans: Vec<SelectionSourceSpan>,
87    pub full_selection_group: Option<String>,
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct SelectionSourceSpan {
92    pub visible: Range<usize>,
93    pub source: Range<usize>,
94    pub source_full: Range<usize>,
95    pub atomic: bool,
96}
97
98impl SelectionSource {
99    pub fn new(source: impl Into<String>, visible: impl Into<String>) -> Self {
100        Self {
101            source: source.into(),
102            visible: visible.into(),
103            spans: Vec::new(),
104            full_selection_group: None,
105        }
106    }
107
108    pub fn identity(text: impl Into<String>) -> Self {
109        let text = text.into();
110        let len = text.len();
111        Self {
112            source: text.clone(),
113            visible: text,
114            spans: vec![SelectionSourceSpan {
115                visible: 0..len,
116                source: 0..len,
117                source_full: 0..len,
118                atomic: false,
119            }],
120            full_selection_group: None,
121        }
122    }
123
124    pub fn full_selection_group(mut self, group: impl Into<String>) -> Self {
125        self.full_selection_group = Some(group.into());
126        self
127    }
128
129    pub fn push_span(&mut self, visible: Range<usize>, source: Range<usize>, atomic: bool) {
130        self.push_span_with_full_source(visible, source.clone(), source, atomic);
131    }
132
133    pub fn push_span_with_full_source(
134        &mut self,
135        visible: Range<usize>,
136        source: Range<usize>,
137        source_full: Range<usize>,
138        atomic: bool,
139    ) {
140        if visible.start <= visible.end
141            && visible.end <= self.visible.len()
142            && source.start <= source.end
143            && source.end <= self.source.len()
144            && source_full.start <= source_full.end
145            && source_full.end <= self.source.len()
146        {
147            self.spans.push(SelectionSourceSpan {
148                visible,
149                source,
150                source_full,
151                atomic,
152            });
153        }
154    }
155
156    pub fn visible_len(&self) -> usize {
157        self.visible.len()
158    }
159
160    pub fn source_slice_for_visible(&self, a: usize, b: usize) -> Option<&str> {
161        let (a, b) = (a.min(b), a.max(b));
162        if a == 0 && b >= self.visible.len() && !self.source.is_empty() {
163            return Some(&self.source);
164        }
165        let a = clamp_to_char_boundary(&self.visible, a.min(self.visible.len()));
166        let b = clamp_to_char_boundary(&self.visible, b.min(self.visible.len()));
167        let lo = self.source_offset_for_visible(a, Bias::Start)?;
168        let hi = self.source_offset_for_visible(b, Bias::End)?;
169        let (lo, hi) = (lo.min(hi), lo.max(hi));
170        let lo = clamp_to_char_boundary(&self.source, lo.min(self.source.len()));
171        let hi = clamp_to_char_boundary(&self.source, hi.min(self.source.len()));
172        (lo < hi).then(|| &self.source[lo..hi])
173    }
174
175    pub fn source_text_for_visible(&self, a: usize, b: usize) -> Option<String> {
176        let (a, b) = (a.min(b), a.max(b));
177        if a == 0 && b >= self.visible.len() && !self.source.is_empty() {
178            return Some(self.source.clone());
179        }
180        let a = clamp_to_char_boundary(&self.visible, a.min(self.visible.len()));
181        let b = clamp_to_char_boundary(&self.visible, b.min(self.visible.len()));
182        if a >= b {
183            return None;
184        }
185        if self.spans.is_empty() {
186            return self.source_slice_for_visible(a, b).map(str::to_string);
187        }
188
189        let mut out = String::new();
190        for span in &self.spans {
191            let start = a.max(span.visible.start);
192            let end = b.min(span.visible.end);
193            if start >= end {
194                continue;
195            }
196            if span.atomic || (start == span.visible.start && end == span.visible.end) {
197                out.push_str(&self.source[span.source_full.clone()]);
198                continue;
199            }
200            let lo = source_offset_in_span(span, start, Bias::Start)?;
201            let hi = source_offset_in_span(span, end, Bias::End)?;
202            let (lo, hi) = (lo.min(hi), lo.max(hi));
203            let lo = clamp_to_char_boundary(&self.source, lo.min(self.source.len()));
204            let hi = clamp_to_char_boundary(&self.source, hi.min(self.source.len()));
205            if lo < hi {
206                out.push_str(&self.source[lo..hi]);
207            }
208        }
209        if out.is_empty() { None } else { Some(out) }
210    }
211
212    fn full_group_for_visible(&self, start: usize, end: usize) -> Option<&str> {
213        (start == 0 && end >= self.visible.len())
214            .then_some(self.full_selection_group.as_deref())
215            .flatten()
216    }
217
218    fn source_offset_for_visible(&self, byte: usize, bias: Bias) -> Option<usize> {
219        if self.spans.is_empty() {
220            return Some(byte.min(self.source.len()));
221        }
222        for span in &self.spans {
223            if byte < span.visible.start || byte > span.visible.end {
224                continue;
225            }
226            if byte == span.visible.end && byte != span.visible.start && matches!(bias, Bias::Start)
227            {
228                continue;
229            }
230            if span.atomic {
231                return Some(match bias {
232                    Bias::Start => span.source.start,
233                    Bias::End => span.source.end,
234                });
235            }
236            let visible_len = span.visible.end.saturating_sub(span.visible.start);
237            let source_len = span.source.end.saturating_sub(span.source.start);
238            if visible_len == 0 {
239                return Some(match bias {
240                    Bias::Start => span.source.start,
241                    Bias::End => span.source.end,
242                });
243            }
244            let offset = byte.saturating_sub(span.visible.start).min(visible_len);
245            let mapped = if source_len == visible_len {
246                span.source.start + offset
247            } else {
248                span.source.start
249                    + ((offset as f32 / visible_len as f32) * source_len as f32) as usize
250            };
251            return Some(mapped.min(span.source.end));
252        }
253        let first = self.spans.first()?;
254        if byte <= first.visible.start {
255            return Some(first.source.start);
256        }
257        let last = self.spans.last()?;
258        if byte >= last.visible.end {
259            return Some(last.source.end);
260        }
261        self.spans
262            .windows(2)
263            .find(|pair| byte > pair[0].visible.end && byte < pair[1].visible.start)
264            .map(|pair| match bias {
265                Bias::Start => pair[0].source.end,
266                Bias::End => pair[1].source.start,
267            })
268    }
269}
270
271fn source_offset_in_span(span: &SelectionSourceSpan, byte: usize, bias: Bias) -> Option<usize> {
272    if span.atomic {
273        return Some(match bias {
274            Bias::Start => span.source_full.start,
275            Bias::End => span.source_full.end,
276        });
277    }
278    let visible_len = span.visible.end.saturating_sub(span.visible.start);
279    let source_len = span.source.end.saturating_sub(span.source.start);
280    if visible_len == 0 {
281        return Some(match bias {
282            Bias::Start => span.source.start,
283            Bias::End => span.source.end,
284        });
285    }
286    let offset = byte.saturating_sub(span.visible.start).min(visible_len);
287    let mapped = if source_len == visible_len {
288        span.source.start + offset
289    } else {
290        span.source.start + ((offset as f32 / visible_len as f32) * source_len as f32) as usize
291    };
292    Some(mapped.min(span.source.end))
293}
294
295#[derive(Clone, Copy)]
296enum Bias {
297    Start,
298    End,
299}
300
301impl Selection {
302    /// A collapsed caret at `(key, byte)`. Convenience for tests and
303    /// app-side initialization.
304    pub fn caret(key: impl Into<String>, byte: usize) -> Self {
305        let pt = SelectionPoint::new(key, byte);
306        Self {
307            range: Some(SelectionRange {
308                anchor: pt.clone(),
309                head: pt,
310            }),
311        }
312    }
313
314    /// True when there is no active selection.
315    pub fn is_empty(&self) -> bool {
316        self.range.is_none()
317    }
318
319    /// True when the selection lives entirely inside `key` — both
320    /// anchor and head reference it. False for cross-element
321    /// selections and for the empty selection.
322    pub fn is_within(&self, key: &str) -> bool {
323        match &self.range {
324            Some(r) => r.anchor.key == key && r.head.key == key,
325            None => false,
326        }
327    }
328
329    /// True when `key` is the anchor's key (the originating leaf).
330    pub fn anchored_at(&self, key: &str) -> bool {
331        self.range.as_ref().is_some_and(|r| r.anchor.key == key)
332    }
333
334    /// View the selection through one leaf's lens: returns
335    /// `Some(TextSelection)` only when the selection lives entirely
336    /// inside `key`. Cross-element selections return `None` here —
337    /// callers that need a per-leaf slice for a spanned leaf should
338    /// instead consult the document-order range.
339    pub fn within(&self, key: &str) -> Option<TextSelection> {
340        let r = self.range.as_ref()?;
341        if r.anchor.key == key && r.head.key == key {
342            Some(TextSelection {
343                anchor: r.anchor.byte,
344                head: r.head.byte,
345            })
346        } else {
347            None
348        }
349    }
350
351    /// Replace this selection's slice for `key` from a freshly
352    /// produced [`TextSelection`]. Used by editable widgets after
353    /// folding an event: take the slice via [`Self::within`], let the
354    /// widget mutate it, and write it back. No-op when the selection
355    /// isn't currently within `key`.
356    pub fn set_within(&mut self, key: &str, sel: TextSelection) {
357        let Some(r) = self.range.as_mut() else { return };
358        if r.anchor.key == key && r.head.key == key {
359            r.anchor.byte = sel.anchor;
360            r.head.byte = sel.head;
361        }
362    }
363
364    /// Clear the selection.
365    pub fn clear(&mut self) {
366        self.range = None;
367    }
368}
369
370/// Compute the byte range within `key`'s text that should be
371/// highlighted, given the current `selection` and the document-order
372/// list of selectable leaves. Returns `None` when `key` isn't part
373/// of the selection range.
374///
375/// The painter calls this for each selectable leaf to decide whether
376/// (and where) to draw a highlight band:
377///
378/// - Single-leaf selection: returns the `(lo, hi)` byte range when
379///   `key` matches both endpoints.
380/// - Anchor leaf (in cross-leaf): returns `(anchor.byte, text_len)`
381///   for the leaf where the drag started.
382/// - Head leaf (in cross-leaf): returns `(0, head.byte)` for the
383///   leaf where the drag currently ends.
384/// - Middle leaf: returns `(0, text_len)` — fully selected.
385///
386/// Anchor / head are normalized to document order using
387/// `order` (keys in tree order, e.g. `selection_order` from
388/// [`crate::state::UiState::selection_order`]).
389pub fn slice_for_leaf(
390    selection: &Selection,
391    order: &[crate::event::UiTarget],
392    key: &str,
393    text_len: usize,
394) -> Option<(usize, usize)> {
395    let r = selection.range.as_ref()?;
396    if r.anchor.key == r.head.key {
397        if r.anchor.key != key {
398            return None;
399        }
400        let (lo, hi) = (
401            r.anchor.byte.min(r.head.byte).min(text_len),
402            r.anchor.byte.max(r.head.byte).min(text_len),
403        );
404        return (lo < hi).then_some((lo, hi));
405    }
406    let pos = |k: &str| order.iter().position(|t| t.key == k);
407    let (a_idx, h_idx, key_idx) = (pos(&r.anchor.key)?, pos(&r.head.key)?, pos(key)?);
408    let (lo_idx, lo_byte, hi_idx, hi_byte) = if a_idx <= h_idx {
409        (a_idx, r.anchor.byte, h_idx, r.head.byte)
410    } else {
411        (h_idx, r.head.byte, a_idx, r.anchor.byte)
412    };
413    if key_idx < lo_idx || key_idx > hi_idx {
414        return None;
415    }
416    let lo = if key_idx == lo_idx {
417        lo_byte.min(text_len)
418    } else {
419        0
420    };
421    let hi = if key_idx == hi_idx {
422        hi_byte.min(text_len)
423    } else {
424        text_len
425    };
426    (lo < hi).then_some((lo, hi))
427}
428
429/// Walk `tree` and return the substring covered by `selection`.
430/// Returns `None` for an empty selection or when the selection
431/// references a key with no matching keyed text leaf in the tree.
432///
433/// For single-leaf selections (the only kind P1a renders) the
434/// returned string is `value[lo..hi]` for that leaf. Cross-leaf
435/// selections walk in tree order: anchor leaf from anchor.byte to
436/// end, every leaf strictly between anchor and head fully, head leaf
437/// up to head.byte. Joined with `\n` between leaves.
438pub fn selected_text(tree: &El, selection: &Selection) -> Option<String> {
439    let r = selection.range.as_ref()?;
440    if r.anchor.key == r.head.key {
441        if let Some(source) = find_keyed_selection_source(tree, &r.anchor.key) {
442            let lo = r.anchor.byte.min(r.head.byte);
443            let hi = r.anchor.byte.max(r.head.byte);
444            return source.source_text_for_visible(lo, hi);
445        }
446        let value = find_keyed_text(tree, &r.anchor.key)?;
447        let lo = r.anchor.byte.min(r.head.byte).min(value.len());
448        let hi = r.anchor.byte.max(r.head.byte).min(value.len());
449        if lo >= hi {
450            return None;
451        }
452        return Some(value[lo..hi].to_string());
453    }
454    // Cross-leaf walk in tree order.
455    let mut leaves: Vec<(String, LeafSelectionText)> = Vec::new();
456    collect_keyed_selection_leaves(tree, &mut leaves);
457    let anchor_idx = leaves.iter().position(|(k, _)| *k == r.anchor.key)?;
458    let head_idx = leaves.iter().position(|(k, _)| *k == r.head.key)?;
459    let (lo_idx, lo_byte, hi_idx, hi_byte) = if anchor_idx <= head_idx {
460        (anchor_idx, r.anchor.byte, head_idx, r.head.byte)
461    } else {
462        (head_idx, r.head.byte, anchor_idx, r.anchor.byte)
463    };
464    let mut out = String::new();
465    let mut last_group: Option<String> = None;
466    for (i, (_, value)) in leaves
467        .iter()
468        .enumerate()
469        .skip(lo_idx)
470        .take(hi_idx - lo_idx + 1)
471    {
472        let start = if i == lo_idx {
473            lo_byte.min(value.visible_len())
474        } else {
475            0
476        };
477        let end = if i == hi_idx {
478            hi_byte.min(value.visible_len())
479        } else {
480            value.visible_len()
481        };
482        if start >= end {
483            continue;
484        }
485        let Some(slice) = value.source_text_for_visible(start, end) else {
486            continue;
487        };
488        let group = value.full_group_for_visible(start, end).map(str::to_string);
489        if group.is_some() && group == last_group {
490            continue;
491        }
492        if !out.is_empty() {
493            out.push('\n');
494        }
495        out.push_str(&slice);
496        last_group = group;
497    }
498    if out.is_empty() { None } else { Some(out) }
499}
500
501pub(crate) fn find_keyed_text(node: &El, key: &str) -> Option<String> {
502    if node.key.as_deref() == Some(key) {
503        if let Some(source) = &node.selection_source {
504            return Some(source.visible.clone());
505        }
506        if matches!(node.kind, Kind::Text | Kind::Heading)
507            && let Some(t) = &node.text
508        {
509            return Some(t.clone());
510        }
511        let mut out = String::new();
512        collect_text_content(node, &mut out);
513        if !out.is_empty() {
514            return Some(out);
515        }
516    }
517    node.children.iter().find_map(|c| find_keyed_text(c, key))
518}
519
520pub(crate) fn find_keyed_selection_source(node: &El, key: &str) -> Option<SelectionSource> {
521    if node.key.as_deref() == Some(key)
522        && let Some(source) = &node.selection_source
523    {
524        return Some(source.clone());
525    }
526    node.children
527        .iter()
528        .find_map(|c| find_keyed_selection_source(c, key))
529}
530
531fn collect_text_content(node: &El, out: &mut String) {
532    if matches!(node.kind, Kind::Text | Kind::Heading)
533        && let Some(t) = &node.text
534    {
535        out.push_str(t);
536    }
537    for c in &node.children {
538        collect_text_content(c, out);
539    }
540}
541
542enum LeafSelectionText {
543    Source(SelectionSource),
544    Text(String),
545}
546
547impl LeafSelectionText {
548    fn visible_len(&self) -> usize {
549        match self {
550            LeafSelectionText::Source(source) => source.visible_len(),
551            LeafSelectionText::Text(text) => text.len(),
552        }
553    }
554
555    fn source_text_for_visible(&self, start: usize, end: usize) -> Option<String> {
556        match self {
557            LeafSelectionText::Source(source) => source.source_text_for_visible(start, end),
558            LeafSelectionText::Text(text) => {
559                let start = start.min(text.len());
560                let end = end.min(text.len());
561                (start < end).then(|| text[start..end].to_string())
562            }
563        }
564    }
565
566    fn full_group_for_visible(&self, start: usize, end: usize) -> Option<&str> {
567        match self {
568            LeafSelectionText::Source(source) => source.full_group_for_visible(start, end),
569            LeafSelectionText::Text(_) => None,
570        }
571    }
572}
573
574fn collect_keyed_selection_leaves(node: &El, out: &mut Vec<(String, LeafSelectionText)>) {
575    if let (Some(k), Some(source)) = (&node.key, &node.selection_source) {
576        out.push((k.clone(), LeafSelectionText::Source(source.clone())));
577        return;
578    }
579    if matches!(node.kind, Kind::Text | Kind::Heading)
580        && let (Some(k), Some(t)) = (&node.key, &node.text)
581    {
582        out.push((k.clone(), LeafSelectionText::Text(t.clone())));
583    }
584    for c in &node.children {
585        collect_keyed_selection_leaves(c, out);
586    }
587}
588
589/// Word range containing `byte`, returned as `(lo, hi)` byte offsets
590/// into `text`. A *word* is a maximal run of `is_word_char` scalars
591/// (alphanumeric, `_`, or `'`); when `byte` lands on a non-word
592/// character the result is just that single codepoint, matching the
593/// browser convention where double-clicking a punctuation mark
594/// selects only that mark rather than the surrounding whitespace.
595/// Used for double-click word selection.
596///
597/// `byte` is clamped to a UTF-8 char boundary; positions inside a
598/// multi-byte codepoint snap to the previous boundary. An empty
599/// `text` returns `(0, 0)`.
600pub fn word_range_at(text: &str, byte: usize) -> (usize, usize) {
601    if text.is_empty() {
602        return (0, 0);
603    }
604    let byte = clamp_to_char_boundary(text, byte.min(text.len()));
605    // At the very end of the text, point at the previous codepoint so
606    // double-click after the last word still selects that word rather
607    // than collapsing to (len, len).
608    let probe = if byte == text.len() {
609        prev_char_boundary(text, byte)
610    } else {
611        byte
612    };
613    let probe_char = text[probe..].chars().next().unwrap_or(' ');
614    if !is_word_char(probe_char) {
615        // Non-word char → select just this codepoint. Avoids the
616        // awkward "comma + space" double-select that grouping would
617        // produce.
618        return (probe, probe + probe_char.len_utf8());
619    }
620
621    // Word char → expand left and right through the run.
622    let mut lo = probe;
623    while lo > 0 {
624        let p = prev_char_boundary(text, lo);
625        let ch = text[p..].chars().next().unwrap();
626        if !is_word_char(ch) {
627            break;
628        }
629        lo = p;
630    }
631    let mut hi = probe;
632    while hi < text.len() {
633        let ch = text[hi..].chars().next().unwrap();
634        if !is_word_char(ch) {
635            break;
636        }
637        hi += ch.len_utf8();
638    }
639    (lo, hi)
640}
641
642/// Line range containing `byte`, returned as `(lo, hi)` byte offsets
643/// into `text`. The range excludes the trailing `\n` so the matching
644/// substring renders the visible line. An empty text returns `(0, 0)`.
645/// Used for triple-click line selection.
646pub fn line_range_at(text: &str, byte: usize) -> (usize, usize) {
647    let byte = byte.min(text.len());
648    let lo = text[..byte].rfind('\n').map(|i| i + 1).unwrap_or(0);
649    let hi = text[byte..]
650        .find('\n')
651        .map(|i| byte + i)
652        .unwrap_or(text.len());
653    (lo, hi)
654}
655
656fn is_word_char(c: char) -> bool {
657    c.is_alphanumeric() || c == '_' || c == '\''
658}
659
660fn clamp_to_char_boundary(text: &str, byte: usize) -> usize {
661    let mut b = byte;
662    while b > 0 && !text.is_char_boundary(b) {
663        b -= 1;
664    }
665    b
666}
667
668fn prev_char_boundary(text: &str, byte: usize) -> usize {
669    let mut b = byte.saturating_sub(1);
670    while b > 0 && !text.is_char_boundary(b) {
671        b -= 1;
672    }
673    b
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn empty_selection_has_no_views() {
682        let sel = Selection::default();
683        assert!(sel.is_empty());
684        assert!(!sel.is_within("name"));
685        assert!(sel.within("name").is_none());
686    }
687
688    #[test]
689    fn caret_constructor_is_within_its_key() {
690        let sel = Selection::caret("name", 3);
691        assert!(!sel.is_empty());
692        assert!(sel.is_within("name"));
693        assert!(!sel.is_within("email"));
694        let view = sel.within("name").expect("within name");
695        assert_eq!(view, TextSelection::caret(3));
696    }
697
698    #[test]
699    fn within_returns_none_for_cross_element_selection() {
700        let sel = Selection {
701            range: Some(SelectionRange {
702                anchor: SelectionPoint::new("para_a", 0),
703                head: SelectionPoint::new("para_b", 5),
704            }),
705        };
706        // Cross-element: neither lens reveals the full selection.
707        assert!(sel.within("para_a").is_none());
708        assert!(sel.within("para_b").is_none());
709        // But the originating-leaf check still works.
710        assert!(sel.anchored_at("para_a"));
711        assert!(!sel.anchored_at("para_b"));
712    }
713
714    #[test]
715    fn set_within_writes_back_a_modified_slice() {
716        let mut sel = Selection::caret("name", 0);
717        let mut view = sel.within("name").expect("caret");
718        view.head = 5; // simulate widget editing the slice
719        sel.set_within("name", view);
720        let view_back = sel.within("name").expect("still within name");
721        assert_eq!(view_back, TextSelection::range(0, 5));
722    }
723
724    #[test]
725    fn set_within_is_a_noop_when_selection_is_not_in_key() {
726        let mut sel = Selection::caret("name", 0);
727        sel.set_within("email", TextSelection::range(0, 9));
728        // Selection unchanged.
729        assert_eq!(sel.within("name"), Some(TextSelection::caret(0)));
730        assert!(sel.within("email").is_none());
731    }
732
733    #[test]
734    fn selected_text_returns_single_leaf_substring() {
735        let tree = crate::widgets::text::text("Hello, world!").key("p");
736        let sel = Selection {
737            range: Some(SelectionRange {
738                anchor: SelectionPoint::new("p", 7),
739                head: SelectionPoint::new("p", 12),
740            }),
741        };
742        assert_eq!(selected_text(&tree, &sel).as_deref(), Some("world"));
743    }
744
745    #[test]
746    fn selected_text_reads_text_inside_keyed_composite_widget() {
747        let sel = Selection {
748            range: Some(SelectionRange {
749                anchor: SelectionPoint::new("name", 1),
750                head: SelectionPoint::new("name", 4),
751            }),
752        };
753        let tree = crate::widgets::text_input::text_input("hello", &sel, "name");
754        assert_eq!(selected_text(&tree, &sel).as_deref(), Some("ell"));
755    }
756
757    #[test]
758    fn selected_text_walks_tree_order_for_cross_leaf_selection() {
759        let tree = crate::column([
760            crate::widgets::text::text("alpha").key("a"),
761            crate::widgets::text::text("bravo").key("b"),
762            crate::widgets::text::text("charlie").key("c"),
763        ]);
764        // Anchor inside "alpha" at byte 2, head inside "charlie" at
765        // byte 4 — should yield "pha\nbravo\nchar" (joined by newline
766        // between leaves; full middle leaf included).
767        let sel = Selection {
768            range: Some(SelectionRange {
769                anchor: SelectionPoint::new("a", 2),
770                head: SelectionPoint::new("c", 4),
771            }),
772        };
773        assert_eq!(
774            selected_text(&tree, &sel).as_deref(),
775            Some("pha\nbravo\nchar")
776        );
777    }
778
779    #[test]
780    fn selected_text_uses_source_payload_for_single_leaf() {
781        let mut source = SelectionSource::new("This is **bold**.", "This is bold.");
782        source.push_span(0..8, 0..8, false);
783        source.push_span_with_full_source(8..12, 10..14, 8..16, false);
784        source.push_span(12..13, 16..17, false);
785        let tree = crate::text_runs([crate::text("This is "), crate::text("bold").bold()])
786            .key("md:p")
787            .selectable()
788            .selection_source(source);
789
790        let inner_only = Selection {
791            range: Some(SelectionRange {
792                anchor: SelectionPoint::new("md:p", 8),
793                head: SelectionPoint::new("md:p", 12),
794            }),
795        };
796        assert_eq!(
797            selected_text(&tree, &inner_only).as_deref(),
798            Some("**bold**")
799        );
800
801        let partial_inner = Selection {
802            range: Some(SelectionRange {
803                anchor: SelectionPoint::new("md:p", 9),
804                head: SelectionPoint::new("md:p", 11),
805            }),
806        };
807        assert_eq!(selected_text(&tree, &partial_inner).as_deref(), Some("ol"));
808
809        let through_styled_span = Selection {
810            range: Some(SelectionRange {
811                anchor: SelectionPoint::new("md:p", 0),
812                head: SelectionPoint::new("md:p", 12),
813            }),
814        };
815        assert_eq!(
816            selected_text(&tree, &through_styled_span).as_deref(),
817            Some("This is **bold**")
818        );
819
820        let whole = Selection {
821            range: Some(SelectionRange {
822                anchor: SelectionPoint::new("md:p", 0),
823                head: SelectionPoint::new("md:p", 13),
824            }),
825        };
826        assert_eq!(
827            selected_text(&tree, &whole).as_deref(),
828            Some("This is **bold**.")
829        );
830    }
831
832    #[test]
833    fn selected_text_dedupes_adjacent_full_source_group_leaves() {
834        let mut first = SelectionSource::new("| **Ada** | dev |", "Ada");
835        first.push_span_with_full_source(0..3, 4..7, 0..17, false);
836        let first = first.full_selection_group("row:0");
837
838        let mut second = SelectionSource::new("| **Ada** | dev |", "dev");
839        second.push_span_with_full_source(0..3, 12..15, 0..17, false);
840        let second = second.full_selection_group("row:0");
841
842        let tree = crate::row([
843            crate::text("Ada")
844                .key("a")
845                .selectable()
846                .selection_source(first),
847            crate::text("dev")
848                .key("b")
849                .selectable()
850                .selection_source(second),
851        ]);
852        let sel = Selection {
853            range: Some(SelectionRange {
854                anchor: SelectionPoint::new("a", 0),
855                head: SelectionPoint::new("b", 3),
856            }),
857        };
858
859        assert_eq!(
860            selected_text(&tree, &sel).as_deref(),
861            Some("| **Ada** | dev |")
862        );
863    }
864
865    #[test]
866    fn slice_for_leaf_single_leaf() {
867        let order = order_for(&["a", "b", "c"]);
868        let sel = Selection {
869            range: Some(SelectionRange {
870                anchor: SelectionPoint::new("b", 2),
871                head: SelectionPoint::new("b", 5),
872            }),
873        };
874        assert_eq!(slice_for_leaf(&sel, &order, "b", 10), Some((2, 5)));
875        assert_eq!(slice_for_leaf(&sel, &order, "a", 10), None);
876        assert_eq!(slice_for_leaf(&sel, &order, "c", 10), None);
877    }
878
879    #[test]
880    fn slice_for_leaf_cross_leaf_anchor_to_head_in_doc_order() {
881        // anchor = a@2, head = c@4: spans a, b, c.
882        let order = order_for(&["a", "b", "c"]);
883        let sel = Selection {
884            range: Some(SelectionRange {
885                anchor: SelectionPoint::new("a", 2),
886                head: SelectionPoint::new("c", 4),
887            }),
888        };
889        assert_eq!(
890            slice_for_leaf(&sel, &order, "a", 10),
891            Some((2, 10)),
892            "anchor leaf: from anchor.byte to text_len"
893        );
894        assert_eq!(
895            slice_for_leaf(&sel, &order, "b", 8),
896            Some((0, 8)),
897            "middle leaf: fully selected"
898        );
899        assert_eq!(
900            slice_for_leaf(&sel, &order, "c", 10),
901            Some((0, 4)),
902            "head leaf: from 0 to head.byte"
903        );
904    }
905
906    #[test]
907    fn slice_for_leaf_cross_leaf_reversed_drag() {
908        // anchor in c (later), head in a (earlier) — order shouldn't
909        // matter; the slice is the same as forward drag.
910        let order = order_for(&["a", "b", "c"]);
911        let sel = Selection {
912            range: Some(SelectionRange {
913                anchor: SelectionPoint::new("c", 3),
914                head: SelectionPoint::new("a", 1),
915            }),
916        };
917        // Forward in doc order: a@1..end, b full, c 0..3.
918        assert_eq!(slice_for_leaf(&sel, &order, "a", 5), Some((1, 5)));
919        assert_eq!(slice_for_leaf(&sel, &order, "b", 6), Some((0, 6)));
920        assert_eq!(slice_for_leaf(&sel, &order, "c", 9), Some((0, 3)));
921    }
922
923    #[test]
924    fn slice_for_leaf_returns_none_for_leaves_outside_range() {
925        // 5-leaf order; selection covers only b..d.
926        let order = order_for(&["a", "b", "c", "d", "e"]);
927        let sel = Selection {
928            range: Some(SelectionRange {
929                anchor: SelectionPoint::new("b", 0),
930                head: SelectionPoint::new("d", 0),
931            }),
932        };
933        assert_eq!(slice_for_leaf(&sel, &order, "a", 10), None);
934        assert_eq!(slice_for_leaf(&sel, &order, "e", 10), None);
935        // Boundary leaves with collapsed endpoints: anchor at b@0
936        // means b's slice is (0, len). head at d@0 means d's slice is
937        // (0, 0) which collapses → None.
938        assert_eq!(slice_for_leaf(&sel, &order, "b", 4), Some((0, 4)));
939        assert_eq!(slice_for_leaf(&sel, &order, "c", 7), Some((0, 7)));
940        assert_eq!(slice_for_leaf(&sel, &order, "d", 5), None);
941    }
942
943    fn order_for(keys: &[&str]) -> Vec<crate::event::UiTarget> {
944        keys.iter()
945            .map(|k| crate::event::UiTarget {
946                key: (*k).to_string(),
947                node_id: format!("root.{k}"),
948                rect: crate::tree::Rect::new(0.0, 0.0, 0.0, 0.0),
949                tooltip: None,
950                scroll_offset_y: 0.0,
951            })
952            .collect()
953    }
954
955    #[test]
956    fn selected_text_returns_none_for_empty_or_unknown_keys() {
957        let tree = crate::widgets::text::text("hi").key("p");
958        assert!(selected_text(&tree, &Selection::default()).is_none());
959        let unknown = Selection::caret("missing", 0);
960        assert!(selected_text(&tree, &unknown).is_none());
961    }
962
963    #[test]
964    fn word_range_at_picks_run_around_byte() {
965        let text = "Hello, world!";
966        // Byte 0 in "Hello" → whole word.
967        assert_eq!(word_range_at(text, 0), (0, 5));
968        // Byte 3 (inside "Hello") → whole word.
969        assert_eq!(word_range_at(text, 3), (0, 5));
970        // Byte 5 (the comma) → run of non-word chars (just ",").
971        assert_eq!(word_range_at(text, 5), (5, 6));
972        // Byte 6 (the space) → run of non-word chars (just " ").
973        assert_eq!(word_range_at(text, 6), (6, 7));
974        // Byte 7 (start of "world") → "world".
975        assert_eq!(word_range_at(text, 7), (7, 12));
976        // Byte 12 ("!") → "!".
977        assert_eq!(word_range_at(text, 12), (12, 13));
978    }
979
980    #[test]
981    fn word_range_at_treats_apostrophe_and_underscore_as_word_chars() {
982        // Contractions stay one word.
983        assert_eq!(word_range_at("don't stop", 2), (0, 5));
984        // Identifier-style.
985        assert_eq!(word_range_at("foo_bar baz", 4), (0, 7));
986    }
987
988    #[test]
989    fn word_range_at_handles_end_of_text_and_empty() {
990        let text = "hello";
991        // Byte at len → snaps back into the trailing word.
992        assert_eq!(word_range_at(text, 5), (0, 5));
993        // Empty text → (0, 0).
994        assert_eq!(word_range_at("", 0), (0, 0));
995    }
996
997    #[test]
998    fn word_range_at_clamps_off_utf8_boundary() {
999        // 'é' is two bytes; byte=1 sits inside the codepoint and snaps
1000        // back to byte 0, then expands into the run of non-ASCII word chars.
1001        let text = "café";
1002        let (lo, hi) = word_range_at(text, 1);
1003        assert_eq!((lo, hi), (0, text.len()));
1004    }
1005
1006    #[test]
1007    fn line_range_at_returns_line_around_byte() {
1008        let text = "first\nsecond line\nthird";
1009        // First line: bytes 0..5 ("first"), \n at byte 5.
1010        assert_eq!(line_range_at(text, 0), (0, 5));
1011        assert_eq!(line_range_at(text, 3), (0, 5));
1012        assert_eq!(line_range_at(text, 5), (0, 5));
1013        // Second line: bytes 6..17 ("second line"), \n at byte 17.
1014        assert_eq!(line_range_at(text, 6), (6, 17));
1015        assert_eq!(line_range_at(text, 12), (6, 17));
1016        assert_eq!(line_range_at(text, 17), (6, 17));
1017        // Third (final, no trailing \n) line: bytes 18..23.
1018        assert_eq!(line_range_at(text, 18), (18, 23));
1019        assert_eq!(line_range_at(text, 23), (18, 23));
1020    }
1021
1022    #[test]
1023    fn line_range_at_handles_empty_and_single_line() {
1024        assert_eq!(line_range_at("", 0), (0, 0));
1025        assert_eq!(line_range_at("just one line", 4), (0, 13));
1026    }
1027}