tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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}