1use crate::env::RuntimeState;
4use crate::{TextAffinity, TextPosition};
5use fission_ir::{CoreIR, Op, WidgetId};
6use serde::{Deserialize, Serialize};
7use std::{error::Error, fmt};
8
9#[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 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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub enum SelectionRegionCommand {
60 Clear,
61 SelectAll,
62 Select(TextRegionSelection),
63}
64
65#[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 pub fn selection(self, state: &RuntimeState) -> Option<TextRegionSelection> {
83 state.selectable_text.region_selection(self.id)
84 }
85
86 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 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(®ion_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(®ion_id) else {
252 return Vec::new();
253 };
254 let mut members = Vec::new();
255 for child in ®ion.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(®ion_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(®ion_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}