From 56e48b8d0f69ff6859f8aca70f12c3c5873b2b56 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 5 Jul 2026 18:04:55 +0300 Subject: [PATCH] feat(ribbon): collapse overflowing panels to flyout buttons, anchor dropdowns to their widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third stage of the adaptive tool area (shrink -> compact -> collapse). When even the compact panels don't fit, DensitySwap picks a new CollapsePanels widget that keeps a left prefix of panels inline and collapses the rest to buttons; clicking a button opens the full panel as an overlay flyout anchored below it (closes on outside press, mirroring color_select's Floating). A collapsed panel's button shows a live representative tool — the panel's last-used tool (tracked per panel in Ribbon::note_panel_tool on every tool click), defaulting to its first — rendered as a large icon. To support this, render_small now also renders Large* items small and render_large now renders plain Tool/Dropdown items large. Large dropdowns render as icon + label + a bottom arrow strip via a shared render_large_dropdown helper, used in both the full ribbon and collapsed panels. Ribbon dropdowns (tool dropdowns + undo/redo history) now anchor their overlay directly below their widget at any size: a transparent PosReport wrapper records each dropdown button's screen bounds into a thread-local map on draw, and the overlays read it via Ribbon::dd_anchor instead of the layout-estimating compute_*_left helpers. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 2 + src/app/update/dialog.rs | 4 + src/app/update/mod.rs | 4 + src/ui/ribbon/collapse.rs | 394 ++++++++++++++++++++++++++++++++++++ src/ui/ribbon/mod.rs | 414 ++++++++++++++++++++++++++++++-------- src/ui/ribbon/widgets.rs | 243 ++++++++++++---------- src/ui/wrap_bar.rs | 157 ++++++++++++++- 7 files changed, 1024 insertions(+), 194 deletions(-) create mode 100644 src/ui/ribbon/collapse.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 70b00aa05..9e8ec72e0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1403,6 +1403,8 @@ pub enum Message { SnapClearAll, /// Toggle a ribbon dropdown open/closed. ToggleRibbonDropdown(String), + /// Toggle a collapsed ribbon panel's flyout open/closed (by panel title). + ToggleRibbonPanel(String), /// Close any open ribbon dropdown (click-catcher outside the panel). CloseRibbonDropdown, /// User selected a specific item from a ribbon dropdown. diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 23c356533..8574730fe 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -96,6 +96,10 @@ impl OpenCADStudio { pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEvent) -> Task { + // Dismiss any open dropdown / collapsed-panel flyout on tool use, + // and remember this tool as its panel's last-used one. + self.ribbon.close_dropdown(); + self.ribbon.note_panel_tool(&tool_id); self.ribbon.activate_tool(&tool_id); match event { ModuleEvent::Command(cmd) => return self.dispatch_command(&cmd), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index de5bc628b..8579a0e00 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1655,6 +1655,10 @@ impl OpenCADStudio { self.ribbon.toggle_dropdown(&id); Task::none() } + Message::ToggleRibbonPanel(id) => { + self.ribbon.toggle_collapsed_panel(&id); + Task::none() + } Message::CloseRibbonDropdown => { self.ribbon.close_dropdown(); Task::none() diff --git a/src/ui/ribbon/collapse.rs b/src/ui/ribbon/collapse.rs new file mode 100644 index 000000000..6897ed21f --- /dev/null +++ b/src/ui/ribbon/collapse.rs @@ -0,0 +1,394 @@ +//! Collapsing ribbon panels. +//! +//! Given a list of panels (each with an inline element, a collapsed title +//! button, and a full flyout), this widget lays out as many inline panels as +//! fit the width from the left and collapses the rest into their title buttons. +//! Clicking a collapsed button opens that panel's flyout as an overlay anchored +//! just below the button. + +use std::cell::Cell; + +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; + +/// One ribbon panel in its three renderings. +pub struct Panel<'a> { + pub id: String, + pub inline: Element<'a, Message>, + pub button: Element<'a, Message>, + pub flyout: Element<'a, Message>, +} + +pub struct CollapsePanels<'a> { + panels: Vec>, + /// Title of the panel whose flyout is open (if any). + open: Option, + /// Row height (a full ribbon tool-area height). + row_h: f32, + /// Number of panels shown inline from the left; set during layout. + split: Cell, +} + +impl<'a> CollapsePanels<'a> { + pub fn new(panels: Vec>, open: Option, row_h: f32) -> Self { + Self { + panels, + open, + row_h, + split: Cell::new(0), + } + } + + /// Element used inline for panel `i` given the split `k`. + fn shown(&self, i: usize, k: usize) -> &Element<'a, Message> { + if i < k { + &self.panels[i].inline + } else { + &self.panels[i].button + } + } + + fn shown_mut(&mut self, i: usize, k: usize) -> &mut Element<'a, Message> { + if i < k { + &mut self.panels[i].inline + } else { + &mut self.panels[i].button + } + } + + /// Tree index of the element shown inline for panel `i` given split `k`. + fn shown_tree(i: usize, k: usize) -> usize { + // trees are laid out [inline_0, button_0, flyout_0, inline_1, …] + if i < k { + 3 * i + } else { + 3 * i + 1 + } + } +} + +impl<'a> Widget for CollapsePanels<'a> { + fn children(&self) -> Vec { + let mut v = Vec::with_capacity(self.panels.len() * 3); + for p in &self.panels { + v.push(widget::Tree::new(&p.inline)); + v.push(widget::Tree::new(&p.button)); + v.push(widget::Tree::new(&p.flyout)); + } + v + } + + fn diff(&self, tree: &mut widget::Tree) { + let mut refs: Vec<&dyn Widget> = Vec::new(); + for p in &self.panels { + refs.push(p.inline.as_widget()); + refs.push(p.button.as_widget()); + refs.push(p.flyout.as_widget()); + } + 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 n = self.panels.len(); + + // Measure every inline panel and every collapsed button. + let mut inline_w = vec![0.0f32; n]; + let mut button_w = vec![0.0f32; n]; + for i in 0..n { + let inline_node = self.panels[i].inline.as_widget_mut().layout( + &mut tree.children[3 * i], + renderer, + &natural, + ); + let button_node = self.panels[i].button.as_widget_mut().layout( + &mut tree.children[3 * i + 1], + renderer, + &natural, + ); + inline_w[i] = inline_node.size().width; + button_w[i] = button_node.size().width; + } + + // Expand a prefix of panels from the left while the row still fits with + // the remaining panels collapsed to buttons. + let all_buttons: f32 = button_w.iter().sum(); + let mut used = all_buttons; + let mut k = 0; + for i in 0..n { + let candidate = used - button_w[i] + inline_w[i]; + if candidate <= max_w { + used = candidate; + k = i + 1; + } else { + break; + } + } + self.split.set(k); + + // Place the chosen element for each panel left-to-right. + let mut children: Vec = Vec::with_capacity(n); + let mut x = 0.0f32; + for i in 0..n { + let tree_idx = Self::shown_tree(i, k); + let node = self.shown_mut(i, k).as_widget_mut().layout( + &mut tree.children[tree_idx], + renderer, + &natural, + ); + let h = node.size().height; + let y = ((self.row_h - h) / 2.0).max(0.0); + let w = node.size().width; + children.push(node.move_to(Point::new(x, y))); + x += w; + } + + layout::Node::with_children(Size::new(x, self.row_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, + ) { + let k = self.split.get(); + for (i, child_layout) in layout.children().enumerate() { + let tree_idx = Self::shown_tree(i, k); + self.shown_mut(i, k).as_widget_mut().update( + &mut tree.children[tree_idx], + 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 k = self.split.get(); + let mut interaction = mouse::Interaction::default(); + for (i, child_layout) in layout.children().enumerate() { + let tree_idx = Self::shown_tree(i, k); + let it = self.shown(i, k).as_widget().mouse_interaction( + &tree.children[tree_idx], + child_layout, + cursor, + viewport, + renderer, + ); + if it != mouse::Interaction::default() { + interaction = it; + } + } + interaction + } + + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + let k = self.split.get(); + for (i, child_layout) in layout.children().enumerate() { + let tree_idx = Self::shown_tree(i, k); + self.shown_mut(i, k).as_widget_mut().operate( + &mut tree.children[tree_idx], + 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, + ) { + let k = self.split.get(); + for (i, child_layout) in layout.children().enumerate() { + let tree_idx = Self::shown_tree(i, k); + self.shown(i, k).as_widget().draw( + &tree.children[tree_idx], + 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> { + let k = self.split.get(); + let open_id = self.open.clone()?; + let p = self.panels.iter().position(|pan| pan.id == open_id)?; + // Only collapsed panels (index >= k) show a flyout. + if p < k { + return None; + } + + let child_layout = layout.children().nth(p)?; + let b = child_layout.bounds(); + let anchor = Point::new(b.x + translation.x, b.y + b.height + translation.y); + + Some(overlay::Element::new(Box::new(FlyoutOverlay { + flyout: &mut self.panels[p].flyout, + tree: &mut tree.children[3 * p + 2], + anchor, + }))) + } +} + +impl<'a> From> for Element<'a, Message> { + fn from(w: CollapsePanels<'a>) -> Self { + Element::new(w) + } +} + +/// Overlay that renders an open panel's flyout anchored below its button and +/// closes it when the user presses outside. +struct FlyoutOverlay<'a, 'b> { + flyout: &'b mut Element<'a, Message>, + tree: &'b mut widget::Tree, + anchor: Point, +} + +impl overlay::Overlay for FlyoutOverlay<'_, '_> { + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { + let viewport = Rectangle::with_size(bounds); + let limits = layout::Limits::new(Size::ZERO, viewport.size()); + let node = self + .flyout + .as_widget_mut() + .layout(self.tree, renderer, &limits); + let size = node.size(); + let mut x = self.anchor.x; + let mut y = self.anchor.y; + if x + size.width > viewport.width { + x = (viewport.width - size.width).max(0.0); + } + if y + size.height > viewport.height { + y = (self.anchor.y - size.height).max(0.0); + } + layout::Node::with_children(size, vec![node]).translate(Vector::new(x, y)) + } + + fn draw( + &self, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + let child = layout.children().next().unwrap(); + self.flyout.as_widget().draw( + self.tree, + renderer, + theme, + style, + child, + cursor, + &child.bounds(), + ); + } + + fn update( + &mut self, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) { + let child = layout.children().next().unwrap(); + let vp = child.bounds(); + + // Close when a press lands outside the flyout. Capture the event so the + // press doesn't also reach the collapsed button underneath (which would + // immediately re-open the flyout). + if let Event::Mouse(mouse::Event::ButtonPressed(_)) = event { + if !cursor.is_over(vp) { + shell.publish(Message::CloseRibbonDropdown); + shell.capture_event(); + return; + } + } + + self.flyout + .as_widget_mut() + .update(self.tree, event, child, cursor, renderer, clipboard, shell, &vp); + } + + fn operate( + &mut self, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + let child = layout.children().next().unwrap(); + self.flyout + .as_widget_mut() + .operate(self.tree, child, renderer, operation); + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + ) -> mouse::Interaction { + let child = layout.children().next().unwrap(); + self.flyout + .as_widget() + .mouse_interaction(self.tree, child, cursor, &child.bounds(), renderer) + } +} diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 2fe279096..2fd6acb5a 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -22,6 +22,8 @@ use crate::ui::properties::{lw_options, LinetypeItem}; mod widgets; use widgets::{StyleContext, *}; +mod collapse; +use collapse::{CollapsePanels, Panel}; use crate::ui::wrap_bar::{DensitySwap, WrapBar, WrapFlow}; // ── Ribbon state ─────────────────────────────────────────────────────────── @@ -33,6 +35,11 @@ pub struct Ribbon { pub wireframe: bool, pub ortho_mode: bool, pub open_dropdown: Option, + /// Title of the collapsed panel whose flyout is currently open, if any. + pub collapsed_open: Option, + /// Last tool used from each panel (panel title → tool id). A collapsed panel + /// shows this tool on its button, defaulting to the panel's first tool. + last_panel_tool: HashMap<&'static str, &'static str>, last_cmd: HashMap<&'static str, &'static str>, pub layer_names: Vec, pub active_layer: String, @@ -93,6 +100,8 @@ impl Ribbon { wireframe: false, ortho_mode: true, open_dropdown: None, + collapsed_open: None, + last_panel_tool: HashMap::default(), last_cmd: HashMap::default(), // Empty until a document is open — populated by sync_ribbon_layers. layer_names: vec![], @@ -131,6 +140,17 @@ impl Ribbon { f32::from_bits(self.tool_bar_h.load(Ordering::Relaxed)) } + /// (top, left) screen offset at which to anchor the open dropdown `id`'s + /// overlay — just below its widget. Falls back to below the whole ribbon + /// when the widget's position hasn't been recorded yet. + fn dd_anchor(&self, id: &str) -> (f32, f32) { + if let Some(b) = crate::ui::wrap_bar::dropdown_bounds(id) { + (b.y + b.height, b.x) + } else { + (self.tab_bar_height() + self.tool_bar_height(), 0.0) + } + } + pub fn set_styles( &mut self, text: Vec, @@ -209,6 +229,34 @@ impl Ribbon { } pub fn close_dropdown(&mut self) { self.open_dropdown = None; + self.collapsed_open = None; + } + + /// Toggle the flyout of a collapsed ribbon panel (identified by its title). + pub fn toggle_collapsed_panel(&mut self, id: &str) { + if self.collapsed_open.as_deref() == Some(id) { + self.collapsed_open = None; + } else { + self.collapsed_open = Some(id.to_string()); + } + } + + /// Record `tool_id` as the last-used tool of whichever active-module panel + /// contains it, so a collapsed panel shows that tool on its button. + pub fn note_panel_tool(&mut self, tool_id: &str) { + if let Some(module) = self.modules.get(self.active) { + for group in module.ribbon_groups() { + if let Some(id) = group + .tools + .iter() + .filter_map(item_id) + .find(|id| *id == tool_id) + { + self.last_panel_tool.insert(group.title, id); + return; + } + } + } } /// Returns the index of the Layout module in the modules list. @@ -386,9 +434,10 @@ impl Ribbon { }; // Adaptive tool area, widest→narrowest: - // 1. full — full-size panels on one row - // 2. compact — large buttons shrink to icon-only columns - // 3. wrapped — compact panels flex-wrap onto extra rows + // 1. full — full-size panels on one row + // 2. compact — large buttons shrink to icon-only columns + // 3. collapse — panels that still don't fit collapse to title + // buttons that open the full panel as a flyout // DensitySwap shows the widest variant that fits the width. let build = |compact: bool| { build_tool_groups( @@ -419,9 +468,72 @@ impl Ribbon { r.push(w) }) .into(); - let wrapped: Element<'_, Message> = - WrapFlow::new(build(true)).spacing_x(0.0).row_h(TOOL_BAR_H).into(); - DensitySwap::new(vec![full, compact, wrapped]) + + let panels: Vec> = groups + .iter() + .map(|g| Panel { + id: g.title.to_string(), + inline: render_group( + true, + g, + &self.active_tool, + &self.open_dropdown, + &self.last_cmd, + self.wireframe, + self.ortho_mode, + &self.layer_infos, + &self.active_layer, + self.active_color, + &self.active_linetype, + self.active_lineweight, + &style_ctx, + ), + button: collapse_button( + g, + self.last_panel_tool.get(g.title).copied(), + &self.active_tool, + &self.open_dropdown, + &self.last_cmd, + self.wireframe, + self.ortho_mode, + &self.layer_infos, + &self.active_layer, + self.active_color, + &self.active_linetype, + self.active_lineweight, + &style_ctx, + ), + flyout: container(render_group( + false, + g, + &self.active_tool, + &self.open_dropdown, + &self.last_cmd, + self.wireframe, + self.ortho_mode, + &self.layer_infos, + &self.active_layer, + self.active_color, + &self.active_linetype, + self.active_lineweight, + &style_ctx, + )) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(RIBBON_BG)), + border: Border { + color: BORDER_DARK, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .into(), + }) + .collect(); + let collapse: Element<'_, Message> = + CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H).into(); + + DensitySwap::new(vec![full, compact, collapse]) .report_height(self.tool_bar_h.clone()) .into() } else { @@ -495,12 +607,13 @@ impl Ribbon { }) .width(Length::Fixed(170.0)); + let (top, left) = self.dd_anchor(open_id); let positioned = container(panel) .align_left(Fill) .align_top(Fill) .padding(Padding { - top: 28.0, - left: compute_history_dropdown_left(open_id), + top, + left, ..Default::default() }) .width(Fill) @@ -619,8 +732,7 @@ impl Ribbon { }) .width(Length::Fixed(190.0)); - let top_offset = self.tab_bar_height() + self.tool_bar_height(); - let left_offset = compute_dropdown_left(&groups, open_id); + let (top_offset, left_offset) = self.dd_anchor(open_id); let positioned = container(panel) .align_left(Fill) .align_top(Fill) @@ -1113,85 +1225,223 @@ fn build_tool_groups<'a>( for group in groups { if !first_group { - widgets.push( - container(text("")) - .width(1) - .height(Length::Fixed(TOOL_BAR_H)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER_DARK)), - ..Default::default() - }) - .into(), - ); + widgets.push(tool_divider()); } first_group = false; - - let mut items_row: Vec> = Vec::new(); - let mut small_buf: Vec> = Vec::new(); - - for item in &group.tools { - let is_large = match item { - RibbonItem::LargeTool(_) | RibbonItem::LargeDropdown { .. } => !compact, - RibbonItem::LayerComboGroup { .. } - | RibbonItem::PropertiesGroup { .. } - | RibbonItem::StyleComboGroup { .. } => true, - _ => false, - }; - - if is_large { - flush_small_col(&mut small_buf, &mut items_row); - items_row.push(render_large( - item, - active_tool, - open_dd, - last_cmd, - wireframe, - ortho_mode, - layer_infos, - active_layer, - active_color, - active_linetype, - active_lineweight, - style_ctx, - )); - } else { - small_buf.push(render_small( - item, - active_tool, - open_dd, - last_cmd, - wireframe, - ortho_mode, - )); - if small_buf.len() == 3 { - flush_small_col(&mut small_buf, &mut items_row); - } - } - } - flush_small_col(&mut small_buf, &mut items_row); - - let tools_el = items_row - .into_iter() - .fold(row![].spacing(2).height(Fill).align_y(iced::Top), |r, e| { - r.push(e) - }); - - widgets.push( - column![ - tools_el, - container(text(group.title).size(9).color(GROUP_LABEL)).padding([1, 4]), - ] - .align_x(iced::Center) - .spacing(0) - .padding([3u16, 4]) - .height(Length::Fixed(TOOL_BAR_H)) - .into(), - ); + widgets.push(render_group( + compact, + group, + active_tool, + open_dd, + last_cmd, + wireframe, + ortho_mode, + layer_infos, + active_layer, + active_color, + active_linetype, + active_lineweight, + style_ctx, + )); } widgets } +/// A 1px vertical divider between ribbon panels, full tool-area height. +fn tool_divider<'a>() -> Element<'a, Message> { + container(text("")) + .width(1) + .height(Length::Fixed(TOOL_BAR_H)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(BORDER_DARK)), + ..Default::default() + }) + .into() +} + +/// Render a single ribbon panel (tools + group label), fixed `TOOL_BAR_H` tall. +/// When `compact`, large tools/dropdowns are drawn as small icon columns. +#[allow(clippy::too_many_arguments)] +fn render_group<'a>( + compact: bool, + group: &RibbonGroup, + active_tool: &Option, + open_dd: &Option, + last_cmd: &HashMap<&'static str, &'static str>, + wireframe: bool, + ortho_mode: bool, + layer_infos: &'a [LayerInfo], + active_layer: &'a str, + active_color: AcadColor, + active_linetype: &'a str, + active_lineweight: LineWeight, + style_ctx: &StyleContext, +) -> Element<'a, Message> { + let mut items_row: Vec> = Vec::new(); + let mut small_buf: Vec> = Vec::new(); + + for item in &group.tools { + let is_large = match item { + RibbonItem::LargeTool(_) | RibbonItem::LargeDropdown { .. } => !compact, + RibbonItem::LayerComboGroup { .. } + | RibbonItem::PropertiesGroup { .. } + | RibbonItem::StyleComboGroup { .. } => true, + _ => false, + }; + + if is_large { + flush_small_col(&mut small_buf, &mut items_row); + items_row.push(render_large( + item, + active_tool, + open_dd, + last_cmd, + wireframe, + ortho_mode, + layer_infos, + active_layer, + active_color, + active_linetype, + active_lineweight, + style_ctx, + )); + } else { + small_buf.push(render_small( + item, + active_tool, + open_dd, + last_cmd, + wireframe, + ortho_mode, + )); + if small_buf.len() == 3 { + flush_small_col(&mut small_buf, &mut items_row); + } + } + } + flush_small_col(&mut small_buf, &mut items_row); + + let tools_el = items_row + .into_iter() + .fold(row![].spacing(2).height(Fill).align_y(iced::Top), |r, e| { + r.push(e) + }); + + column![ + tools_el, + container(text(group.title).size(9).color(GROUP_LABEL)).padding([1, 4]), + ] + .align_x(iced::Center) + .spacing(0) + .padding([3u16, 4]) + .height(Length::Fixed(TOOL_BAR_H)) + .into() +} + +/// The top-level command id of a ribbon item, if it has one. +fn item_id(it: &RibbonItem) -> Option<&'static str> { + match it { + RibbonItem::Tool(t) | RibbonItem::LargeTool(t) => Some(t.id), + RibbonItem::Dropdown { id, .. } | RibbonItem::LargeDropdown { id, .. } => Some(*id), + _ => None, + } +} + +/// The tool a collapsed panel shows on its button: the last-used one, else the +/// panel's first tool-like item. +fn representative<'g>(group: &'g RibbonGroup, last_used: Option<&str>) -> Option<&'g RibbonItem> { + if let Some(want) = last_used { + if let Some(found) = group + .tools + .iter() + .find(|&it| item_id(it).map_or(false, |id| id == want)) + { + return Some(found); + } + } + group.tools.iter().find(|&it| item_id(it).is_some()) +} + +/// A collapsed panel: its representative tool (a live button that updates to the +/// last-used tool) plus a title + ▾ opener for the full flyout. Fixed +/// `TOOL_BAR_H` tall so it lines up with inline panels. +#[allow(clippy::too_many_arguments)] +fn collapse_button<'a>( + group: &RibbonGroup, + last_used: Option<&str>, + active_tool: &Option, + open_dd: &Option, + last_cmd: &HashMap<&'static str, &'static str>, + wireframe: bool, + ortho_mode: bool, + layer_infos: &'a [LayerInfo], + active_layer: &'a str, + active_color: AcadColor, + active_linetype: &'a str, + active_lineweight: LineWeight, + style_ctx: &StyleContext, +) -> Element<'a, Message> { + let title = group.title; + + // Show the representative tool with a large icon (Tool/Dropdown alike — + // render_large handles every tool-like item). + let face: Element<'_, Message> = match representative(group, last_used) { + Some(item) => render_large( + item, + active_tool, + open_dd, + last_cmd, + wireframe, + ortho_mode, + layer_infos, + active_layer, + active_color, + active_linetype, + active_lineweight, + style_ctx, + ), + None => text("").into(), + }; + + let opener = button( + row![ + text(title.to_string()).size(9).color(GROUP_LABEL), + crate::ui::icons::arrow_down(8.0, GROUP_LABEL), + ] + .spacing(3) + .align_y(iced::Center), + ) + .on_press(Message::ToggleRibbonPanel(title.to_string())) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered => Color { + r: 0.25, + g: 0.25, + b: 0.25, + a: 1.0, + }, + _ => Color::TRANSPARENT, + })), + border: Border { + radius: 2.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .padding([1, 4]); + + column![ + container(face).height(Fill).align_y(iced::Center), + opener, + ] + .align_x(iced::Center) + .spacing(2) + .padding([3u16, 4]) + .height(Length::Fixed(TOOL_BAR_H)) + .into() +} + impl Default for Ribbon { fn default() -> Self { Self::new() diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 61b9b3ba9..e00abe014 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -13,6 +13,7 @@ use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; use crate::app::Message; use crate::modules::{IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef}; +use crate::ui::wrap_bar::PosReport; use crate::ui::icons; use crate::ui::properties::{acad_color_display, LwItem}; @@ -453,10 +454,11 @@ pub(super) fn render_small<'a>( .delay(Duration::from_millis(400)) .style(tip_style); - row![icon_with_tip, arr_with_tip] - .spacing(0) - .height(ROW_H) - .into() + PosReport::new( + *id, + row![icon_with_tip, arr_with_tip].spacing(0).height(ROW_H), + ) + .into() } _ => text("").into(), @@ -465,6 +467,109 @@ pub(super) fn render_small<'a>( // ── Large item renderer ──────────────────────────────────────────────────── +/// A large dropdown button: the current icon on top, its ▾ directly beneath the +/// icon, then the label at the bottom. Shared by `LargeDropdown` / `Dropdown` in +/// the full ribbon and by a collapsed panel whose representative is a dropdown. +/// `explicit_label` overrides the derived (current-item) label when given. +#[allow(clippy::too_many_arguments)] +pub(super) fn render_large_dropdown<'a>( + id: &'static str, + icon: IconKind, + explicit_label: Option<&str>, + items: &[(&'static str, &'static str, IconKind)], + default: &'static str, + active_tool: &Option, + open_dd: &Option, + last_cmd: &HashMap<&'static str, &'static str>, +) -> Element<'a, Message> { + let active = active_tool.as_deref() == Some(id) + || items + .iter() + .any(|(cmd, _, _)| active_tool.as_deref() == Some(*cmd)); + let dd_open = open_dd.as_deref() == Some(id); + let last = last_cmd.get(id).copied().unwrap_or(default); + let cur_icon = last_cmd + .get(id) + .copied() + .and_then(|cmd| items.iter().find(|(c, _, _)| *c == cmd).map(|(_, _, ik)| *ik)) + .or_else(|| items.first().map(|(_, _, ik)| *ik)) + .unwrap_or(icon); + let cur_label = last_cmd + .get(id) + .copied() + .and_then(|cmd| items.iter().find(|(c, _, _)| *c == cmd).map(|(_, lbl, _)| *lbl)) + .or_else(|| items.first().map(|(_, lbl, _)| *lbl)) + .unwrap_or(id); + let label = explicit_label.unwrap_or(cur_label); + let tip_text = format!("{}\nCommand: {}", cur_label, last); + let arr_tip = format!("{} options", label); + + // Icon on top with the label beneath it, then the ▾ strip at the very bottom. + let top_btn = button( + column![ + make_icon(cur_icon, LARGE_ICON), + text(label.to_string()).size(10).color(LABEL_COLOR), + ] + .align_x(iced::Center) + .spacing(3), + ) + .on_press(Message::RibbonToolClick { + tool_id: last.to_string(), + event: ModuleEvent::Command(last.to_string()), + }) + .style(move |_: &Theme, status| tool_btn_style(active, status)) + .width(Length::Fixed(LARGE_W)) + .height(Fill) + .padding(Padding { + top: 6.0, + right: 4.0, + bottom: 2.0, + left: 4.0, + }); + + let arr_btn = button( + container(icons::arrow_down(9.0, ARROW_COLOR)) + .width(Fill) + .height(Fill) + .align_x(iced::Center) + .align_y(iced::Center), + ) + .on_press(Message::ToggleRibbonDropdown(id.to_string())) + .style(move |_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => TOOL_HOVER, + _ if dd_open => TOOL_ACTIVE, + _ => Color::TRANSPARENT, + })), + border: Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .width(Length::Fixed(LARGE_W)) + .height(LARGE_ARR) + .padding(0); + + let top_with_tip = tooltip(top_btn, make_tip(tip_text), TipPos::Right) + .gap(6.0) + .delay(Duration::from_millis(400)) + .style(tip_style); + let arr_with_tip = tooltip(arr_btn, make_tip(arr_tip), TipPos::Right) + .gap(6.0) + .delay(Duration::from_millis(400)) + .style(tip_style); + + PosReport::new( + id, + column![top_with_tip, arr_with_tip] + .spacing(0) + .width(Length::Fixed(LARGE_W)) + .height(Fill), + ) + .into() +} + /// Render a full-height large button (LargeTool, LargeDropdown, LayerCombo, StyleCombo). pub(super) fn render_large<'a>( item: &RibbonItem, @@ -481,7 +586,9 @@ pub(super) fn render_large<'a>( style_ctx: &StyleContext, ) -> Element<'a, Message> { match item { - RibbonItem::LargeTool(t) => { + // A plain Tool renders large too, so a collapsed panel can show its + // representative tool as a big icon. + RibbonItem::LargeTool(t) | RibbonItem::Tool(t) => { let active = is_active_tool(t.id, active_tool, wireframe, ortho_mode); let event = t.event.clone(); let tool_id = t.id.to_string(); @@ -517,100 +624,27 @@ pub(super) fn render_large<'a>( icon, items, default, - } => { - let active = active_tool.as_deref() == Some(*id) - || items - .iter() - .any(|(cmd, _, _)| active_tool.as_deref() == Some(*cmd)); - let dd_open = open_dd.as_deref() == Some(*id); - let last = last_cmd.get(id).copied().unwrap_or(*default); - let cur_icon = last_cmd - .get(id) - .copied() - .and_then(|cmd| { - items - .iter() - .find(|(c, _, _)| *c == cmd) - .map(|(_, _, ik)| *ik) - }) - .or_else(|| items.first().map(|(_, _, ik)| *ik)) - .unwrap_or(*icon); + } => render_large_dropdown( + *id, + *icon, + Some(*label), + items, + *default, + active_tool, + open_dd, + last_cmd, + ), - let cur_label = last_cmd - .get(id) - .copied() - .and_then(|cmd| { - items - .iter() - .find(|(c, _, _)| *c == cmd) - .map(|(_, lbl, _)| *lbl) - }) - .or_else(|| items.first().map(|(_, lbl, _)| *lbl)) - .unwrap_or(*label); - let tip_text = format!("{}\nCommand: {}", cur_label, last); - let arr_tip = format!("{} options", label); - - let top_btn = button( - column![ - make_icon(cur_icon, LARGE_ICON), - text(*label).size(10).color(LABEL_COLOR), - ] - .align_x(iced::Center) - .spacing(3), - ) - .on_press(Message::RibbonToolClick { - tool_id: last.to_string(), - event: ModuleEvent::Command(last.to_string()), - }) - .style(move |_: &Theme, status| tool_btn_style(active, status)) - .width(Length::Fixed(LARGE_W)) - .height(Fill) - .padding(Padding { - top: 6.0, - right: 4.0, - bottom: 2.0, - left: 4.0, - }); - - let arr_btn = button( - container(icons::arrow_down(9.0, ARROW_COLOR)) - .width(Fill) - .height(Fill) - .align_x(iced::Center) - .align_y(iced::Center), - ) - .on_press(Message::ToggleRibbonDropdown(id.to_string())) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => TOOL_HOVER, - _ if dd_open => TOOL_ACTIVE, - _ => Color::TRANSPARENT, - })), - border: Border { - radius: 3.0.into(), - ..Default::default() - }, - ..Default::default() - }) - .width(Length::Fixed(LARGE_W)) - .height(LARGE_ARR) - .padding(0); - - let top_with_tip = tooltip(top_btn, make_tip(tip_text), TipPos::Right) - .gap(6.0) - .delay(Duration::from_millis(400)) - .style(tip_style); - let arr_with_tip = tooltip(arr_btn, make_tip(arr_tip), TipPos::Right) - .gap(6.0) - .delay(Duration::from_millis(400)) - .style(tip_style); - - column![top_with_tip, arr_with_tip] - .spacing(0) - .width(Length::Fixed(LARGE_W)) - .height(Fill) - .into() - } + // A plain Dropdown renders large too (used by a collapsed panel whose + // representative tool is a dropdown). + RibbonItem::Dropdown { + id, + icon, + items, + default, + } => render_large_dropdown( + *id, *icon, None, items, *default, active_tool, open_dd, last_cmd, + ), RibbonItem::LayerComboGroup { row2, row3 } => { const COMBO_W: f32 = LARGE_W * 2.5; @@ -953,8 +987,6 @@ pub(super) fn render_large<'a>( }) .into() } - - _ => text("").into(), } } @@ -1059,17 +1091,6 @@ pub(super) fn compute_prop_combo_left(groups: &[RibbonGroup], _dd_id: &str) -> f base + LARGE_W + 4.0 } -pub(super) fn compute_history_dropdown_left(open_id: &str) -> f32 { - let logo_w = 38.0; - let leading_gap = 6.0; - let ctrl_w = TOP_HIST_W + TOP_ARR_W; - - match open_id { - UNDO_HISTORY_ID => logo_w + leading_gap, - REDO_HISTORY_ID => logo_w + leading_gap + ctrl_w + TOP_HIST_GAP, - _ => logo_w + leading_gap, - } -} // ── Message helpers ──────────────────────────────────────────────────────── diff --git a/src/ui/wrap_bar.rs b/src/ui/wrap_bar.rs index 1134f6fea..f8b791474 100644 --- a/src/ui/wrap_bar.rs +++ b/src/ui/wrap_bar.rs @@ -14,10 +14,12 @@ //! The measured total height is written to `height_out` (if set) so callers can //! anchor overlays below the possibly-taller bar. +use std::cell::{Cell, RefCell}; use std::sync::atomic::{AtomicU32, Ordering}; -use std::cell::Cell; use std::sync::Arc; +use rustc_hash::FxHashMap; + use iced::advanced::layout::{self, Layout}; use iced::advanced::widget::{self, Widget}; use iced::advanced::{mouse, overlay, renderer, Clipboard, Shell}; @@ -25,6 +27,19 @@ use iced::{Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vect use crate::app::Message; +thread_local! { + /// Screen bounds of every ribbon dropdown button, keyed by dropdown id, so + /// an open dropdown's overlay can anchor directly below its widget at any + /// size. Written by `PosReport` on draw, read by `dropdown_bounds`. + static DD_BOUNDS: RefCell> = + RefCell::new(FxHashMap::default()); +} + +/// Last-drawn screen bounds of the dropdown button with this id. +pub fn dropdown_bounds(id: &str) -> Option { + DD_BOUNDS.with(|m| m.borrow().get(id).copied()) +} + pub struct WrapBar<'a> { lead: Element<'a, Message>, /// Optional Fill filler occupying the gap between `lead` and `trail`. @@ -802,3 +817,143 @@ impl<'a> From> for Element<'a, Message> { Element::new(w) } } + +/// A transparent wrapper that records its child's screen bounds under `id` on +/// every draw, so an open dropdown can anchor its overlay just below the widget. +pub struct PosReport<'a> { + id: &'static str, + child: Element<'a, Message>, +} + +impl<'a> PosReport<'a> { + pub fn new(id: &'static str, child: impl Into>) -> Self { + Self { + id, + child: child.into(), + } + } +} + +impl<'a> Widget for PosReport<'a> { + fn children(&self) -> Vec { + vec![widget::Tree::new(&self.child)] + } + + fn diff(&self, tree: &mut widget::Tree) { + tree.diff_children(&[self.child.as_widget()]); + } + + fn size(&self) -> Size { + self.child.as_widget().size() + } + + fn size_hint(&self) -> Size { + self.child.as_widget().size_hint() + } + + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.child + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + + 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, + ) { + self.child.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.child + .as_widget() + .mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer) + } + + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.child + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + DD_BOUNDS.with(|m| { + m.borrow_mut().insert(self.id, layout.bounds()); + }); + self.child.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.child.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} + +impl<'a> From> for Element<'a, Message> { + fn from(w: PosReport<'a>) -> Self { + Element::new(w) + } +}