feat(ribbon): degrade tool panels per-panel from the right instead of all at once

The tool area no longer swaps every panel between a full and a compact
row together. CollapsePanels is now the whole tool area: it measures each
panel at full, compact and collapsed widths and degrades from the right —
first shrinking a panel's large buttons to compact icon columns one panel
at a time until the row fits, then (only if all-compact still overflows)
collapsing the rightmost panels to their ▾ flyout buttons. Left panels
stay full as long as possible.

Removes the global DensitySwap full/compact/collapse variant selection and
the now-unused build_tool_groups / tool_divider helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-05 19:04:47 +03:00
commit 5164dc1387
2 changed files with 123 additions and 189 deletions

View file

@ -1,12 +1,11 @@
//! Collapsing ribbon panels. //! Adaptive ribbon panels.
//! //!
//! Given a list of panels (each with an inline element, a collapsed title //! Lays the active module's panels on one row. When they don't all fit, panels
//! button, and a full flyout), this widget lays out as many inline panels as //! degrade **from the right**: first a panel's large buttons shrink to compact
//! fit the width from the left and collapses the rest into their title buttons. //! icon columns, then — if it still doesn't fit — the panel collapses to a title
//! Clicking a collapsed button opens that panel's flyout as an overlay anchored //! button whose click opens the full panel as a flyout overlay.
//! just below the button.
use std::cell::Cell; use std::cell::RefCell;
use iced::advanced::layout::{self, Layout}; use iced::advanced::layout::{self, Layout};
use iced::advanced::widget::{self, Widget}; use iced::advanced::widget::{self, Widget};
@ -15,67 +14,71 @@ use iced::{Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vect
use crate::app::Message; use crate::app::Message;
/// One ribbon panel in its three renderings. /// One panel in its four renderings.
pub struct Panel<'a> { pub struct Panel<'a> {
pub id: String, pub id: String,
pub inline: Element<'a, Message>, pub full: Element<'a, Message>,
pub compact: Element<'a, Message>,
pub button: Element<'a, Message>, pub button: Element<'a, Message>,
pub flyout: Element<'a, Message>, pub flyout: Element<'a, Message>,
} }
// Per-panel degradation level; also the offset of the shown element within the
// panel's 4 trees ([full, compact, button, flyout]).
const FULL: u8 = 0;
const COMPACT: u8 = 1;
const COLLAPSED: u8 = 2;
pub struct CollapsePanels<'a> { pub struct CollapsePanels<'a> {
panels: Vec<Panel<'a>>, panels: Vec<Panel<'a>>,
/// Title of the panel whose flyout is open (if any). /// Title of the panel whose flyout is open (if any).
open: Option<String>, open: Option<String>,
/// Row height (a full ribbon tool-area height). /// Row height (a full ribbon tool-area height).
row_h: f32, row_h: f32,
/// Number of panels shown inline from the left; set during layout. /// Chosen degradation level per panel; set during layout.
split: Cell<usize>, levels: RefCell<Vec<u8>>,
} }
impl<'a> CollapsePanels<'a> { impl<'a> CollapsePanels<'a> {
pub fn new(panels: Vec<Panel<'a>>, open: Option<String>, row_h: f32) -> Self { pub fn new(panels: Vec<Panel<'a>>, open: Option<String>, row_h: f32) -> Self {
let n = panels.len();
Self { Self {
panels, panels,
open, open,
row_h, row_h,
split: Cell::new(0), levels: RefCell::new(vec![FULL; n]),
} }
} }
/// Element used inline for panel `i` given the split `k`. fn shown(&self, i: usize, level: u8) -> &Element<'a, Message> {
fn shown(&self, i: usize, k: usize) -> &Element<'a, Message> { match level {
if i < k { FULL => &self.panels[i].full,
&self.panels[i].inline COMPACT => &self.panels[i].compact,
} else { _ => &self.panels[i].button,
&self.panels[i].button
} }
} }
fn shown_mut(&mut self, i: usize, k: usize) -> &mut Element<'a, Message> { fn shown_mut(&mut self, i: usize, level: u8) -> &mut Element<'a, Message> {
if i < k { match level {
&mut self.panels[i].inline FULL => &mut self.panels[i].full,
} else { COMPACT => &mut self.panels[i].compact,
&mut self.panels[i].button _ => &mut self.panels[i].button,
} }
} }
/// Tree index of the element shown inline for panel `i` given split `k`. fn levels_snapshot(&self, n: usize) -> Vec<u8> {
fn shown_tree(i: usize, k: usize) -> usize { let mut v = self.levels.borrow().clone();
// trees are laid out [inline_0, button_0, flyout_0, inline_1, …] v.resize(n, FULL);
if i < k { v
3 * i
} else {
3 * i + 1
}
} }
} }
impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> { impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> {
fn children(&self) -> Vec<widget::Tree> { fn children(&self) -> Vec<widget::Tree> {
let mut v = Vec::with_capacity(self.panels.len() * 3); let mut v = Vec::with_capacity(self.panels.len() * 4);
for p in &self.panels { for p in &self.panels {
v.push(widget::Tree::new(&p.inline)); v.push(widget::Tree::new(&p.full));
v.push(widget::Tree::new(&p.compact));
v.push(widget::Tree::new(&p.button)); v.push(widget::Tree::new(&p.button));
v.push(widget::Tree::new(&p.flyout)); v.push(widget::Tree::new(&p.flyout));
} }
@ -85,7 +88,8 @@ impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> {
fn diff(&self, tree: &mut widget::Tree) { fn diff(&self, tree: &mut widget::Tree) {
let mut refs: Vec<&dyn Widget<Message, Theme, Renderer>> = Vec::new(); let mut refs: Vec<&dyn Widget<Message, Theme, Renderer>> = Vec::new();
for p in &self.panels { for p in &self.panels {
refs.push(p.inline.as_widget()); refs.push(p.full.as_widget());
refs.push(p.compact.as_widget());
refs.push(p.button.as_widget()); refs.push(p.button.as_widget());
refs.push(p.flyout.as_widget()); refs.push(p.flyout.as_widget());
} }
@ -103,57 +107,69 @@ impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> {
limits: &layout::Limits, limits: &layout::Limits,
) -> layout::Node { ) -> layout::Node {
let max_w = limits.max().width; let max_w = limits.max().width;
let natural = let natural = layout::Limits::new(Size::ZERO, Size::new(f32::INFINITY, f32::INFINITY));
layout::Limits::new(Size::ZERO, Size::new(f32::INFINITY, f32::INFINITY));
let n = self.panels.len(); let n = self.panels.len();
// Measure every inline panel and every collapsed button. // Measure each panel at all three densities.
let mut inline_w = vec![0.0f32; n]; let mut full_w = vec![0.0f32; n];
let mut compact_w = vec![0.0f32; n];
let mut button_w = vec![0.0f32; n]; let mut button_w = vec![0.0f32; n];
for i in 0..n { for i in 0..n {
let inline_node = self.panels[i].inline.as_widget_mut().layout( full_w[i] = self.panels[i].full.as_widget_mut()
&mut tree.children[3 * i], .layout(&mut tree.children[4 * i], renderer, &natural)
renderer, .size()
&natural, .width;
); compact_w[i] = self.panels[i].compact.as_widget_mut()
let button_node = self.panels[i].button.as_widget_mut().layout( .layout(&mut tree.children[4 * i + 1], renderer, &natural)
&mut tree.children[3 * i + 1], .size()
renderer, .width;
&natural, button_w[i] = self.panels[i].button.as_widget_mut()
); .layout(&mut tree.children[4 * i + 2], renderer, &natural)
inline_w[i] = inline_node.size().width; .size()
button_w[i] = button_node.size().width; .width;
} }
// Expand a prefix of panels from the left while the row still fits with let width_of = |lv: u8, i: usize| -> f32 {
// the remaining panels collapsed to buttons. match lv {
let all_buttons: f32 = button_w.iter().sum(); FULL => full_w[i],
let mut used = all_buttons; COMPACT => compact_w[i],
let mut k = 0; _ => button_w[i],
for i in 0..n { }
let candidate = used - button_w[i] + inline_w[i]; };
if candidate <= max_w { let total = |levels: &[u8]| -> f32 { (0..n).map(|i| width_of(levels[i], i)).sum() };
used = candidate;
k = i + 1; // Start all full; degrade from the RIGHT. Phase 1 shrinks large panels
} else { // to compact one at a time; phase 2 (only if compact still overflows)
// collapses them to buttons.
let mut levels = vec![FULL; n];
for i in (0..n).rev() {
if total(&levels) <= max_w {
break; break;
} }
levels[i] = COMPACT;
} }
self.split.set(k); for i in (0..n).rev() {
if total(&levels) <= max_w {
break;
}
levels[i] = COLLAPSED;
}
*self.levels.borrow_mut() = levels.clone();
// Place the chosen element for each panel left-to-right. // Place the chosen element for each panel left-to-right.
let mut children: Vec<layout::Node> = Vec::with_capacity(n); let mut children: Vec<layout::Node> = Vec::with_capacity(n);
let mut x = 0.0f32; let mut x = 0.0f32;
for i in 0..n { for i in 0..n {
let tree_idx = Self::shown_tree(i, k); let level = levels[i];
let node = self.shown_mut(i, k).as_widget_mut().layout( let tree_idx = 4 * i + level as usize;
let node = self.shown_mut(i, level).as_widget_mut().layout(
&mut tree.children[tree_idx], &mut tree.children[tree_idx],
renderer, renderer,
&natural, &natural,
); );
let h = node.size().height; let h = node.size().height;
let y = ((self.row_h - h) / 2.0).max(0.0);
let w = node.size().width; let w = node.size().width;
let y = ((self.row_h - h) / 2.0).max(0.0);
children.push(node.move_to(Point::new(x, y))); children.push(node.move_to(Point::new(x, y)));
x += w; x += w;
} }
@ -172,10 +188,11 @@ impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> {
shell: &mut Shell<'_, Message>, shell: &mut Shell<'_, Message>,
viewport: &Rectangle, viewport: &Rectangle,
) { ) {
let k = self.split.get(); let levels = self.levels_snapshot(self.panels.len());
for (i, child_layout) in layout.children().enumerate() { for (i, child_layout) in layout.children().enumerate() {
let tree_idx = Self::shown_tree(i, k); let level = levels[i];
self.shown_mut(i, k).as_widget_mut().update( let tree_idx = 4 * i + level as usize;
self.shown_mut(i, level).as_widget_mut().update(
&mut tree.children[tree_idx], &mut tree.children[tree_idx],
event, event,
child_layout, child_layout,
@ -196,11 +213,12 @@ impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> {
viewport: &Rectangle, viewport: &Rectangle,
renderer: &Renderer, renderer: &Renderer,
) -> mouse::Interaction { ) -> mouse::Interaction {
let k = self.split.get(); let levels = self.levels_snapshot(self.panels.len());
let mut interaction = mouse::Interaction::default(); let mut interaction = mouse::Interaction::default();
for (i, child_layout) in layout.children().enumerate() { for (i, child_layout) in layout.children().enumerate() {
let tree_idx = Self::shown_tree(i, k); let level = levels[i];
let it = self.shown(i, k).as_widget().mouse_interaction( let tree_idx = 4 * i + level as usize;
let it = self.shown(i, level).as_widget().mouse_interaction(
&tree.children[tree_idx], &tree.children[tree_idx],
child_layout, child_layout,
cursor, cursor,
@ -221,10 +239,11 @@ impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> {
renderer: &Renderer, renderer: &Renderer,
operation: &mut dyn widget::Operation, operation: &mut dyn widget::Operation,
) { ) {
let k = self.split.get(); let levels = self.levels_snapshot(self.panels.len());
for (i, child_layout) in layout.children().enumerate() { for (i, child_layout) in layout.children().enumerate() {
let tree_idx = Self::shown_tree(i, k); let level = levels[i];
self.shown_mut(i, k).as_widget_mut().operate( let tree_idx = 4 * i + level as usize;
self.shown_mut(i, level).as_widget_mut().operate(
&mut tree.children[tree_idx], &mut tree.children[tree_idx],
child_layout, child_layout,
renderer, renderer,
@ -243,10 +262,11 @@ impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> {
cursor: mouse::Cursor, cursor: mouse::Cursor,
viewport: &Rectangle, viewport: &Rectangle,
) { ) {
let k = self.split.get(); let levels = self.levels_snapshot(self.panels.len());
for (i, child_layout) in layout.children().enumerate() { for (i, child_layout) in layout.children().enumerate() {
let tree_idx = Self::shown_tree(i, k); let level = levels[i];
self.shown(i, k).as_widget().draw( let tree_idx = 4 * i + level as usize;
self.shown(i, level).as_widget().draw(
&tree.children[tree_idx], &tree.children[tree_idx],
renderer, renderer,
theme, theme,
@ -266,11 +286,11 @@ impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> {
_viewport: &Rectangle, _viewport: &Rectangle,
translation: Vector, translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> { ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
let k = self.split.get(); let levels = self.levels_snapshot(self.panels.len());
let open_id = self.open.clone()?; let open_id = self.open.clone()?;
let p = self.panels.iter().position(|pan| pan.id == open_id)?; let p = self.panels.iter().position(|pan| pan.id == open_id)?;
// Only collapsed panels (index >= k) show a flyout. // Only a collapsed panel shows a flyout.
if p < k { if levels.get(p).copied().unwrap_or(FULL) != COLLAPSED {
return None; return None;
} }
@ -280,7 +300,7 @@ impl<'a> Widget<Message, Theme, Renderer> for CollapsePanels<'a> {
Some(overlay::Element::new(Box::new(FlyoutOverlay { Some(overlay::Element::new(Box::new(FlyoutOverlay {
flyout: &mut self.panels[p].flyout, flyout: &mut self.panels[p].flyout,
tree: &mut tree.children[3 * p + 2], tree: &mut tree.children[4 * p + 3],
anchor, anchor,
}))) })))
} }
@ -352,9 +372,6 @@ impl overlay::Overlay<Message, Theme, Renderer> for FlyoutOverlay<'_, '_> {
let child = layout.children().next().unwrap(); let child = layout.children().next().unwrap();
let vp = child.bounds(); 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 let Event::Mouse(mouse::Event::ButtonPressed(_)) = event {
if !cursor.is_over(vp) { if !cursor.is_over(vp) {
shell.publish(Message::CloseRibbonDropdown); shell.publish(Message::CloseRibbonDropdown);

View file

@ -24,7 +24,7 @@ mod widgets;
use widgets::{StyleContext, *}; use widgets::{StyleContext, *};
mod collapse; mod collapse;
use collapse::{CollapsePanels, Panel}; use collapse::{CollapsePanels, Panel};
use crate::ui::wrap_bar::{DensitySwap, WrapBar, WrapFlow}; use crate::ui::wrap_bar::{WrapBar, WrapFlow};
// ── Ribbon state ─────────────────────────────────────────────────────────── // ── Ribbon state ───────────────────────────────────────────────────────────
@ -468,47 +468,30 @@ impl Ribbon {
active_table_style: self.active_table_style.clone(), active_table_style: self.active_table_style.clone(),
}; };
// Adaptive tool area, widest→narrowest: // Adaptive tool area: panels sit on one row; when they don't all
// 1. full — full-size panels on one row // fit they degrade from the right — a panel's large buttons first
// 2. compact — large buttons shrink to icon-only columns // shrink to compact icon columns, then it collapses to a ▾ flyout
// 3. collapse — panels that still don't fit collapse to title // button. See `CollapsePanels`.
// 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(
compact,
&groups,
&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,
)
};
let full: Element<'_, Message> = build(false)
.into_iter()
.fold(row![].spacing(0).height(Length::Fixed(TOOL_BAR_H)), |r, w| {
r.push(w)
})
.into();
let compact: Element<'_, Message> = build(true)
.into_iter()
.fold(row![].spacing(0).height(Length::Fixed(TOOL_BAR_H)), |r, w| {
r.push(w)
})
.into();
let panels: Vec<Panel<'_>> = groups let panels: Vec<Panel<'_>> = groups
.iter() .iter()
.map(|g| Panel { .map(|g| Panel {
id: g.title.to_string(), id: g.title.to_string(),
inline: render_group( full: 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,
),
compact: render_group(
true, true,
g, g,
&self.active_tool, &self.active_tool,
@ -565,12 +548,7 @@ impl Ribbon {
.into(), .into(),
}) })
.collect(); .collect();
let collapse: Element<'_, Message> = CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H).into()
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 { } else {
text("").into() text("").into()
}; };
@ -1179,67 +1157,6 @@ impl Ribbon {
} }
} }
/// Render the active module's ribbon panels as a list of group elements (with
/// 1px dividers between them). When `compact`, large tools/dropdowns are drawn
/// as small icon columns so each panel is narrower; the combo groups (layer /
/// properties / style) always stay full. Each panel is a fixed `TOOL_BAR_H`
/// tall so the list can be laid out in a row or flex-wrapped across rows.
#[allow(clippy::too_many_arguments)]
fn build_tool_groups<'a>(
compact: bool,
groups: &[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,
) -> Vec<Element<'a, Message>> {
let mut widgets: Vec<Element<Message>> = Vec::new();
let mut first_group = true;
for group in groups {
if !first_group {
widgets.push(tool_divider());
}
first_group = false;
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. /// Render a single ribbon panel (tools + group label), fixed `TOOL_BAR_H` tall.
/// When `compact`, large tools/dropdowns are drawn as small icon columns. /// When `compact`, large tools/dropdowns are drawn as small icon columns.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]