1use std::collections::HashMap;
17
18use std::cell::OnceCell;
19
20use frus_core::Color;
21
22use crate::interaction::WidgetId;
23use crate::selectiontoolbar::{SelectionToolbar, ToolbarContext, ToolbarItem};
24use crate::theme::Theme;
25use crate::widget::Widget;
26use crate::widgettheme::TextSelectionTheme;
27
28#[derive(Copy, Clone, Debug, PartialEq, Eq)]
30pub struct RegionPoint {
31 pub text: WidgetId,
33 pub index: usize,
35}
36
37#[derive(Clone, Debug, PartialEq)]
40pub struct RegionSelection {
41 pub area: WidgetId,
43 pub anchor: RegionPoint,
45 pub extent: RegionPoint,
47 pub ranges: HashMap<WidgetId, (usize, usize)>,
49 pub all: bool,
51 pub bar: bool,
54 pub handles: bool,
57 pub start: Option<RegionPoint>,
60 pub end: Option<RegionPoint>,
62}
63
64impl RegionSelection {
65 pub fn new(
68 area: WidgetId,
69 anchor: RegionPoint,
70 extent: RegionPoint,
71 stops: &[(WidgetId, usize)],
72 ) -> Self {
73 let ranges = region_ranges(stops, anchor, extent);
74 let all = !stops.is_empty()
75 && stops
76 .iter()
77 .all(|(id, len)| *len == 0 || ranges.get(id) == Some(&(0, *len)));
78 let covered = stops
79 .iter()
80 .filter_map(|(id, _)| ranges.get(id).map(|range| (*id, *range)));
81 let (mut start, mut end) = (None, None);
82 for (id, (low, high)) in covered {
83 start.get_or_insert(RegionPoint {
84 text: id,
85 index: low,
86 });
87 end = Some(RegionPoint {
88 text: id,
89 index: high,
90 });
91 }
92 Self {
93 area,
94 anchor,
95 extent,
96 ranges,
97 all,
98 bar: false,
99 handles: false,
100 start,
101 end,
102 }
103 }
104
105 #[must_use]
108 pub fn with_bar(mut self, bar: bool) -> Self {
109 self.bar = bar;
110 self.handles = bar;
111 self
112 }
113
114 pub fn is_empty(&self) -> bool {
117 self.ranges.is_empty()
118 }
119}
120
121pub fn region_ranges(
130 stops: &[(WidgetId, usize)],
131 anchor: RegionPoint,
132 extent: RegionPoint,
133) -> HashMap<WidgetId, (usize, usize)> {
134 let position = |point: RegionPoint| {
135 stops
136 .iter()
137 .position(|(id, _)| *id == point.text)
138 .map(|at| (at, point.index.min(stops[at].1)))
139 };
140 let (Some(a), Some(b)) = (position(anchor), position(extent)) else {
141 return HashMap::new();
142 };
143 let (start, end) = if a <= b { (a, b) } else { (b, a) };
144 let mut ranges = HashMap::new();
145 for (at, (id, len)) in stops.iter().enumerate().take(end.0 + 1).skip(start.0) {
146 let low = if at == start.0 { start.1 } else { 0 };
147 let high = if at == end.0 { end.1 } else { *len };
148 if low < high {
149 ranges.insert(*id, (low, high));
150 }
151 }
152 ranges
153}
154
155pub fn region_copy(
159 texts: &[(WidgetId, &str)],
160 ranges: &HashMap<WidgetId, (usize, usize)>,
161) -> String {
162 let mut lines: Vec<String> = Vec::new();
163 for (id, text) in texts {
164 if let Some((start, end)) = ranges.get(id) {
165 lines.push(text.chars().skip(*start).take(end - start).collect());
166 }
167 }
168 lines.join("\n")
169}
170
171#[derive(Copy, Clone, Debug, PartialEq)]
174pub struct TextStop {
175 pub id: WidgetId,
177 pub rect: crate::Rect,
179 pub area: WidgetId,
181}
182
183pub struct SelectionArea<Msg = crate::callback::Callback> {
210 inner: Box<dyn Widget<Msg>>,
211 toolbar_build: Option<ToolbarBuild<Msg>>,
213 toolbars: [OnceCell<Option<Box<dyn Widget<Msg>>>>; ToolbarContext::VARIANTS as usize],
216 look: TextSelectionTheme,
218}
219
220type ToolbarBuild<Msg> = Box<dyn Fn(&ToolbarContext) -> Option<Box<dyn Widget<Msg>>>>;
222
223impl<Msg: Clone + 'static> SelectionArea<Msg> {
224 pub fn around(child: impl Widget<Msg> + 'static) -> Self {
226 Self {
227 inner: Box::new(child),
228 toolbar_build: None,
229 toolbars: Default::default(),
230 look: TextSelectionTheme::default(),
231 }
232 }
233
234 #[must_use]
250 pub fn selection_toolbar(
251 mut self,
252 build: impl Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>> + 'static,
253 ) -> Self {
254 self.toolbar_build = Some(Box::new(move |context: &ToolbarContext| {
255 let mut items = Vec::new();
256 if context.has_selection {
257 items.push(ToolbarItem::copy());
258 }
259 if !context.all_selected {
260 items.push(ToolbarItem::select_all());
261 }
262 let items = build(context, items);
263 (!items.is_empty())
264 .then(|| Box::new(SelectionToolbar::new(items)) as Box<dyn Widget<Msg>>)
265 }));
266 self.toolbars = Default::default();
267 self
268 }
269
270 #[must_use]
273 pub fn selection_color(mut self, color: Color) -> Self {
274 self.look.selection_color = Some(color);
275 self
276 }
277
278 #[must_use]
281 pub fn handle_color(mut self, color: Color) -> Self {
282 self.look.handle_color = Some(color);
283 self
284 }
285}
286
287impl<Msg> SelectionArea<Msg> {
288 fn restyle(&self, base: frus_layout::Style) -> frus_layout::Style {
290 base
291 }
292}
293
294crate::transparent::forward_transparent!(SelectionArea {
295 fn theme_override(&self, inherited: &Theme) -> Option<Box<Theme>> {
298 let mut mine = inherited.clone();
299 mine.widgets.text.selectable = true;
300 let look = &mut mine.widgets.text_selection;
301 look.selection_color = self.look.selection_color.or(look.selection_color);
302 look.handle_color = self.look.handle_color.or(look.handle_color);
303 Some(
304 self.inner
305 .theme_override(&mine)
306 .unwrap_or_else(|| Box::new(mine)),
307 )
308 }
309
310 fn area_toolbar(&self, context: ToolbarContext) -> Option<Option<&dyn Widget<Msg>>> {
313 let build = self.toolbar_build.as_ref()?;
314 let bar = self.toolbars[usize::from(context.variant())].get_or_init(|| build(&context));
315 Some(bar.as_deref())
316 }
317
318 fn key(&self) -> Option<u64> {
320 self.inner.key()
321 }
322 fn positioned(&self) -> Option<crate::positioned::Positioning> {
323 self.inner.positioned()
324 }
325 fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
326 self.inner.media_override(inherited)
327 }
328 fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
329 self.inner.scaffold_override()
330 }
331 fn scroll_behavior_override(&self) -> Option<crate::ScrollBehavior> {
332 self.inner.scroll_behavior_override()
333 }
334 fn autofill_group(&self) -> bool {
335 self.inner.autofill_group()
336 }
337});
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 fn id(n: u64) -> WidgetId {
344 WidgetId::from_u64(n)
345 }
346
347 fn at(text: u64, index: usize) -> RegionPoint {
348 RegionPoint {
349 text: id(text),
350 index,
351 }
352 }
353
354 fn stops() -> Vec<(WidgetId, usize)> {
356 vec![(id(1), 5), (id(2), 10), (id(3), 4)]
357 }
358
359 #[test]
362 fn a_selection_takes_the_tail_the_middle_and_the_head() {
363 let ranges = region_ranges(&stops(), at(1, 2), at(3, 3));
364 assert_eq!(ranges.len(), 3);
365 assert_eq!(ranges[&id(1)], (2, 5), "from the press to the end");
366 assert_eq!(ranges[&id(2)], (0, 10), "the whole of the one between");
367 assert_eq!(ranges[&id(3)], (0, 3), "from the start to the pointer");
368 }
369
370 #[test]
373 fn dragging_upward_selects_the_same_words() {
374 let down = region_ranges(&stops(), at(1, 2), at(3, 3));
375 let up = region_ranges(&stops(), at(3, 3), at(1, 2));
376 assert_eq!(down, up);
377 }
378
379 #[test]
382 fn both_ends_in_one_text_are_the_range_between_them() {
383 assert_eq!(region_ranges(&stops(), at(2, 7), at(2, 3))[&id(2)], (3, 7));
384 assert_eq!(region_ranges(&stops(), at(2, 3), at(2, 7))[&id(2)], (3, 7));
385 assert!(region_ranges(&stops(), at(2, 4), at(2, 4)).is_empty());
386 }
387
388 #[test]
391 fn a_text_with_nothing_selected_has_no_range() {
392 let ranges = region_ranges(&stops(), at(1, 5), at(3, 0));
393 assert_eq!(ranges.len(), 1, "only the one between: {ranges:?}");
394 assert_eq!(ranges[&id(2)], (0, 10));
395 }
396
397 #[test]
400 fn an_end_is_clamped_and_a_missing_one_selects_nothing() {
401 assert_eq!(region_ranges(&stops(), at(1, 0), at(1, 99))[&id(1)], (0, 5));
402 assert!(region_ranges(&stops(), at(1, 0), at(9, 1)).is_empty());
403 }
404
405 #[test]
407 fn the_copy_is_one_line_per_text() {
408 let texts = [(id(1), "héllo"), (id(2), "second line"), (id(3), "tail")];
409 let ranges = region_ranges(&stops_for(&texts), at(1, 1), at(3, 2));
410 assert_eq!(region_copy(&texts, &ranges), "éllo\nsecond line\nta");
411 let none = region_ranges(&stops_for(&texts), at(2, 3), at(2, 3));
412 assert_eq!(region_copy(&texts, &none), "");
413 }
414
415 #[test]
418 fn a_selection_knows_when_it_holds_everything() {
419 let every = RegionSelection::new(id(9), at(1, 0), at(3, 4), &stops());
420 assert!(every.all);
421 let less = RegionSelection::new(id(9), at(1, 1), at(3, 4), &stops());
422 assert!(!less.all);
423 assert!(!RegionSelection::new(id(9), at(1, 0), at(3, 4), &stops()).bar);
424 assert!(
425 RegionSelection::new(id(9), at(1, 0), at(3, 4), &stops())
426 .with_bar(true)
427 .bar
428 );
429 }
430
431 #[test]
434 fn the_ends_are_in_reading_order() {
435 for (a, b) in [(at(1, 2), at(3, 3)), (at(3, 3), at(1, 2))] {
436 let selection = RegionSelection::new(id(9), a, b, &stops());
437 assert_eq!(selection.start, Some(at(1, 2)));
438 assert_eq!(selection.end, Some(at(3, 3)));
439 }
440 let skipped = RegionSelection::new(id(9), at(1, 5), at(3, 2), &stops());
442 assert_eq!(skipped.start, Some(at(2, 0)));
443 let nothing = RegionSelection::new(id(9), at(2, 4), at(2, 4), &stops());
444 assert_eq!((nothing.start, nothing.end), (None, None));
445 }
446
447 fn stops_for(texts: &[(WidgetId, &str)]) -> Vec<(WidgetId, usize)> {
448 texts
449 .iter()
450 .map(|(id, t)| (*id, t.chars().count()))
451 .collect()
452 }
453
454 mod frame {
456 use super::*;
457 use crate::{build_ui, Container, Flex, Point, Rect, Runtime, Size, Text, Theme};
458
459 fn tree() -> impl Widget<()> {
460 Flex::column()
461 .width(300.0)
462 .height(400.0)
463 .child(Text::new("outside").no_wrap())
464 .child(SelectionArea::around(
465 Flex::column()
466 .child(Text::new("alpha").no_wrap())
467 .child(Text::new("beta").no_wrap())
468 .child(Text::new("").no_wrap()),
469 ))
470 .child(SelectionArea::around(
471 Flex::column().child(Text::new("gamma").no_wrap()),
472 ))
473 }
474
475 fn frame(rt: &Runtime) -> crate::Ui<()> {
476 build_ui(&tree(), Size::new(300.0, 400.0), rt, &Theme::default())
477 }
478
479 #[test]
482 fn the_texts_of_an_area_are_registered_in_order_under_its_identity() {
483 let ui = frame(&Runtime::default());
484 let stops = ui.text_stops();
485 assert_eq!(stops.len(), 3, "alpha, beta, gamma: {stops:?}");
486 assert_eq!(stops[0].area, stops[1].area, "one area holds the first two");
487 assert_ne!(stops[1].area, stops[2].area, "and another holds the third");
488 assert!(stops[0].rect.y < stops[1].rect.y, "in painted order");
489 let first: Vec<_> = ui.text_stops_in(stops[0].area).map(|s| s.id).collect();
490 assert_eq!(first, vec![stops[0].id, stops[1].id]);
491 assert!(stops.iter().all(|s| s.rect.y > 10.0), "{stops:?}");
493 }
494
495 #[test]
498 fn a_press_finds_its_text_and_a_stray_drag_the_nearest() {
499 let ui = frame(&Runtime::default());
500 let stops: Vec<TextStop> = ui.text_stops().to_vec();
501 let inside = Point::new(stops[0].rect.x + 2.0, stops[0].rect.y + 2.0);
502 assert_eq!(ui.text_stop_at(inside).map(|s| s.id), Some(stops[0].id));
503 assert_eq!(
504 ui.text_stop_at(Point::new(290.0, 390.0)),
505 None,
506 "empty space"
507 );
508 let far = Point::new(5.0, 399.0);
510 assert_eq!(
511 ui.nearest_text_stop(stops[0].area, far).map(|s| s.id),
512 Some(stops[1].id)
513 );
514 assert_eq!(
515 ui.nearest_text_stop(stops[2].area, far).map(|s| s.id),
516 Some(stops[2].id)
517 );
518 let beside = Point::new(250.0, stops[1].rect.y + 3.0);
520 assert_eq!(
521 ui.nearest_text_stop(stops[0].area, beside).map(|s| s.id),
522 Some(stops[1].id)
523 );
524 }
525
526 #[test]
530 fn a_replayed_boundary_still_registers_its_texts() {
531 let tree = SelectionArea::around(
532 Container::new()
533 .repaint_boundary()
534 .child(Flex::column().child(Text::new("cached").no_wrap())),
535 );
536 let rt = Runtime::default();
537 let theme = Theme::default();
538 let size = Size::new(200.0, 100.0);
539 let first = build_ui::<()>(&tree, size, &rt, &theme);
540 let second = build_ui::<()>(&tree, size, &rt, &theme);
541 assert_eq!(
542 rt.paint_cache.borrow().last_frame_stats(),
543 (1, 0),
544 "the boundary was replayed"
545 );
546 assert_eq!(first.text_stops().len(), 1);
547 assert_eq!(second.text_stops(), first.text_stops());
548 }
549
550 #[test]
554 fn a_selection_repaints_a_boundary_that_was_cached() {
555 let tree = SelectionArea::around(
556 Container::new()
557 .repaint_boundary()
558 .child(Flex::column().child(Text::new("cached").no_wrap())),
559 );
560 let mut rt = Runtime::default();
561 let theme = Theme::default();
562 let size = Size::new(200.0, 100.0);
563 let painted = |ui: &crate::Ui<()>| {
564 ui.scene()
565 .primitives()
566 .iter()
567 .filter(|p| matches!(p, crate::Primitive::Rect { color, .. } if *color == theme.selection))
568 .count()
569 };
570 let bare = build_ui::<()>(&tree, size, &rt, &theme);
571 assert_eq!(painted(&bare), 0);
572 let id = bare.text_stops()[0].id;
573 let area = bare.text_stops()[0].area;
574 let point = RegionPoint { text: id, index: 0 };
575 let end = RegionPoint { text: id, index: 3 };
576 rt.region = Some(RegionSelection::new(area, point, end, &[(id, 6)]));
577 let selected = build_ui::<()>(&tree, size, &rt, &theme);
578 assert_eq!(
579 painted(&selected),
580 1,
581 "the cached subtree was painted again"
582 );
583 let _: Rect = selected.text_stops()[0].rect;
584 }
585
586 #[test]
590 fn an_areas_colours_paint_its_selection() {
591 let (red, blue) = (
592 frus_core::Color::rgb(1.0, 0.0, 0.0),
593 frus_core::Color::rgb(0.0, 0.0, 1.0),
594 );
595 let words = || Flex::column().child(Text::new("colour").no_wrap());
596 let theme = Theme::default();
597 let size = Size::new(200.0, 100.0);
598 let count = |ui: &crate::Ui<()>| {
599 let (mut rects, mut paths) = (Vec::new(), Vec::new());
600 for p in ui.scene().primitives() {
601 match p {
602 crate::Primitive::Rect { color, .. } => rects.push(*color),
603 crate::Primitive::Path {
604 fill: Some(color), ..
605 } => paths.push(*color),
606 _ => {}
607 }
608 }
609 (rects, paths)
610 };
611 let selected = |tree: &dyn Widget<()>| {
612 let mut rt = Runtime::default();
613 let bare = build_ui::<()>(tree, size, &rt, &theme);
614 let stop = bare.text_stops()[0];
615 let (from, to) = (
616 RegionPoint {
617 text: stop.id,
618 index: 1,
619 },
620 RegionPoint {
621 text: stop.id,
622 index: 4,
623 },
624 );
625 rt.region =
626 Some(RegionSelection::new(stop.area, from, to, &[(stop.id, 6)]).with_bar(true));
627 count(&build_ui::<()>(tree, size, &rt, &theme))
628 };
629
630 let tailored = SelectionArea::around(words())
631 .selection_color(red)
632 .handle_color(blue);
633 let (rects, paths) = selected(&tailored);
634 assert!(rects.contains(&red), "the highlight: {rects:?}");
635 assert!(!rects.contains(&theme.selection));
636 assert_eq!(paths.iter().filter(|c| **c == blue).count(), 2, "{paths:?}");
637
638 let plain = SelectionArea::around(words());
639 let (rects, paths) = selected(&plain);
640 assert!(rects.contains(&theme.selection));
641 let primary = theme.scheme.primary;
642 assert_eq!(paths.iter().filter(|c| **c == primary).count(), 2);
643 }
644
645 #[test]
648 fn the_area_answers_for_its_bar() {
649 let context = crate::ToolbarContext {
650 has_selection: true,
651 can_paste: false,
652 all_selected: false,
653 };
654 let plain = SelectionArea::<()>::around(Text::new("x"));
655 assert!(plain.area_toolbar(context).is_none());
656 let copy_only =
657 SelectionArea::<()>::around(Text::new("x")).selection_toolbar(|_, mut items| {
658 items.truncate(1);
659 items
660 });
661 assert!(matches!(copy_only.area_toolbar(context), Some(Some(_))));
662 let wrapped = crate::Keyed::new(7u64, copy_only);
663 assert!(matches!(wrapped.area_toolbar(context), Some(Some(_))));
664 let none =
665 SelectionArea::<()>::around(Text::new("x")).selection_toolbar(|_, _| Vec::new());
666 assert!(matches!(none.area_toolbar(context), Some(None)));
667 let theme = Theme::default();
669 let scoped = Widget::<()>::theme_override(&none, &theme).expect("a theme");
670 assert!(scoped.widgets.text.selectable);
671 }
672 }
673}