1use std::rc::Rc;
46
47use frust::authoring::Role;
48use frust::authoring::text::{
49 FontFamily, FontWeight, GenericSlot, TextContext, TextLayout, TextStyle,
50};
51use frust::authoring::{
52 BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
53 PaintScene, PointerPhase, SemanticsCtx, View, Widget,
54};
55use frust::{Brightness, ShapeScale, Theme};
56use kurbo::{Point, Rect, Size, Vec2};
57use peniko::Color;
58
59use crate::press::presses;
60
61const SEG_PAD: f64 = 3.0;
63const SEG_GAP: f64 = 2.0;
65const SEG_BTN_PAD_X: f64 = 14.0;
67const SEG_BTN_PAD_Y: f64 = 7.0;
69const SEG_FONT_SIZE: f32 = 11.0;
71const SEG_BORDER_W: f64 = 1.0;
73
74const SEG_LIGHT_SHADOW_BLUR: f64 = 2.0;
80const SEG_LIGHT_SHADOW_ALPHA: f32 = 0.08;
85
86const SEG_CONTAINER_BG: Color = Color::from_rgb8(0x1e, 0x23, 0x30); const SEG_CONTAINER_BORDER: Color = Color::from_rgb8(0x3e, 0x3f, 0x44); const SEG_ACTIVE_FILL_DARK: Color = Color::from_rgb8(0x27, 0x2d, 0x3d); const SEG_ACTIVE_TEXT: Color = Color::from_rgb8(0xff, 0xb6, 0x27); const SEG_INACTIVE_TEXT: Color = Color::from_rgb8(0x6b, 0x65, 0x56); fn with_alpha(color: Color, alpha: f32) -> Color {
97 let c = color.components;
98 Color::new([c[0], c[1], c[2], alpha])
99}
100
101struct SegColors {
103 container_bg: Color,
104 container_border: Color,
105 active_fill: Color,
106 active_text: Color,
107 inactive_text: Color,
108 active_shadow: Option<Color>,
110}
111
112fn resolve_seg_colors(theme: Option<&Theme>) -> SegColors {
115 match theme {
116 Some(theme) => {
117 let scheme = theme.scheme();
118 let (active_fill, active_shadow) = match theme.brightness {
119 Brightness::Light => (
120 scheme.surface_container_lowest,
121 Some(with_alpha(scheme.shadow, SEG_LIGHT_SHADOW_ALPHA)),
122 ),
123 Brightness::Dark => (scheme.surface_container_highest, None),
124 };
125 SegColors {
126 container_bg: scheme.surface_container_high,
127 container_border: scheme.outline,
128 active_fill,
129 active_text: scheme.primary,
130 inactive_text: scheme.on_surface_variant,
131 active_shadow,
132 }
133 }
134 None => SegColors {
135 container_bg: SEG_CONTAINER_BG,
136 container_border: SEG_CONTAINER_BORDER,
137 active_fill: SEG_ACTIVE_FILL_DARK,
138 active_text: SEG_ACTIVE_TEXT,
139 inactive_text: SEG_INACTIVE_TEXT,
140 active_shadow: None,
141 },
142 }
143}
144
145fn container_radius(theme: Option<&Theme>, size: Size) -> f64 {
148 match theme {
149 Some(theme) => ShapeScale::resolve(theme.shape.small, size.width, size.height),
150 None => 6.0,
151 }
152}
153
154fn segment_radius(theme: Option<&Theme>, size: Size) -> f64 {
157 match theme {
158 Some(theme) => ShapeScale::resolve(theme.shape.extra_small, size.width, size.height),
159 None => 4.0,
160 }
161}
162
163type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
165
166pub struct SegmentedControlView<State: 'static> {
168 labels: Vec<String>,
169 selected: usize,
170 on_select: OnSelect<State>,
171}
172
173pub fn segmented_control<State: 'static, F: Fn(&mut State, usize) + 'static>(
177 labels: Vec<String>,
178 selected: usize,
179 on_select: F,
180) -> SegmentedControlView<State> {
181 SegmentedControlView {
182 labels,
183 selected,
184 on_select: Rc::new(on_select),
185 }
186}
187
188struct GlyphLabel {
190 content: String,
191 layout: Option<TextLayout>,
192 laid_out_style: Option<TextStyle>,
193 laid_out_max_width: Option<f32>,
194}
195
196impl GlyphLabel {
197 fn new(content: impl Into<String>) -> Self {
198 Self {
199 content: content.into(),
200 layout: None,
201 laid_out_style: None,
202 laid_out_max_width: None,
203 }
204 }
205
206 fn set_content(&mut self, content: impl Into<String>) {
207 let content = content.into();
208 if self.content != content {
209 self.content = content;
210 self.layout = None;
211 }
212 }
213
214 fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: Option<f32>) -> Size {
215 if let Some(cached) = &self.layout
216 && self.laid_out_max_width == max_width
217 && self.laid_out_style.as_ref() == Some(style)
218 {
219 return cached.size();
220 }
221 let text_ctx = ctx.text_context::<TextContext>();
222 let laid = text_ctx.layout(&self.content, style, max_width);
223 let size = laid.size();
224 self.layout = Some(laid);
225 self.laid_out_style = Some(style.clone());
226 self.laid_out_max_width = max_width;
227 size
228 }
229
230 fn paint(&self, origin: Point, scene: &mut dyn PaintScene) {
231 if let Some(layout) = &self.layout {
232 for run in layout.to_scene_runs(origin) {
233 scene.draw_glyph_run(run);
234 }
235 }
236 }
237}
238
239struct Segment {
241 label: GlyphLabel,
242 text: String,
243 x: f64,
245 width: f64,
246 label_size: Size,
247}
248
249fn ui_face() -> FontFamily {
251 FontFamily::stack_with_generic(["IBM Plex Mono"], GenericSlot::Monospace)
252}
253
254fn seg_style(theme: Option<&Theme>, color: Color) -> TextStyle {
257 TextStyle {
258 family: theme.map_or_else(ui_face, |t| t.type_scale.label_large.family.clone()),
259 weight: FontWeight::MEDIUM,
260 ..TextStyle::new(SEG_FONT_SIZE, color)
261 }
262}
263
264pub struct SegmentedControlWidget {
266 segments: Vec<Segment>,
267 selected: usize,
268 on_select: frust::authoring::ErasedArgCallback<usize>,
269 height: f64,
270 pressed: Option<usize>,
271 captured: Option<usize>,
272}
273
274impl<State: 'static> View<State> for SegmentedControlView<State> {
275 type Element = SegmentedControlWidget;
276
277 fn build(&self, _ctx: &mut BuildCtx<'_>) -> SegmentedControlWidget {
278 let segments = self
279 .labels
280 .iter()
281 .map(|l| Segment {
282 label: GlyphLabel::new(l.clone()),
283 text: l.clone(),
284 x: 0.0,
285 width: 0.0,
286 label_size: Size::ZERO,
287 })
288 .collect();
289 SegmentedControlWidget {
290 segments,
291 selected: self.selected,
292 on_select: frust::authoring::erase_callback_arg(&self.on_select),
293 height: 0.0,
294 pressed: None,
295 captured: None,
296 }
297 }
298
299 fn rebuild(
300 &self,
301 prev: &Self,
302 element: &mut SegmentedControlWidget,
303 _ctx: &mut BuildCtx<'_>,
304 ) -> ChangeFlags {
305 element.on_select = frust::authoring::erase_callback_arg(&self.on_select);
306 let mut flags = ChangeFlags::NONE;
307 if prev.labels.len() != self.labels.len() {
308 element.segments = self
309 .labels
310 .iter()
311 .map(|l| Segment {
312 label: GlyphLabel::new(l.clone()),
313 text: l.clone(),
314 x: 0.0,
315 width: 0.0,
316 label_size: Size::ZERO,
317 })
318 .collect();
319 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
320 } else {
321 for (seg, label) in element.segments.iter_mut().zip(self.labels.iter()) {
322 if &seg.text != label {
323 seg.label.set_content(label.clone());
324 seg.text = label.clone();
325 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
326 }
327 }
328 }
329 if prev.selected != self.selected {
330 element.selected = self.selected;
331 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
332 }
333 flags
334 }
335}
336
337impl SegmentedControlWidget {
338 fn hit_index(&self, pos: Point) -> Option<usize> {
340 self.segments.iter().position(|s| {
341 Rect::from_origin_size(Point::new(s.x, 0.0), Size::new(s.width, self.height))
342 .contains(pos)
343 })
344 }
345}
346
347impl Widget for SegmentedControlWidget {
348 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
349 let theme = Theme::from_layout_ctx(ctx);
352 let colors = resolve_seg_colors(theme);
353 let active_style = seg_style(theme, colors.active_text);
354 let inactive_style = seg_style(theme, colors.inactive_text);
355
356 let mut x = SEG_PAD;
357 let mut content_height = 0.0_f64;
358 for (i, seg) in self.segments.iter_mut().enumerate() {
359 let style = if i == self.selected {
360 &active_style
361 } else {
362 &inactive_style
363 };
364 let label_size = seg.label.layout(ctx, style, None);
365 seg.label_size = label_size;
366 seg.x = x;
367 seg.width = label_size.width + SEG_BTN_PAD_X * 2.0;
368 let seg_height = label_size.height + SEG_BTN_PAD_Y * 2.0;
369 content_height = content_height.max(seg_height);
370 x += seg.width + SEG_GAP;
371 }
372 let inner_width = if self.segments.is_empty() {
373 0.0
374 } else {
375 x - SEG_GAP - SEG_PAD
376 };
377 let width = inner_width + SEG_PAD * 2.0;
378 let height = content_height + SEG_PAD * 2.0;
379 self.height = height;
380 bc.constrain(Size::new(width, height))
381 }
382
383 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
384 let theme = Theme::from_paint_ctx(ctx);
385 let colors = resolve_seg_colors(theme);
386 let origin = ctx.origin();
387 let size = ctx.size();
388
389 let radius = container_radius(theme, size);
391 scene.fill_rounded_rect(origin, size, radius, colors.container_bg);
392 let rr = kurbo::RoundedRect::from_rect(Rect::from_origin_size(Point::ORIGIN, size), radius);
393 scene.stroke_path(
394 origin,
395 &kurbo::Shape::to_path(&rr, 0.1),
396 SEG_BORDER_W,
397 &peniko::Brush::Solid(colors.container_border),
398 );
399
400 if let Some(seg) = self.segments.get(self.selected) {
402 let seg_origin = origin + Vec2::new(seg.x, SEG_PAD);
403 let seg_size = Size::new(seg.width, size.height - SEG_PAD * 2.0);
404 let seg_radius = segment_radius(theme, seg_size);
405 if let Some(shadow) = colors.active_shadow {
406 scene.draw_shadow(
407 seg_origin + Vec2::new(0.0, 1.0),
408 seg_size,
409 seg_radius,
410 SEG_LIGHT_SHADOW_BLUR,
411 shadow,
412 );
413 }
414 scene.fill_rounded_rect(seg_origin, seg_size, seg_radius, colors.active_fill);
415 }
416
417 for seg in &self.segments {
419 let label_y = origin.y + (size.height - seg.label_size.height) / 2.0;
420 let label_x = origin.x + seg.x + (seg.width - seg.label_size.width) / 2.0;
421 seg.label.paint(Point::new(label_x, label_y), scene);
422 }
423 }
424
425 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
426 let InputEvent::Pointer(p) = event else {
427 return EventResult::Ignored;
428 };
429 match p.phase {
430 PointerPhase::Down if !presses(p) => EventResult::Ignored,
431 PointerPhase::Down => match self.hit_index(p.position) {
432 Some(i) => {
433 self.pressed = Some(i);
434 self.captured = Some(i);
435 ctx.capture_pointer();
436 ctx.request_redraw();
437 EventResult::Handled
438 }
439 None => EventResult::Ignored,
440 },
441 PointerPhase::Move => {
442 let Some(cap) = self.captured else {
443 return EventResult::Ignored;
444 };
445 self.pressed = (self.hit_index(p.position) == Some(cap)).then_some(cap);
446 ctx.request_redraw();
447 EventResult::Handled
448 }
449 PointerPhase::Up => {
450 let Some(cap) = self.captured else {
451 return EventResult::Ignored;
452 };
453 if self.hit_index(p.position) == Some(cap) {
454 (self.on_select)(ctx, cap);
455 }
456 self.pressed = None;
457 self.captured = None;
458 ctx.request_redraw();
459 EventResult::Handled
460 }
461 PointerPhase::Cancel => {
462 if self.captured.is_none() {
463 return EventResult::Ignored;
464 }
465 self.pressed = None;
466 self.captured = None;
467 ctx.request_redraw();
468 EventResult::Handled
469 }
470 }
471 }
472
473 fn semantics(&self, ctx: &mut SemanticsCtx) {
474 ctx.push_container(
475 Role::TabList,
476 |_| {},
477 |ctx| {
478 for (i, seg) in self.segments.iter().enumerate() {
479 ctx.push_node(Role::Tab, |node| {
480 node.set_label(seg.text.as_str());
481 node.set_selected(i == self.selected);
482 });
483 }
484 },
485 );
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use frust::authoring::PointerButton;
493 use std::any::Any;
494
495 #[derive(Default)]
496 struct Recorder {
497 rrects: Vec<(Point, Size, f64, Color)>,
498 shadows: Vec<(Point, Size, f64, f64, Color)>,
499 strokes: Vec<Color>,
500 glyph_colors: Vec<Color>,
501 }
502
503 impl PaintScene for Recorder {
504 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
505 fn draw_text(&mut self, _o: Point, _t: &str) {}
506 fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
507 self.rrects.push((o, s, radius, color));
508 }
509 fn draw_shadow(&mut self, o: Point, s: Size, radius: f64, std_dev: f64, color: Color) {
510 self.shadows.push((o, s, radius, std_dev, color));
511 }
512 fn stroke_path(&mut self, _o: Point, _p: &kurbo::BezPath, _w: f64, brush: &peniko::Brush) {
513 if let peniko::Brush::Solid(c) = brush {
514 self.strokes.push(*c);
515 }
516 }
517 fn draw_glyph_run(&mut self, run: frust::authoring::scene::GlyphRun) {
518 if let peniko::Brush::Solid(c) = run.brush {
519 self.glyph_colors.push(c);
520 }
521 }
522 }
523
524 fn build(view: &SegmentedControlView<Vec<usize>>) -> SegmentedControlWidget {
525 let mut counter = 0u64;
526 View::<Vec<usize>>::build(view, &mut BuildCtx::new(&mut counter))
527 }
528
529 fn labels() -> Vec<String> {
530 vec!["grid".into(), "stack".into(), "zoom".into()]
531 }
532
533 fn layout(w: &mut SegmentedControlWidget, theme: Option<&Theme>) -> Size {
534 let mut tcx = TextContext::new();
535 let mut lctx = match theme {
536 Some(t) => {
537 LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(t as &dyn Any))
538 }
539 None => LayoutCtx::with_text_context(&mut tcx as &mut dyn Any),
540 };
541 w.layout(&mut lctx, &BoxConstraints::loose(Size::new(800.0, 100.0)))
542 }
543
544 fn paint(w: &mut SegmentedControlWidget, size: Size, theme: Option<&Theme>) -> Recorder {
545 let mut rec = Recorder::default();
546 let mut pctx = match theme {
547 Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
548 None => PaintCtx::new(Point::ZERO, size),
549 };
550 w.paint(&mut pctx, &mut rec);
551 rec
552 }
553
554 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
555 InputEvent::Pointer(frust_core::PointerEvent {
556 phase,
557 position: Point::new(x, y),
558 button: PointerButton::Primary,
559 })
560 }
561
562 #[test]
563 fn unthemed_paint_uses_dark_active_fill_and_no_shadow() {
564 let view: SegmentedControlView<Vec<usize>> = segmented_control(labels(), 0, |_s, _i| {});
565 let mut w = build(&view);
566 let size = layout(&mut w, None);
567 let rec = paint(&mut w, size, None);
568 assert_eq!(rec.rrects[0].3, SEG_CONTAINER_BG);
570 assert_eq!(rec.rrects[1].3, SEG_ACTIVE_FILL_DARK);
571 assert_eq!(rec.strokes[0], SEG_CONTAINER_BORDER);
572 assert!(rec.shadows.is_empty(), "no shadow in dark/unthemed");
573 assert_eq!(rec.glyph_colors[0], SEG_ACTIVE_TEXT);
575 assert_eq!(rec.glyph_colors[1], SEG_INACTIVE_TEXT);
576 }
577
578 #[test]
579 fn glyph_dark_theme_resolves_roles_without_shadow() {
580 let theme = crate::baseline();
581 let view: SegmentedControlView<Vec<usize>> = segmented_control(labels(), 1, |_s, _i| {});
582 let mut w = build(&view);
583 let size = layout(&mut w, Some(&theme));
584 let rec = paint(&mut w, size, Some(&theme));
585 let scheme = theme.scheme();
586 assert_eq!(rec.rrects[0].3, scheme.surface_container_high);
587 assert_eq!(rec.rrects[1].3, scheme.surface_container_highest);
588 assert_eq!(rec.strokes[0], scheme.outline);
589 assert!(rec.shadows.is_empty());
590 assert_eq!(rec.glyph_colors[1], scheme.primary);
592 }
593
594 #[test]
595 fn glyph_light_theme_adds_an_ink_lift_shadow_under_the_active_segment() {
596 let theme = crate::baseline().with_brightness(Brightness::Light);
597 let view: SegmentedControlView<Vec<usize>> = segmented_control(labels(), 0, |_s, _i| {});
598 let mut w = build(&view);
599 let size = layout(&mut w, Some(&theme));
600 let rec = paint(&mut w, size, Some(&theme));
601 let scheme = theme.scheme();
602 assert_eq!(rec.rrects[1].3, scheme.surface_container_lowest);
604 assert_eq!(rec.shadows.len(), 1);
606 let (_, _, _, blur, color) = rec.shadows[0];
607 assert_eq!(blur, SEG_LIGHT_SHADOW_BLUR);
608 assert_eq!(color, with_alpha(scheme.shadow, SEG_LIGHT_SHADOW_ALPHA));
609 }
610
611 #[test]
612 fn selection_is_controlled() {
613 let view: SegmentedControlView<Vec<usize>> =
614 segmented_control(labels(), 0, |s: &mut Vec<usize>, i| s.push(i));
615 let mut counter = 0u64;
616 let mut w = View::<Vec<usize>>::build(&view, &mut BuildCtx::new(&mut counter));
617 layout(&mut w, None);
618 let mut log: Vec<usize> = Vec::new();
619 let state_any: &mut dyn Any = &mut log;
620 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, w.height));
621 let cx = w.segments[2].x + w.segments[2].width / 2.0;
622 w.event(&mut ctx, &ev(PointerPhase::Down, cx, w.height / 2.0));
623 w.event(&mut ctx, &ev(PointerPhase::Up, cx, w.height / 2.0));
624 assert_eq!(log, vec![2]);
625 assert_eq!(w.selected, 0);
626 }
627
628 #[test]
629 fn semantics_yields_a_tablist_of_tabs_with_selection() {
630 fn logic(_s: &mut ()) -> SegmentedControlView<()> {
631 segmented_control(
632 vec!["grid".into(), "stack".into(), "zoom".into()],
633 2,
634 |_s: &mut (), _i| {},
635 )
636 }
637 let mut root: frust_core::RenderRoot<(), SegmentedControlView<()>> =
638 frust_core::RenderRoot::new();
639 let mut state = ();
640 root.rebuild(&mut logic, &mut state);
641 let mut tcx = TextContext::new();
642 root.layout_with_text(Size::new(400.0, 60.0), &mut tcx as &mut dyn Any);
643 let update = root.semantics();
644 let selected = update
645 .nodes
646 .iter()
647 .find(|(_, n)| n.role() == Role::Tab && n.label() == Some("zoom"))
648 .expect("the selected segment");
649 assert_eq!(selected.1.is_selected(), Some(true));
650 }
651
652 #[cfg(feature = "bundled-fonts")]
655 fn three_segments(_: &mut ()) -> SegmentedControlView<()> {
656 segmented_control(
657 vec!["grid".into(), "stack".into(), "zoom".into()],
658 2,
659 |_s: &mut (), _i| {},
660 )
661 }
662
663 #[cfg(feature = "bundled-fonts")]
664 #[test]
665 fn labels_paint_in_their_role_face_under_the_glyph_theme() {
666 use crate::badge::typeface_probe::{Face, painted_faces};
667 let faces = painted_faces(three_segments, crate::baseline(), Size::new(400.0, 60.0));
668 assert_eq!(faces, [Face::PlexMono; 3]);
669 }
670
671 #[cfg(feature = "bundled-fonts")]
672 #[test]
673 fn labels_follow_a_live_theme_family_swap() {
674 use crate::badge::typeface_probe::{Face, faces_across_a_live_swap};
675 let (before, after) = faces_across_a_live_swap(three_segments, Size::new(400.0, 60.0));
676 assert_eq!(before, [Face::PlexMono; 3]);
677 assert_eq!(after, [Face::SpaceMono; 3]);
678 }
679}