//! `llimphi-widget-segmented` — control de opciones mutuamente exclusivas. //! //! N opciones horizontales con UNA activa. Patrón iOS/macOS para //! alternativas radio-style cuando son pocas (2-5) y caben en línea. //! Si son más, usar un `tabs` o un dropdown. //! //! Render-only: la app guarda `selected: usize` en el modelo y //! dispatcha `Msg::SelectSegment(usize)` al click. #![forbid(unsafe_code)] // Sin `std`: este widget no usa nada del sistema —sólo `alloc` para // `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula // WASM o al kernel. Antes enlazaba `std` por omisión (W4.6). #![no_std] extern crate alloc; use alloc::vec::Vec; use alloc::vec; use alloc::string::ToString; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{length, percent, FlexDirection, Size, Style}, AlignItems, JustifyContent, Rect, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::llimphi_text::Alignment; use llimphi_compositor::View; use llimphi_theme::{radius, Theme}; /// Paleta del control. #[derive(Debug, Clone, Copy)] pub struct SegmentedPalette { pub bg_track: Color, pub bg_active: Color, pub fg_active: Color, pub fg_inactive: Color, /// Relleno de hover sobre un segmento **inactivo**. Antes existía un /// `fg_hover` (color de texto en hover) que NINGÚN código aplicaba — /// un campo muerto que anunciaba un hover inexistente. `View` sólo /// puede cambiar el fondo en hover (`hover_fill`), no el color de /// texto, así que el hover real es de fondo. pub bg_hover: Color, } impl SegmentedPalette { pub fn from_theme(t: &Theme) -> Self { Self { bg_track: t.bg_button, bg_active: t.bg_panel, fg_active: t.fg_text, fg_inactive: t.fg_muted, bg_hover: t.bg_row_hover, } } } /// Posición del indicador deslizante como fracciones del ancho del track: /// `(left, width)` en `[0,1]`, con `width = 1/n` y `left = t/n` clampeado para /// no salirse. `t` es la posición **animada** (0..n-1) que el host tween-ea /// hacia el `selected`. Pura y testeable; casa exacto con segmentos de igual /// ancho y gap 0. pub fn indicator_bounds(t: f32, n: usize) -> (f32, f32) { if n == 0 { return (0.0, 1.0); } let nf = n as f32; let width = 1.0 / nf; let left = (t / nf).clamp(0.0, 1.0 - width); (left, width) } /// Variante con **indicador deslizante animado**: en vez de pintar el fondo del /// segmento activo, un único realce flota y se desliza a la posición `indicator_t` /// (que el host anima con `llimphi-motion` hacia `selected`). Segmentos de igual /// ancho, gap 0 → el realce cae exacto sobre cada uno. `selected` sólo decide el /// color del texto. El resto (teclado con [`step_segment`], a11y) igual que /// [`segmented_view`]. pub fn segmented_view_sliding( labels: &[&str], selected: usize, indicator_t: f32, make_msg: F, palette: &SegmentedPalette, ) -> View where Msg: Clone + 'static, F: Fn(usize) -> Msg, { let n = labels.len(); let (left_frac, width_frac) = indicator_bounds(indicator_t, n); // Realce flotante: nodo absoluto posicionado por porcentaje del track. let seg_radius = radius::XS; let indicator = View::new(Style { position: llimphi_compositor::llimphi_layout::taffy::prelude::Position::Absolute, inset: Rect { left: percent(left_frac), top: length(0.0_f32), right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(), bottom: length(0.0_f32), }, size: Size { width: percent(width_frac), height: percent(1.0_f32) }, ..Default::default() }) .fill(palette.bg_active) .radius(seg_radius) .paint_with(move |scene, _ts, rect| { // Mismo gloss superior que el segmento activo estático. use llimphi_compositor::peniko::kurbo::{Affine, Point, RoundedRect}; use llimphi_compositor::peniko::{Fill, Gradient}; if rect.w <= 0.0 || rect.h <= 0.0 { return; } let (x0, y0) = (rect.x as f64, rect.y as f64); let (x1, y1) = ((rect.x + rect.w) as f64, (rect.y + rect.h) as f64); let rr = RoundedRect::new(x0, y0, x1, y1, seg_radius); let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y0 + (y1 - y0) * 0.5)) .with_stops([Color::from_rgba8(255, 255, 255, 28), Color::from_rgba8(255, 255, 255, 0)].as_slice()); scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr); }); // Segmentos: sólo texto (el realce lo da el indicador) + hover en inactivos. let seg_nodes: Vec> = labels .iter() .enumerate() .map(|(i, label)| { let is_active = i == selected; let fg = if is_active { palette.fg_active } else { palette.fg_inactive }; let mut node = View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, flex_grow: 1.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .radius(seg_radius) .text_aligned(label.to_string(), 11.5, fg, Alignment::Center) .role(llimphi_compositor::Role::Tab) .aria_label(label.to_string()) .aria_pressed(is_active) .on_click(make_msg(i)); if !is_active { node = node.hover_fill(palette.bg_hover); } node }) .collect(); let seg_row = View::new(Style { position: llimphi_compositor::llimphi_layout::taffy::prelude::Position::Relative, flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .children(seg_nodes); View::new(Style { position: llimphi_compositor::llimphi_layout::taffy::prelude::Position::Relative, size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, padding: Rect { left: length(2.0_f32), right: length(2.0_f32), top: length(2.0_f32), bottom: length(2.0_f32), }, ..Default::default() }) .fill(palette.bg_track) .radius(radius::SM) .children(vec![indicator, seg_row]) } /// Construye el control. `labels` son los textos visibles; `selected` /// es el índice activo (0-based). `make_msg(i)` se llama al click. pub fn segmented_view( labels: &[&str], selected: usize, make_msg: F, palette: &SegmentedPalette, ) -> View where Msg: Clone + 'static, F: Fn(usize) -> Msg, { let children: Vec> = labels .iter() .enumerate() .map(|(i, label)| segment_view(i, label, i == selected, make_msg(i), palette)) .collect(); View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: length(28.0_f32), }, padding: Rect { left: length(2.0_f32), right: length(2.0_f32), top: length(2.0_f32), bottom: length(2.0_f32), }, gap: Size { width: length(2.0_f32), height: length(0.0_f32), }, ..Default::default() }) .fill(palette.bg_track) .radius(radius::SM) .children(children) } fn segment_view( _idx: usize, label: &str, is_active: bool, msg: Msg, palette: &SegmentedPalette, ) -> View { let (bg, fg) = if is_active { (Some(palette.bg_active), palette.fg_active) } else { (None, palette.fg_inactive) }; let seg_radius = radius::XS; let mut node = View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32), }, flex_grow: 1.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), padding: Rect { left: length(8.0_f32), right: length(8.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .radius(seg_radius) .text_aligned(label.to_string(), 11.5, fg, Alignment::Center) // Semántica: rol Tab + label + pressed=is_active. AccessKit anuncia // "Pestaña