From 9e1b99763122d4118e12b9d3616929389618ee02 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 5 Jul 2026 16:17:44 +0300 Subject: [PATCH] feat(ui): flex-wrap the ribbon top bar and status bar when narrow Add shared adaptive-bar widgets in src/ui/wrap_bar.rs: - WrapBar: two-block bar (lead + optional Fill middle + trail). Fits on one row, otherwise the trailing block drops below; with no middle both blocks flex-wrap in their own row bands and stack so a wrapped lead item never shares a row with a trailing item. - WrapFlow: CSS-style flex-wrap row that spreads its items across as many rows as the width needs. Ribbon tab row: quick-access buttons and undo/redo are merged into one WrapFlow; the tabs are a second WrapFlow. Dropdown overlays anchor to the measured (possibly taller) bar height instead of a hardcoded 28px. Status bar: the hamburger menu plus the Model/layout tabs form one WrapFlow; the status pills form another. The layout-tab scroll arrows and horizontal scrollable are removed in favour of wrapping. Co-Authored-By: Claude Opus 4.8 --- src/ui/mod.rs | 1 + src/ui/ribbon/mod.rs | 88 +++--- src/ui/statusbar/mod.rs | 286 ++++++++++--------- src/ui/wrap_bar.rs | 614 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 811 insertions(+), 178 deletions(-) create mode 100644 src/ui/wrap_bar.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 19da65675..38640b8d1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -15,6 +15,7 @@ pub mod statusbar; pub mod style; pub mod text_util; pub mod window; +pub mod wrap_bar; pub use command_line::CommandLine; pub use properties::PropertiesPanel; diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index cbcb6bea5..48d414395 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -6,6 +6,9 @@ // // Dropdown items within a group are collected into columns of 3 rows. +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + use rustc_hash::FxHashMap as HashMap; use acadrust::types::{Color as AcadColor, LineWeight}; @@ -19,6 +22,7 @@ use crate::ui::properties::{lw_options, LinetypeItem}; mod widgets; use widgets::{StyleContext, *}; +use crate::ui::wrap_bar::{WrapBar, WrapFlow}; // ── Ribbon state ─────────────────────────────────────────────────────────── @@ -52,6 +56,9 @@ pub struct Ribbon { pub active_mleader_style: String, pub table_style_names: Vec, pub active_table_style: String, + /// Measured tab-bar height (28 on one row, 56 when tabs wrap). Written by + /// the `WrapBar` widget during layout, read when anchoring dropdowns. + tab_bar_h: Arc, } /// Per-layer display data shown in the ribbon layer dropdown. @@ -106,9 +113,15 @@ impl Ribbon { active_mleader_style: String::new(), table_style_names: vec![], active_table_style: String::new(), + tab_bar_h: Arc::new(AtomicU32::new(28.0f32.to_bits())), } } + /// Current tab-bar height as last measured by the `WrapBar` widget. + fn tab_bar_height(&self) -> f32 { + f32::from_bits(self.tab_bar_h.load(Ordering::Relaxed)) + } + pub fn set_styles( &mut self, text: Vec, @@ -217,34 +230,29 @@ impl Ribbon { undo_count: usize, redo_count: usize, ) -> Element<'_, Message> { - // ── Quick-access file commands ───────────────────────────────────── - let quick_access = row![ - quick_access_btn(crate::ui::icons::DOC_NEW, "New", "NEW"), - quick_access_btn(crate::ui::icons::FOLDER_OPEN, "Open", "OPEN"), - quick_access_btn(crate::ui::icons::SAVE, "Save", "SAVE"), - quick_access_btn(crate::ui::icons::FILE_EXPORT, "Save As", "SAVEAS"), - quick_access_btn(crate::ui::icons::PRINT, "Print", "PRINT"), - ] - .spacing(TOP_HIST_GAP) - .align_y(iced::Center); + // ── Quick-access file commands + undo/redo, one merged flow ──────── + let lead = WrapFlow::new(vec![ + quick_access_btn(crate::ui::icons::DOC_NEW, "New", "NEW").into(), + quick_access_btn(crate::ui::icons::FOLDER_OPEN, "Open", "OPEN").into(), + quick_access_btn(crate::ui::icons::SAVE, "Save", "SAVE").into(), + quick_access_btn(crate::ui::icons::FILE_EXPORT, "Save As", "SAVEAS").into(), + quick_access_btn(crate::ui::icons::PRINT, "Print", "PRINT").into(), + render_history_control("Undo", UNDO_HISTORY_ID, undo_count, &self.open_dropdown).into(), + render_history_control("Redo", REDO_HISTORY_ID, redo_count, &self.open_dropdown).into(), + ]) + .spacing_x(TOP_HIST_GAP) + .row_h(28.0); - // ── Tab buttons ─────────────────────────────────────────────────── - let history_controls = row![ - render_history_control("Undo", UNDO_HISTORY_ID, undo_count, &self.open_dropdown), - render_history_control("Redo", REDO_HISTORY_ID, redo_count, &self.open_dropdown), - ] - .spacing(TOP_HIST_GAP) - .align_y(iced::Center); + // The quick-access flow and the tabs flow each flex-wrap; WrapBar stacks + // them so a wrapped tab never shares a row with a quick-access button. - let tab_buttons = self.modules.iter().enumerate().fold( - row![quick_access, history_controls] - .align_y(iced::Center) - .spacing(6), - |row_acc, (i, module)| { + let tab_items = self.modules.iter().enumerate().fold( + Vec::>::new(), + |mut acc, (i, module)| { // The Layout module no longer has a ribbon tab — its paper-space // tools live in the right-edge side toolbar (see ui::side_toolbar). if module.id() == "layout" { - return row_acc; + return acc; } let is_active = i == self.active; @@ -324,17 +332,23 @@ impl Ribbon { }, ..Default::default() }); - row_acc.push(btn) + acc.push(btn.into()); + acc }, ); - let tab_bar = container(tab_buttons) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TOPBAR_BG)), - ..Default::default() - }) - .width(Length::Fill) - .height(28); + let tabs = WrapFlow::new(tab_items).spacing_x(6.0).row_h(28.0); + + let tab_bar = container( + WrapBar::new(lead.into(), tabs.into()) + .spacing(6.0) + .report_height(self.tab_bar_h.clone()), + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(TOPBAR_BG)), + ..Default::default() + }) + .width(Length::Fill); // ── Tool area ───────────────────────────────────────────────────── let effective_active = if !is_paper @@ -654,7 +668,7 @@ impl Ribbon { }) .width(Length::Fixed(190.0)); - let top_offset = 28.0 + TOOL_BAR_H; + let top_offset = self.tab_bar_height() + TOOL_BAR_H; let left_offset = compute_dropdown_left(&groups, open_id); let positioned = container(panel) .align_left(Fill) @@ -777,7 +791,7 @@ impl Ribbon { }) .width(Length::Fixed(220.0)); - let top_offset = 28.0 + TOOL_BAR_H; + let top_offset = self.tab_bar_height() + TOOL_BAR_H; let groups = self.modules.get(self.active)?.ribbon_groups(); let left_offset = compute_layer_combo_left(&groups); let positioned = container(panel) @@ -901,7 +915,7 @@ impl Ribbon { }) .width(Length::Fixed(LARGE_W * 2.3)); - let top_offset = 28.0 + TOOL_BAR_H; + let top_offset = self.tab_bar_height() + TOOL_BAR_H; let left_offset = compute_dropdown_left(&groups, open_id); let positioned = container(panel) .align_left(Fill) @@ -939,7 +953,7 @@ impl Ribbon { }) .width(Length::Fixed(200.0)); - let top_offset = 28.0 + TOOL_BAR_H; + let top_offset = self.tab_bar_height() + TOOL_BAR_H; let groups = self.modules.get(self.active)?.ribbon_groups(); let left_offset = compute_prop_combo_left(&groups, PROP_COLOR_ID); let positioned = container(panel) @@ -1028,7 +1042,7 @@ impl Ribbon { }) .width(Length::Fixed(220.0)); - let top_offset = 28.0 + TOOL_BAR_H; + let top_offset = self.tab_bar_height() + TOOL_BAR_H; let groups = self.modules.get(self.active)?.ribbon_groups(); let left_offset = compute_prop_combo_left(&groups, PROP_LINETYPE_ID); let positioned = container(list) @@ -1104,7 +1118,7 @@ impl Ribbon { }) .width(Length::Fixed(width)); - let top_offset = 28.0 + TOOL_BAR_H; + let top_offset = self.tab_bar_height() + TOOL_BAR_H; let groups = self.modules.get(self.active)?.ribbon_groups(); let left_offset = compute_prop_combo_left(&groups, dd_id); let positioned = container(panel) diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index d501d3467..fca037a83 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -4,17 +4,15 @@ pub mod statusbar_config; pub mod statusbar_menu; use iced::widget::tooltip::Position as TipPos; -use iced::widget::scrollable::{Direction, Scrollbar}; use iced::widget::{ - button, column, container, mouse_area, row, scrollable, text, text_input, tooltip, Row, + button, column, container, mouse_area, row, text, text_input, tooltip, }; use iced::{Background, Border, Color, Element, Length, Theme}; -/// Scrollable id of the status-bar layout-tab strip, so the ‹ › arrows (and -/// `Message::ScrollLayoutTabs`) can scroll it. +/// Scrollable id of the status-bar layout-tab strip (retained so the existing +/// `Message::ScrollLayoutTabs` handler still resolves; the strip now flex-wraps +/// instead of scrolling). pub const LAYOUT_TABS_SCROLL_ID: &str = "statusbar-layout-tabs"; -/// How far one ‹ / › arrow click scrolls the layout-tab strip. -const LAYOUT_TAB_SCROLL_STEP: f32 = 96.0; /// Widget id of the inline layout-rename text input, so the rename can grab /// keyboard focus the moment it opens (issue #86). @@ -23,6 +21,7 @@ pub const LAYOUT_RENAME_INPUT_ID: &str = "layout_rename_input"; use crate::app::Message; use crate::snap::Snapper; use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill}; +use crate::ui::wrap_bar::{WrapBar, WrapFlow}; #[derive(Clone, Default)] pub struct StatusBar { @@ -137,151 +136,189 @@ impl StatusBar { status_pill(scale_label).into() }; // Build the right-side pills, honouring the user's per-pill visibility. + // They live in a flex-wrap flow (WrapFlow) so they spill onto extra rows + // when the width can't hold them all on one line. let vis = |p: StatusPill| config.is_visible(p); - let mut right_status = Row::new().spacing(2).align_y(iced::Center); + let mut pills: Vec> = Vec::new(); if vis(StatusPill::Coords) { - right_status = right_status.push(tip( - status_pill(format_coords(cursor_world)).into(), - "Cursor coordinates (X, Y, Z)", - )); + pills.push( + tip( + status_pill(format_coords(cursor_world)).into(), + "Cursor coordinates (X, Y, Z)", + ) + .into(), + ); } if vis(StatusPill::Ortho) { - right_status = right_status.push(tip( - toggle_pill(crate::ui::icons::ST_ORTHO, ortho_mode, Message::ToggleOrtho), - "Orthogonal Mode\nF8", - )); + pills.push( + tip( + toggle_pill(crate::ui::icons::ST_ORTHO, ortho_mode, Message::ToggleOrtho), + "Orthogonal Mode\nF8", + ) + .into(), + ); } if vis(StatusPill::Lwt) { - right_status = right_status.push(tip( - toggle_pill(crate::ui::icons::ST_LWT, lineweight_display, Message::ToggleLineweightDisplay), - "Show Lineweight\nLWDISPLAY", - )); + pills.push( + tip( + toggle_pill(crate::ui::icons::ST_LWT, lineweight_display, Message::ToggleLineweightDisplay), + "Show Lineweight\nLWDISPLAY", + ) + .into(), + ); } if vis(StatusPill::Polar) { - right_status = right_status.push(polar_pill(polar_mode, polar_increment_deg)); + pills.push(polar_pill(polar_mode, polar_increment_deg).into()); } if vis(StatusPill::Dyn) { - right_status = right_status.push(tip( - toggle_pill(crate::ui::icons::ST_DYN, dyn_input, Message::ToggleDynInput), - "Dynamic Input\nF12", - )); + pills.push( + tip( + toggle_pill(crate::ui::icons::ST_DYN, dyn_input, Message::ToggleDynInput), + "Dynamic Input\nF12", + ) + .into(), + ); } if vis(StatusPill::Otrack) { - right_status = right_status.push(tip( - toggle_pill(crate::ui::icons::ST_OTRACK, otrack, Message::ToggleOTrack), - "Object Snap Tracking\nF11", - )); + pills.push( + tip( + toggle_pill(crate::ui::icons::ST_OTRACK, otrack, Message::ToggleOTrack), + "Object Snap Tracking\nF11", + ) + .into(), + ); } if vis(StatusPill::Osnap) { - right_status = - right_status.push(osnap_btn(osnap_active, snapper.snap_enabled, popup_open)); + pills.push(osnap_btn(osnap_active, snapper.snap_enabled, popup_open).into()); } if vis(StatusPill::Space) { - right_status = right_status.push(tip( - space_mode_btn(¤t_layout, in_mspace), - "PAPER: double-click viewport to enter MSPACE\nMODEL: click to switch to Model Space", - )); + pills.push( + tip( + space_mode_btn(¤t_layout, in_mspace), + "PAPER: double-click viewport to enter MSPACE\nMODEL: click to switch to Model Space", + ) + .into(), + ); } if vis(StatusPill::Scale) { - right_status = right_status.push(scale_element); + pills.push(scale_element); } if vis(StatusPill::Units) { - right_status = right_status.push(tip( - units_btn( - crate::ui::popup::units_popup::unit_short(insertion_units), - units_popup_open, - ), - "Drawing Units (INSUNITS)\nClick to change", - )); + pills.push( + tip( + units_btn( + crate::ui::popup::units_popup::unit_short(insertion_units), + units_popup_open, + ), + "Drawing Units (INSUNITS)\nClick to change", + ) + .into(), + ); } if vis(StatusPill::Transparency) { - right_status = right_status.push(tip( - toggle_pill( - crate::ui::icons::ST_TRANSPARENCY, - transparency_display, - Message::ToggleTransparencyDisplay, - ), - "Show Transparency\nForce opaque when off", - )); + pills.push( + tip( + toggle_pill( + crate::ui::icons::ST_TRANSPARENCY, + transparency_display, + Message::ToggleTransparencyDisplay, + ), + "Show Transparency\nForce opaque when off", + ) + .into(), + ); } if vis(StatusPill::Isolate) { - right_status = right_status.push(tip( - toggle_pill(crate::ui::icons::ST_ISOLATE, isolation_active, Message::ToggleIsolatePopup), - "Isolate Objects\nClick for Isolate / Hide / End", - )); + pills.push( + tip( + toggle_pill(crate::ui::icons::ST_ISOLATE, isolation_active, Message::ToggleIsolatePopup), + "Isolate Objects\nClick for Isolate / Hide / End", + ) + .into(), + ); } if vis(StatusPill::QuickProps) { - right_status = right_status.push(tip( - toggle_pill(crate::ui::icons::ST_QUICKPROPS, quick_properties, Message::ToggleQuickProperties), - "Quick Properties\nFloating panel on selection", - )); + pills.push( + tip( + toggle_pill(crate::ui::icons::ST_QUICKPROPS, quick_properties, Message::ToggleQuickProperties), + "Quick Properties\nFloating panel on selection", + ) + .into(), + ); } if vis(StatusPill::SelFilter) { - right_status = right_status.push(tip( - toggle_pill( - crate::ui::icons::ST_FILTER, - selection_filter_active, - Message::ToggleSelectionFilterPopup, - ), - "Selection Filtering\nLimit which object types can be picked", - )); + pills.push( + tip( + toggle_pill( + crate::ui::icons::ST_FILTER, + selection_filter_active, + Message::ToggleSelectionFilterPopup, + ), + "Selection Filtering\nLimit which object types can be picked", + ) + .into(), + ); } if vis(StatusPill::SelCycle) { - right_status = right_status.push(tip( - toggle_pill(crate::ui::icons::ST_SELCYCLE, selection_cycling, Message::ToggleSelectionCycling), - "Selection Cycling\nRepeat-click to step through overlapping objects", - )); + pills.push( + tip( + toggle_pill(crate::ui::icons::ST_SELCYCLE, selection_cycling, Message::ToggleSelectionCycling), + "Selection Cycling\nRepeat-click to step through overlapping objects", + ) + .into(), + ); } if vis(StatusPill::Vp) && !vp_label.is_empty() { - right_status = right_status.push(tip( - status_pill(vp_label).into(), - "Viewport count in active layout", - )); + pills.push( + tip( + status_pill(vp_label).into(), + "Viewport count in active layout", + ) + .into(), + ); } if vis(StatusPill::CleanScreen) { - right_status = right_status.push(tip( - toggle_pill(crate::ui::icons::ST_CLEANSCREEN, clean_screen, Message::ToggleCleanScreen), - "Clean Screen\nHide ribbon and panels", - )); + pills.push( + tip( + toggle_pill(crate::ui::icons::ST_CLEANSCREEN, clean_screen, Message::ToggleCleanScreen), + "Clean Screen\nHide ribbon and panels", + ) + .into(), + ); } // Customization handle: opens the pill show/hide menu. - right_status = right_status.push(tip( - customize_btn(), - "Customization\nShow or hide status-bar items", - )); - let right_status = right_status; + pills.push( + tip( + customize_btn(), + "Customization\nShow or hide status-bar items", + ) + .into(), + ); + let right_status = WrapFlow::new(pills).spacing_x(2.0).row_h(30.0); - let mut bar = Row::new().align_y(iced::Center).spacing(0); - bar = bar.push(menu_btn); + // Left area: hamburger menu + Model/layout tabs in a flex-wrap flow, so + // they spill onto lower rows when narrow (no scroll arrows). The pills + // wrap in their own flow; WrapBar stacks the two areas so a wrapped tab + // never shares a row with a pill. + let mut left: Vec> = Vec::new(); + left.push(menu_btn.into()); if show_layout_tabs { - // The layout tabs live in a horizontal scrollable that takes only - // the space left between the menu and the right-side tools, so many - // layouts scroll instead of pushing the tools off the bar (#199). - // ‹ › arrows scroll it (the mouse wheel works too). - let mut tabs_row = Row::new().align_y(iced::Center).spacing(0); for name in layouts { let is_active = name == current_layout; let renaming = rename_state .filter(|(orig, _)| *orig == name) .map(|(_, edit)| edit.as_str()); - tabs_row = tabs_row.push(space_tab(name, is_active, renaming)); + left.push(space_tab(name, is_active, renaming).into()); } - tabs_row = tabs_row.push(add_btn); - - let tabs_scroll = scrollable(tabs_row) - .direction(Direction::Horizontal(Scrollbar::hidden())) - .id(iced::advanced::widget::Id::new(LAYOUT_TABS_SCROLL_ID)) - .width(Length::Shrink); - - bar = bar.push(scroll_arrow(false)); - bar = bar.push(container(tabs_scroll).width(Length::Fill)); - bar = bar.push(scroll_arrow(true)); - } else { - bar = bar.push(iced::widget::Space::new().width(Length::Fill)); + left.push(add_btn.into()); } - bar = bar.push(right_status); + let left_area = WrapFlow::new(left).spacing_x(2.0).row_h(30.0); - container(bar) + let wrap = WrapBar::new(left_area.into(), right_status.into()) + .min_row_h(30.0) + .justify_end(true); + + container(wrap) .style(|_: &Theme| container::Style { background: Some(Background::Color(BAR_BG)), border: Border { @@ -292,11 +329,10 @@ impl StatusBar { ..Default::default() }) .width(Length::Fill) - // Match the drawing (document) tab bar height so the three + // One row matches the drawing (document) tab bar height so the three // horizontal strips — tabs, status bar, command line — line up. - // `center_y` (not `height`) also vertically centres every pill in - // the taller bar instead of top-aligning them (issue #216). - .center_y(Length::Fixed(30.0)) + // WrapBar vertically centres every pill (issue #216) and grows to a + // second row when the width can't hold both blocks. .padding([0, 4]) .into() } @@ -308,38 +344,6 @@ fn format_coords(p: glam::Vec3) -> String { format!("{:.4}, {:.4}, {:.4}", p.x, p.y, p.z) } -// ── Layout-tab scroll arrow ─────────────────────────────────────────────── - -/// A ‹ / › arrow that scrolls the layout-tab strip. `right` picks the -/// direction (and step sign). -fn scroll_arrow<'a>(right: bool) -> Element<'a, Message> { - let glyph = if right { - crate::ui::icons::arrow_right(12.0, ICON_COLOR) - } else { - crate::ui::icons::arrow_left(12.0, ICON_COLOR) - }; - let step = if right { - LAYOUT_TAB_SCROLL_STEP - } else { - -LAYOUT_TAB_SCROLL_STEP - }; - button(glyph) - .on_press(Message::ScrollLayoutTabs(step)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => PILL_BG, - _ => Color::TRANSPARENT, - })), - border: Border { - radius: 3.0.into(), - ..Default::default() - }, - ..Default::default() - }) - .padding([4, 6]) - .into() -} - // ── Customization handle ────────────────────────────────────────────────── fn customize_btn() -> Element<'static, Message> { diff --git a/src/ui/wrap_bar.rs b/src/ui/wrap_bar.rs new file mode 100644 index 000000000..4a5da31df --- /dev/null +++ b/src/ui/wrap_bar.rs @@ -0,0 +1,614 @@ +//! Adaptive two-block bar that wraps its trailing block onto a second row when +//! the width can't hold both blocks side by side. Used by the ribbon tab row +//! (lead = quick-access + undo/redo, trail = tabs) and the status bar +//! (lead = menu, trail = status pills, with the layout-tab strip as the Fill +//! `middle` filler between them). +//! +//! Layout, per row: +//! • one row → `lead` at the left; `trail` packed after it (`justify_end` +//! false) or flush to the right edge (`justify_end` true); +//! an optional Fill `middle` stretches through the gap. +//! • two rows → `lead` (+ `middle` filling the rest) on row 1; `trail` on +//! row 2, left- or right-aligned per `justify_end`. +//! +//! The measured total height is written to `height_out` (if set) so callers can +//! anchor overlays below the possibly-taller bar. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::widget::{self, Widget}; +use iced::advanced::{mouse, overlay, renderer, Clipboard, Shell}; +use iced::{Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vector}; + +use crate::app::Message; + +pub struct WrapBar<'a> { + lead: Element<'a, Message>, + /// Optional Fill filler occupying the gap between `lead` and `trail`. + middle: Option>, + trail: Element<'a, Message>, + /// Gap between adjacent blocks on a shared row. + spacing: f32, + /// Minimum height of a single row. + min_row_h: f32, + /// When true, `trail` sits at the right edge (justified); otherwise it is + /// packed immediately after `lead`. + justify_end: bool, + /// Receives the measured total height (bits of an `f32`). + height_out: Option>, +} + +impl<'a> WrapBar<'a> { + pub fn new(lead: Element<'a, Message>, trail: Element<'a, Message>) -> Self { + Self { + lead, + middle: None, + trail, + spacing: 0.0, + min_row_h: 28.0, + justify_end: false, + height_out: None, + } + } + + pub fn spacing(mut self, spacing: f32) -> Self { + self.spacing = spacing; + self + } + + pub fn min_row_h(mut self, h: f32) -> Self { + self.min_row_h = h; + self + } + + pub fn justify_end(mut self, justify: bool) -> Self { + self.justify_end = justify; + self + } + + pub fn middle(mut self, middle: Element<'a, Message>) -> Self { + self.middle = Some(middle); + self + } + + pub fn report_height(mut self, out: Arc) -> Self { + self.height_out = Some(out); + self + } + + /// Elements in row order: lead, [middle], trail. + fn refs(&self) -> Vec<&Element<'a, Message>> { + let mut v = Vec::with_capacity(3); + v.push(&self.lead); + if let Some(m) = &self.middle { + v.push(m); + } + v.push(&self.trail); + v + } + + fn refs_mut(&mut self) -> Vec<&mut Element<'a, Message>> { + let mut v = Vec::with_capacity(3); + v.push(&mut self.lead); + if let Some(m) = &mut self.middle { + v.push(m); + } + v.push(&mut self.trail); + v + } +} + +impl<'a> Widget for WrapBar<'a> { + fn children(&self) -> Vec { + self.refs().iter().map(|e| widget::Tree::new(*e)).collect() + } + + fn diff(&self, tree: &mut widget::Tree) { + let refs: Vec<_> = self.refs().iter().map(|e| e.as_widget()).collect(); + tree.diff_children(&refs); + } + + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Shrink) + } + + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let max = limits.max(); + // Measure blocks at their natural (unwrapped) width so the fit decision + // and one-row placement use true content widths. A flex-wrap `trail` + // (WrapFlow) only wraps when it is later laid out with a bounded width. + let natural = + layout::Limits::new(Size::ZERO, Size::new(f32::INFINITY, f32::INFINITY)); + + let has_middle = self.middle.is_some(); + let trail_idx = if has_middle { 2 } else { 1 }; + + let mut lead_node = + self.lead + .as_widget_mut() + .layout(&mut tree.children[0], renderer, &natural); + let mut trail_node = self.trail.as_widget_mut().layout( + &mut tree.children[trail_idx], + renderer, + &natural, + ); + + let mut lead_sz = lead_node.size(); + let mut trail_sz = trail_node.size(); + + let width = if max.width.is_finite() { + max.width + } else { + lead_sz.width + self.spacing + trail_sz.width + }; + + let fits = max.width.is_finite() + && lead_sz.width + self.spacing + trail_sz.width <= max.width; + + let row_h = lead_sz.height.max(trail_sz.height).max(self.min_row_h); + let bounded = layout::Limits::new(Size::ZERO, Size::new(width, f32::INFINITY)); + + let (lead_pos, trail_pos, middle_x, middle_gap, total_h); + if fits { + // One row: lead left, trail packed after it or flush right. + let trail_x = if self.justify_end { + (width - trail_sz.width).max(lead_sz.width + self.spacing) + } else { + lead_sz.width + self.spacing + }; + lead_pos = Point::new(0.0, (row_h - lead_sz.height) / 2.0); + trail_pos = Point::new(trail_x, (row_h - trail_sz.height) / 2.0); + middle_x = lead_sz.width + self.spacing; + middle_gap = (trail_x - self.spacing - middle_x).max(0.0); + total_h = row_h; + } else if has_middle { + // 3-slot justified bar: lead on row 1, middle fills the rest of it, + // trail (flex-wrap) drops onto the row(s) below. + lead_pos = Point::new(0.0, (row_h - lead_sz.height) / 2.0); + middle_x = lead_sz.width + self.spacing; + middle_gap = (width - middle_x).max(0.0); + + trail_node = self.trail.as_widget_mut().layout( + &mut tree.children[trail_idx], + renderer, + &bounded, + ); + trail_sz = trail_node.size(); + trail_pos = Point::new(0.0, row_h); + total_h = row_h + trail_sz.height; + } else { + // 2-slot dual-wrap: lead and trail each wrap within their OWN row + // band, stacked so a wrapped lead item never lands on a trail row. + lead_node = + self.lead + .as_widget_mut() + .layout(&mut tree.children[0], renderer, &bounded); + lead_sz = lead_node.size(); + let lead_h = lead_sz.height.max(self.min_row_h); + + trail_node = self.trail.as_widget_mut().layout( + &mut tree.children[trail_idx], + renderer, + &bounded, + ); + trail_sz = trail_node.size(); + let trail_h = trail_sz.height.max(self.min_row_h); + + lead_pos = Point::new(0.0, (lead_h - lead_sz.height) / 2.0); + trail_pos = Point::new(0.0, lead_h + (trail_h - trail_sz.height) / 2.0); + middle_x = 0.0; + middle_gap = 0.0; + total_h = lead_h + trail_h; + } + + let mut children: Vec = Vec::with_capacity(3); + children.push(lead_node.move_to(lead_pos)); + + if has_middle { + let mid_limits = + layout::Limits::new(Size::new(middle_gap, 0.0), Size::new(middle_gap, row_h)); + let mut mid_node = self.middle.as_mut().unwrap().as_widget_mut().layout( + &mut tree.children[1], + renderer, + &mid_limits, + ); + let mid_y = ((row_h - mid_node.size().height) / 2.0).max(0.0); + mid_node = mid_node.move_to(Point::new(middle_x, mid_y)); + children.push(mid_node); + } + + children.push(trail_node.move_to(trail_pos)); + + if let Some(out) = &self.height_out { + out.store(total_h.to_bits(), Ordering::Relaxed); + } + + layout::Node::with_children(Size::new(width, total_h), children) + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + for ((child, state), child_layout) in self + .refs_mut() + .into_iter() + .zip(tree.children.iter_mut()) + .zip(layout.children()) + { + child.as_widget_mut().update( + state, + event, + child_layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + } + + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + let mut interaction = mouse::Interaction::default(); + for ((child, state), child_layout) in self + .refs() + .into_iter() + .zip(tree.children.iter()) + .zip(layout.children()) + { + let i = child.as_widget().mouse_interaction( + state, + child_layout, + cursor, + viewport, + renderer, + ); + if i != mouse::Interaction::default() { + interaction = i; + } + } + interaction + } + + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + for ((child, state), child_layout) in self + .refs_mut() + .into_iter() + .zip(tree.children.iter_mut()) + .zip(layout.children()) + { + child + .as_widget_mut() + .operate(state, child_layout, renderer, operation); + } + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + for ((child, state), child_layout) in self + .refs() + .into_iter() + .zip(tree.children.iter()) + .zip(layout.children()) + { + child.as_widget().draw( + state, + renderer, + theme, + style, + child_layout, + cursor, + viewport, + ); + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + // At most one child (the hovered tooltip, if any) yields an overlay; + // return the first. Borrow fields and tree slots disjointly so the + // returned overlay can outlive this call. + let layouts: Vec> = layout.children().collect(); + let has_middle = self.middle.is_some(); + + // Split the tree children into disjoint mutable slots. + let (lead_tree, rest) = tree.children.split_at_mut(1); + if let Some(ll) = layouts.first() { + if let Some(ov) = self.lead.as_widget_mut().overlay( + &mut lead_tree[0], + *ll, + renderer, + viewport, + translation, + ) { + return Some(ov); + } + } + + if has_middle { + let (mid_tree, trail_tree) = rest.split_at_mut(1); + if let (Some(m), Some(ml)) = (self.middle.as_mut(), layouts.get(1)) { + if let Some(ov) = m.as_widget_mut().overlay( + &mut mid_tree[0], + *ml, + renderer, + viewport, + translation, + ) { + return Some(ov); + } + } + if let Some(tl) = layouts.get(2) { + return self.trail.as_widget_mut().overlay( + &mut trail_tree[0], + *tl, + renderer, + viewport, + translation, + ); + } + } else if let Some(tl) = layouts.get(1) { + return self.trail.as_widget_mut().overlay( + &mut rest[0], + *tl, + renderer, + viewport, + translation, + ); + } + None + } +} + +impl<'a> From> for Element<'a, Message> { + fn from(w: WrapBar<'a>) -> Self { + Element::new(w) + } +} + +/// Flex-wrap row: lays its items left-to-right and wraps onto a new row when +/// the next item would exceed the available width. Each row is `row_h` tall and +/// items are vertically centred. Used for the status-bar pills so they spread +/// across multiple rows when the width can't hold them on one line. +pub struct WrapFlow<'a> { + items: Vec>, + spacing_x: f32, + spacing_y: f32, + row_h: f32, +} + +impl<'a> WrapFlow<'a> { + pub fn new(items: Vec>) -> Self { + Self { + items, + spacing_x: 2.0, + spacing_y: 0.0, + row_h: 28.0, + } + } + + pub fn spacing_x(mut self, s: f32) -> Self { + self.spacing_x = s; + self + } + + pub fn row_h(mut self, h: f32) -> Self { + self.row_h = h; + self + } +} + +impl<'a> Widget for WrapFlow<'a> { + fn children(&self) -> Vec { + self.items.iter().map(widget::Tree::new).collect() + } + + fn diff(&self, tree: &mut widget::Tree) { + let refs: Vec<_> = self.items.iter().map(|e| e.as_widget()).collect(); + tree.diff_children(&refs); + } + + fn size(&self) -> Size { + Size::new(Length::Shrink, Length::Shrink) + } + + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let max_w = limits.max().width; + let natural = + layout::Limits::new(Size::ZERO, Size::new(f32::INFINITY, f32::INFINITY)); + + let mut nodes: Vec = Vec::with_capacity(self.items.len()); + let mut x = 0.0f32; + let mut y = 0.0f32; + let mut used_w = 0.0f32; + + for (item, state) in self.items.iter_mut().zip(tree.children.iter_mut()) { + let node = item.as_widget_mut().layout(state, renderer, &natural); + let sz = node.size(); + if x > 0.0 && x + sz.width > max_w { + x = 0.0; + y += self.row_h + self.spacing_y; + } + let cy = y + ((self.row_h - sz.height) / 2.0).max(0.0); + nodes.push(node.move_to(Point::new(x, cy))); + x += sz.width + self.spacing_x; + used_w = used_w.max(x - self.spacing_x); + } + + let total_h = if self.items.is_empty() { + self.row_h + } else { + y + self.row_h + }; + layout::Node::with_children(Size::new(used_w.max(0.0), total_h), nodes) + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + for ((item, state), child_layout) in self + .items + .iter_mut() + .zip(tree.children.iter_mut()) + .zip(layout.children()) + { + item.as_widget_mut().update( + state, + event, + child_layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + } + + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + let mut interaction = mouse::Interaction::default(); + for ((item, state), child_layout) in self + .items + .iter() + .zip(tree.children.iter()) + .zip(layout.children()) + { + let i = item.as_widget().mouse_interaction( + state, + child_layout, + cursor, + viewport, + renderer, + ); + if i != mouse::Interaction::default() { + interaction = i; + } + } + interaction + } + + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + for ((item, state), child_layout) in self + .items + .iter_mut() + .zip(tree.children.iter_mut()) + .zip(layout.children()) + { + item.as_widget_mut() + .operate(state, child_layout, renderer, operation); + } + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + for ((item, state), child_layout) in self + .items + .iter() + .zip(tree.children.iter()) + .zip(layout.children()) + { + item.as_widget() + .draw(state, renderer, theme, style, child_layout, cursor, viewport); + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + overlay::from_children( + &mut self.items, + tree, + layout, + renderer, + viewport, + translation, + ) + } +} + +impl<'a> From> for Element<'a, Message> { + fn from(w: WrapFlow<'a>) -> Self { + Element::new(w) + } +}