Skip to main content

fission_core/
selection.rs

1//! Coordinated selection across read-only text descendants.
2
3use crate::env::RuntimeState;
4use crate::{TextAffinity, TextPosition};
5use fission_ir::{CoreIR, Op, WidgetId};
6use serde::{Deserialize, Serialize};
7use std::{error::Error, fmt};
8
9/// A position in one selectable descendant of a [`SelectionRegion`](crate::ui::SelectionRegion).
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub struct TextRegionPosition {
12    pub node_id: WidgetId,
13    pub offset: TextPosition,
14}
15
16impl TextRegionPosition {
17    /// Creates a region position after validating its UTF-8 offset.
18    pub fn new(
19        node_id: WidgetId,
20        text: &str,
21        offset: usize,
22    ) -> Result<Self, crate::text_editing::TextOffsetError> {
23        Ok(Self {
24            node_id,
25            offset: TextPosition::from_utf8(text, offset)?,
26        })
27    }
28
29    pub const fn at(node_id: WidgetId, offset: TextPosition) -> Self {
30        Self { node_id, offset }
31    }
32}
33
34/// A directional selection which can span several read-only text nodes.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub struct TextRegionSelection {
37    pub base: TextRegionPosition,
38    pub extent: TextRegionPosition,
39    pub affinity: TextAffinity,
40}
41
42impl TextRegionSelection {
43    pub const fn collapsed(at: TextRegionPosition) -> Self {
44        Self {
45            base: at,
46            extent: at,
47            affinity: TextAffinity::Downstream,
48        }
49    }
50
51    pub fn is_collapsed(self) -> bool {
52        self.base.node_id == self.extent.node_id
53            && self.base.offset.utf8_offset() == self.extent.offset.utf8_offset()
54    }
55}
56
57/// Programmatic operation for a selection region.
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub enum SelectionRegionCommand {
60    Clear,
61    SelectAll,
62    Select(TextRegionSelection),
63}
64
65/// Stable handle used to inspect or update a selection region's retained state.
66#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
67pub struct SelectionRegionController {
68    id: WidgetId,
69}
70
71impl SelectionRegionController {
72    pub const fn new(id: WidgetId) -> Self {
73        Self { id }
74    }
75
76    pub const fn id(self) -> WidgetId {
77        self.id
78    }
79
80    /// Reads the retained selection, for example from `ViewHandle::runtime()`
81    /// during a declarative build.
82    pub fn selection(self, state: &RuntimeState) -> Option<TextRegionSelection> {
83        state.selectable_text.region_selection(self.id)
84    }
85
86    /// Applies a selection command to the current lowered tree.
87    pub fn apply(
88        self,
89        state: &mut RuntimeState,
90        ir: &CoreIR,
91        command: SelectionRegionCommand,
92    ) -> Result<(), SelectionRegionError> {
93        apply_region_command(&mut state.selectable_text, ir, self.id, command)
94    }
95
96    /// Selects a range expressed in Unicode scalar offsets into the region's
97    /// combined accessibility value.
98    pub fn select_scalar_range(
99        self,
100        state: &mut RuntimeState,
101        ir: &CoreIR,
102        base: usize,
103        extent: usize,
104        affinity: TextAffinity,
105    ) -> Result<(), SelectionRegionError> {
106        let document =
107            region_document(ir, self.id).ok_or(SelectionRegionError::MissingRegion(self.id))?;
108        let base = TextPosition::from_scalar_offset(&document.text, base).map_err(|_| {
109            SelectionRegionError::InvalidOffset {
110                node: self.id,
111                offset: base,
112            }
113        })?;
114        let extent = TextPosition::from_scalar_offset(&document.text, extent).map_err(|_| {
115            SelectionRegionError::InvalidOffset {
116                node: self.id,
117                offset: extent,
118            }
119        })?;
120        let base = document
121            .position_at(base.utf8_offset())
122            .ok_or(SelectionRegionError::EmptyRegion(self.id))?;
123        let extent = document
124            .position_at(extent.utf8_offset())
125            .ok_or(SelectionRegionError::EmptyRegion(self.id))?;
126        set_region_selection(
127            &mut state.selectable_text,
128            self.id,
129            &document,
130            TextRegionSelection {
131                base,
132                extent,
133                affinity,
134            },
135        )
136    }
137}
138
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub enum SelectionRegionError {
141    MissingRegion(WidgetId),
142    NotAMember { region: WidgetId, node: WidgetId },
143    InvalidOffset { node: WidgetId, offset: usize },
144    EmptyRegion(WidgetId),
145}
146
147impl fmt::Display for SelectionRegionError {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        match self {
150            Self::MissingRegion(id) => {
151                write!(f, "selection region {id} is not in the current tree")
152            }
153            Self::NotAMember { region, node } => {
154                write!(
155                    f,
156                    "text node {node} is not a member of selection region {region}"
157                )
158            }
159            Self::InvalidOffset { node, offset } => {
160                write!(
161                    f,
162                    "offset {offset} is invalid for selectable text node {node}"
163                )
164            }
165            Self::EmptyRegion(id) => write!(f, "selection region {id} has no selectable text"),
166        }
167    }
168}
169
170impl Error for SelectionRegionError {}
171
172#[derive(Clone, Debug)]
173pub(crate) struct RegionDocument {
174    pub text: String,
175    pub members: Vec<RegionMember>,
176}
177
178#[derive(Clone, Debug)]
179pub(crate) struct RegionMember {
180    pub node_id: WidgetId,
181    pub text: String,
182    pub document_start: usize,
183}
184
185impl RegionDocument {
186    pub fn position_offset(&self, position: TextRegionPosition) -> Option<usize> {
187        let member = self
188            .members
189            .iter()
190            .find(|member| member.node_id == position.node_id)?;
191        let local = position.offset.utf8_offset();
192        (local <= member.text.len() && member.text.is_char_boundary(local))
193            .then_some(member.document_start + local)
194    }
195
196    fn position_at(&self, document_offset: usize) -> Option<TextRegionPosition> {
197        let offset = document_offset.min(self.text.len());
198        let member = self
199            .members
200            .iter()
201            .rev()
202            .find(|member| member.document_start <= offset)?;
203        let local = offset
204            .saturating_sub(member.document_start)
205            .min(member.text.len());
206        Some(TextRegionPosition::at(
207            member.node_id,
208            TextPosition::floor(&member.text, local),
209        ))
210    }
211
212    pub fn selected_text(&self, selection: TextRegionSelection) -> Option<String> {
213        let base = self.position_offset(selection.base)?;
214        let extent = self.position_offset(selection.extent)?;
215        let start = base.min(extent);
216        let end = base.max(extent);
217        self.text.get(start..end).map(ToOwned::to_owned)
218    }
219}
220
221pub(crate) fn region_document(ir: &CoreIR, region_id: WidgetId) -> Option<RegionDocument> {
222    let node = ir.nodes.get(&region_id)?;
223    let semantics = match &node.op {
224        Op::Semantics(semantics) => semantics,
225        _ => return None,
226    };
227    let config = semantics.selection_region.as_ref()?;
228    if config.excluded {
229        return None;
230    }
231    Some(document_from_members(
232        selectable_members(ir, region_id),
233        &config.separator,
234        ir,
235    ))
236}
237
238pub(crate) fn implicit_document(ir: &CoreIR, node_id: WidgetId) -> Option<RegionDocument> {
239    let semantics = selectable_semantics(ir, node_id)?;
240    Some(document_from_values(
241        [(node_id, semantics.value.clone().unwrap_or_default())],
242        "",
243    ))
244}
245
246pub(crate) fn document_for_selection_owner(ir: &CoreIR, owner: WidgetId) -> Option<RegionDocument> {
247    region_document(ir, owner).or_else(|| implicit_document(ir, owner))
248}
249
250pub(crate) fn selectable_members(ir: &CoreIR, region_id: WidgetId) -> Vec<WidgetId> {
251    let Some(region) = ir.nodes.get(&region_id) else {
252        return Vec::new();
253    };
254    let mut members = Vec::new();
255    for child in &region.children {
256        collect_members(ir, *child, &mut members);
257    }
258    members
259}
260
261pub(crate) fn selectable_members_in_subtree(ir: &CoreIR, root: WidgetId) -> Vec<WidgetId> {
262    let mut members = Vec::new();
263    collect_members(ir, root, &mut members);
264    members
265}
266
267fn collect_members(ir: &CoreIR, node_id: WidgetId, members: &mut Vec<WidgetId>) {
268    let Some(node) = ir.nodes.get(&node_id) else {
269        return;
270    };
271    if let Op::Semantics(semantics) = &node.op {
272        if semantics.selection_region.is_some() {
273            return;
274        }
275        if semantics.selectable_text && !semantics.disabled {
276            members.push(node_id);
277            return;
278        }
279    }
280    for child in &node.children {
281        collect_members(ir, *child, members);
282    }
283}
284
285fn document_from_members(members: Vec<WidgetId>, separator: &str, ir: &CoreIR) -> RegionDocument {
286    document_from_values(
287        members.into_iter().filter_map(|id| {
288            selectable_semantics(ir, id)
289                .map(|semantics| (id, semantics.value.clone().unwrap_or_default()))
290        }),
291        separator,
292    )
293}
294
295fn document_from_values(
296    values: impl IntoIterator<Item = (WidgetId, String)>,
297    separator: &str,
298) -> RegionDocument {
299    let mut text = String::new();
300    let mut members = Vec::new();
301    for (index, (node_id, value)) in values.into_iter().enumerate() {
302        if index > 0 {
303            text.push_str(separator);
304        }
305        let document_start = text.len();
306        text.push_str(&value);
307        members.push(RegionMember {
308            node_id,
309            text: value,
310            document_start,
311        });
312    }
313    RegionDocument { text, members }
314}
315
316pub(crate) fn selectable_semantics(
317    ir: &CoreIR,
318    node_id: WidgetId,
319) -> Option<&fission_ir::Semantics> {
320    match &ir.nodes.get(&node_id)?.op {
321        Op::Semantics(semantics) if semantics.selectable_text && !semantics.disabled => {
322            Some(semantics)
323        }
324        _ => None,
325    }
326}
327
328pub(crate) fn apply_region_command(
329    states: &mut crate::env::SelectableTextStateMap,
330    ir: &CoreIR,
331    region_id: WidgetId,
332    command: SelectionRegionCommand,
333) -> Result<(), SelectionRegionError> {
334    let document = document_for_selection_owner(ir, region_id)
335        .ok_or(SelectionRegionError::MissingRegion(region_id))?;
336    if document.members.is_empty() {
337        return Err(SelectionRegionError::EmptyRegion(region_id));
338    }
339    match command {
340        SelectionRegionCommand::Clear => {
341            states.clear_region(region_id, &document);
342            Ok(())
343        }
344        SelectionRegionCommand::SelectAll => {
345            let first = &document.members[0];
346            let last = document.members.last().expect("non-empty region");
347            let selection = TextRegionSelection {
348                base: TextRegionPosition::at(first.node_id, TextPosition::START),
349                extent: TextRegionPosition::at(last.node_id, TextPosition::at_end(&last.text)),
350                affinity: TextAffinity::Downstream,
351            };
352            set_region_selection(states, region_id, &document, selection)
353        }
354        SelectionRegionCommand::Select(selection) => {
355            set_region_selection(states, region_id, &document, selection)
356        }
357    }
358}
359
360pub(crate) fn set_region_selection(
361    states: &mut crate::env::SelectableTextStateMap,
362    region_id: WidgetId,
363    document: &RegionDocument,
364    selection: TextRegionSelection,
365) -> Result<(), SelectionRegionError> {
366    for position in [selection.base, selection.extent] {
367        let Some(member) = document
368            .members
369            .iter()
370            .find(|member| member.node_id == position.node_id)
371        else {
372            return Err(SelectionRegionError::NotAMember {
373                region: region_id,
374                node: position.node_id,
375            });
376        };
377        let offset = position.offset.utf8_offset();
378        if offset > member.text.len() || !member.text.is_char_boundary(offset) {
379            return Err(SelectionRegionError::InvalidOffset {
380                node: position.node_id,
381                offset,
382            });
383        }
384    }
385
386    let base = document
387        .position_offset(selection.base)
388        .expect("validated base");
389    let extent = document
390        .position_offset(selection.extent)
391        .expect("validated extent");
392    let start = base.min(extent);
393    let end = base.max(extent);
394
395    for member in &document.members {
396        let member_start = member.document_start;
397        let member_end = member_start + member.text.len();
398        let local_start = start.max(member_start).min(member_end) - member_start;
399        let local_end = end.max(member_start).min(member_end) - member_start;
400        let state = states.states.entry(member.node_id).or_default();
401        state.anchor = local_start;
402        state.caret = local_end;
403        state.selecting = false;
404    }
405    states.region_mut_or_default(region_id).selection = Some(selection);
406    Ok(())
407}
408
409pub(crate) fn clear_other_regions(
410    states: &mut crate::env::SelectableTextStateMap,
411    ir: &CoreIR,
412    active_region: WidgetId,
413) {
414    let stale_regions: Vec<WidgetId> = states
415        .regions
416        .keys()
417        .copied()
418        .filter(|id| *id != active_region)
419        .collect();
420    for region_id in stale_regions {
421        if let Some(document) = document_for_selection_owner(ir, region_id) {
422            states.clear_region(region_id, &document);
423        } else if let Some(state) = states.regions.get_mut(&region_id) {
424            state.selection = None;
425            state.selecting = false;
426        }
427    }
428}
429
430pub(crate) fn reconcile_selection_state(
431    states: &mut crate::env::SelectableTextStateMap,
432    ir: &CoreIR,
433) {
434    let active_text: std::collections::HashSet<WidgetId> = ir
435        .nodes
436        .iter()
437        .filter_map(|(id, node)| match &node.op {
438            Op::Semantics(semantics) if semantics.selectable_text && !semantics.disabled => {
439                Some(*id)
440            }
441            _ => None,
442        })
443        .collect();
444    states.states.retain(|id, _| active_text.contains(id));
445
446    let active_regions: std::collections::HashSet<WidgetId> = ir
447        .nodes
448        .iter()
449        .filter_map(|(id, node)| match &node.op {
450            Op::Semantics(semantics)
451                if semantics
452                    .selection_region
453                    .as_ref()
454                    .is_some_and(|region| !region.excluded) =>
455            {
456                Some(*id)
457            }
458            Op::Semantics(semantics) if semantics.selectable_text && !semantics.disabled => {
459                Some(*id)
460            }
461            _ => None,
462        })
463        .collect();
464    states.regions.retain(|id, _| active_regions.contains(id));
465
466    let retained: Vec<(WidgetId, TextRegionSelection)> = states
467        .regions
468        .iter()
469        .filter_map(|(id, state)| state.selection.map(|selection| (*id, selection)))
470        .collect();
471    for (region_id, selection) in retained {
472        let Some(document) = document_for_selection_owner(ir, region_id) else {
473            states.regions.remove(&region_id);
474            continue;
475        };
476        if set_region_selection(states, region_id, &document, selection).is_err() {
477            states.clear_region(region_id, &document);
478        }
479    }
480}