Skip to main content

llimphi_widget_segmented/
lib.rs

1//! `llimphi-widget-segmented` — control de opciones mutuamente exclusivas.
2//!
3//! N opciones horizontales con UNA activa. Patrón iOS/macOS para
4//! alternativas radio-style cuando son pocas (2-5) y caben en línea.
5//! Si son más, usar un `tabs` o un dropdown.
6//!
7//! Render-only: la app guarda `selected: usize` en el modelo y
8//! dispatcha `Msg::SelectSegment(usize)` al click.
9
10#![forbid(unsafe_code)]
11
12use llimphi_ui::llimphi_layout::taffy::{
13    prelude::{length, percent, FlexDirection, Size, Style},
14    AlignItems, JustifyContent, Rect,
15};
16use llimphi_ui::llimphi_raster::peniko::Color;
17use llimphi_ui::llimphi_text::Alignment;
18use llimphi_ui::View;
19use llimphi_theme::{radius, Theme};
20
21/// Paleta del control.
22#[derive(Debug, Clone, Copy)]
23pub struct SegmentedPalette {
24    pub bg_track: Color,
25    pub bg_active: Color,
26    pub fg_active: Color,
27    pub fg_inactive: Color,
28    pub fg_hover: Color,
29}
30
31impl SegmentedPalette {
32    pub fn from_theme(t: &Theme) -> Self {
33        Self {
34            bg_track: t.bg_button,
35            bg_active: t.bg_panel,
36            fg_active: t.fg_text,
37            fg_inactive: t.fg_muted,
38            fg_hover: t.fg_text,
39        }
40    }
41}
42
43/// Construye el control. `labels` son los textos visibles; `selected`
44/// es el índice activo (0-based). `make_msg(i)` se llama al click.
45pub fn segmented_view<Msg, F>(
46    labels: &[&str],
47    selected: usize,
48    make_msg: F,
49    palette: &SegmentedPalette,
50) -> View<Msg>
51where
52    Msg: Clone + 'static,
53    F: Fn(usize) -> Msg,
54{
55    let children: Vec<View<Msg>> = labels
56        .iter()
57        .enumerate()
58        .map(|(i, label)| segment_view(i, label, i == selected, make_msg(i), palette))
59        .collect();
60
61    View::new(Style {
62        flex_direction: FlexDirection::Row,
63        size: Size {
64            width: percent(1.0_f32),
65            height: length(28.0_f32),
66        },
67        padding: Rect {
68            left: length(2.0_f32),
69            right: length(2.0_f32),
70            top: length(2.0_f32),
71            bottom: length(2.0_f32),
72        },
73        gap: Size {
74            width: length(2.0_f32),
75            height: length(0.0_f32),
76        },
77        ..Default::default()
78    })
79    .fill(palette.bg_track)
80    .radius(radius::SM)
81    .children(children)
82}
83
84fn segment_view<Msg: Clone + 'static>(
85    _idx: usize,
86    label: &str,
87    is_active: bool,
88    msg: Msg,
89    palette: &SegmentedPalette,
90) -> View<Msg> {
91    let (bg, fg) = if is_active {
92        (Some(palette.bg_active), palette.fg_active)
93    } else {
94        (None, palette.fg_inactive)
95    };
96
97    let seg_radius = radius::XS;
98    let mut node = View::new(Style {
99        size: Size {
100            width: percent(1.0_f32),
101            height: percent(1.0_f32),
102        },
103        flex_grow: 1.0,
104        align_items: Some(AlignItems::Center),
105        justify_content: Some(JustifyContent::Center),
106        padding: Rect {
107            left: length(8.0_f32),
108            right: length(8.0_f32),
109            top: length(0.0_f32),
110            bottom: length(0.0_f32),
111        },
112        ..Default::default()
113    })
114    .radius(seg_radius)
115    .text_aligned(label.to_string(), 11.5, fg, Alignment::Center)
116    // Semántica: rol Tab + label + pressed=is_active. AccessKit anuncia
117    // "Pestaña <label>, presionada / sin presionar".
118    .role(llimphi_ui::Role::Tab)
119    .aria_label(label.to_string())
120    .aria_pressed(is_active)
121    .on_click(msg);
122
123    if let Some(c) = bg {
124        node = node.fill(c).paint_with(move |scene, _ts, rect| {
125            // Gloss superior sólo en el segmento activo — refuerza
126            // "esto está seleccionado" con la misma firma de button (P6).
127            // Los segmentos inactivos quedan planos para que el contraste
128            // sea inequívoco.
129            use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect};
130            use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient};
131            if rect.w <= 0.0 || rect.h <= 0.0 {
132                return;
133            }
134            let x0 = rect.x as f64;
135            let y0 = rect.y as f64;
136            let x1 = (rect.x + rect.w) as f64;
137            let y1 = (rect.y + rect.h) as f64;
138            let y_mid = y0 + (y1 - y0) * 0.5;
139            let rr = RoundedRect::new(x0, y0, x1, y1, seg_radius);
140            let top = Color::from_rgba8(255, 255, 255, 28);
141            let bot = Color::from_rgba8(255, 255, 255, 0);
142            let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid))
143                .with_stops([top, bot].as_slice());
144            scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr);
145        });
146    }
147    node
148}