tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 484Markdown 258TOML 131RON 38118 Definiciones

⎇ main widgets/toolbar/src/lib.rs Rust · 777 líneas · 26.8 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1//! `llimphi-widget-toolbar` — barra de herramientas moderna.
2//!
3//! Grupos de botones-ícono planos: hover con fondo redondeado, estado
4//! **activo** con fondo de selección + ícono en acento, **deshabilitado**
5//! atenuado y sin click. Entre grupos, un separador vertical sutil.
6//!
7//! El widget es render-only y agnóstico del `Msg`:
8//! - los **íconos los dibuja el caller** vía closure `Fn(size, color) ->
9//!   View` (mismo contrato que `dock-rail::make_icon`) — el widget resuelve
10//!   el color según el estado (activo/normal/deshabilitado);
11//! - los **grupos son datos** (`Vec<ToolbarGroup>`): el caller los arma,
12//!   reordena o filtra — la barra es componible/configurable por
13//!   construcción, sin sistema de config propio.
14//!
15//! ```ignore
16//! let barra = toolbar_view(
17//!     vec![
18//!         ToolbarGroup::new(vec![
19//!             ToolbarItem::new(|s, c| icon_view(Icon::ChevronUp, c, 1.7), Msg::Subir)
20//!                 .with_label("subir"),
21//!         ]),
22//!         ToolbarGroup::new(vec![
23//!             ToolbarItem::new(|s, c| icon_view(Icon::Rows, c, 1.7), Msg::Vista(0)).active(lista),
24//!             ToolbarItem::new(|s, c| icon_view(Icon::Grid, c, 1.7), Msg::Vista(2)).active(iconos),
25//!         ]),
26//!     ],
27//!     36.0,
28//!     &ToolbarPalette::from_theme(&theme),
29//! );
30//! ```
31
32#![forbid(unsafe_code)]
33
34
35// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
36// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
37// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
38#![no_std]
39
40extern crate alloc;
41
42use alloc::boxed::Box;
43use alloc::string::String;
44use alloc::vec::Vec;
45use alloc::vec;
46use llimphi_compositor::llimphi_layout::taffy::{
47    prelude::{auto, length, percent, FlexDirection, Size, Style},
48    AlignItems, JustifyContent, Rect,
49};
50use llimphi_compositor::peniko::Color;
51use llimphi_compositor::View;
52
53/// Tamaño del ícono que se le pide al caller (px).
54const ICON_PX: f32 = 16.0;
55
56/// Paleta de la barra — subset del `Theme` semántico.
57#[derive(Debug, Clone, Copy)]
58pub struct ToolbarPalette {
59    /// Fondo de la franja.
60    pub bg_bar: Color,
61    /// Fondo del botón al hover.
62    pub bg_hover: Color,
63    /// Fondo del botón activo (toggle prendido).
64    pub bg_active: Color,
65    /// Ícono/label normal.
66    pub fg: Color,
67    /// Ícono del botón activo.
68    pub fg_active: Color,
69    /// Ícono/label deshabilitado.
70    pub fg_disabled: Color,
71    /// Separador vertical entre grupos.
72    pub separator: Color,
73}
74
75impl ToolbarPalette {
76    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
77        Self {
78            bg_bar: t.bg_panel_alt,
79            bg_hover: t.bg_row_hover,
80            bg_active: t.bg_selected,
81            fg: t.fg_muted,
82            fg_active: t.accent,
83            fg_disabled: t.fg_placeholder,
84            separator: t.border,
85        }
86    }
87}
88
89impl Default for ToolbarPalette {
90    fn default() -> Self {
91        Self::from_theme(&llimphi_theme::Theme::dark())
92    }
93}
94
95/// Un botón de la barra. El ícono es una closure `(size_px, color) -> View`
96/// — el widget la invoca con el color ya resuelto por estado.
97pub struct ToolbarItem<Msg> {
98    pub icon: Box<dyn Fn(f32, Color) -> View<Msg>>,
99    /// Texto corto opcional a la derecha del ícono (la barra es icon-first).
100    pub label: Option<String>,
101    /// Toggle prendido (fondo de selección + acento).
102    pub active: bool,
103    /// `false` = atenuado y sin click.
104    pub enabled: bool,
105    pub on_click: Msg,
106}
107
108impl<Msg> ToolbarItem<Msg> {
109    pub fn new(
110        icon: impl Fn(f32, Color) -> View<Msg> + 'static,
111        on_click: Msg,
112    ) -> Self {
113        Self {
114            icon: Box::new(icon),
115            label: None,
116            active: false,
117            enabled: true,
118            on_click,
119        }
120    }
121
122    pub fn with_label(mut self, label: impl Into<String>) -> Self {
123        self.label = Some(label.into());
124        self
125    }
126
127    pub fn active(mut self, active: bool) -> Self {
128        self.active = active;
129        self
130    }
131
132    pub fn enabled(mut self, enabled: bool) -> Self {
133        self.enabled = enabled;
134        self
135    }
136}
137
138/// Un grupo de botones contiguos; entre grupos va un separador.
139pub struct ToolbarGroup<Msg> {
140    pub items: Vec<ToolbarItem<Msg>>,
141}
142
143impl<Msg> ToolbarGroup<Msg> {
144    pub fn new(items: Vec<ToolbarItem<Msg>>) -> Self {
145        Self { items }
146    }
147}
148
149/// Publica el ancho **medido** de la barra: el widget lo escribe cuando se
150/// pinta y el host lo lee en el cuadro siguiente para decidir el overflow con
151/// [`overflow_count`].
152///
153/// Es la pieza que faltaba y por la que el overflow estaba a medias: el ancho
154/// disponible no se sabe hasta que taffy repartió, o sea **después** de armar
155/// el `View`. Un frame de latencia en el peor caso (el primero, o el del
156/// resize), que es exactamente lo que hace cualquier toolbar con overflow.
157#[derive(Debug, Clone, Default)]
158pub struct AnchoBarra(alloc::sync::Arc<core::sync::atomic::AtomicU32>);
159
160impl AnchoBarra {
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// Ancho útil (px) del último cuadro pintado — ya descontado el padding
166    /// lateral, o sea el espacio real para ítems. `0.0` si todavía no se pintó.
167    pub fn px(&self) -> f32 {
168        f32::from_bits(self.0.load(core::sync::atomic::Ordering::Relaxed))
169    }
170
171    fn escribir(&self, v: f32) {
172        self.0.store(v.max(0.0).to_bits(), core::sync::atomic::Ordering::Relaxed);
173    }
174}
175
176/// Lo que [`toolbar_view`] no ofrece y [`toolbar_view_ext`] sí. `Default` es la
177/// barra de siempre.
178pub struct ToolbarOpciones<Msg> {
179    /// Dónde publicar el ancho medido (ver [`AnchoBarra`]).
180    pub medidor: Option<AnchoBarra>,
181    /// Cuántos ítems mostrar en línea. `None` = todos. El host lo saca de
182    /// [`overflow_count`] con los anchos de [`item_widths`] y el ancho de
183    /// `medidor`.
184    pub visibles: Option<usize>,
185    /// `Msg` del botón «⋯». La barra lo dibuja **sólo si** hay ítems
186    /// escondidos; sin este `Msg` los de más simplemente no se muestran.
187    pub on_overflow: Option<Msg>,
188    /// Hover sobre un ítem: `Some(i)` al entrar, `None` al salir. Es lo que
189    /// alimenta al `tooltip` (que es render puro y lo monta el host en su
190    /// `view_overlay`); la posición del ancla sale de [`item_offsets`].
191    pub on_hover: Option<alloc::sync::Arc<dyn Fn(Option<usize>) -> Msg + Send + Sync>>,
192}
193
194impl<Msg> Default for ToolbarOpciones<Msg> {
195    fn default() -> Self {
196        Self { medidor: None, visibles: None, on_overflow: None, on_hover: None }
197    }
198}
199
200/// Padding lateral de la barra (cada lado).
201const BAR_PAD_X: f32 = 6.0;
202/// Separación entre ítems.
203pub const ITEM_GAP: f32 = 2.0;
204/// Padding horizontal (cada lado) de un botón.
205const BTN_PAD_X: f32 = 7.0;
206/// Separación ícono↔label dentro del botón.
207const BTN_GAP: f32 = 5.0;
208/// Ancho del separador entre grupos, márgenes incluidos.
209pub const SEPARATOR_W: f32 = 1.0 + 5.0 * 2.0;
210/// Ancho del botón «⋯» — un ítem sólo-ícono.
211pub const MORE_W: f32 = ICON_PX + BTN_PAD_X * 2.0;
212
213/// Ancho de un ítem con la **misma cuenta que el render**: ícono + padding, y
214/// el label medido con la fuente real si lo hay. Es la fuente de verdad que
215/// comparten el pintado, [`item_offsets`] y el `overflow_count` del host — si
216/// el host la estimara por su cuenta, el corte no coincidiría con lo dibujado.
217pub fn item_width(label: Option<&str>) -> f32 {
218    let base = ICON_PX + BTN_PAD_X * 2.0;
219    match label {
220        Some(t) => base + BTN_GAP + llimphi_compositor::llimphi_text::text_width(t, 11.5),
221        None => base,
222    }
223}
224
225/// Los anchos de todos los ítems, en orden y **atravesando los grupos** (los
226/// separadores no son ítems). Es el argumento de [`overflow_count`].
227pub fn item_widths<Msg>(groups: &[ToolbarGroup<Msg>]) -> Vec<f32> {
228    groups
229        .iter()
230        .flat_map(|g| g.items.iter())
231        .map(|it| item_width(it.label.as_deref()))
232        .collect()
233}
234
235/// `(left, width)` de cada ítem en coordenadas de la barra, contando el padding
236/// lateral, los gaps **y los separadores de grupo**. Lo necesita el host para
237/// anclar el `tooltip` sobre el ítem hovereado sin re-derivar el layout (que es
238/// como se desalinean estas cosas).
239pub fn item_offsets<Msg>(groups: &[ToolbarGroup<Msg>]) -> Vec<(f32, f32)> {
240    let mut out = Vec::new();
241    let mut x = BAR_PAD_X;
242    let n = groups.len();
243    for (gi, g) in groups.iter().enumerate() {
244        for it in &g.items {
245            let w = item_width(it.label.as_deref());
246            out.push((x, w));
247            x += w + ITEM_GAP;
248        }
249        if gi + 1 < n {
250            x += SEPARATOR_W + ITEM_GAP;
251        }
252    }
253    out
254}
255
256/// Un ítem que no entró en la barra, listo para el menú de overflow.
257///
258/// El widget **no monta el menú**: un popup vive en `App::view_overlay` con su
259/// scrim, y eso es del host. Lo que sí le corresponde es decir *qué* quedó
260/// afuera, con su estado, en el mismo orden — que es donde se desincroniza un
261/// menú armado a mano (el índice del `on_pick` deja de ser el del ítem).
262///
263/// El puente al menú canónico es una línea:
264///
265/// ```ignore
266/// let ocultos = items_ocultos(&groups, visibles);
267/// let items: Vec<_> = ocultos.iter()
268///     .map(|o| {
269///         let mut it = ContextMenuItem::action(o.label.clone().unwrap_or_default());
270///         if !o.enabled { it = it.disabled(); }
271///         it.checked(o.active)
272///     })
273///     .collect();
274/// // y en `on_pick(i)` → `ocultos[i].msg.clone()`
275/// ```
276pub struct ItemOculto<Msg> {
277    pub label: Option<String>,
278    pub active: bool,
279    pub enabled: bool,
280    pub msg: Msg,
281}
282
283/// Los ítems a partir de `visibles` (los que el corte dejó afuera), en el mismo
284/// orden en que están en la barra. Se llama **antes** de `toolbar_view_ext`,
285/// que consume los grupos.
286pub fn items_ocultos<Msg: Clone>(groups: &[ToolbarGroup<Msg>], visibles: usize) -> Vec<ItemOculto<Msg>> {
287    groups
288        .iter()
289        .flat_map(|g| g.items.iter())
290        .skip(visibles)
291        .map(|it| ItemOculto {
292            label: it.label.clone(),
293            active: it.active,
294            enabled: it.enabled,
295            msg: it.on_click.clone(),
296        })
297        .collect()
298}
299
300/// Compone la barra: franja horizontal de alto `height` con los grupos
301/// alineados a la izquierda.
302pub fn toolbar_view<Msg: Clone + 'static>(
303    groups: Vec<ToolbarGroup<Msg>>,
304    height: f32,
305    palette: &ToolbarPalette,
306) -> View<Msg> {
307    toolbar_view_ext(groups, height, palette, ToolbarOpciones::default())
308}
309
310/// [`toolbar_view`] que **se parte en renglones** cuando no cabe a lo ancho.
311///
312/// Es la barra de un panel que el usuario angosta con el divisor: ahí el corte
313/// por overflow esconde justo los últimos botones y la de una línea los deja
314/// afuera del panel sin aviso. Cada renglón conserva el alto `height`; la barra
315/// crece hacia abajo lo que haga falta.
316pub fn toolbar_view_envuelta<Msg: Clone + 'static>(
317    groups: Vec<ToolbarGroup<Msg>>,
318    height: f32,
319    palette: &ToolbarPalette,
320) -> View<Msg> {
321    let mut v = toolbar_view(groups, height, palette);
322    v.style.flex_wrap = llimphi_compositor::llimphi_layout::taffy::style::FlexWrap::Wrap;
323    v.style.size.height = auto();
324    v.style.min_size.height = length(height);
325    v.style.gap.height = length(ITEM_GAP);
326    v
327}
328
329/// [`toolbar_view`] con [`ToolbarOpciones`]: medición post-layout, corte por
330/// overflow con botón «⋯», y hover por ítem para el tooltip. Con
331/// `ToolbarOpciones::default()` produce exactamente la misma barra.
332pub fn toolbar_view_ext<Msg: Clone + 'static>(
333    groups: Vec<ToolbarGroup<Msg>>,
334    height: f32,
335    palette: &ToolbarPalette,
336    opciones: ToolbarOpciones<Msg>,
337) -> View<Msg> {
338    let ToolbarOpciones { medidor, visibles, on_overflow, on_hover } = opciones;
339    let total_items: usize = groups.iter().map(|g| g.items.len()).sum();
340    let visibles = visibles.unwrap_or(total_items).min(total_items);
341    let hay_ocultos = visibles < total_items;
342
343    let mut kids: Vec<View<Msg>> = Vec::new();
344    let n = groups.len();
345    let mut idx = 0usize;
346    for (gi, group) in groups.into_iter().enumerate() {
347        let mut puestos = 0usize;
348        for item in group.items {
349            if idx >= visibles {
350                idx += 1;
351                continue;
352            }
353            let mut b = button(item, height, palette);
354            if let Some(f) = &on_hover {
355                // Enter y leave con el mismo callback: el host guarda `Some(i)`
356                // o `None` y el tooltip aparece/desaparece por ese dato. Un par
357                // de Msgs distintos obligaría a cada caller a inventar los dos.
358                let (fe, fl) = (f.clone(), f.clone());
359                b = b.on_pointer_enter(fe(Some(idx))).on_pointer_leave(fl(None));
360            }
361            kids.push(b);
362            puestos += 1;
363            idx += 1;
364        }
365        // El separador sólo tiene sentido si de este grupo quedó algo Y
366        // todavía viene algo más: si no, la barra cortada termina en una
367        // rayita colgada.
368        if gi + 1 < n && puestos > 0 && idx < visibles {
369            kids.push(separator(height, palette));
370        }
371    }
372    if hay_ocultos {
373        if let Some(msg) = on_overflow {
374            kids.push(boton_mas(height, palette, msg, total_items - visibles));
375        }
376    }
377    let vista = View::new(Style {
378        flex_direction: FlexDirection::Row,
379        size: Size {
380            width: percent(1.0_f32),
381            height: length(height),
382        },
383        flex_shrink: 0.0,
384        align_items: Some(AlignItems::CENTER),
385        padding: Rect {
386            left: length(6.0_f32),
387            right: length(6.0_f32),
388            top: length(0.0_f32),
389            bottom: length(0.0_f32),
390        },
391        gap: Size {
392            width: length(2.0_f32),
393            height: length(0.0_f32),
394        },
395        ..Default::default()
396    })
397    .fill(palette.bg_bar)
398    .role(llimphi_compositor::Role::Toolbar)
399    .children(kids);
400
401    match medidor {
402        // Se mide en el pintado porque es el único momento en que el rect ya
403        // está resuelto. Se publica el ancho ÚTIL (sin el padding), que es lo
404        // que `overflow_count` espera recibir.
405        Some(m) => vista.paint_with(move |_scene, _ts, rect| {
406            m.escribir(rect.w - BAR_PAD_X * 2.0);
407        }),
408        None => vista,
409    }
410}
411
412/// La misma barra **de pie**: una columna de ancho `width` con los botones
413/// cuadrados apilados y un separador **horizontal** entre grupos.
414///
415/// Es la barra de herramientas de un editor —la caja que vive al canto
416/// izquierdo del lienzo—, no una franja de acciones sobre el contenido. Por eso
417/// no lleva overflow ni etiquetas: en vertical el espacio sobra hacia abajo y lo
418/// que falta es ancho, así que el `label` de un ítem viaja sólo al nombre
419/// accesible. Todo lo demás —hover, activo, deshabilitado, paleta— es idéntico a
420/// [`toolbar_view`]: son la misma barra en dos ejes, no dos widgets.
421pub fn toolbar_view_vertical<Msg: Clone + 'static>(
422    groups: Vec<ToolbarGroup<Msg>>,
423    width: f32,
424    palette: &ToolbarPalette,
425) -> View<Msg> {
426    let mut kids: Vec<View<Msg>> = Vec::new();
427    let n = groups.len();
428    for (gi, group) in groups.into_iter().enumerate() {
429        let puestos = group.items.len();
430        for item in group.items {
431            kids.push(button_cuadrado(item, width - 10.0, palette));
432        }
433        if gi + 1 < n && puestos > 0 {
434            kids.push(separator_horizontal(width, palette));
435        }
436    }
437    View::new(Style {
438        flex_direction: FlexDirection::Column,
439        size: Size {
440            width: length(width),
441            height: percent(1.0_f32),
442        },
443        flex_shrink: 0.0,
444        align_items: Some(AlignItems::CENTER),
445        padding: Rect {
446            left: length(0.0_f32),
447            right: length(0.0_f32),
448            top: length(6.0_f32),
449            bottom: length(6.0_f32),
450        },
451        gap: Size {
452            width: length(0.0_f32),
453            height: length(2.0_f32),
454        },
455        ..Default::default()
456    })
457    .fill(palette.bg_bar)
458    .role(llimphi_compositor::Role::Toolbar)
459    .children(kids)
460}
461
462/// Botón **cuadrado** de la barra vertical: el ícono centrado y nada más. El
463/// `label` no se pinta (no hay ancho) pero sigue siendo el nombre accesible.
464fn button_cuadrado<Msg: Clone + 'static>(
465    item: ToolbarItem<Msg>,
466    lado: f32,
467    palette: &ToolbarPalette,
468) -> View<Msg> {
469    let fg = if !item.enabled {
470        palette.fg_disabled
471    } else if item.active {
472        palette.fg_active
473    } else {
474        palette.fg
475    };
476    let aria = item.label.clone();
477    let icono = View::new(Style {
478        size: Size {
479            width: length(ICON_PX),
480            height: length(ICON_PX),
481        },
482        flex_shrink: 0.0,
483        ..Default::default()
484    })
485    .children(vec![(item.icon)(ICON_PX, fg)]);
486    let mut btn = View::new(Style {
487        size: Size {
488            width: length(lado),
489            height: length(lado),
490        },
491        flex_shrink: 0.0,
492        align_items: Some(AlignItems::CENTER),
493        justify_content: Some(JustifyContent::CENTER),
494        ..Default::default()
495    })
496    .radius(6.0)
497    .role(llimphi_compositor::Role::Button)
498    .aria_pressed(item.active)
499    .aria_disabled(!item.enabled)
500    .children(vec![icono]);
501    if let Some(aria) = aria {
502        btn = btn.aria_label(aria);
503    }
504    if item.active {
505        btn = btn.fill(palette.bg_active);
506    }
507    if item.enabled {
508        btn = btn.hover_fill(palette.bg_hover).on_click(item.on_click);
509    }
510    btn
511}
512
513/// Separador entre grupos de la barra vertical: una raya fina de lado a lado.
514fn separator_horizontal<Msg: Clone + 'static>(width: f32, palette: &ToolbarPalette) -> View<Msg> {
515    View::new(Style {
516        size: Size {
517            width: length(width - 16.0),
518            height: length(1.0_f32),
519        },
520        flex_shrink: 0.0,
521        margin: Rect {
522            left: length(0.0_f32),
523            right: length(0.0_f32),
524            top: length(5.0_f32),
525            bottom: length(5.0_f32),
526        },
527        ..Default::default()
528    })
529    .fill(palette.separator)
530}
531
532/// Botón «⋯» del overflow. Lleva la cuenta de lo escondido en el nombre
533/// accesible: «Más herramientas (3)» dice algo; «⋯» no dice nada.
534fn boton_mas<Msg: Clone + 'static>(
535    bar_h: f32,
536    palette: &ToolbarPalette,
537    msg: Msg,
538    ocultos: usize,
539) -> View<Msg> {
540    View::new(Style {
541        size: Size { width: length(MORE_W), height: length(bar_h - 8.0) },
542        flex_shrink: 0.0,
543        align_items: Some(AlignItems::CENTER),
544        justify_content: Some(JustifyContent::CENTER),
545        ..Default::default()
546    })
547    .text("⋯", 14.0, palette.fg)
548    .radius(6.0)
549    .hover_fill(palette.bg_hover)
550    .cursor(llimphi_compositor::Cursor::Pointer)
551    .role(llimphi_compositor::Role::Button)
552    .aria_label(alloc::format!("Más herramientas ({ocultos})"))
553    .aria_expanded(false)
554    .on_click(msg)
555}
556
557fn button<Msg: Clone + 'static>(
558    item: ToolbarItem<Msg>,
559    bar_h: f32,
560    palette: &ToolbarPalette,
561) -> View<Msg> {
562    let fg = if !item.enabled {
563        palette.fg_disabled
564    } else if item.active {
565        palette.fg_active
566    } else {
567        palette.fg
568    };
569    // Nombre accesible: el label visible si lo hay. Un ítem sólo-ícono queda
570    // mudo para el lector — es el caller quien debe darle `with_label`.
571    let aria = item.label.clone();
572    let mut inner: Vec<View<Msg>> = vec![View::new(Style {
573        size: Size {
574            width: length(ICON_PX),
575            height: length(ICON_PX),
576        },
577        flex_shrink: 0.0,
578        ..Default::default()
579    })
580    .children(vec![(item.icon)(ICON_PX, fg)])];
581    if let Some(label) = item.label {
582        inner.push(
583            View::new(Style {
584                size: Size { width: auto(), height: percent(1.0_f32) },
585                align_items: Some(AlignItems::CENTER),
586                ..Default::default()
587            })
588            .text(label, 11.5, fg),
589        );
590    }
591    let mut btn = View::new(Style {
592        flex_direction: FlexDirection::Row,
593        size: Size {
594            width: auto(),
595            height: length(bar_h - 8.0),
596        },
597        flex_shrink: 0.0,
598        align_items: Some(AlignItems::CENTER),
599        justify_content: Some(JustifyContent::CENTER),
600        padding: Rect {
601            left: length(7.0_f32),
602            right: length(7.0_f32),
603            top: length(0.0_f32),
604            bottom: length(0.0_f32),
605        },
606        gap: Size {
607            width: length(5.0_f32),
608            height: length(0.0_f32),
609        },
610        ..Default::default()
611    })
612    .radius(6.0)
613    .role(llimphi_compositor::Role::Button)
614    .aria_pressed(item.active)
615    .aria_disabled(!item.enabled)
616    .children(inner);
617    if let Some(aria) = aria {
618        btn = btn.aria_label(aria);
619    }
620    if item.active {
621        btn = btn.fill(palette.bg_active);
622    }
623    if item.enabled {
624        btn = btn.hover_fill(palette.bg_hover).on_click(item.on_click);
625    }
626    btn
627}
628
629fn separator<Msg: Clone + 'static>(bar_h: f32, palette: &ToolbarPalette) -> View<Msg> {
630    View::new(Style {
631        size: Size {
632            width: length(1.0_f32),
633            height: length(bar_h - 16.0),
634        },
635        flex_shrink: 0.0,
636        margin: Rect {
637            left: length(5.0_f32),
638            right: length(5.0_f32),
639            top: length(0.0_f32),
640            bottom: length(0.0_f32),
641        },
642        ..Default::default()
643    })
644    .fill(palette.separator)
645}
646
647// ─────────────────────────────────────────────────────────────────────────
648// Overflow → "más" (helper puro, lado host)
649// ─────────────────────────────────────────────────────────────────────────
650
651/// Cuántos ítems de la barra **caben** en `avail` px, reservando `more_w` para
652/// el botón "⋯/más" **sólo si** no entran todos. Devuelve el número de ítems a
653/// mostrar en línea; el resto va a un menú de overflow que el host arma con
654/// [`ToolbarItem`]s. El host mide los anchos (ícono fijo ≈ alto + label por
655/// `text_width` + padding) y llama a esto en su `update`/`view` con el ancho
656/// disponible (post-layout, que conoce de su propio contenedor). `gap` es la
657/// separación horizontal entre ítems. Pura y testeable.
658pub fn overflow_count(item_widths: &[f32], avail: f32, more_w: f32, gap: f32) -> usize {
659    let n = item_widths.len();
660    if n == 0 {
661        return 0;
662    }
663    // ¿Entran todos sin botón de overflow?
664    let total: f32 = item_widths.iter().sum::<f32>() + gap * (n as f32 - 1.0);
665    if total <= avail {
666        return n;
667    }
668    // No entran: reservar el botón "más" y contar los que caben.
669    let budget = (avail - more_w).max(0.0);
670    let mut used = 0.0_f32;
671    let mut count = 0usize;
672    for w in item_widths {
673        let add = if count == 0 { *w } else { gap + *w };
674        if used + add <= budget {
675            used += add;
676            count += 1;
677        } else {
678            break;
679        }
680    }
681    count
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn caben_todos_sin_boton_de_mas() {
690        // 3 ítems de 40 + 2 gaps de 4 = 128 ≤ 200 → todos.
691        assert_eq!(overflow_count(&[40.0, 40.0, 40.0], 200.0, 30.0, 4.0), 3);
692    }
693
694    #[test]
695    fn reserva_el_boton_y_corta() {
696        // 5 ítems de 40 (+gaps) no entran en 150; con more_w=30 el budget es 120:
697        // 40, +4+40=84, +4+40=128 > 120 → caben 2.
698        assert_eq!(overflow_count(&[40.0, 40.0, 40.0, 40.0, 40.0], 150.0, 30.0, 4.0), 2);
699    }
700
701    /// Grupos de prueba: dos con dos ítems cada uno, con labels distintos.
702    fn grupos() -> Vec<ToolbarGroup<u8>> {
703        let icono = |_s: f32, _c: Color| View::<u8>::new(Style::default());
704        vec![
705            ToolbarGroup::new(vec![
706                ToolbarItem::new(icono, 0).with_label("subir"),
707                ToolbarItem::new(icono, 1).with_label("bajar"),
708            ]),
709            ToolbarGroup::new(vec![
710                ToolbarItem::new(icono, 2).with_label("lista"),
711                ToolbarItem::new(icono, 3).with_label("iconos").active(true),
712            ]),
713        ]
714    }
715
716    #[test]
717    fn los_anchos_atraviesan_los_grupos_y_los_offsets_cuentan_separadores() {
718        let g = grupos();
719        let anchos = item_widths(&g);
720        assert_eq!(anchos.len(), 4, "los separadores no son ítems");
721        let offs = item_offsets(&g);
722        assert_eq!(offs.len(), 4);
723        // El primero arranca en el padding de la barra.
724        assert_eq!(offs[0].0, 6.0);
725        // Dentro del grupo: sólo el gap.
726        assert!((offs[1].0 - (offs[0].0 + anchos[0] + ITEM_GAP)).abs() < 1e-3);
727        // Cruzando de grupo: además el separador con sus márgenes.
728        let esperado = offs[1].0 + anchos[1] + ITEM_GAP + SEPARATOR_W + ITEM_GAP;
729        assert!((offs[2].0 - esperado).abs() < 1e-3, "{} vs {esperado}", offs[2].0);
730        // Y el ancho publicado por ítem es el mismo que el de `item_widths`.
731        for (i, (_, w)) in offs.iter().enumerate() {
732            assert_eq!(*w, anchos[i]);
733        }
734    }
735
736    #[test]
737    fn el_ancho_de_un_item_crece_con_el_label() {
738        let solo_icono = item_width(None);
739        let con_label = item_width(Some("iconos"));
740        assert!(con_label > solo_icono, "el label ocupa lugar y hay que contarlo");
741        // El botón «⋯» mide lo mismo que un ítem sólo-ícono: es uno.
742        assert_eq!(MORE_W, solo_icono);
743    }
744
745    #[test]
746    fn los_ocultos_salen_en_orden_y_con_su_estado() {
747        let g = grupos();
748        let ocultos = items_ocultos(&g, 2);
749        assert_eq!(ocultos.len(), 2);
750        assert_eq!(ocultos[0].label.as_deref(), Some("lista"));
751        assert_eq!(ocultos[0].msg, 2, "el Msg tiene que ser el del ítem, no su índice");
752        assert_eq!(ocultos[1].label.as_deref(), Some("iconos"));
753        assert!(ocultos[1].active, "el estado viaja al menú: un toggle prendido se ve prendido");
754        // Sin corte no hay nada oculto.
755        assert!(items_ocultos(&g, 4).is_empty());
756    }
757
758    #[test]
759    fn el_medidor_publica_el_ancho_util_no_el_total() {
760        let m = AnchoBarra::new();
761        assert_eq!(m.px(), 0.0, "sin pintar todavía no sabemos nada");
762        // Lo que escribe el pintado: el rect menos los dos paddings.
763        m.escribir(500.0 - 6.0 * 2.0);
764        assert_eq!(m.px(), 488.0);
765        // Y ese es el número que espera `overflow_count`: con cuatro ítems de
766        // 120 no entran todos en 488 (480 + 3 gaps = 486 ≤ 488 sí entran; con
767        // 122 ya no).
768        assert_eq!(overflow_count(&[122.0; 4], m.px(), MORE_W, ITEM_GAP), 3);
769    }
770
771    #[test]
772    fn cero_items_o_espacio_nulo() {
773        assert_eq!(overflow_count(&[], 100.0, 30.0, 4.0), 0);
774        // Espacio 0: no cabe ninguno (budget negativo → 0).
775        assert_eq!(overflow_count(&[40.0, 40.0], 0.0, 30.0, 4.0), 0);
776    }
777}