feat(ribbon): collapse overflowing panels to flyout buttons, anchor dropdowns to their widget

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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-05 18:04:55 +03:00
1 parent ff36a1bdac
commit 56e48b8d0f
7 files changed
+1024 -194

No files matched your search

+2
View File
@@ -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.
+4
View File
@@ -96,6 +96,10 @@ impl OpenCADStudio {
pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEvent) -> Task<Message> {
// 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),
+4
View File
@@ -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()
+394
View File
@@ -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<Panel<'a>>,
/// Title of the panel whose flyout is open (if any).
open: Option<String>,
/// Row height (a full ribbon tool-area height).
row_h: f32,
/// Number of panels shown inline from the left; set during layout.
split: Cell<usize>,
}
impl<'a> CollapsePanels<'a> {
pub fn new(panels: Vec<Panel<'a>>, open: Option<String>, 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<Message, Theme, Renderer> for CollapsePanels<'a> {
fn children(&self) -> Vec<widget::Tree> {
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<Message, Theme, Renderer>> = 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<Length> {
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<layout::Node> = 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<overlay::Element<'b, Message, Theme, Renderer>> {
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<CollapsePanels<'a>> 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<Message, Theme, Renderer> 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)
}
}
+332 -82
View File
@@ -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<String>,
/// Title of the collapsed panel whose flyout is currently open, if any.
pub collapsed_open: Option<String>,
/// 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<String>,
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<String>,
@@ -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<Panel<'_>> = 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<Element<Message>> = Vec::new();
let mut small_buf: Vec<Element<Message>> = 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<String>,
open_dd: &Option<String>,
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<Element<Message>> = Vec::new();
let mut small_buf: Vec<Element<Message>> = 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<String>,
open_dd: &Option<String>,
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()
+132 -111
View File
@@ -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<String>,
open_dd: &Option<String>,
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 ────────────────────────────────────────────────────────
+156 -1
View File
@@ -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<FxHashMap<&'static str, Rectangle>> =
RefCell::new(FxHashMap::default());
}
/// Last-drawn screen bounds of the dropdown button with this id.
pub fn dropdown_bounds(id: &str) -> Option<Rectangle> {
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<DensitySwap<'a>> 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<Element<'a, Message>>) -> Self {
Self {
id,
child: child.into(),
}
}
}
impl<'a> Widget<Message, Theme, Renderer> for PosReport<'a> {
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.child)]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(&[self.child.as_widget()]);
}
fn size(&self) -> Size<Length> {
self.child.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
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<overlay::Element<'b, Message, Theme, Renderer>> {
self.child.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a> From<PosReport<'a>> for Element<'a, Message> {
fn from(w: PosReport<'a>) -> Self {
Element::new(w)
}
}