1use std::any::Any;
4use std::cell::{Cell, OnceCell};
5
6use frus_core::{
7 fits, Color, FontWeight, MaskShader, Point, Rect, ResolvedTextStyle, Scene, ShaderMask,
8 TextAlign, TextBlock, TextOverflow, TextStyle,
9};
10use frus_layout::{Dimension, Style};
11use frus_text::TextLayout;
12
13use crate::interaction::{Key, Status};
14use crate::runtime::Edit;
15use crate::selectiontoolbar::{SelectionToolbar, ToolbarContext, ToolbarItem};
16use crate::textinput::{
17 handle_at, handles_in, move_caret, paint_handle, paint_handles, selection_box_in, word_range,
18 ToolbarBuild,
19};
20use crate::theme::Theme;
21use crate::widget::Widget;
22use crate::widgettheme::DefaultTextStyle;
23
24pub struct Text {
30 content: String,
31 style: TextStyle,
32 wrap: Option<bool>,
35 overflow: Option<TextOverflow>,
37 max_lines: Option<usize>,
40 align: Option<TextAlign>,
42 heading: bool,
45 shrinkable: bool,
54 selection: Option<Box<Selection>>,
58 shaping: Cell<Option<Shaping>>,
66 scale: f32,
70 area_bars: OnceCell<Box<AreaBars>>,
74}
75
76type AreaBars = [OnceCell<Box<dyn Any>>; ToolbarContext::VARIANTS as usize];
78
79struct Selection {
81 on: bool,
84 toolbar_build: Option<Box<dyn Any>>,
88 toolbars: [OnceCell<Box<dyn Any>>; ToolbarContext::VARIANTS as usize],
92}
93
94#[derive(Copy, Clone)]
97struct Shaping {
98 style: ResolvedTextStyle,
99 wrap: bool,
100}
101
102pub(crate) struct Resolved {
112 pub style: ResolvedTextStyle,
113 pub align: TextAlign,
114 pub wrap: bool,
115 pub overflow: TextOverflow,
116 pub max_lines: Option<usize>,
117 pub shrinkable: bool,
120}
121
122pub(crate) const ELLIPSIS: &str = "…";
124
125pub(crate) fn truncate(content: &str, style: &ResolvedTextStyle, max_width: f32) -> String {
138 let measure = |text: &str| frus_text::measure_resolved(text, style).width;
139 if measure(content) <= max_width {
140 return content.to_string();
141 }
142 let mut chars: Vec<char> = content.chars().collect();
143 while !chars.is_empty() {
144 chars.pop();
145 let kept: String = chars.iter().collect();
146 let candidate = format!("{}{ELLIPSIS}", kept.trim_end());
147 if measure(&candidate) <= max_width {
148 return candidate;
149 }
150 }
151 ELLIPSIS.to_string()
152}
153
154pub(crate) fn ellipsise(line: &str, style: &ResolvedTextStyle, max_width: f32) -> String {
161 let measure = |text: &str| frus_text::measure_resolved(text, style).width;
162 let mut chars: Vec<char> = line.trim_end().chars().collect();
163 loop {
164 let kept: String = chars.iter().collect();
165 let candidate = format!("{}{ELLIPSIS}", kept.trim_end());
166 if chars.is_empty() || max_width <= 0.0 || measure(&candidate) <= max_width {
167 return candidate;
168 }
169 chars.pop();
170 }
171}
172
173pub(crate) struct Fitted {
176 pub text: String,
178 pub too_wide: bool,
180 pub too_tall: bool,
182}
183
184impl Fitted {
185 fn over(&self) -> bool {
187 self.too_wide || self.too_tall
188 }
189}
190
191impl Text {
192 pub fn new(content: impl Into<String>) -> Self {
199 Self {
200 content: content.into(),
201 ..Self::defaults()
202 }
203 }
204
205 pub fn styled(content: impl Into<String>, style: TextStyle) -> Self {
214 Self {
215 content: content.into(),
216 style,
217 ..Self::defaults()
218 }
219 }
220
221 fn defaults() -> Self {
224 Self {
225 content: String::new(),
226 style: TextStyle::NONE,
227 wrap: None,
228 overflow: None,
229 max_lines: None,
230 align: None,
231 heading: false,
232 shrinkable: false,
233 selection: None,
234 shaping: Cell::new(None),
235 scale: frus_core::text_scale(),
236 area_bars: OnceCell::new(),
237 }
238 }
239
240 pub fn selectable(mut self) -> Self {
264 self.selection_mut().on = true;
265 self
266 }
267
268 pub fn selection_toolbar<Msg: 'static>(
277 mut self,
278 build: impl Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>> + 'static,
279 ) -> Self {
280 let build: ToolbarBuild<Msg> = Box::new(build);
281 let selection = self.selection_mut();
282 selection.toolbar_build = Some(Box::new(build));
283 selection.toolbars = Default::default();
285 self
286 }
287
288 fn selection_mut(&mut self) -> &mut Selection {
290 self.selection.get_or_insert_with(|| {
291 Box::new(Selection {
292 on: false,
293 toolbar_build: None,
294 toolbars: Default::default(),
295 })
296 })
297 }
298
299 fn selecting(&self) -> Option<&Selection> {
301 self.selection.as_deref().filter(|selection| selection.on)
302 }
303
304 fn selection_layout(&self, width: f32) -> Option<TextLayout> {
308 self.selecting()?;
309 Some(self.text_layout(width))
310 }
311
312 fn text_layout(&self, width: f32) -> TextLayout {
315 let shaping = self.shaping.get().unwrap_or_else(|| {
316 let resolved = frus_core::with_text_scale(self.scale, || self.resolved(None));
317 Shaping {
318 style: resolved.style,
319 wrap: resolved.wrap,
320 }
321 });
322 TextLayout::resolved(&self.content, &shaping.style, shaping.wrap.then_some(width))
323 }
324
325 fn remember(&self, r: &Resolved) {
327 self.shaping.set(Some(Shaping {
328 style: r.style,
329 wrap: r.wrap,
330 }));
331 }
332
333 pub fn heading(mut self) -> Self {
344 self.heading = true;
345 self
346 }
347
348 pub fn wrap(mut self) -> Self {
352 self.wrap = Some(true);
353 self
354 }
355
356 pub fn no_wrap(mut self) -> Self {
360 self.wrap = Some(false);
361 self
362 }
363
364 pub fn soft_wrap(mut self, soft_wrap: bool) -> Self {
369 self.wrap = Some(soft_wrap);
370 self
371 }
372
373 pub fn align(mut self, align: TextAlign) -> Self {
380 self.align = Some(align);
381 self
382 }
383
384 pub fn max_lines(mut self, max_lines: usize) -> Self {
390 self.max_lines = Some(max_lines.max(1));
391 self.shrinkable = true;
392 self
393 }
394
395 pub fn overflow(mut self, overflow: TextOverflow) -> Self {
400 self.overflow = Some(overflow);
401 self.shrinkable = true;
402 self
403 }
404
405 pub fn ellipsis(mut self) -> Self {
419 self.wrap = Some(false);
420 self.overflow = Some(TextOverflow::Ellipsis);
421 self.shrinkable = true;
422 self
423 }
424
425 pub fn size(mut self, size: f32) -> Self {
427 self.style.size = Some(size);
428 self
429 }
430
431 pub fn weight(mut self, weight: FontWeight) -> Self {
433 self.style.weight = Some(weight);
434 self
435 }
436
437 pub fn italic(mut self) -> Self {
439 self.style.italic = Some(true);
440 self
441 }
442
443 pub fn family(mut self, family: frus_core::FontFamily) -> Self {
454 self.style.family = Some(family);
455 self
456 }
457
458 pub fn height(mut self, height: f32) -> Self {
468 self.style.height = Some(height);
469 self
470 }
471
472 pub fn color(mut self, color: Color) -> Self {
474 self.style.color = Some(color);
475 self
476 }
477
478 pub fn underline(mut self) -> Self {
480 self.style = self.style.underline();
481 self
482 }
483
484 pub fn strikethrough(mut self) -> Self {
486 self.style = self.style.strikethrough();
487 self
488 }
489
490 pub fn decoration(mut self, decoration: frus_core::TextDecoration) -> Self {
492 self.style.decoration = Some(decoration);
493 self
494 }
495
496 pub fn decoration_color(mut self, color: Color) -> Self {
498 self.style.decoration_color = Some(color);
499 self
500 }
501}
502
503impl Text {
504 pub(crate) fn resolved(&self, theme: Option<&Theme>) -> Resolved {
517 let handed = theme.map_or(DefaultTextStyle::NONE, |t| t.widgets.text);
518 Resolved {
519 style: handed.style.merge(self.style).resolved(),
520 align: self.align.or(handed.align).unwrap_or(TextAlign::Start),
521 wrap: self.wrap.or(handed.soft_wrap).unwrap_or(true),
522 overflow: self
523 .overflow
524 .or(handed.overflow)
525 .unwrap_or(TextOverflow::Clip),
526 max_lines: self.max_lines.or(handed.max_lines),
527 shrinkable: self.shrinkable
532 || (self.overflow.is_none() && handed.overflow.is_some())
533 || (self.max_lines.is_none() && handed.max_lines.is_some()),
534 }
535 }
536
537 pub(crate) fn fitted(&self, width: f32, r: &Resolved) -> Fitted {
543 let mut text = self.content.clone();
551 let mut too_tall = false;
552 if let Some(max) = r.max_lines {
553 let spans =
556 frus_text::line_spans_resolved(&self.content, &r.style, Some(width), r.wrap);
557 if spans.len() > max {
558 too_tall = true;
559 let cut = spans[max].start;
560 let last = spans[max - 1].start;
561 text = if r.overflow == TextOverflow::Ellipsis {
562 let ended = ellipsise(&self.content[last..cut], &r.style, width);
563 format!("{}{ended}", &self.content[..last])
564 } else {
565 self.content[..cut].to_string()
566 };
567 }
568 }
569 let too_wide = !r.wrap && frus_text::measure_resolved(&text, &r.style).width > width + 0.5;
572 if too_wide && !too_tall && r.overflow == TextOverflow::Ellipsis {
573 text = ellipsise(&text, &r.style, width);
574 }
575 Fitted {
576 text,
577 too_wide,
578 too_tall,
579 }
580 }
581
582 fn fills(r: &Resolved) -> bool {
589 r.align != TextAlign::Start
590 }
591
592 fn capped(height: f32, r: &Resolved) -> f32 {
595 match r.max_lines {
596 Some(max) => height.min(r.style.line_height() * max as f32),
597 None => height,
598 }
599 }
600
601 fn paint_grips(&self, bounds: Rect, status: &Status, theme: &Theme, scene: &mut Scene) {
606 let (start, end) = status.region_grips;
608 if start.is_some() || end.is_some() {
609 let layout = self.text_layout(bounds.width);
610 let color = theme
611 .widgets
612 .text_selection
613 .handle_color_in(theme)
614 .fade(status.opacity);
615 let offset = Point::new(bounds.x, bounds.y);
616 let origin = Point::new(0.0, 0.0);
617 for (index, is_start) in [(start, true), (end, false)] {
618 if let Some(index) = index {
619 let handle = handle_at(&layout, origin, index, is_start);
620 paint_handle(scene, handle, offset, color, is_start);
621 }
622 }
623 }
624 if !(status.focused && status.handles) {
625 return;
626 }
627 let Some((start, end)) = status.selection else {
628 return;
629 };
630 let Some(layout) = self.selection_layout(bounds.width) else {
631 return;
632 };
633 let edit = Edit {
634 cursor: end,
635 anchor: Some(start),
636 composing: None,
637 };
638 let len = self.content.chars().count();
639 if let Some(handles) = handles_in(&layout, Point::new(0.0, 0.0), len, &edit) {
640 let color = theme
641 .widgets
642 .text_selection
643 .handle_color_in(theme)
644 .fade(status.opacity);
645 paint_handles(scene, handles, Point::new(bounds.x, bounds.y), color);
646 }
647 }
648
649 fn fade(bounds: Rect, horizontal: bool, r: &Resolved) -> ShaderMask {
650 let extent = if horizontal {
654 bounds.width
655 } else {
656 bounds.height
657 };
658 let run = (extent * 0.2).min(r.style.line_height() * 3.0);
659 let (from, to) = if horizontal {
660 (
661 Point::new(bounds.x + bounds.width - run, bounds.y),
662 Point::new(bounds.x + bounds.width, bounds.y),
663 )
664 } else {
665 (
666 Point::new(bounds.x, bounds.y + bounds.height - run),
667 Point::new(bounds.x, bounds.y + bounds.height),
668 )
669 };
670 ShaderMask::new(MaskShader::Linear {
671 from,
672 to,
673 from_color: Color::WHITE,
674 to_color: Color::WHITE.fade(0.0),
675 })
676 }
677}
678
679impl Text {
680 fn boxed(&self, r: &Resolved) -> Style {
686 let min_width = if r.shrinkable {
690 Dimension::Length(0.0)
691 } else {
692 Dimension::Auto
693 };
694 if r.wrap || Self::fills(r) {
698 return Style {
699 min_width,
700 ..Default::default()
701 };
702 }
703 let measured = frus_text::measure_resolved(&self.content, &r.style);
707 Style {
708 width: Dimension::Length(measured.width.ceil()),
709 height: Dimension::Length(Self::capped(measured.height, r).ceil()),
710 min_width,
711 max_width: if r.shrinkable {
718 Dimension::Percent(1.0)
719 } else {
720 Dimension::Auto
721 },
722 ..Default::default()
723 }
724 }
725}
726
727impl<Msg: Clone + 'static> Widget<Msg> for Text {
728 fn style(&self) -> Style {
729 self.boxed(&self.resolved(None))
730 }
731
732 fn style_themed(&self, theme: &Theme) -> Style {
737 let r = self.resolved(Some(theme));
738 self.remember(&r);
739 self.boxed(&r)
740 }
741
742 fn main_axis_floor(&self, theme: &Theme) -> Option<f32> {
749 let r = self.resolved(Some(theme));
750 if r.shrinkable || !(r.wrap || Self::fills(&r)) {
753 return None;
754 }
755 Some(
756 frus_text::measure_resolved(&self.content, &r.style)
757 .width
758 .ceil(),
759 )
760 }
761
762 fn text_baseline(&self, theme: &Theme) -> Option<f32> {
766 let r = self.resolved(Some(theme));
767 Some(frus_text::baseline(
768 r.style.size,
769 r.style.weight,
770 r.style.italic,
771 ))
772 }
773
774 fn measure(&self, theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
775 let r = self.resolved(Some(theme));
776 if !r.wrap && !Self::fills(&r) {
777 return None;
778 }
779 let content = self.content.clone();
780 let style = r.style;
781 let max_lines = r.max_lines;
782 let wrap = r.wrap;
783 Some(Box::new(move |max_width, _| {
784 let mut size = frus_text::measure_wrapped_resolved(
789 &content,
790 &style,
791 if wrap { max_width } else { None },
794 );
795 if let Some(max) = max_lines {
796 size.height = size.height.min(fits(style.line_height() * max as f32));
802 }
803 size
804 }))
805 }
806
807 fn measure_key(&self, theme: &Theme) -> Option<u64> {
808 let r = self.resolved(Some(theme));
809 if !r.wrap && !Self::fills(&r) {
810 return None;
811 }
812 use std::hash::{Hash, Hasher};
813 let mut hasher = std::collections::hash_map::DefaultHasher::new();
814 self.content.hash(&mut hasher);
815 r.style.size.to_bits().hash(&mut hasher);
819 r.style.weight.to_u16().hash(&mut hasher);
820 r.style.italic.hash(&mut hasher);
821 r.style.height.map(f32::to_bits).hash(&mut hasher);
825 r.style.family.hash(&mut hasher);
826 r.max_lines.hash(&mut hasher);
827 r.wrap.hash(&mut hasher);
828 Some(hasher.finish())
829 }
830
831 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
832 &[]
833 }
834
835 fn fill_axes(&self, theme: &Theme) -> crate::widget::FillAxes {
839 if Self::fills(&self.resolved(Some(theme))) {
840 crate::widget::FillAxes::WIDTH
841 } else {
842 crate::widget::FillAxes::NONE
843 }
844 }
845
846 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
847 let r = self.resolved(Some(theme));
848 self.remember(&r);
849 let color = r
850 .style
851 .color
852 .unwrap_or(theme.on_surface)
853 .fade(status.opacity);
854 let fitted = self.fitted(bounds.width, &r);
855 let own = status
859 .selection
860 .filter(|_| status.focused && self.selecting().is_some());
861 if let Some((start, end)) = status.region.or(own) {
862 let highlight = theme
863 .widgets
864 .text_selection
865 .selection_color_in(theme)
866 .fade(status.opacity);
867 {
868 let layout = self.text_layout(bounds.width);
869 let len = self.content.chars().count();
870 for rect in layout.selection_rects(start.min(len), end.min(len)) {
871 scene.fill_rect(
872 Rect::new(
873 bounds.x + rect.x,
874 bounds.y + rect.y,
875 rect.width,
876 rect.height,
877 ),
878 highlight,
879 );
880 }
881 }
882 }
883 let block = TextBlock {
884 width: (r.wrap || r.align != TextAlign::Start).then_some(bounds.width),
888 soft_wrap: r.wrap,
889 align: r.align,
890 };
891 let draw = |scene: &mut Scene| {
892 scene.text_block(
893 Point::new(bounds.x, bounds.y),
894 fitted.text.clone(),
895 &r.style,
896 color,
897 block,
898 );
899 };
900 if !fitted.over()
903 || r.overflow == TextOverflow::Visible
904 || r.overflow == TextOverflow::Ellipsis
905 {
906 draw(scene);
907 self.paint_grips(bounds, &status, theme, scene);
908 return;
909 }
910 let outer = scene.current_clip();
913 scene.set_clip(outer.intersect(bounds));
914 match r.overflow {
915 TextOverflow::Fade => {
916 let horizontal = fitted.too_wide && !fitted.too_tall;
917 scene.masked(Self::fade(bounds, horizontal, &r), draw);
918 }
919 _ => draw(scene),
920 }
921 scene.set_clip(outer);
922 self.paint_grips(bounds, &status, theme, scene);
923 }
924
925 fn on_click(&self) -> Option<Msg> {
926 None
927 }
928
929 fn focusable(&self) -> bool {
933 self.selecting().is_some()
934 }
935
936 fn text_value(&self) -> Option<&str> {
940 self.selecting().map(|_| self.content.as_str())
941 }
942
943 fn takes_typing(&self) -> bool {
944 false
945 }
946
947 fn selection_text(&self) -> Option<&str> {
949 (!self.content.is_empty()).then_some(self.content.as_str())
950 }
951
952 fn region_toolbar(
955 &self,
956 context: ToolbarContext,
957 width: f32,
958 range: (usize, usize),
959 ) -> Option<(Rect, &dyn Widget<Msg>)> {
960 let layout = self.text_layout(width);
961 let len = self.content.chars().count();
962 let edit = Edit {
963 cursor: range.1,
964 anchor: Some(range.0),
965 composing: None,
966 };
967 let anchor = selection_box_in(&layout, Point::new(0.0, 0.0), len, &edit);
968 let bars = self.area_bars.get_or_init(Box::default);
969 let bar = bars[usize::from(context.variant())]
970 .get_or_init(|| {
971 let mut items: Vec<ToolbarItem<Msg>> = Vec::new();
972 if context.has_selection {
973 items.push(ToolbarItem::copy());
974 }
975 if !context.all_selected {
976 items.push(ToolbarItem::select_all());
977 }
978 let bar: Option<Box<dyn Widget<Msg>>> = (!items.is_empty())
979 .then(|| Box::new(SelectionToolbar::new(items)) as Box<dyn Widget<Msg>>);
980 Box::new(bar) as Box<dyn Any>
981 })
982 .downcast_ref::<Option<Box<dyn Widget<Msg>>>>()?
983 .as_deref()?;
984 Some((anchor, bar))
985 }
986
987 fn selection_grip(
988 &self,
989 width: f32,
990 index: usize,
991 start: bool,
992 ) -> Option<crate::SelectionHandle> {
993 let len = self.content.chars().count();
994 Some(handle_at(
995 &self.text_layout(width),
996 Point::new(0.0, 0.0),
997 index.min(len),
998 start,
999 ))
1000 }
1001
1002 fn selection_hit(&self, local_x: f32, local_y: f32, width: f32) -> Option<usize> {
1003 if self.content.is_empty() {
1004 return None;
1005 }
1006 Some(
1007 self.text_layout(width)
1008 .hit_test(Point::new(local_x, local_y)),
1009 )
1010 }
1011
1012 fn cursor_at(
1013 &self,
1014 local_x: f32,
1015 local_y: f32,
1016 width: f32,
1017 _scroll_cursor: usize,
1018 ) -> Option<usize> {
1019 let layout = self.selection_layout(width)?;
1020 Some(layout.hit_test(Point::new(local_x, local_y)))
1021 }
1022
1023 fn word_at(&self, index: usize) -> Option<(usize, usize)> {
1024 self.selecting()?;
1025 let chars: Vec<char> = self.content.chars().collect();
1026 word_range(&chars, index)
1027 }
1028
1029 fn selected_text(&self, edit: &Edit) -> Option<String> {
1030 self.selecting()?;
1031 let chars: Vec<char> = self.content.chars().collect();
1032 let len = chars.len();
1033 let (start, end) = edit.selection_range()?;
1034 let (start, end) = (start.min(len), end.min(len));
1035 (start < end).then(|| chars[start..end].iter().collect())
1036 }
1037
1038 fn on_edit(&self, edit: &mut Edit, key: &Key) -> Option<Msg> {
1041 self.selecting()?;
1042 let chars: Vec<char> = self.content.chars().collect();
1043 let len = chars.len();
1044 let mut cursor = edit.cursor.min(len);
1045 let mut anchor = edit.anchor.map(|anchor| anchor.min(len));
1046 if move_caret(&chars, &mut cursor, &mut anchor, key) {
1047 edit.cursor = cursor;
1048 edit.anchor = anchor;
1049 }
1050 None
1051 }
1052
1053 fn selection_handles(
1054 &self,
1055 width: f32,
1056 edit: &Edit,
1057 _scroll_y: f32,
1058 ) -> Option<[crate::SelectionHandle; 2]> {
1059 let layout = self.selection_layout(width)?;
1060 let len = self.content.chars().count();
1061 handles_in(&layout, Point::new(0.0, 0.0), len, edit)
1062 }
1063
1064 fn selection_anchor(&self, width: f32, edit: &Edit, _scroll_y: f32) -> Option<Rect> {
1065 let layout = self.selection_layout(width)?;
1066 let len = self.content.chars().count();
1067 Some(selection_box_in(&layout, Point::new(0.0, 0.0), len, edit))
1068 }
1069
1070 fn selection_toolbar(&self, context: ToolbarContext) -> Option<&dyn Widget<Msg>> {
1073 let selection = self.selecting()?;
1074 selection.toolbars[usize::from(context.variant())]
1075 .get_or_init(|| {
1076 let mut items = Vec::new();
1077 if context.has_selection {
1078 items.push(ToolbarItem::copy());
1079 }
1080 if !context.all_selected {
1081 items.push(ToolbarItem::select_all());
1082 }
1083 let build = selection
1084 .toolbar_build
1085 .as_ref()
1086 .and_then(|build| build.downcast_ref::<ToolbarBuild<Msg>>());
1087 let items = match build {
1088 Some(build) => build(&context, items),
1089 None => items,
1090 };
1091 let bar: Option<Box<dyn Widget<Msg>>> = (!items.is_empty())
1092 .then(|| Box::new(SelectionToolbar::new(items)) as Box<dyn Widget<Msg>>);
1093 Box::new(bar) as Box<dyn Any>
1094 })
1095 .downcast_ref::<Option<Box<dyn Widget<Msg>>>>()?
1096 .as_deref()
1097 }
1098
1099 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
1100 let role = if self.heading {
1103 frus_core::Role::Heading
1104 } else {
1105 frus_core::Role::Label
1106 };
1107 Some(frus_core::SemanticsProperties::new(role).label(self.content.clone()))
1108 }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 #[test]
1120 fn an_ellipsising_text_is_cut_to_its_box() {
1121 let long = "A task name that is really rather long indeed and keeps going";
1122 let style = TextStyle::new(18.0);
1123 let full = frus_text::measure_style(long, style).width;
1124 assert!(full > 300.0, "the fixture has to overflow: {full}");
1125
1126 let cut = truncate(long, &style.resolved(), 150.0);
1127 assert!(cut.ends_with(ELLIPSIS), "cut with an ellipsis: {cut}");
1128 let cut_width = frus_text::measure_style(&cut, style).width;
1129 assert!(cut_width <= 150.0, "and it fits: {cut_width}");
1130 assert!(cut.len() > 4, "without cutting everything: {cut}");
1131
1132 assert_eq!(truncate("Short", &style.resolved(), 500.0), "Short");
1134 assert_eq!(truncate(long, &style.resolved(), 0.0), ELLIPSIS);
1137 }
1138
1139 #[test]
1142 fn an_ellipsising_text_lets_the_layout_shrink_it() {
1143 use frus_layout::Dimension;
1144 let plain =
1145 Widget::<()>::style(&Text::new("A rather long label indeed").size(18.0).no_wrap());
1146 let cut = Widget::<()>::style(
1147 &Text::new("A rather long label indeed")
1148 .size(18.0)
1149 .ellipsis(),
1150 );
1151 assert_eq!(plain.min_width, Dimension::Auto, "content is its own floor");
1152 assert_eq!(
1153 cut.min_width,
1154 Dimension::Length(0.0),
1155 "and this one has none"
1156 );
1157 assert_eq!(plain.width, cut.width, "both still ask for the same width");
1158 }
1159
1160 use super::*;
1161 use frus_core::Primitive;
1162 use frus_core::{MaskShader, TextAlign, TextOverflow};
1163
1164 #[test]
1173 fn a_paragraph_sized_to_fit_is_not_squeezed_into_wrapping() {
1174 use crate::{build_ui, Container, Flex, Runtime, Size};
1175 use frus_core::FontWeight;
1176 use frus_layout::Align;
1177
1178 const MARK: Color = Color {
1179 r: 1.0,
1180 g: 0.0,
1181 b: 1.0,
1182 a: 1.0,
1183 };
1184 for (label, lines) in [
1187 ("Write code", 1.0_f32),
1188 ("A rather long task name that certainly wraps", 2.0),
1189 ] {
1190 let tree = Container::<()>::new().width(400.0).height(600.0).child(
1191 Flex::column()
1192 .width(376.0)
1193 .align(Align::Center)
1194 .gap(18.0)
1195 .child(Text::new(label).size(24.0).weight(FontWeight::Bold).wrap())
1196 .child(Container::new().height(20.0).color(MARK)),
1197 );
1198 let ui = build_ui(
1199 &tree,
1200 Size::new(400.0, 600.0),
1201 &Runtime::default(),
1202 &Theme::default(),
1203 );
1204 let paragraph = ui
1205 .scene()
1206 .primitives()
1207 .iter()
1208 .find_map(|p| match p {
1209 Primitive::Text {
1210 position,
1211 max_width,
1212 ..
1213 } => Some((*position, max_width.unwrap_or(f32::MAX))),
1214 _ => None,
1215 })
1216 .expect("the paragraph is painted");
1217 let below = ui
1218 .scene()
1219 .primitives()
1220 .iter()
1221 .find_map(|p| match p {
1222 Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
1223 _ => None,
1224 })
1225 .expect("the label under it is painted");
1226 let painted =
1228 frus_text::measure_wrapped(label, 24.0, FontWeight::Bold, false, Some(paragraph.1));
1229 let want = frus_core::fits(lines * frus_text::line_height(24.0));
1235 assert!(
1236 (painted.height - want).abs() < 0.01,
1237 "{label:?} wrapped onto {} lines in a box of {}",
1238 painted.height / frus_text::line_height(24.0),
1239 paragraph.1
1240 );
1241 assert!(
1242 below.y >= paragraph.0.y + painted.height - 0.01,
1243 "{label:?}: the label underneath overlaps the paragraph ({below:?} vs {painted:?})"
1244 );
1245 }
1246 }
1247
1248 #[test]
1251 fn a_line_limit_cuts_the_paragraph_and_marks_the_cut() {
1252 let long = "one two three four five six seven eight nine ten eleven twelve";
1253 let text = Text::new(long)
1254 .size(14.0)
1255 .wrap()
1256 .max_lines(2)
1257 .overflow(TextOverflow::Ellipsis);
1258 let fitted = text.fitted(90.0, &text.resolved(None));
1259 assert!(
1262 fitted.text.lines().count() == 1,
1263 "one string, no newlines in it: {:?}",
1264 fitted.text
1265 );
1266 let lines = frus_text::line_spans(
1267 &fitted.text,
1268 14.0,
1269 FontWeight::Regular,
1270 false,
1271 Some(90.0),
1272 true,
1273 );
1274 assert_eq!(lines.len(), 2, "and it breaks into two: {:?}", fitted.text);
1275 assert!(fitted.too_tall, "and there was more");
1276 assert!(
1277 fitted.text.ends_with(ELLIPSIS),
1278 "ending in an ellipsis: {:?}",
1279 fitted.text
1280 );
1281 }
1282
1283 #[test]
1285 fn a_limit_it_does_not_reach_changes_nothing() {
1286 let text = Text::new("one two").size(14.0).wrap().max_lines(4);
1287 let fitted = text.fitted(400.0, &text.resolved(None));
1288 assert_eq!(fitted.text, "one two");
1289 assert!(!fitted.too_tall && !fitted.too_wide);
1290 }
1291
1292 #[test]
1295 fn a_limit_caps_the_measured_height() {
1296 let long = "one two three four five six seven eight nine ten eleven twelve";
1297 let free = Text::new(long).size(14.0).wrap();
1298 let capped = Text::new(long).size(14.0).wrap().max_lines(2);
1299 let at = |t: &Text| {
1300 Widget::<()>::measure(t, &Theme::default()).expect("measure")(Some(90.0), None).height
1301 };
1302 assert!(at(&free) > at(&capped), "the limit is doing something");
1303 assert!(
1304 (at(&capped) - frus_text::line_height(14.0) * 2.0).abs() < 0.5,
1305 "two lines exactly: {}",
1306 at(&capped)
1307 );
1308 }
1309
1310 #[test]
1320 fn a_limit_counts_lines_of_the_height_that_was_asked_for() {
1321 let long = "one two three four five six seven eight nine ten eleven twelve";
1322 let plain = Text::new(long).size(14.0).wrap().max_lines(2);
1323 let open = Text::new(long).size(14.0).height(2.4).wrap().max_lines(2);
1324 let at = |t: &Text| {
1325 Widget::<()>::measure(t, &Theme::default()).expect("measure")(Some(90.0), None).height
1326 };
1327 let ratio = at(&open) / at(&plain);
1328 assert!(
1329 (ratio - 2.0).abs() < 0.02,
1330 "a doubled leading doubles the two-line cap: {ratio}"
1331 );
1332 }
1333
1334 #[test]
1338 fn clip_and_visible_differ_by_the_clip_and_not_the_words() {
1339 let long = "a label far too long for the box it was given";
1340 let paint = |overflow: TextOverflow| {
1341 let text = Text::new(long).size(16.0).no_wrap().overflow(overflow);
1342 let mut scene = Scene::new();
1343 Widget::<()>::paint(
1344 &text,
1345 Rect::new(0.0, 0.0, 60.0, 20.0),
1346 Status::default(),
1347 &Theme::default(),
1348 &mut scene,
1349 );
1350 match &scene.primitives()[0] {
1351 Primitive::Text { text, clip, .. } => (text.clone(), *clip),
1352 other => panic!("a text, not {other:?}"),
1353 }
1354 };
1355 let (visible_text, visible_clip) = paint(TextOverflow::Visible);
1356 let (clipped_text, clipped_clip) = paint(TextOverflow::Clip);
1357 assert_eq!(visible_text, long, "nothing is cut");
1358 assert_eq!(clipped_text, long, "nothing is cut here either");
1359 assert_eq!(visible_clip, Rect::UNBOUNDED, "and nothing stops it");
1360 assert_eq!(
1361 clipped_clip,
1362 Rect::new(0.0, 0.0, 60.0, 20.0),
1363 "this one stops"
1364 );
1365 }
1366
1367 #[test]
1374 fn a_subtree_hands_a_text_its_size() {
1375 let mut theme = Theme::default();
1376 theme.widgets.text.style.size = Some(30.0);
1377 let text = Text::new("Handed down").no_wrap();
1379
1380 let alone = Widget::<()>::style(&text);
1381 let handed = Widget::<()>::style_themed(&text, &theme);
1382 let (Dimension::Length(alone_w), Dimension::Length(handed_w)) = (alone.width, handed.width)
1383 else {
1384 panic!("a single line is a box of a known size");
1385 };
1386 assert!(
1387 handed_w > alone_w * 1.5,
1388 "the box grew with the type: {alone_w} → {handed_w}"
1389 );
1390
1391 let mut scene = Scene::new();
1392 Widget::<()>::paint(
1393 &text,
1394 Rect::new(0.0, 0.0, handed_w, 40.0),
1395 Status::default(),
1396 &theme,
1397 &mut scene,
1398 );
1399 match &scene.primitives()[0] {
1400 Primitive::Text { size, .. } => assert_eq!(
1401 *size, 30.0,
1402 "and the glyphs agree with the box they were measured into"
1403 ),
1404 other => panic!("a text, not {other:?}"),
1405 }
1406 }
1407
1408 #[test]
1415 fn a_chosen_size_outranks_a_handed_one() {
1416 let mut theme = Theme::default();
1417 theme.widgets.text.style.size = Some(30.0);
1418 let width = |text: &Text| match Widget::<()>::style_themed(text, &theme).width {
1419 Dimension::Length(w) => w,
1420 other => panic!("a known width, not {other:?}"),
1421 };
1422 let untouched = width(&Text::new("Handed down").no_wrap());
1424 let chosen = width(&Text::new("Handed down").no_wrap().size(16.0));
1425 assert!(
1426 untouched > chosen,
1427 "the one that chose kept its size: {untouched} vs {chosen}"
1428 );
1429 assert_eq!(
1430 chosen,
1431 width(&Text::new("Handed down").no_wrap().size(16.0)),
1432 "and it keeps it however often it is asked"
1433 );
1434 }
1435
1436 #[test]
1442 fn handing_down_a_colour_leaves_the_sizes_alone() {
1443 let red = Color::rgb(1.0, 0.0, 0.0);
1444 let mut theme = Theme::default();
1445 theme.widgets.text.style.color = Some(red);
1446 let text = Text::new("Words").no_wrap().size(22.0);
1447 assert_eq!(
1448 Widget::<()>::style_themed(&text, &theme).width,
1449 Widget::<()>::style(&text).width,
1450 "the box did not move"
1451 );
1452 let mut scene = Scene::new();
1453 Widget::<()>::paint(
1454 &text,
1455 Rect::new(0.0, 0.0, 300.0, 40.0),
1456 Status::default(),
1457 &theme,
1458 &mut scene,
1459 );
1460 match &scene.primitives()[0] {
1461 Primitive::Text { size, color, .. } => {
1462 assert_eq!(*color, red, "the colour came down");
1463 assert_eq!(*size, 22.0, "the size stayed put");
1464 }
1465 other => panic!("a text, not {other:?}"),
1466 }
1467 }
1468
1469 #[test]
1477 fn a_handed_down_limit_lets_the_text_give_way() {
1478 let mut theme = Theme::default();
1479 theme.widgets.text.max_lines = Some(1);
1480 theme.widgets.text.overflow = Some(TextOverflow::Ellipsis);
1481 let text = Text::new("a label far too long for the box it will be given");
1482 assert_eq!(
1483 Widget::<()>::style(&text).min_width,
1484 Dimension::Auto,
1485 "left alone it refuses to shrink"
1486 );
1487 assert_eq!(
1488 Widget::<()>::style_themed(&text, &theme).min_width,
1489 Dimension::Length(0.0),
1490 "handed a limit, it gives way"
1491 );
1492 let mut scene = Scene::new();
1493 Widget::<()>::paint(
1494 &text,
1495 Rect::new(0.0, 0.0, 80.0, 20.0),
1496 Status::default(),
1497 &theme,
1498 &mut scene,
1499 );
1500 match &scene.primitives()[0] {
1501 Primitive::Text { text, .. } => {
1502 assert!(text.ends_with(ELLIPSIS), "and it was cut: {text:?}")
1503 }
1504 other => panic!("a text, not {other:?}"),
1505 }
1506 }
1507
1508 #[test]
1517 fn a_handed_down_alignment_still_asks_for_the_width() {
1518 let mut theme = Theme::default();
1519 theme.widgets.text.align = Some(TextAlign::Center);
1520 let text = Text::new("Centred").no_wrap();
1521 assert_eq!(
1522 Widget::<()>::fill_axes(&text, &Theme::default()),
1523 crate::widget::FillAxes::NONE,
1524 "left alone it hugs its words"
1525 );
1526 assert_eq!(
1527 Widget::<()>::fill_axes(&text, &theme),
1528 crate::widget::FillAxes::WIDTH,
1529 "handed a centring, it asks for the line"
1530 );
1531 let mut scene = Scene::new();
1532 Widget::<()>::paint(
1533 &text,
1534 Rect::new(0.0, 0.0, 300.0, 20.0),
1535 Status::default(),
1536 &theme,
1537 &mut scene,
1538 );
1539 match &scene.primitives()[0] {
1540 Primitive::Text {
1541 align, max_width, ..
1542 } => {
1543 assert_eq!(*align, TextAlign::Center);
1544 assert_eq!(*max_width, Some(300.0), "and it was given a box to sit in");
1545 }
1546 other => panic!("a text, not {other:?}"),
1547 }
1548 }
1549
1550 #[test]
1557 fn nested_wrappers_compose_field_by_field() {
1558 use crate::{build_ui, Runtime, Size};
1559 let muted = Color::rgb(0.4, 0.4, 0.45);
1560 let outer = crate::DefaultTextStyle::from_text_style(TextStyle::NONE.color(muted));
1561 let inner = crate::DefaultTextStyle::from_text_style(TextStyle::new(9.0));
1562 let root: Box<dyn Widget<()>> =
1563 Box::new(outer.around(inner.around(Text::new("Both").no_wrap())));
1564 let ui = build_ui(
1565 root.as_ref(),
1566 Size::new(300.0, 100.0),
1567 &Runtime::default(),
1568 &Theme::default(),
1569 );
1570 let found = ui.scene().primitives().iter().find_map(|p| match p {
1571 Primitive::Text { size, color, .. } => Some((*size, *color)),
1572 _ => None,
1573 });
1574 assert_eq!(
1575 found,
1576 Some((9.0, muted)),
1577 "the size came from the inner wrapper and the colour from the outer"
1578 );
1579 }
1580
1581 #[test]
1589 fn a_style_may_fix_the_size_and_still_inherit_the_weight() {
1590 let mut theme = Theme::default();
1591 theme.widgets.text.style.weight = Some(FontWeight::Bold);
1592 let text = Text::styled("Words", TextStyle::new(20.0)).no_wrap();
1593
1594 let mut scene = Scene::new();
1595 Widget::<()>::paint(
1596 &text,
1597 Rect::new(0.0, 0.0, 300.0, 40.0),
1598 Status::default(),
1599 &theme,
1600 &mut scene,
1601 );
1602 match &scene.primitives()[0] {
1603 Primitive::Text { size, weight, .. } => {
1604 assert_eq!(*size, 20.0, "the size it named");
1605 assert_eq!(*weight, FontWeight::Bold, "the weight it did not");
1606 }
1607 other => panic!("a text, not {other:?}"),
1608 }
1609 let bold_20 = frus_text::measure_styled("Words", 20.0, FontWeight::Bold, false).width;
1611 match Widget::<()>::style_themed(&text, &theme).width {
1612 Dimension::Length(w) => assert!(
1613 (w - bold_20.ceil()).abs() < 0.01,
1614 "measured {w}, drawn at {bold_20}"
1615 ),
1616 other => panic!("a known width, not {other:?}"),
1617 }
1618 }
1619
1620 #[test]
1628 fn a_reader_who_asked_for_larger_text_gets_a_larger_box_too() {
1629 use crate::{build_ui, MediaQuery, Runtime, Size};
1630 let drawn = |scaler: f32| {
1631 MediaQuery::new(Size::new(400.0, 200.0))
1632 .with_text_scaler(scaler)
1633 .scope(|| {
1634 let text: Box<dyn Widget<()>> = Box::new(Text::new("Readable").no_wrap());
1635 let ui = build_ui(
1636 text.as_ref(),
1637 Size::new(400.0, 200.0),
1638 &Runtime::default(),
1639 &Theme::default(),
1640 );
1641 let rect = ui.scene().primitives().iter().find_map(|p| match p {
1642 Primitive::Text { size, bounds, .. } => Some((*size, *bounds)),
1643 _ => None,
1644 });
1645 rect.expect("the text is drawn")
1646 })
1647 };
1648 let (plain_size, plain_box) = drawn(1.0);
1649 let (big_size, big_box) = drawn(1.5);
1650
1651 assert_eq!(plain_size, 16.0, "the framework's own, unscaled");
1652 assert_eq!(big_size, 24.0, "and the reader's 1.5 of it");
1653 assert!(
1654 big_box.width > plain_box.width * 1.4,
1655 "the box grew with the glyphs: {} → {}",
1656 plain_box.width,
1657 big_box.width
1658 );
1659 let measured = frus_text::measure_styled("Readable", big_size, FontWeight::Regular, false);
1662 assert!(
1663 (big_box.width - measured.width.ceil()).abs() < 0.51,
1664 "reserved {} for glyphs measuring {}",
1665 big_box.width,
1666 measured.width
1667 );
1668 }
1669
1670 #[test]
1674 fn with_no_surface_described_nothing_is_scaled() {
1675 assert_eq!(
1676 Text::new("x").resolved(None).style.size,
1677 frus_core::DEFAULT_TEXT_SIZE
1678 );
1679 }
1680
1681 #[test]
1684 fn a_text_that_fits_is_not_clipped() {
1685 let text = Text::new("short")
1686 .size(16.0)
1687 .no_wrap()
1688 .overflow(TextOverflow::Clip);
1689 let mut scene = Scene::new();
1690 Widget::<()>::paint(
1691 &text,
1692 Rect::new(0.0, 0.0, 300.0, 20.0),
1693 Status::default(),
1694 &Theme::default(),
1695 &mut scene,
1696 );
1697 match &scene.primitives()[0] {
1698 Primitive::Text { clip, .. } => assert_eq!(*clip, Rect::UNBOUNDED),
1699 other => panic!("a text, not {other:?}"),
1700 }
1701 }
1702
1703 #[test]
1706 fn fade_wraps_the_text_in_a_masked_group() {
1707 let text = Text::new("a label far too long for the box it was given")
1708 .size(16.0)
1709 .no_wrap()
1710 .overflow(TextOverflow::Fade);
1711 let mut scene = Scene::new();
1712 Widget::<()>::paint(
1713 &text,
1714 Rect::new(0.0, 0.0, 60.0, 20.0),
1715 Status::default(),
1716 &Theme::default(),
1717 &mut scene,
1718 );
1719 match &scene.primitives()[0] {
1720 Primitive::Layer {
1721 primitives, filter, ..
1722 } => {
1723 assert_eq!(primitives.len(), 1, "the text, and only the text");
1724 let mask = filter.mask.expect("a mask");
1725 match mask.shader {
1726 MaskShader::Linear { from, to, .. } => {
1729 assert!(
1730 to.x > from.x && (to.x - 60.0).abs() < 0.01,
1731 "{from:?} {to:?}"
1732 );
1733 assert!((to.y - from.y).abs() < 0.01, "horizontal");
1734 }
1735 other => panic!("a linear fade, not {other:?}"),
1736 }
1737 }
1738 other => panic!("a masked layer, not {other:?}"),
1739 }
1740 }
1741
1742 #[test]
1745 fn alignment_hands_the_renderer_a_width_and_start_does_not() {
1746 let painted = |align: TextAlign| {
1747 let text = Text::new("x").size(16.0).no_wrap().align(align);
1748 let mut scene = Scene::new();
1749 Widget::<()>::paint(
1750 &text,
1751 Rect::new(0.0, 0.0, 200.0, 20.0),
1752 Status::default(),
1753 &Theme::default(),
1754 &mut scene,
1755 );
1756 match &scene.primitives()[0] {
1757 Primitive::Text {
1758 align, max_width, ..
1759 } => (*align, *max_width),
1760 other => panic!("a text, not {other:?}"),
1761 }
1762 };
1763 assert_eq!(painted(TextAlign::Center), (TextAlign::Center, Some(200.0)));
1764 assert_eq!(painted(TextAlign::Start), (TextAlign::Start, None));
1765 }
1766
1767 #[test]
1770 fn an_overflow_mode_is_what_makes_a_text_shrinkable() {
1771 use frus_layout::Dimension;
1772 let plain = Widget::<()>::style(&Text::new("a long enough label"));
1773 let clipped =
1774 Widget::<()>::style(&Text::new("a long enough label").overflow(TextOverflow::Clip));
1775 assert_eq!(plain.min_width, Dimension::Auto);
1776 assert_eq!(clipped.min_width, Dimension::Length(0.0));
1777 }
1778
1779 #[test]
1780 fn text_paints_a_text_primitive() {
1781 let text = Text::new("Salut")
1782 .size(20.0)
1783 .no_wrap()
1784 .color(Color::rgb(1.0, 0.0, 0.0));
1785 let mut scene = Scene::new();
1786 Widget::<()>::paint(
1787 &text,
1788 Rect::new(5.0, 6.0, 100.0, 24.0),
1789 Status::default(),
1790 &Theme::default(),
1791 &mut scene,
1792 );
1793
1794 assert_eq!(scene.primitives().len(), 1);
1795 assert_eq!(
1796 scene.primitives()[0],
1797 Primitive::Text {
1798 position: Point::new(5.0, 6.0),
1799 text: "Salut".to_string(),
1800 size: 20.0,
1801 color: Color::rgb(1.0, 0.0, 0.0),
1802 weight: FontWeight::Regular,
1803 italic: false,
1804 max_width: None,
1805 soft_wrap: false,
1806 align: TextAlign::Start,
1807 decoration: frus_core::TextDecoration::NONE,
1808 decoration_color: None,
1809 height: None,
1810 family: None,
1811 clip: Rect::UNBOUNDED,
1812 bounds: Rect::UNBOUNDED,
1815 owner: 0,
1816 letter_spacing: 0.0,
1817 }
1818 );
1819 }
1820
1821 #[test]
1822 fn wrapped_text_measures_to_the_offered_width() {
1823 let long = "a paragraph long enough to wrap onto several lines";
1824 let text = Text::new(long).wrap();
1825 let measure = Widget::<()>::measure(&text, &Theme::default()).expect("measure closure");
1827 let free = measure(None, None);
1828 let narrow = measure(Some(120.0), None);
1829 assert!(narrow.width <= 120.0);
1830 assert!(narrow.height > free.height, "wrapped → taller");
1831 let other = Text::new("short").wrap();
1833 assert_ne!(
1834 Widget::<()>::measure_key(&text, &Theme::default()),
1835 Widget::<()>::measure_key(&other, &Theme::default())
1836 );
1837 let plain = Text::new(long).no_wrap();
1841 assert!(Widget::<()>::measure(&plain, &Theme::default()).is_none());
1842 assert!(Widget::<()>::measure_key(&plain, &Theme::default()).is_none());
1843 }
1844
1845 #[test]
1846 fn styled_text_carries_weight_and_italic() {
1847 let theme = Theme::default();
1848 let text = Text::styled("Title", theme.text.title_medium).italic();
1850 let mut scene = Scene::new();
1851 Widget::<()>::paint(
1852 &text,
1853 Rect::new(0.0, 0.0, 100.0, 24.0),
1854 Status::default(),
1855 &theme,
1856 &mut scene,
1857 );
1858 match &scene.primitives()[0] {
1859 Primitive::Text {
1860 size,
1861 weight,
1862 italic,
1863 color,
1864 ..
1865 } => {
1866 assert_eq!(*size, 16.0);
1867 assert_eq!(*weight, FontWeight::Medium);
1868 assert!(*italic);
1869 assert_eq!(*color, theme.on_surface);
1871 }
1872 _ => panic!("expected text"),
1873 }
1874 }
1875
1876 #[test]
1877 fn bold_text_lays_out_wider() {
1878 let w = |text: &Text| {
1879 Widget::<()>::measure(text, &Theme::default()).expect("measure")(None, None).width
1880 };
1881 assert!(
1882 w(&Text::new("Width").weight(FontWeight::Bold)) > w(&Text::new("Width")),
1883 "bold must be wider"
1884 );
1885 }
1886
1887 #[test]
1891 fn text_wraps_down_a_column_and_runs_on_along_a_row() {
1892 use crate::{build_ui, Container, Flex, Runtime, Size};
1893 let long = "one two three four five six seven eight nine ten eleven twelve";
1894 let painted = |root: &dyn Widget<()>| {
1897 let rt = Runtime::default();
1898 let theme = Theme::default();
1899 let ui = build_ui(root, Size::new(120.0, 300.0), &rt, &theme);
1900 ui.scene()
1901 .primitives()
1902 .iter()
1903 .find_map(|p| match p {
1904 Primitive::Text { bounds, .. } => Some(*bounds),
1905 _ => None,
1906 })
1907 .expect("the text")
1908 };
1909 let column: Container<()> = Container::new()
1910 .width(120.0)
1911 .child(Flex::column().child(Text::new(long).size(12.0)));
1912 let row: Container<()> = Container::new().width(120.0).child(
1915 Flex::row()
1916 .child(Text::new(long).size(12.0))
1917 .child(Container::new().width(10.0).height(10.0)),
1918 );
1919 let tall = painted(&column);
1920 let wide = painted(&row);
1921 assert!(
1922 tall.width <= 120.5,
1923 "the column gave it 120: {}",
1924 tall.width
1925 );
1926 assert!(tall.height > 20.0, "so it wrapped: {}", tall.height);
1927 assert!(wide.width > 120.0, "the row did not: {}", wide.width);
1928 }
1929}
1930
1931#[cfg(test)]
1932mod reader_font_size {
1933 use frus_core::{Primitive, Size};
1934
1935 use crate::theme::Theme;
1936 use crate::{build_ui_inspected, MediaQuery, Runtime, Widget};
1937
1938 type W = Box<dyn Widget<()>>;
1939 type Case = (&'static str, fn() -> W);
1940
1941 fn glyphs(make: fn() -> W, scale: f32) -> Vec<(String, f32)> {
1943 let root = make();
1944 MediaQuery::new(Size::new(400.0, 300.0))
1945 .with_text_scaler(scale)
1946 .scope(|| {
1947 let (ui, _) = build_ui_inspected(
1948 root.as_ref(),
1949 Size::new(400.0, 300.0),
1950 &Runtime::default(),
1951 &Theme::default(),
1952 );
1953 ui.scene()
1954 .primitives()
1955 .iter()
1956 .filter_map(|p| match p {
1957 Primitive::Text { text, size, .. } => Some((text.clone(), *size)),
1958 _ => None,
1959 })
1960 .collect()
1961 })
1962 }
1963
1964 fn cases() -> Vec<Case> {
1965 vec![
1966 ("Chip", || Box::new(crate::Chip::<()>::new("Filter"))),
1967 ("Button", || Box::new(crate::Button::<()>::new("Save"))),
1968 ("ListTile", || {
1969 Box::new(crate::ListTile::<()>::new().title("A row"))
1970 }),
1971 ("SnackBar", || Box::new(crate::SnackBar::<()>::new("Saved"))),
1972 ("Kbd", || Box::new(crate::Kbd::new("Ctrl"))),
1973 ("DropdownButton", || {
1974 Box::new(crate::DropdownButton::new("Pick", ()))
1975 }),
1976 ("Text", || Box::new(crate::Text::new("A line"))),
1977 ("TextField", || {
1978 Box::new(crate::TextField::<()>::new("typed"))
1979 }),
1980 ("Table", || {
1981 Box::new(crate::Table::<()>::new(2).row(&["one", "two"]))
1982 }),
1983 ("Tree", || {
1984 Box::new(crate::Tree::new(|_| ()).node(1, 0, "a branch", false, false))
1985 }),
1986 ]
1987 }
1988
1989 #[test]
2002 fn the_text_a_widget_paints_follows_the_readers_font_size() {
2003 for (name, make) in cases() {
2004 let plain = glyphs(make, 1.0);
2005 let doubled = glyphs(make, 2.0);
2006 assert!(!plain.is_empty(), "{name} paints no text at all");
2007 assert_eq!(
2008 plain.len(),
2009 doubled.len(),
2010 "{name}: a different number of runs at twice the size"
2011 );
2012 let grew = plain
2013 .iter()
2014 .zip(&doubled)
2015 .any(|((_, a), (_, b))| *b > *a + 0.01);
2016 assert!(
2017 grew,
2018 "{name}: not one glyph followed the reader — sizes {plain:?}"
2019 );
2020 }
2021 }
2022
2023 #[test]
2027 fn a_box_that_holds_text_grows_with_it() {
2028 for (name, make) in cases() {
2029 let root = make();
2030 MediaQuery::new(Size::new(400.0, 300.0))
2031 .with_text_scaler(2.0)
2032 .scope(|| {
2033 let (ui, _) = build_ui_inspected(
2034 root.as_ref(),
2035 Size::new(400.0, 300.0),
2036 &Runtime::default(),
2037 &Theme::default(),
2038 );
2039 let mut checked = 0;
2040 for p in ui.scene().primitives() {
2041 if let Primitive::Text {
2042 text, size, bounds, ..
2043 } = p
2044 {
2045 if bounds.height <= 0.0 {
2049 continue;
2050 }
2051 let style = frus_core::ResolvedTextStyle::exact(*size);
2055 let needed = frus_text::measure_resolved(text, &style).height;
2056 checked += 1;
2057 assert!(
2058 needed <= bounds.height + 0.51,
2059 "{name}: {text:?} needs {needed:.1} px in a box of {:.1}",
2060 bounds.height
2061 );
2062 }
2063 }
2064 assert!(checked > 0, "{name}: no text was checked at all");
2065 });
2066 }
2067 }
2068
2069 #[test]
2074 fn an_app_bar_caps_its_title_rather_than_growing() {
2075 let title_size = |scale: f32| {
2076 let bar = crate::AppBar::<()>::new().title(crate::Text::new("A title"));
2077 MediaQuery::new(Size::new(400.0, 300.0))
2078 .with_text_scaler(scale)
2079 .scope(|| {
2080 let root = bar.build();
2081 let (ui, _) = build_ui_inspected(
2082 root.as_ref(),
2083 Size::new(400.0, 300.0),
2084 &Runtime::default(),
2085 &Theme::default(),
2086 );
2087 ui.scene()
2088 .primitives()
2089 .iter()
2090 .find_map(|p| match p {
2091 Primitive::Text { text, size, .. } if text == "A title" => Some(*size),
2092 _ => None,
2093 })
2094 .expect("the bar paints its title")
2095 })
2096 };
2097 let plain = title_size(1.0);
2098 assert!(
2099 (title_size(1.2) - plain * 1.2).abs() < 0.1,
2100 "under the cap the title follows the reader"
2101 );
2102 let capped = plain * crate::APP_BAR_MAX_TITLE_SCALE;
2103 for scale in [1.5, 2.0, 4.0] {
2104 assert!(
2105 (title_size(scale) - capped).abs() < 0.1,
2106 "at x{scale} the title is {} rather than the capped {capped}",
2107 title_size(scale)
2108 );
2109 }
2110 }
2111}
2112
2113#[cfg(test)]
2114mod selectable_tests {
2115 use super::*;
2116 use crate::{build_ui, EditAction, Flex, Runtime, Size};
2117 use frus_core::Primitive;
2118
2119 const WORDS: &str = "one two three four five six seven eight nine ten";
2120
2121 fn hit(text: &Text, x: f32, y: f32, width: f32) -> Option<usize> {
2122 Widget::<()>::cursor_at(text, x, y, width, 0)
2123 }
2124
2125 fn selected(cursor: usize, anchor: Option<usize>) -> Edit {
2126 Edit {
2127 cursor,
2128 anchor,
2129 composing: None,
2130 }
2131 }
2132
2133 fn width_of(prefix: &str) -> f32 {
2135 frus_text::measure_resolved(prefix, &TextStyle::NONE.resolved()).width
2136 }
2137
2138 fn bar_for(text: &Text, context: ToolbarContext) -> Option<Vec<EditAction>> {
2140 let bar = Widget::<()>::selection_toolbar(text, context)?;
2141 Some(
2142 bar.children()
2143 .iter()
2144 .map(|button| button.edit_action().expect("a built-in action"))
2145 .collect(),
2146 )
2147 }
2148
2149 #[test]
2152 fn an_ordinary_text_is_not_selectable() {
2153 let text = Text::new("ORDER-4417");
2154 assert!(!Widget::<()>::focusable(&text));
2155 assert_eq!(Widget::<()>::text_value(&text), None);
2156 assert_eq!(hit(&text, 10.0, 5.0, 200.0), None);
2157 assert_eq!(Widget::<()>::word_at(&text, 2), None);
2158 assert_eq!(
2159 Widget::<()>::selected_text(&text, &selected(5, Some(0))),
2160 None
2161 );
2162 assert!(Widget::<()>::selection_toolbar(&text, ToolbarContext::default()).is_none());
2163 let configured = Text::new("x").selection_toolbar::<()>(|_, items| items);
2165 assert!(!Widget::<()>::focusable(&configured));
2166 assert_eq!(Widget::<()>::text_value(&configured), None);
2167 }
2168
2169 #[test]
2172 fn a_selectable_text_takes_focus_but_no_typing() {
2173 let text = Text::new("ORDER-4417").selectable();
2174 assert!(Widget::<()>::focusable(&text));
2175 assert_eq!(Widget::<()>::text_value(&text), Some("ORDER-4417"));
2176 assert!(!Widget::<()>::takes_typing(&text));
2177 }
2178
2179 #[test]
2182 fn a_press_lands_between_the_letters_it_is_over() {
2183 let text = Text::new("hello world").selectable();
2184 assert_eq!(hit(&text, 0.0, 5.0, 400.0), Some(0));
2185 assert_eq!(hit(&text, width_of("hello"), 5.0, 400.0), Some(5));
2186 assert_eq!(hit(&text, width_of("hello w"), 5.0, 400.0), Some(7));
2187 assert_eq!(hit(&text, 1000.0, 5.0, 400.0), Some(11));
2188 }
2189
2190 #[test]
2193 fn a_wrapped_paragraph_is_hit_line_by_line() {
2194 let text = Text::new(WORDS).selectable();
2195 let style = TextStyle::NONE.resolved();
2196 let spans = frus_text::line_spans(
2197 WORDS,
2198 style.size,
2199 style.weight,
2200 style.italic,
2201 Some(90.0),
2202 true,
2203 );
2204 assert!(spans.len() >= 3, "the fixture wraps: {spans:?}");
2205 let line = style.line_height();
2206 for (index, span) in spans.iter().enumerate().take(3) {
2207 let y = line * index as f32 + line * 0.5;
2208 assert_eq!(
2209 hit(&text, 0.0, y, 90.0),
2210 Some(span.start),
2211 "the start of line {index}"
2212 );
2213 }
2214 }
2215
2216 #[test]
2220 fn a_hit_follows_the_leading_the_paragraph_is_drawn_with() {
2221 let text = Text::styled(WORDS, TextStyle::new(16.0).height(2.0)).selectable();
2222 let spans =
2223 frus_text::line_spans(WORDS, 16.0, FontWeight::Regular, false, Some(90.0), true);
2224 assert!(spans.len() >= 3, "the fixture wraps: {spans:?}");
2225 assert_eq!(hit(&text, 0.0, 48.0, 90.0), Some(spans[1].start));
2227 }
2228
2229 #[test]
2234 fn a_hit_follows_the_size_a_subtree_handed_down() {
2235 let mut theme = Theme::default();
2236 theme.widgets.text.style.size = Some(30.0);
2237 let text = Text::new("hello world").selectable().no_wrap();
2238 let _ = build_ui::<()>(&text, Size::new(400.0, 100.0), &Runtime::default(), &theme);
2240 let big = frus_text::measure_resolved("hello", &TextStyle::new(30.0).resolved()).width;
2241 assert!(big > width_of("hello") * 1.5, "the fixture: {big}");
2242 assert_eq!(hit(&text, big, 10.0, 400.0), Some(5));
2243 }
2244
2245 #[test]
2248 fn a_hit_follows_the_readers_font_setting() {
2249 let text =
2250 frus_core::with_text_scale(2.0, || Text::new("hello world").selectable().no_wrap());
2251 let doubled = frus_text::measure_resolved(
2252 "hello",
2253 &frus_core::with_text_scale(2.0, || TextStyle::NONE.resolved()),
2254 )
2255 .width;
2256 assert!(doubled > width_of("hello") * 1.9, "the fixture: {doubled}");
2257 assert_eq!(hit(&text, doubled, 10.0, 600.0), Some(5));
2258 }
2259
2260 #[test]
2263 fn the_selected_text_is_the_characters_between_the_ends() {
2264 let text = Text::new("héllo wörld").selectable();
2265 let copy = |edit| Widget::<()>::selected_text(&text, &edit);
2266 assert_eq!(copy(selected(4, Some(1))).as_deref(), Some("éll"));
2267 assert_eq!(copy(selected(1, Some(4))).as_deref(), Some("éll"));
2268 assert_eq!(copy(selected(3, Some(3))), None, "a caret selects nothing");
2269 assert_eq!(copy(selected(3, None)), None);
2270 assert_eq!(
2271 copy(selected(usize::MAX, Some(0))).as_deref(),
2272 Some("héllo wörld"),
2273 "the end of everything, as Select all asks for it"
2274 );
2275 }
2276
2277 #[test]
2278 fn a_double_click_selects_the_word() {
2279 let text = Text::new("hello wörld_2!").selectable();
2280 assert_eq!(Widget::<()>::word_at(&text, 8), Some((6, 13)));
2281 assert_eq!(
2282 Widget::<()>::word_at(&text, 13),
2283 Some((13, 14)),
2284 "a separator"
2285 );
2286 }
2287
2288 #[test]
2291 fn the_arrows_move_the_caret_and_typing_does_nothing() {
2292 let text = Text::new("hello world").selectable();
2293 let press = |edit: &mut Edit, key: Key| Widget::<()>::on_edit(&text, edit, &key);
2294 let mut edit = selected(2, None);
2295 let right = Key::Right {
2296 shift: false,
2297 word: false,
2298 };
2299 assert_eq!(press(&mut edit, right), None);
2300 assert_eq!((edit.cursor, edit.anchor), (3, None));
2301 let shift_right = Key::Right {
2302 shift: true,
2303 word: false,
2304 };
2305 press(&mut edit, shift_right);
2306 assert_eq!(edit.selection_range(), Some((3, 4)), "Shift extends");
2307 let word_left = Key::Left {
2308 shift: false,
2309 word: true,
2310 };
2311 press(&mut edit, word_left);
2312 assert_eq!((edit.cursor, edit.anchor), (0, None), "a word to the left");
2313 let select_to_end = Key::End {
2314 shift: true,
2315 doc: true,
2316 };
2317 press(&mut edit, select_to_end);
2318 assert_eq!(edit.selection_range(), Some((0, 11)));
2319
2320 let before = edit;
2321 for key in [
2322 Key::Text("x".into()),
2323 Key::Backspace,
2324 Key::Delete,
2325 Key::Enter,
2326 Key::Escape,
2327 ] {
2328 assert_eq!(press(&mut edit, key.clone()), None);
2329 assert_eq!(edit, before, "{key:?} changed nothing");
2330 }
2331 }
2332
2333 fn paint(text: &Text, status: Status) -> Vec<Primitive> {
2334 let mut scene = Scene::new();
2335 Widget::<()>::paint(
2336 text,
2337 Rect::new(10.0, 20.0, 300.0, 40.0),
2338 status,
2339 &Theme::default(),
2340 &mut scene,
2341 );
2342 scene.primitives().to_vec()
2343 }
2344
2345 fn highlights(primitives: &[Primitive]) -> Vec<Rect> {
2346 let selection = Theme::default().selection;
2347 primitives
2348 .iter()
2349 .filter_map(|p| match p {
2350 Primitive::Rect { rect, color, .. } if *color == selection => Some(*rect),
2351 _ => None,
2352 })
2353 .collect()
2354 }
2355
2356 #[test]
2359 fn a_selection_is_highlighted_under_the_words() {
2360 let text = Text::new("hello world").selectable().no_wrap();
2361 let focused = Status {
2362 focused: true,
2363 selection: Some((0, 5)),
2364 ..Status::default()
2365 };
2366 let primitives = paint(&text, focused);
2367 let boxes = highlights(&primitives);
2368 assert_eq!(boxes.len(), 1, "one line, one box: {boxes:?}");
2369 assert!(
2370 (boxes[0].x - 10.0).abs() < 0.5,
2371 "from the text's left: {:?}",
2372 boxes[0]
2373 );
2374 assert!(
2375 (boxes[0].width - width_of("hello")).abs() < 1.0,
2376 "as wide as the word: {:?}",
2377 boxes[0]
2378 );
2379 assert_eq!(boxes[0].y, 20.0, "on the text's line");
2380 let rect = primitives
2381 .iter()
2382 .position(|p| matches!(p, Primitive::Rect { .. }));
2383 let words = primitives
2384 .iter()
2385 .position(|p| matches!(p, Primitive::Text { .. }));
2386 assert!(rect < words, "below the words, so they stay readable");
2387
2388 let unfocused = Status {
2389 selection: Some((0, 5)),
2390 ..Status::default()
2391 };
2392 assert!(
2393 highlights(&paint(&text, unfocused)).is_empty(),
2394 "not focused"
2395 );
2396 assert!(highlights(&paint(&Text::new("hello world"), focused)).is_empty());
2398 }
2399
2400 #[test]
2403 fn a_touch_selection_has_handles_where_they_are_painted() {
2404 let text = Text::new("hello world").selectable().no_wrap();
2405 let touched = Status {
2406 focused: true,
2407 handles: true,
2408 selection: Some((0, 5)),
2409 ..Status::default()
2410 };
2411 let paths = |primitives: &[Primitive]| {
2412 primitives
2413 .iter()
2414 .filter(|p| matches!(p, Primitive::Path { .. }))
2415 .count()
2416 };
2417 assert_eq!(paths(&paint(&text, touched)), 2, "two handles");
2418 let no_handles = Status {
2419 handles: false,
2420 ..touched
2421 };
2422 assert_eq!(paths(&paint(&text, no_handles)), 0);
2423
2424 let [start, end] =
2425 Widget::<()>::selection_handles(&text, 300.0, &selected(5, Some(0)), 0.0)
2426 .expect("the handles of a selection");
2427 assert!(
2428 (end.line_center.x - start.line_center.x - width_of("hello")).abs() < 1.0,
2429 "{start:?} {end:?}"
2430 );
2431 assert!(
2432 start.rect.y > start.line_center.y,
2433 "they hang below their line"
2434 );
2435 assert!(
2436 Widget::<()>::selection_handles(&text, 300.0, &selected(5, None), 0.0).is_none(),
2437 "a caret has none"
2438 );
2439 }
2440
2441 #[test]
2443 fn the_bar_is_anchored_on_the_selection() {
2444 let text = Text::new("hello world").selectable().no_wrap();
2445 let anchor = Widget::<()>::selection_anchor(&text, 300.0, &selected(5, Some(0)), 0.0)
2446 .expect("an anchor");
2447 assert!(anchor.x.abs() < 0.5, "{anchor:?}");
2448 assert!((anchor.width - width_of("hello")).abs() < 1.0, "{anchor:?}");
2449 }
2450
2451 #[test]
2454 fn the_bar_offers_copy_and_select_all_only() {
2455 use EditAction::{Copy, SelectAll};
2456 let text = Text::new("hello world").selectable();
2457 let context = |has_selection, can_paste, all_selected| ToolbarContext {
2458 has_selection,
2459 can_paste,
2460 all_selected,
2461 };
2462 assert_eq!(
2463 bar_for(&text, context(true, true, false)),
2464 Some(vec![Copy, SelectAll])
2465 );
2466 assert_eq!(bar_for(&text, context(true, false, true)), Some(vec![Copy]));
2467 assert_eq!(
2468 bar_for(&text, context(false, true, false)),
2469 Some(vec![SelectAll])
2470 );
2471 assert_eq!(
2472 bar_for(&text, context(false, false, true)),
2473 None,
2474 "nothing to offer"
2475 );
2476 }
2477
2478 #[test]
2480 fn the_application_can_change_the_bar() {
2481 let context = ToolbarContext {
2482 has_selection: true,
2483 can_paste: false,
2484 all_selected: false,
2485 };
2486 let more = Text::new("hello")
2487 .selectable()
2488 .selection_toolbar::<()>(|_, mut items| {
2489 items.push(ToolbarItem::custom("Share", ()));
2490 items
2491 });
2492 let bar = Widget::<()>::selection_toolbar(&more, context).expect("a bar");
2493 assert_eq!(
2494 bar.children().len(),
2495 3,
2496 "Copy, Select all and the application's"
2497 );
2498
2499 let none = Text::new("hello")
2500 .selection_toolbar::<()>(|_, _| Vec::new())
2501 .selectable();
2502 assert!(
2503 Widget::<()>::selection_toolbar(&none, context).is_none(),
2504 "an empty list shows no bar, whichever order the calls were made in"
2505 );
2506 }
2507
2508 #[test]
2511 fn a_selectable_text_is_a_target_in_a_tree() {
2512 let found = |selectable: bool| {
2513 let text = if selectable {
2514 Text::new("ORDER-4417").selectable()
2515 } else {
2516 Text::new("ORDER-4417")
2517 };
2518 let tree = Flex::column().width(300.0).height(100.0).child(text);
2519 let ui = build_ui::<()>(
2520 &tree,
2521 Size::new(300.0, 100.0),
2522 &Runtime::default(),
2523 &Theme::default(),
2524 );
2525 ui.focus_hit(Point::new(5.0, 5.0)).is_some()
2526 };
2527 assert!(found(true));
2528 assert!(!found(false));
2529 }
2530}