Skip to main content

frust_glyph/
segmented.rs

1//! [`segmented_control`]/[`SegmentedControlView`]: a pill container of
2//! mutually-exclusive segments — the active one gets a raised surface fill and
3//! accent text (the Glyph design system's `.segmented`/`.segmented
4//! button`/`.segmented button.active` rules, retrieved 2026-07-21).
5//!
6//! # Controlled selection
7//!
8//! Like [`crate::tabs`] and `crate::material::navigation_bar`, this is
9//! a **controlled** component: it reports the requested index through
10//! `on_select(index)` and never mutates its own `selected`.
11//!
12//! # Per-brightness active treatment (the source's dark/light split)
13//!
14//! The source deliberately treats the active segment differently per
15//! brightness ("segmented-control
16//! fill gets a subtle shadow instead of a glow on light surfaces — glow reads
17//! as haze on white, a shadow reads as lift"):
18//!
19//! - **Dark**: the active fill is `bg-overlay` (`surface_container_highest`),
20//!   no shadow.
21//! - **Light**: the active fill is `bg-surface` (`surface_container_lowest`,
22//!   the lightest slot on the light baseline — the per-brightness structural
23//!   inversion documented in [`crate::tokens`]), plus a subtle
24//!   `0 1px 2px rgba(34,29,18,0.08)` ink drop shadow (`SEG_LIGHT_SHADOW_*`,
25//!   an inline source token since no clean `Elevation` level matches this
26//!   1px/2px micro-shadow — the shadow color resolves from the theme's warm-ink
27//!   `shadow` role at [`SEG_LIGHT_SHADOW_ALPHA`]).
28//!
29//! Because the two brightnesses draw from *different* surface roles (and only
30//! light carries a shadow), the active treatment branches on `theme.brightness`
31//! rather than resolving one role for both — the one place a Glyph widget reads
32//! brightness directly, mirroring the source.
33//!
34//! # Why this widget doesn't nest `Text` children
35//!
36//! See [`crate::badge`]'s module docs — the per-segment dynamic color
37//! (accent vs muted) isn't one of `TextView`'s four fixed roles, so this widget
38//! shapes/paints its own label glyph runs directly via `frust_text`.
39//!
40//! Each label reads its family at layout from the live theme's `labelLarge`
41//! type-scale role (IBM Plex Mono under Glyph's own scale), falling back to
42//! Glyph's IBM Plex Mono stack unthemed, so a theme swap reshapes the labels
43//! (see [`crate::badge`]'s Typeface section).
44
45use 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
61/// Container inner padding, logical px (`.segmented{padding:3px}`).
62const SEG_PAD: f64 = 3.0;
63/// Gap between segments, logical px (`.segmented{gap:2px}`).
64const SEG_GAP: f64 = 2.0;
65/// Segment horizontal padding, logical px (`.segmented button{padding:7px 14px}`).
66const SEG_BTN_PAD_X: f64 = 14.0;
67/// Segment vertical padding, logical px.
68const SEG_BTN_PAD_Y: f64 = 7.0;
69/// Label font size, logical px (`.segmented button{font-size:11px}`).
70const SEG_FONT_SIZE: f32 = 11.0;
71/// Container border width, logical px (`.segmented{border:1px}`).
72const SEG_BORDER_W: f64 = 1.0;
73
74/// The light-mode active-segment ink shadow (`box-shadow:0 1px 2px
75/// rgba(34,29,18,0.08)`): a `blur_std_dev` of 2 (the CSS blur radius stored
76/// directly, matching `frust_theme::elevation`'s v1 treatment) and an alpha of
77/// 0.08 over the theme's warm-ink `shadow` role. Inline rather than an
78/// `Elevation` level: no level matches this 1px/2px micro-shadow.
79const SEG_LIGHT_SHADOW_BLUR: f64 = 2.0;
80/// See [`SEG_LIGHT_SHADOW_BLUR`]. The unthemed fallback is the Glyph **dark**
81/// baseline, which carries no active-segment shadow at all, so there is no
82/// unthemed shadow color constant — the light ink shadow only ever appears via
83/// a threaded light theme's warm-ink `shadow` role.
84const SEG_LIGHT_SHADOW_ALPHA: f32 = 0.08;
85
86// ---- Unthemed fallback constants (Glyph **dark** values) -------------------
87
88const SEG_CONTAINER_BG: Color = Color::from_rgb8(0x1e, 0x23, 0x30); // bg-raised
89const SEG_CONTAINER_BORDER: Color = Color::from_rgb8(0x3e, 0x3f, 0x44); // border-bright
90const SEG_ACTIVE_FILL_DARK: Color = Color::from_rgb8(0x27, 0x2d, 0x3d); // bg-overlay
91const SEG_ACTIVE_TEXT: Color = Color::from_rgb8(0xff, 0xb6, 0x27); // amber
92const SEG_INACTIVE_TEXT: Color = Color::from_rgb8(0x6b, 0x65, 0x56); // fg-dim
93
94/// Return `color` with its alpha channel replaced (mirrors the same helper in
95/// the sibling Glyph widgets).
96fn with_alpha(color: Color, alpha: f32) -> Color {
97    let c = color.components;
98    Color::new([c[0], c[1], c[2], alpha])
99}
100
101/// The resolved segmented-control palette.
102struct SegColors {
103    container_bg: Color,
104    container_border: Color,
105    active_fill: Color,
106    active_text: Color,
107    inactive_text: Color,
108    /// `Some` only on the light baseline — the per-brightness ink lift shadow.
109    active_shadow: Option<Color>,
110}
111
112/// Resolve the full palette — see the module docs for the per-brightness
113/// active-segment split.
114fn 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
145/// Container radius (`--radius-sm`, 6px) — themed `shape.small`, else the
146/// literal fallback.
147fn 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
154/// Active-segment radius (`.segmented button{border-radius:4px}`) — themed
155/// `shape.extra_small`, else the literal fallback.
156fn 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
163/// A view-held, typed selection callback.
164type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
165
166/// A declarative segmented control. See the [module docs](self).
167pub struct SegmentedControlView<State: 'static> {
168    labels: Vec<String>,
169    selected: usize,
170    on_select: OnSelect<State>,
171}
172
173/// Create a segmented control over `labels`, with `selected` the current
174/// (app-confirmed) index. Fires `on_select(state, index)` on a release inside a
175/// segment — a **controlled** component.
176pub 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
188/// A minimal retained text run — see [`crate::badge`]'s `GlyphLabel`.
189struct 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
239/// One retained segment.
240struct Segment {
241    label: GlyphLabel,
242    text: String,
243    /// Local x/width of the segment's fill rect (filled at layout).
244    x: f64,
245    width: f64,
246    label_size: Size,
247}
248
249/// Glyph's UI face stack (IBM Plex Mono): the labels' unthemed family.
250fn ui_face() -> FontFamily {
251    FontFamily::stack_with_generic(["IBM Plex Mono"], GenericSlot::Monospace)
252}
253
254/// A label's style: fixed size/weight, family from the theme's `labelLarge`
255/// role.
256fn 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
264/// The retained widget for a [`SegmentedControlView`].
265pub 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    /// The segment index under a widget-local pointer position, if any.
339    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        // Resolve both styles up front so the immutable theme borrow ends
350        // before the `&mut ctx` shaping calls below.
351        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        // Container: raised fill + hairline border.
390        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        // Active segment fill (+ light-mode ink lift shadow beneath it).
401        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        // Labels (layout-baked color).
418        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        // rrects[0] container, rrects[1] active fill.
569        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        // Active label accent, others muted.
574        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        // Segment 1 is active → accent.
591        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        // Light active fill is the lightest surface slot, not the highest.
603        assert_eq!(rec.rrects[1].3, scheme.surface_container_lowest);
604        // Exactly one ink shadow, colored from the warm-ink `shadow` role.
605        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    // ---- Typeface: labels follow their type-scale role --------------------
653
654    #[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}