use super::{ItemId, OverflowPolicy, Panel, Representation, Zone}; use std::collections::BTreeMap; #[derive(Clone, Debug)] pub struct Measurement { pub id: ItemId, pub alternatives: Vec<(Representation, f32)>, pub minimum: Option, } #[derive(Clone, Debug, PartialEq)] pub enum Placement { Visible { representation: Representation, width: f32 }, Overflow, } #[derive(Clone, Debug, Default, PartialEq)] pub struct Resolution { pub items: BTreeMap, pub overflow: Vec, pub zone_widths: [f32; 3], /// Separation used when allocating these zones. pub zone_gap: f32, pub forced_overflow: bool, pub overflow_trigger_width: f32, /// Width needed by the smallest visible representations before automatic overflow. pub minimum_width: f32, } fn zone_width(panel: &Panel, zone: &Zone, items: &BTreeMap) -> f32 { let mut width = 0.0; let mut group_count: usize = 0; for group in &zone.groups { let mut content_width = 0.0; let mut occupied = false; for item in &group.items { if let Some(Placement::Visible { width, .. }) = items.get(&item.id) && *width > 0.0 { if occupied { content_width += item.gap_before.unwrap_or(group.spacing); } content_width += *width; occupied = true; } } if occupied { let padding = 2.0 * f32::from(group.padding[1]); let island = if panel.group_surface() == super::GroupSurface::Island { 2.0 * group.island_padding } else { 0.0 }; width += content_width + padding + island; group_count += 1; } } width + panel.zone_spacing(zone) * group_count.saturating_sub(1) as f32 } impl Resolution { fn update_widths(&mut self, panel: &Panel, overflow_width: f32) { self.zone_widths = [ zone_width(panel, &panel.start, &self.items), zone_width(panel, &panel.center, &self.items), zone_width(panel, &panel.end, &self.items), ]; if !self.overflow.is_empty() { self.zone_widths[2] += overflow_width + if self.zone_widths[2] > 0.0 { panel.zone_spacing(&panel.end) } else { 0.0 }; } } fn required_width(&self) -> f32 { self.required_width_with_gap(self.zone_gap) } fn required_width_with_gap(&self, gap: f32) -> f32 { let [start, center, end] = self.zone_widths; if center > 0.0 { let side = start.max(end); center + if side > 0.0 { 2.0 * (side + gap) } else { 0.0 } } else { start + end + if start > 0.0 && end > 0.0 { gap } else { 0.0 } } } pub fn fits(&self, available: f32) -> bool { self.required_width() <= available } /// Clamp a requested inset independently for each output. Content may still /// overflow on an output too small to fit it even without an inset. pub fn side_margin(&self, requested: i32, output_width: i32, padding: f32) -> i32 { let maximum = ((output_width as f32 - 2. * padding - self.minimum_width) / 2.) .floor() .max(0.); requested.max(0).min(maximum as i32) } } pub fn resolve(panel: &Panel, measurements: &[Measurement], available: f32, overflow_width: f32) -> Resolution { resolve_with_gap(panel, measurements, available, overflow_width, 8.) } /// Reserve separation between zones while keeping the center on the panel midpoint. pub fn resolve_with_gap( panel: &Panel, measurements: &[Measurement], available: f32, overflow_width: f32, zone_gap: f32, ) -> Resolution { let available = available.max(0.0); let zone_gap = zone_gap.max(0.); let fits = |resolution: &Resolution| resolution.fits(available); let mut result = Resolution { zone_gap, ..Resolution::default() }; let ordered: Vec<_> = [&panel.start, &panel.center, &panel.end] .into_iter() .flat_map(|zone| &zone.groups) .flat_map(|group| &group.items) .collect(); let measurements: BTreeMap<_, _> = measurements.iter().map(|measured| (&measured.id, measured)).collect(); // Allocation policy follows composition, never the renderer's sample order. // Lower priorities yield first; definition order breaks equal-priority ties. let mut candidates: Vec<_> = ordered.iter().enumerate().collect(); candidates.sort_by_key(|(index, item)| (item.priority, *index)); for item in &ordered { let Some(measured) = measurements.get(&item.id) else { continue; }; let Some(&(representation, width)) = measured.alternatives.first() else { continue; }; if item.overflow == OverflowPolicy::Always { result.items.insert(item.id.clone(), Placement::Overflow); result.overflow.push(item.id.clone()); } else { result .items .insert(item.id.clone(), Placement::Visible { representation, width }); } } result.update_widths(panel, overflow_width); let mut minimum = result.clone(); for measured in measurements.values() { if let Some(Placement::Visible { width, .. }) = minimum.items.get_mut(&measured.id) { for (_, alternative) in &measured.alternatives { *width = width.min(*alternative); } if let Some(flexible) = measured.minimum { *width = width.min(flexible); } } } minimum.update_widths(panel, overflow_width); result.minimum_width = minimum.required_width(); // Flexible content yields space without changing the stored item preference. for (_, item) in &candidates { let Some(measured) = measurements.get(&item.id).filter(|measured| measured.minimum.is_some()) else { continue; }; if fits(&result) { break; } let Some(Placement::Visible { representation, width }) = result.items.get(&measured.id).cloned() else { continue; }; let minimum = measured.minimum.unwrap().min(width); result.items.insert( measured.id.clone(), Placement::Visible { representation, width: minimum, }, ); result.update_widths(panel, overflow_width); } for (_, item) in &candidates { if fits(&result) { break; } let Some(measured) = measurements.get(&item.id) else { continue; }; for &(representation, width) in measured.alternatives.iter().skip(1) { if let Some(Placement::Visible { width: previous, .. }) = result.items.get(&item.id) && width < *previous { result .items .insert(item.id.clone(), Placement::Visible { representation, width }); result.update_widths(panel, overflow_width); if fits(&result) { break; } } } } // Never is honored whenever the mandatory minimums fit. On physically smaller // surfaces, retain access through overflow instead of silently clipping actions. for force in [false, true] { for (_, item) in &candidates { if fits(&result) { break; } if item.overflow == OverflowPolicy::Never && !force { continue; } if matches!(result.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0) { result.items.insert(item.id.clone(), Placement::Overflow); result.overflow.push(item.id.clone()); result.forced_overflow |= force && item.overflow == OverflowPolicy::Never; result.update_widths(panel, overflow_width); } } } // Removing the larger side may make an earlier eviction unnecessary. Give // mandatory items first refusal, then restore by descending priority. for mandatory in [true, false] { for (_, item) in candidates.iter().rev() { if item.overflow == OverflowPolicy::Always || (item.overflow == OverflowPolicy::Never) != mandatory || !matches!(result.items.get(&item.id), Some(Placement::Overflow)) { continue; } let Some(measured) = measurements.get(&item.id) else { continue; }; let Some(&(representation, width)) = measured.alternatives.iter().min_by(|a, b| a.1.total_cmp(&b.1)) else { continue; }; result.items.insert( item.id.clone(), Placement::Visible { representation, width: measured.minimum.map_or(width, |minimum| minimum.min(width)), }, ); result.overflow.retain(|id| id != &item.id); result.update_widths(panel, overflow_width); if !fits(&result) { result.items.insert(item.id.clone(), Placement::Overflow); result.overflow.push(item.id.clone()); result.update_widths(panel, overflow_width); } } } result.forced_overflow = ordered.iter().any(|item| { item.overflow == OverflowPolicy::Never && matches!(result.items.get(&item.id), Some(Placement::Overflow)) }); // A reduction on the shorter side can leave unused room after the other // side shrinks. Restore preferred representations before expanding titles. for (_, item) in candidates.iter().rev() { let Some(measured) = measurements.get(&item.id) else { continue; }; let Some(previous @ Placement::Visible { representation, .. }) = result.items.get(&item.id).cloned() else { continue; }; for &(candidate, width) in measured .alternatives .iter() .take_while(|(candidate, _)| *candidate != representation) { result.items.insert( item.id.clone(), Placement::Visible { representation: candidate, width: measured.minimum.map_or(width, |minimum| minimum.min(width)), }, ); result.update_widths(panel, overflow_width); if fits(&result) { break; } result.items.insert(item.id.clone(), previous.clone()); } } result.update_widths(panel, overflow_width); // Restore the most protected flexible items first, reversing the yield order. for (_, item) in candidates.iter().rev() { let Some(measured) = measurements.get(&item.id).filter(|measured| measured.minimum.is_some()) else { continue; }; let Some(Placement::Visible { representation, width: minimum, }) = result.items.get(&measured.id).cloned() else { continue; }; let Some(maximum) = measured .alternatives .iter() .find_map(|(candidate, width)| (*candidate == representation).then_some(*width)) else { continue; }; let mut low = minimum; let mut high = maximum; for _ in 0..20 { let width = (low + high) * 0.5; *result.items.get_mut(&measured.id).unwrap() = Placement::Visible { representation, width }; result.update_widths(panel, overflow_width); if fits(&result) { low = width } else { high = width } } *result.items.get_mut(&measured.id).unwrap() = Placement::Visible { representation, width: low, }; } result .overflow .sort_by_key(|id| ordered.iter().position(|item| &item.id == id).unwrap()); result.update_widths(panel, overflow_width); result } #[cfg(test)] mod tests { use super::*; #[test] fn asymmetric_overflow_restores_items_that_fit_after_the_larger_side_yields() { use super::super::{Group, Item, ItemKind}; for mirrored in [false, true] { for policy in [OverflowPolicy::Auto, OverflowPolicy::Never, OverflowPolicy::Always] { let group = |id: &str, priority, overflow| { let mut item = Item::new(id, ItemKind::Clock); item.priority = priority; item.overflow = overflow; let mut group = Group::new(id, vec![item], [0, 0], 0.); group.island_padding = 0.; group }; let mut panel = Panel::default(); panel.start.groups = vec![group("small", 0, policy)]; panel.center.groups = vec![group("center", 100, OverflowPolicy::Never)]; panel.end.groups = vec![group("large", 50, policy)]; if mirrored { std::mem::swap(&mut panel.start, &mut panel.end); } let measurements: Vec<_> = [("small", 40.), ("center", 40.), ("large", 200.)] .into_iter() .map(|(id, width)| Measurement { id: ItemId(id.into()), alternatives: vec![(Representation::Wide, width)], minimum: None, }) .collect(); let resolution = resolve(&panel, &measurements, 300., 32.); assert!(resolution.fits(300.)); assert_eq!( matches!(resolution.items[&ItemId("small".into())], Placement::Visible { .. }), policy != OverflowPolicy::Always ); assert!(resolution.overflow.contains(&ItemId("large".into()))); assert_eq!(resolution.forced_overflow, policy == OverflowPolicy::Never); } } } #[test] fn zone_width_ignores_empty_groups_and_gaps_before_hidden_items() { use super::super::{Group, Item, ItemKind}; let mut panel = Panel::default(); let mut first = Item::new("first", ItemKind::Overview); first.gap_before = Some(99.); let hidden = Item::new("hidden", ItemKind::Network); let mut last = Item::new("last", ItemKind::Clock); last.gap_before = Some(7.); let mut group = Group::new("content", vec![first, hidden, last], [0, 3], 5.); panel.style = crate::PanelStyle::Floating; group.island_padding = 4.; let empty = Group::new("empty", vec![Item::new("missing", ItemKind::Media)], [0, 99], 99.); let trailing = Group::new( "trailing", vec![Item::new("trailing", ItemKind::Battery { percentage: false })], [0, 2], 5., ); panel.start.groups = vec![empty.clone(), group, empty, trailing]; panel.start.spacing = 11.; let items = [ ( ItemId("first".into()), Placement::Visible { representation: Representation::Icon, width: 20., }, ), (ItemId("hidden".into()), Placement::Overflow), ( ItemId("last".into()), Placement::Visible { representation: Representation::Wide, width: 30., }, ), ( ItemId("trailing".into()), Placement::Visible { representation: Representation::Icon, width: 10., }, ), ] .into(); assert_eq!(zone_width(&panel, &panel.start, &items), 106.); } #[test] fn a_center_only_item_keeps_its_preferred_size_without_side_gaps() { use super::super::{Group, Item, ItemKind}; let mut panel = Panel::default(); panel.start.groups.clear(); panel.end.groups.clear(); panel.center.groups = vec![Group::new( "center", vec![Item::new("media", ItemKind::Media)], [0, 0], 0., )]; let measurements = [Measurement { id: ItemId("media".into()), alternatives: vec![ (Representation::Wide, 40.), (Representation::Compact, 24.), (Representation::Icon, 12.), ], minimum: None, }]; for gap in [0., 8., 32.] { for available in [40., 48.] { let resolution = resolve_with_gap(&panel, &measurements, available, 24., gap); assert_eq!( resolution.items[&ItemId("media".into())], Placement::Visible { representation: Representation::Wide, width: 40. } ); assert!(resolution.overflow.is_empty()); assert!(resolution.fits(available)); } } } #[test] fn center_collision_spacing_applies_only_to_occupied_sides() { for (widths, required) in [ ([0., 40., 0.], 40.), ([20., 40., 0.], 96.), ([0., 40., 20.], 96.), ([20., 40., 30.], 116.), ([20., 0., 30.], 58.), ([0., 0., 30.], 30.), ] { let resolution = Resolution { zone_widths: widths, zone_gap: 8., ..Default::default() }; assert!(resolution.fits(required)); assert!(!resolution.fits(required - 1.)); } } #[test] fn a_crowded_opposite_zone_does_not_leave_media_compacted_in_unused_space() { use super::super::{Group, Item, ItemKind}; let mut panel = Panel::default(); panel.start.groups = vec![Group::new( "navigation", vec![ Item::new("overview", ItemKind::Overview), Item::new("media", ItemKind::Media), ], [0, 0], 0., )]; panel.center.groups = vec![Group::new( "title", vec![Item::new("title", ItemKind::FocusedWindow)], [0, 0], 0., )]; panel.end.groups = vec![Group::new( "status", vec![Item::new("clock", ItemKind::Clock)], [0, 0], 0., )]; let measured = vec![ Measurement { id: ItemId("overview".into()), alternatives: vec![(Representation::Icon, 28.)], minimum: None, }, Measurement { id: ItemId("media".into()), alternatives: vec![ (Representation::Wide, 180.), (Representation::Compact, 100.), (Representation::Icon, 28.), ], minimum: None, }, Measurement { id: ItemId("title".into()), alternatives: vec![(Representation::Wide, 48.)], minimum: Some(48.), }, Measurement { id: ItemId("clock".into()), alternatives: vec![ (Representation::Wide, 340.), (Representation::Compact, 240.), (Representation::Icon, 140.), ], minimum: None, }, ]; for mirrored in [false, true] { if mirrored { std::mem::swap(&mut panel.start, &mut panel.end); } let resolution = resolve(&panel, &measured, 550., 32.); assert!(resolution.fits(550.)); assert_eq!( resolution.items[&ItemId("media".into())], Placement::Visible { representation: Representation::Wide, width: 180. } ); assert_eq!( resolution.items[&ItemId("clock".into())], Placement::Visible { representation: Representation::Compact, width: 240. } ); } let mut measured = measured; measured[1].alternatives[0].1 = 400.; measured[1].minimum = Some(112.); for mirrored in [false, true] { if mirrored { std::mem::swap(&mut panel.start, &mut panel.end); } let resolution = resolve(&panel, &measured, 550., 32.); assert!(resolution.fits(550.)); assert!( matches!(resolution.items[&ItemId("media".into())], Placement::Visible { representation: Representation::Wide, width } if width > 200. && width < 400.), "flexible media must expand into the unused room beside the center" ); } } #[test] fn fits_uses_the_gap_that_was_resolved() { let panel = Panel::default(); let measured = measurements(&panel); for gap in [0., 2., 16., 48.] { let result = resolve_with_gap(&panel, &measured, 2400., 32., gap); assert_eq!(result.zone_gap, gap); let required = result.required_width_with_gap(gap); assert!(result.fits(required)); assert!(!result.fits(required - 1.)); } } #[test] fn flexible_restoration_stays_within_the_selected_representation() { let mut panel = Panel::default(); panel.start.groups.clear(); panel.center.groups.clear(); panel.end.groups.retain(|group| group.id.0 == "time"); let mut clock = super::super::Item::new("clock", super::super::ItemKind::Clock); clock.overflow = OverflowPolicy::Never; clock.priority = 0; let audio = super::super::Item::new("audio", super::super::ItemKind::Audio); panel.end.groups[0].items = vec![clock, audio]; let result = resolve( &panel, &[ Measurement { id: ItemId("clock".into()), alternatives: vec![(Representation::Wide, 80.), (Representation::Compact, 40.)], minimum: Some(60.), }, Measurement { id: ItemId("audio".into()), alternatives: vec![(Representation::Icon, 100.)], minimum: None, }, ], 96., 32., ); assert_eq!( result.items[&ItemId("clock".into())], Placement::Visible { representation: Representation::Compact, width: 40. } ); assert_eq!(result.overflow, [ItemId("audio".into())]); assert!(result.fits(96.)); } #[test] fn allocation_is_independent_of_measurement_order_even_for_flexible_items() { let panel = Panel::default(); let mut measured = measurements(&panel); for measurement in &mut measured { measurement.minimum = Some(24.); } for width in [2400., 1400., 1000., 800., 400., 80., 32.] { let expected = resolve_with_gap(&panel, &measured, width, 32., 16.); for _ in 0..measured.len() { measured.rotate_left(1); assert_eq!(resolve_with_gap(&panel, &measured, width, 32., 16.), expected); measured.reverse(); assert_eq!(resolve_with_gap(&panel, &measured, width, 32., 16.), expected); measured.reverse(); } } } fn measurements(panel: &Panel) -> Vec { [&panel.start, &panel.center, &panel.end] .into_iter() .flat_map(|zone| &zone.groups) .flat_map(|group| &group.items) .map(|item| Measurement { id: item.id.clone(), alternatives: vec![ (Representation::Wide, 80.0), (Representation::Compact, 40.0), (Representation::Icon, 24.0), ], minimum: matches!(item.kind, crate::panel::ItemKind::FocusedWindow).then_some(48.0), }) .collect() } #[test] fn narrow_panels_adapt_deterministically_and_keep_every_item_reachable() { for background in [crate::PanelStyle::Flat, crate::PanelStyle::Floating] { let panel = Panel::from_preset(background); let measured = measurements(&panel); for width in [2400., 1200., 800., 400., 160., 80., 32.] { let resolution = resolve(&panel, &measured, width, 32.); assert!(resolution.fits(width)); assert_eq!(resolution, resolve(&panel, &measured, width, 32.)); assert_eq!(resolution.items.len(), measured.len()); for id in &resolution.overflow { assert_eq!(resolution.items.get(id), Some(&Placement::Overflow)); } if width >= 400. { assert!(!resolution.forced_overflow); } } } } #[test] fn decoration_and_borders_do_not_change_background_arrangement_or_allocation() { for background in [crate::PanelStyle::Flat, crate::PanelStyle::Floating] { let mut panel = Panel::from_preset(background); let measurements = measurements(&panel); let plain = resolve(&panel, &measurements, 1000., 32.); assert!(!panel.border); panel.border = true; panel.background_opacity = Some(0.4); assert_eq!(resolve(&panel, &measurements, 1000., 32.), plain); assert_eq!(panel.preset(), Some(background)); } } #[test] fn side_margins_stop_before_overflow_and_use_each_outputs_logical_width() { let panel = Panel::default(); let measurements = measurements(&panel); let full = resolve(&panel, &measurements, 4000., 32.); assert!(full.minimum_width < full.required_width()); for output_width in [1600, 2400, 3840] { for requested in [0, 32, 400, 4096] { let margin = full.side_margin(requested, output_width, 12.); let available = output_width as f32 - 2. * (margin as f32 + 12.); let result = resolve(&panel, &measurements, available, 32.); assert!(result.overflow.is_empty(), "output {output_width}, margin {margin}"); assert!(result.fits(available)); assert!(margin <= requested); } } assert!(full.side_margin(4096, 3840, 12.) > full.side_margin(4096, 1600, 12.)); assert_eq!(full.side_margin(4096, 100, 12.), 0); assert!( !resolve(&panel, &measurements, 76., 32.).overflow.is_empty(), "physical output shortage still uses existing overflow" ); } #[test] fn margin_limit_includes_group_padding_and_authored_overflow_without_dependence_on_current_surface_width() { let mut panel = Panel::default(); panel.end.groups[0].items[0].overflow = OverflowPolicy::Always; let measurements = measurements(&panel); let full = resolve(&panel, &measurements, 4000., 32.); let narrow = resolve(&panel, &measurements, 100., 32.); assert_eq!(full.minimum_width, narrow.minimum_width); let margin = full.side_margin(4096, 2400, 12.); let result = resolve(&panel, &measurements, 2400. - 2. * (margin as f32 + 12.), 32.); assert_eq!(result.overflow, full.overflow); let original = full.minimum_width; panel.end.groups[0].padding[1] += 10; let padded = resolve(&panel, &measurements, 4000., 32.); assert!(padded.minimum_width > original); assert!(padded.side_margin(4096, 2400, 12.) < margin); } #[test] fn asymmetric_sides_reserve_a_real_screen_center() { let panel = Panel::default(); let resolution = resolve(&panel, &measurements(&panel), 1500., 32.); let [start, center, end] = resolution.zone_widths; assert!(end > start); assert!(start + 8. <= (1500. - center) * 0.5); assert!((1500. + center) * 0.5 + 8. <= 1500. - end); } #[test] fn always_overflow_is_reserved_and_unavailable_items_are_not_allocated() { let mut panel = Panel::default(); panel.end.groups[0].items[2].overflow = OverflowPolicy::Always; let mut measured = measurements(&panel); measured.remove(3); let resolution = resolve(&panel, &measured, 2000., 32.); assert!(!resolution.items.contains_key(&ItemId("media".into()))); assert_eq!(resolution.overflow, [ItemId("network".into())]); } #[test] fn priority_controls_displacement_and_expansion_restores_requested_sizes() { let mut panel = Panel::default(); panel.start.groups.clear(); panel.center.groups.clear(); panel.end.groups.retain(|group| group.id.0 == "time"); let mut first = panel.end.groups[0].items[0].clone(); first.id = ItemId("low".into()); first.priority = 0; let mut second = first.clone(); second.id = ItemId("high".into()); second.priority = 100; let mut third = first.clone(); third.id = ItemId("middle".into()); third.priority = 50; panel.end.groups[0].items = vec![first, second, third]; let measured: Vec<_> = panel.end.groups[0] .items .iter() .map(|item| Measurement { id: item.id.clone(), alternatives: vec![(Representation::Wide, 50.)], minimum: None, }) .collect(); let initial = panel.clone(); let small = resolve(&panel, &measured, 150., 32.); assert_eq!(small.overflow, [ItemId("low".into())]); assert!(small.fits(150.)); for width in [200., 150., 40., 200.] { let result = resolve(&panel, &measured, width, 32.); assert!(result.fits(width)); if width == 200. { assert!(result.overflow.is_empty()); assert!(result.items.values().all(|placement| matches!(placement, Placement::Visible { representation: Representation::Wide, width } if *width == 50.))); } assert_eq!(panel, initial, "resolution must not rewrite configuration intent"); } } #[test] fn panel_surface_policy_controls_padding_for_every_group() { let mut panel = Panel::from_preset(crate::PanelStyle::Floating); let measured = measurements(&panel); let initial = resolve(&panel, &measured, 2400., 32.); panel.style = crate::PanelStyle::Flat; let plain = resolve(&panel, &measured, 2400., 32.); for (i, zone) in [&panel.start, &panel.center, &panel.end].into_iter().enumerate() { let padding: f32 = zone.groups.iter().map(|group| 2. * group.island_padding).sum(); assert_eq!(plain.zone_widths[i], initial.zone_widths[i] - padding); } panel.style = crate::PanelStyle::Flat; assert_eq!(resolve(&panel, &measured, 2400., 32.), plain); panel.style = crate::PanelStyle::Flat; assert_eq!(resolve(&panel, &measured, 2400., 32.), plain); } }