//! `llimphi-widget-toolbar` — barra de herramientas moderna. //! //! Grupos de botones-ícono planos: hover con fondo redondeado, estado //! **activo** con fondo de selección + ícono en acento, **deshabilitado** //! atenuado y sin click. Entre grupos, un separador vertical sutil. //! //! El widget es render-only y agnóstico del `Msg`: //! - los **íconos los dibuja el caller** vía closure `Fn(size, color) -> //! View` (mismo contrato que `dock-rail::make_icon`) — el widget resuelve //! el color según el estado (activo/normal/deshabilitado); //! - los **grupos son datos** (`Vec`): el caller los arma, //! reordena o filtra — la barra es componible/configurable por //! construcción, sin sistema de config propio. //! //! ```ignore //! let barra = toolbar_view( //! vec![ //! ToolbarGroup::new(vec![ //! ToolbarItem::new(|s, c| icon_view(Icon::ChevronUp, c, 1.7), Msg::Subir) //! .with_label("subir"), //! ]), //! ToolbarGroup::new(vec![ //! ToolbarItem::new(|s, c| icon_view(Icon::Rows, c, 1.7), Msg::Vista(0)).active(lista), //! ToolbarItem::new(|s, c| icon_view(Icon::Grid, c, 1.7), Msg::Vista(2)).active(iconos), //! ]), //! ], //! 36.0, //! &ToolbarPalette::from_theme(&theme), //! ); //! ``` #![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::boxed::Box; use alloc::string::String; use alloc::vec::Vec; use alloc::vec; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{auto, length, percent, FlexDirection, Size, Style}, AlignItems, JustifyContent, Rect, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::View; /// Tamaño del ícono que se le pide al caller (px). const ICON_PX: f32 = 16.0; /// Paleta de la barra — subset del `Theme` semántico. #[derive(Debug, Clone, Copy)] pub struct ToolbarPalette { /// Fondo de la franja. pub bg_bar: Color, /// Fondo del botón al hover. pub bg_hover: Color, /// Fondo del botón activo (toggle prendido). pub bg_active: Color, /// Ícono/label normal. pub fg: Color, /// Ícono del botón activo. pub fg_active: Color, /// Ícono/label deshabilitado. pub fg_disabled: Color, /// Separador vertical entre grupos. pub separator: Color, } impl ToolbarPalette { pub fn from_theme(t: &llimphi_theme::Theme) -> Self { Self { bg_bar: t.bg_panel_alt, bg_hover: t.bg_row_hover, bg_active: t.bg_selected, fg: t.fg_muted, fg_active: t.accent, fg_disabled: t.fg_placeholder, separator: t.border, } } } impl Default for ToolbarPalette { fn default() -> Self { Self::from_theme(&llimphi_theme::Theme::dark()) } } /// Un botón de la barra. El ícono es una closure `(size_px, color) -> View` /// — el widget la invoca con el color ya resuelto por estado. pub struct ToolbarItem { pub icon: Box View>, /// Texto corto opcional a la derecha del ícono (la barra es icon-first). pub label: Option, /// Toggle prendido (fondo de selección + acento). pub active: bool, /// `false` = atenuado y sin click. pub enabled: bool, pub on_click: Msg, } impl ToolbarItem { pub fn new( icon: impl Fn(f32, Color) -> View + 'static, on_click: Msg, ) -> Self { Self { icon: Box::new(icon), label: None, active: false, enabled: true, on_click, } } pub fn with_label(mut self, label: impl Into) -> Self { self.label = Some(label.into()); self } pub fn active(mut self, active: bool) -> Self { self.active = active; self } pub fn enabled(mut self, enabled: bool) -> Self { self.enabled = enabled; self } } /// Un grupo de botones contiguos; entre grupos va un separador. pub struct ToolbarGroup { pub items: Vec>, } impl ToolbarGroup { pub fn new(items: Vec>) -> Self { Self { items } } } /// Publica el ancho **medido** de la barra: el widget lo escribe cuando se /// pinta y el host lo lee en el cuadro siguiente para decidir el overflow con /// [`overflow_count`]. /// /// Es la pieza que faltaba y por la que el overflow estaba a medias: el ancho /// disponible no se sabe hasta que taffy repartió, o sea **después** de armar /// el `View`. Un frame de latencia en el peor caso (el primero, o el del /// resize), que es exactamente lo que hace cualquier toolbar con overflow. #[derive(Debug, Clone, Default)] pub struct AnchoBarra(alloc::sync::Arc); impl AnchoBarra { pub fn new() -> Self { Self::default() } /// Ancho útil (px) del último cuadro pintado — ya descontado el padding /// lateral, o sea el espacio real para ítems. `0.0` si todavía no se pintó. pub fn px(&self) -> f32 { f32::from_bits(self.0.load(core::sync::atomic::Ordering::Relaxed)) } fn escribir(&self, v: f32) { self.0.store(v.max(0.0).to_bits(), core::sync::atomic::Ordering::Relaxed); } } /// Lo que [`toolbar_view`] no ofrece y [`toolbar_view_ext`] sí. `Default` es la /// barra de siempre. pub struct ToolbarOpciones { /// Dónde publicar el ancho medido (ver [`AnchoBarra`]). pub medidor: Option, /// Cuántos ítems mostrar en línea. `None` = todos. El host lo saca de /// [`overflow_count`] con los anchos de [`item_widths`] y el ancho de /// `medidor`. pub visibles: Option, /// `Msg` del botón «⋯». La barra lo dibuja **sólo si** hay ítems /// escondidos; sin este `Msg` los de más simplemente no se muestran. pub on_overflow: Option, /// Hover sobre un ítem: `Some(i)` al entrar, `None` al salir. Es lo que /// alimenta al `tooltip` (que es render puro y lo monta el host en su /// `view_overlay`); la posición del ancla sale de [`item_offsets`]. pub on_hover: Option) -> Msg + Send + Sync>>, } impl Default for ToolbarOpciones { fn default() -> Self { Self { medidor: None, visibles: None, on_overflow: None, on_hover: None } } } /// Padding lateral de la barra (cada lado). const BAR_PAD_X: f32 = 6.0; /// Separación entre ítems. pub const ITEM_GAP: f32 = 2.0; /// Padding horizontal (cada lado) de un botón. const BTN_PAD_X: f32 = 7.0; /// Separación ícono↔label dentro del botón. const BTN_GAP: f32 = 5.0; /// Ancho del separador entre grupos, márgenes incluidos. pub const SEPARATOR_W: f32 = 1.0 + 5.0 * 2.0; /// Ancho del botón «⋯» — un ítem sólo-ícono. pub const MORE_W: f32 = ICON_PX + BTN_PAD_X * 2.0; /// Ancho de un ítem con la **misma cuenta que el render**: ícono + padding, y /// el label medido con la fuente real si lo hay. Es la fuente de verdad que /// comparten el pintado, [`item_offsets`] y el `overflow_count` del host — si /// el host la estimara por su cuenta, el corte no coincidiría con lo dibujado. pub fn item_width(label: Option<&str>) -> f32 { let base = ICON_PX + BTN_PAD_X * 2.0; match label { Some(t) => base + BTN_GAP + llimphi_compositor::llimphi_text::text_width(t, 11.5), None => base, } } /// Los anchos de todos los ítems, en orden y **atravesando los grupos** (los /// separadores no son ítems). Es el argumento de [`overflow_count`]. pub fn item_widths(groups: &[ToolbarGroup]) -> Vec { groups .iter() .flat_map(|g| g.items.iter()) .map(|it| item_width(it.label.as_deref())) .collect() } /// `(left, width)` de cada ítem en coordenadas de la barra, contando el padding /// lateral, los gaps **y los separadores de grupo**. Lo necesita el host para /// anclar el `tooltip` sobre el ítem hovereado sin re-derivar el layout (que es /// como se desalinean estas cosas). pub fn item_offsets(groups: &[ToolbarGroup]) -> Vec<(f32, f32)> { let mut out = Vec::new(); let mut x = BAR_PAD_X; let n = groups.len(); for (gi, g) in groups.iter().enumerate() { for it in &g.items { let w = item_width(it.label.as_deref()); out.push((x, w)); x += w + ITEM_GAP; } if gi + 1 < n { x += SEPARATOR_W + ITEM_GAP; } } out } /// Un ítem que no entró en la barra, listo para el menú de overflow. /// /// El widget **no monta el menú**: un popup vive en `App::view_overlay` con su /// scrim, y eso es del host. Lo que sí le corresponde es decir *qué* quedó /// afuera, con su estado, en el mismo orden — que es donde se desincroniza un /// menú armado a mano (el índice del `on_pick` deja de ser el del ítem). /// /// El puente al menú canónico es una línea: /// /// ```ignore /// let ocultos = items_ocultos(&groups, visibles); /// let items: Vec<_> = ocultos.iter() /// .map(|o| { /// let mut it = ContextMenuItem::action(o.label.clone().unwrap_or_default()); /// if !o.enabled { it = it.disabled(); } /// it.checked(o.active) /// }) /// .collect(); /// // y en `on_pick(i)` → `ocultos[i].msg.clone()` /// ``` pub struct ItemOculto { pub label: Option, pub active: bool, pub enabled: bool, pub msg: Msg, } /// Los ítems a partir de `visibles` (los que el corte dejó afuera), en el mismo /// orden en que están en la barra. Se llama **antes** de `toolbar_view_ext`, /// que consume los grupos. pub fn items_ocultos(groups: &[ToolbarGroup], visibles: usize) -> Vec> { groups .iter() .flat_map(|g| g.items.iter()) .skip(visibles) .map(|it| ItemOculto { label: it.label.clone(), active: it.active, enabled: it.enabled, msg: it.on_click.clone(), }) .collect() } /// Compone la barra: franja horizontal de alto `height` con los grupos /// alineados a la izquierda. pub fn toolbar_view( groups: Vec>, height: f32, palette: &ToolbarPalette, ) -> View { toolbar_view_ext(groups, height, palette, ToolbarOpciones::default()) } /// [`toolbar_view`] que **se parte en renglones** cuando no cabe a lo ancho. /// /// Es la barra de un panel que el usuario angosta con el divisor: ahí el corte /// por overflow esconde justo los últimos botones y la de una línea los deja /// afuera del panel sin aviso. Cada renglón conserva el alto `height`; la barra /// crece hacia abajo lo que haga falta. pub fn toolbar_view_envuelta( groups: Vec>, height: f32, palette: &ToolbarPalette, ) -> View { let mut v = toolbar_view(groups, height, palette); v.style.flex_wrap = llimphi_compositor::llimphi_layout::taffy::style::FlexWrap::Wrap; v.style.size.height = auto(); v.style.min_size.height = length(height); v.style.gap.height = length(ITEM_GAP); v } /// [`toolbar_view`] con [`ToolbarOpciones`]: medición post-layout, corte por /// overflow con botón «⋯», y hover por ítem para el tooltip. Con /// `ToolbarOpciones::default()` produce exactamente la misma barra. pub fn toolbar_view_ext( groups: Vec>, height: f32, palette: &ToolbarPalette, opciones: ToolbarOpciones, ) -> View { let ToolbarOpciones { medidor, visibles, on_overflow, on_hover } = opciones; let total_items: usize = groups.iter().map(|g| g.items.len()).sum(); let visibles = visibles.unwrap_or(total_items).min(total_items); let hay_ocultos = visibles < total_items; let mut kids: Vec> = Vec::new(); let n = groups.len(); let mut idx = 0usize; for (gi, group) in groups.into_iter().enumerate() { let mut puestos = 0usize; for item in group.items { if idx >= visibles { idx += 1; continue; } let mut b = button(item, height, palette); if let Some(f) = &on_hover { // Enter y leave con el mismo callback: el host guarda `Some(i)` // o `None` y el tooltip aparece/desaparece por ese dato. Un par // de Msgs distintos obligaría a cada caller a inventar los dos. let (fe, fl) = (f.clone(), f.clone()); b = b.on_pointer_enter(fe(Some(idx))).on_pointer_leave(fl(None)); } kids.push(b); puestos += 1; idx += 1; } // El separador sólo tiene sentido si de este grupo quedó algo Y // todavía viene algo más: si no, la barra cortada termina en una // rayita colgada. if gi + 1 < n && puestos > 0 && idx < visibles { kids.push(separator(height, palette)); } } if hay_ocultos { if let Some(msg) = on_overflow { kids.push(boton_mas(height, palette, msg, total_items - visibles)); } } let vista = View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: length(height), }, flex_shrink: 0.0, align_items: Some(AlignItems::CENTER), padding: Rect { left: length(6.0_f32), right: length(6.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, gap: Size { width: length(2.0_f32), height: length(0.0_f32), }, ..Default::default() }) .fill(palette.bg_bar) .role(llimphi_compositor::Role::Toolbar) .children(kids); match medidor { // Se mide en el pintado porque es el único momento en que el rect ya // está resuelto. Se publica el ancho ÚTIL (sin el padding), que es lo // que `overflow_count` espera recibir. Some(m) => vista.paint_with(move |_scene, _ts, rect| { m.escribir(rect.w - BAR_PAD_X * 2.0); }), None => vista, } } /// La misma barra **de pie**: una columna de ancho `width` con los botones /// cuadrados apilados y un separador **horizontal** entre grupos. /// /// Es la barra de herramientas de un editor —la caja que vive al canto /// izquierdo del lienzo—, no una franja de acciones sobre el contenido. Por eso /// no lleva overflow ni etiquetas: en vertical el espacio sobra hacia abajo y lo /// que falta es ancho, así que el `label` de un ítem viaja sólo al nombre /// accesible. Todo lo demás —hover, activo, deshabilitado, paleta— es idéntico a /// [`toolbar_view`]: son la misma barra en dos ejes, no dos widgets. pub fn toolbar_view_vertical( groups: Vec>, width: f32, palette: &ToolbarPalette, ) -> View { let mut kids: Vec> = Vec::new(); let n = groups.len(); for (gi, group) in groups.into_iter().enumerate() { let puestos = group.items.len(); for item in group.items { kids.push(button_cuadrado(item, width - 10.0, palette)); } if gi + 1 < n && puestos > 0 { kids.push(separator_horizontal(width, palette)); } } View::new(Style { flex_direction: FlexDirection::Column, size: Size { width: length(width), height: percent(1.0_f32), }, flex_shrink: 0.0, align_items: Some(AlignItems::CENTER), padding: Rect { left: length(0.0_f32), right: length(0.0_f32), top: length(6.0_f32), bottom: length(6.0_f32), }, gap: Size { width: length(0.0_f32), height: length(2.0_f32), }, ..Default::default() }) .fill(palette.bg_bar) .role(llimphi_compositor::Role::Toolbar) .children(kids) } /// Botón **cuadrado** de la barra vertical: el ícono centrado y nada más. El /// `label` no se pinta (no hay ancho) pero sigue siendo el nombre accesible. fn button_cuadrado( item: ToolbarItem, lado: f32, palette: &ToolbarPalette, ) -> View { let fg = if !item.enabled { palette.fg_disabled } else if item.active { palette.fg_active } else { palette.fg }; let aria = item.label.clone(); let icono = View::new(Style { size: Size { width: length(ICON_PX), height: length(ICON_PX), }, flex_shrink: 0.0, ..Default::default() }) .children(vec![(item.icon)(ICON_PX, fg)]); let mut btn = View::new(Style { size: Size { width: length(lado), height: length(lado), }, flex_shrink: 0.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .radius(6.0) .role(llimphi_compositor::Role::Button) .aria_pressed(item.active) .aria_disabled(!item.enabled) .children(vec![icono]); if let Some(aria) = aria { btn = btn.aria_label(aria); } if item.active { btn = btn.fill(palette.bg_active); } if item.enabled { btn = btn.hover_fill(palette.bg_hover).on_click(item.on_click); } btn } /// Separador entre grupos de la barra vertical: una raya fina de lado a lado. fn separator_horizontal(width: f32, palette: &ToolbarPalette) -> View { View::new(Style { size: Size { width: length(width - 16.0), height: length(1.0_f32), }, flex_shrink: 0.0, margin: Rect { left: length(0.0_f32), right: length(0.0_f32), top: length(5.0_f32), bottom: length(5.0_f32), }, ..Default::default() }) .fill(palette.separator) } /// Botón «⋯» del overflow. Lleva la cuenta de lo escondido en el nombre /// accesible: «Más herramientas (3)» dice algo; «⋯» no dice nada. fn boton_mas( bar_h: f32, palette: &ToolbarPalette, msg: Msg, ocultos: usize, ) -> View { View::new(Style { size: Size { width: length(MORE_W), height: length(bar_h - 8.0) }, flex_shrink: 0.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .text("⋯", 14.0, palette.fg) .radius(6.0) .hover_fill(palette.bg_hover) .cursor(llimphi_compositor::Cursor::Pointer) .role(llimphi_compositor::Role::Button) .aria_label(alloc::format!("Más herramientas ({ocultos})")) .aria_expanded(false) .on_click(msg) } fn button( item: ToolbarItem, bar_h: f32, palette: &ToolbarPalette, ) -> View { let fg = if !item.enabled { palette.fg_disabled } else if item.active { palette.fg_active } else { palette.fg }; // Nombre accesible: el label visible si lo hay. Un ítem sólo-ícono queda // mudo para el lector — es el caller quien debe darle `with_label`. let aria = item.label.clone(); let mut inner: Vec> = vec![View::new(Style { size: Size { width: length(ICON_PX), height: length(ICON_PX), }, flex_shrink: 0.0, ..Default::default() }) .children(vec![(item.icon)(ICON_PX, fg)])]; if let Some(label) = item.label { inner.push( View::new(Style { size: Size { width: auto(), height: percent(1.0_f32) }, align_items: Some(AlignItems::CENTER), ..Default::default() }) .text(label, 11.5, fg), ); } let mut btn = View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: auto(), height: length(bar_h - 8.0), }, flex_shrink: 0.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), padding: Rect { left: length(7.0_f32), right: length(7.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, gap: Size { width: length(5.0_f32), height: length(0.0_f32), }, ..Default::default() }) .radius(6.0) .role(llimphi_compositor::Role::Button) .aria_pressed(item.active) .aria_disabled(!item.enabled) .children(inner); if let Some(aria) = aria { btn = btn.aria_label(aria); } if item.active { btn = btn.fill(palette.bg_active); } if item.enabled { btn = btn.hover_fill(palette.bg_hover).on_click(item.on_click); } btn } fn separator(bar_h: f32, palette: &ToolbarPalette) -> View { View::new(Style { size: Size { width: length(1.0_f32), height: length(bar_h - 16.0), }, flex_shrink: 0.0, margin: Rect { left: length(5.0_f32), right: length(5.0_f32), top: length(0.0_f32), bottom: length(0.0_f32), }, ..Default::default() }) .fill(palette.separator) } // ───────────────────────────────────────────────────────────────────────── // Overflow → "más" (helper puro, lado host) // ───────────────────────────────────────────────────────────────────────── /// Cuántos ítems de la barra **caben** en `avail` px, reservando `more_w` para /// el botón "⋯/más" **sólo si** no entran todos. Devuelve el número de ítems a /// mostrar en línea; el resto va a un menú de overflow que el host arma con /// [`ToolbarItem`]s. El host mide los anchos (ícono fijo ≈ alto + label por /// `text_width` + padding) y llama a esto en su `update`/`view` con el ancho /// disponible (post-layout, que conoce de su propio contenedor). `gap` es la /// separación horizontal entre ítems. Pura y testeable. pub fn overflow_count(item_widths: &[f32], avail: f32, more_w: f32, gap: f32) -> usize { let n = item_widths.len(); if n == 0 { return 0; } // ¿Entran todos sin botón de overflow? let total: f32 = item_widths.iter().sum::() + gap * (n as f32 - 1.0); if total <= avail { return n; } // No entran: reservar el botón "más" y contar los que caben. let budget = (avail - more_w).max(0.0); let mut used = 0.0_f32; let mut count = 0usize; for w in item_widths { let add = if count == 0 { *w } else { gap + *w }; if used + add <= budget { used += add; count += 1; } else { break; } } count } #[cfg(test)] mod tests { use super::*; #[test] fn caben_todos_sin_boton_de_mas() { // 3 ítems de 40 + 2 gaps de 4 = 128 ≤ 200 → todos. assert_eq!(overflow_count(&[40.0, 40.0, 40.0], 200.0, 30.0, 4.0), 3); } #[test] fn reserva_el_boton_y_corta() { // 5 ítems de 40 (+gaps) no entran en 150; con more_w=30 el budget es 120: // 40, +4+40=84, +4+40=128 > 120 → caben 2. assert_eq!(overflow_count(&[40.0, 40.0, 40.0, 40.0, 40.0], 150.0, 30.0, 4.0), 2); } /// Grupos de prueba: dos con dos ítems cada uno, con labels distintos. fn grupos() -> Vec> { let icono = |_s: f32, _c: Color| View::::new(Style::default()); vec![ ToolbarGroup::new(vec![ ToolbarItem::new(icono, 0).with_label("subir"), ToolbarItem::new(icono, 1).with_label("bajar"), ]), ToolbarGroup::new(vec![ ToolbarItem::new(icono, 2).with_label("lista"), ToolbarItem::new(icono, 3).with_label("iconos").active(true), ]), ] } #[test] fn los_anchos_atraviesan_los_grupos_y_los_offsets_cuentan_separadores() { let g = grupos(); let anchos = item_widths(&g); assert_eq!(anchos.len(), 4, "los separadores no son ítems"); let offs = item_offsets(&g); assert_eq!(offs.len(), 4); // El primero arranca en el padding de la barra. assert_eq!(offs[0].0, 6.0); // Dentro del grupo: sólo el gap. assert!((offs[1].0 - (offs[0].0 + anchos[0] + ITEM_GAP)).abs() < 1e-3); // Cruzando de grupo: además el separador con sus márgenes. let esperado = offs[1].0 + anchos[1] + ITEM_GAP + SEPARATOR_W + ITEM_GAP; assert!((offs[2].0 - esperado).abs() < 1e-3, "{} vs {esperado}", offs[2].0); // Y el ancho publicado por ítem es el mismo que el de `item_widths`. for (i, (_, w)) in offs.iter().enumerate() { assert_eq!(*w, anchos[i]); } } #[test] fn el_ancho_de_un_item_crece_con_el_label() { let solo_icono = item_width(None); let con_label = item_width(Some("iconos")); assert!(con_label > solo_icono, "el label ocupa lugar y hay que contarlo"); // El botón «⋯» mide lo mismo que un ítem sólo-ícono: es uno. assert_eq!(MORE_W, solo_icono); } #[test] fn los_ocultos_salen_en_orden_y_con_su_estado() { let g = grupos(); let ocultos = items_ocultos(&g, 2); assert_eq!(ocultos.len(), 2); assert_eq!(ocultos[0].label.as_deref(), Some("lista")); assert_eq!(ocultos[0].msg, 2, "el Msg tiene que ser el del ítem, no su índice"); assert_eq!(ocultos[1].label.as_deref(), Some("iconos")); assert!(ocultos[1].active, "el estado viaja al menú: un toggle prendido se ve prendido"); // Sin corte no hay nada oculto. assert!(items_ocultos(&g, 4).is_empty()); } #[test] fn el_medidor_publica_el_ancho_util_no_el_total() { let m = AnchoBarra::new(); assert_eq!(m.px(), 0.0, "sin pintar todavía no sabemos nada"); // Lo que escribe el pintado: el rect menos los dos paddings. m.escribir(500.0 - 6.0 * 2.0); assert_eq!(m.px(), 488.0); // Y ese es el número que espera `overflow_count`: con cuatro ítems de // 120 no entran todos en 488 (480 + 3 gaps = 486 ≤ 488 sí entran; con // 122 ya no). assert_eq!(overflow_count(&[122.0; 4], m.px(), MORE_W, ITEM_GAP), 3); } #[test] fn cero_items_o_espacio_nulo() { assert_eq!(overflow_count(&[], 100.0, 30.0, 4.0), 0); // Espacio 0: no cabe ninguno (budget negativo → 0). assert_eq!(overflow_count(&[40.0, 40.0], 0.0, 30.0, 4.0), 0); } }