feat(ribbon): wrap compact panels onto extra rows when still too narrow

DensitySwap now takes a third, narrowest variant: the compact panels
flex-wrapped (via WrapFlow) across as many rows as the width needs. The
tool area therefore degrades full -> compact -> wrapped as the window
shrinks. build_tool_row becomes build_tool_groups, returning fixed-height
panel elements so they lay out identically in a row or a wrap flow.

The tool bar is no longer a fixed TOOL_BAR_H; DensitySwap reports its
measured height into a cell and the ribbon dropdown overlays anchor at
tab_bar_height() + tool_bar_height() so they track the taller bar.

Second stage of the adaptive tool area (shrink -> wrap -> collapse).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-05 16:51:26 +03:00
commit ff36a1bdac
2 changed files with 79 additions and 55 deletions

View file

@ -59,6 +59,9 @@ pub struct Ribbon {
/// Measured tab-bar height (28 on one row, 56 when tabs wrap). Written by /// Measured tab-bar height (28 on one row, 56 when tabs wrap). Written by
/// the `WrapBar` widget during layout, read when anchoring dropdowns. /// the `WrapBar` widget during layout, read when anchoring dropdowns.
tab_bar_h: Arc<AtomicU32>, tab_bar_h: Arc<AtomicU32>,
/// Measured tool-area height (TOOL_BAR_H on one row, taller when the panels
/// wrap). Written by the `DensitySwap` widget, read when anchoring dropdowns.
tool_bar_h: Arc<AtomicU32>,
} }
/// Per-layer display data shown in the ribbon layer dropdown. /// Per-layer display data shown in the ribbon layer dropdown.
@ -114,6 +117,7 @@ impl Ribbon {
table_style_names: vec![], table_style_names: vec![],
active_table_style: String::new(), active_table_style: String::new(),
tab_bar_h: Arc::new(AtomicU32::new(28.0f32.to_bits())), tab_bar_h: Arc::new(AtomicU32::new(28.0f32.to_bits())),
tool_bar_h: Arc::new(AtomicU32::new(TOOL_BAR_H.to_bits())),
} }
} }
@ -122,6 +126,11 @@ impl Ribbon {
f32::from_bits(self.tab_bar_h.load(Ordering::Relaxed)) f32::from_bits(self.tab_bar_h.load(Ordering::Relaxed))
} }
/// Current tool-area height as last measured by the `DensitySwap` widget.
fn tool_bar_height(&self) -> f32 {
f32::from_bits(self.tool_bar_h.load(Ordering::Relaxed))
}
pub fn set_styles( pub fn set_styles(
&mut self, &mut self,
text: Vec<String>, text: Vec<String>,
@ -376,40 +385,45 @@ impl Ribbon {
active_table_style: self.active_table_style.clone(), active_table_style: self.active_table_style.clone(),
}; };
// Two density variants: full-size panels, and a compact one where // Adaptive tool area, widest→narrowest:
// large buttons shrink to icon-only columns. DensitySwap shows the // 1. full — full-size panels on one row
// widest that fits the current ribbon width. // 2. compact — large buttons shrink to icon-only columns
let full = build_tool_row( // 3. wrapped — compact panels flex-wrap onto extra rows
false, // DensitySwap shows the widest variant that fits the width.
&groups, let build = |compact: bool| {
&self.active_tool, build_tool_groups(
&self.open_dropdown, compact,
&self.last_cmd, &groups,
self.wireframe, &self.active_tool,
self.ortho_mode, &self.open_dropdown,
&self.layer_infos, &self.last_cmd,
&self.active_layer, self.wireframe,
self.active_color, self.ortho_mode,
&self.active_linetype, &self.layer_infos,
self.active_lineweight, &self.active_layer,
&style_ctx, self.active_color,
); &self.active_linetype,
let compact = build_tool_row( self.active_lineweight,
true, &style_ctx,
&groups, )
&self.active_tool, };
&self.open_dropdown, let full: Element<'_, Message> = build(false)
&self.last_cmd, .into_iter()
self.wireframe, .fold(row![].spacing(0).height(Length::Fixed(TOOL_BAR_H)), |r, w| {
self.ortho_mode, r.push(w)
&self.layer_infos, })
&self.active_layer, .into();
self.active_color, let compact: Element<'_, Message> = build(true)
&self.active_linetype, .into_iter()
self.active_lineweight, .fold(row![].spacing(0).height(Length::Fixed(TOOL_BAR_H)), |r, w| {
&style_ctx, r.push(w)
); })
DensitySwap::new(vec![full, compact]).into() .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])
.report_height(self.tool_bar_h.clone())
.into()
} else { } else {
text("").into() text("").into()
}; };
@ -424,8 +438,7 @@ impl Ribbon {
}, },
..Default::default() ..Default::default()
}) })
.width(Length::Fill) .width(Length::Fill);
.height(TOOL_BAR_H);
column![tab_bar, tool_bar].into() column![tab_bar, tool_bar].into()
} }
@ -606,7 +619,7 @@ impl Ribbon {
}) })
.width(Length::Fixed(190.0)); .width(Length::Fixed(190.0));
let top_offset = self.tab_bar_height() + TOOL_BAR_H; let top_offset = self.tab_bar_height() + self.tool_bar_height();
let left_offset = compute_dropdown_left(&groups, open_id); let left_offset = compute_dropdown_left(&groups, open_id);
let positioned = container(panel) let positioned = container(panel)
.align_left(Fill) .align_left(Fill)
@ -729,7 +742,7 @@ impl Ribbon {
}) })
.width(Length::Fixed(220.0)); .width(Length::Fixed(220.0));
let top_offset = self.tab_bar_height() + TOOL_BAR_H; let top_offset = self.tab_bar_height() + self.tool_bar_height();
let groups = self.modules.get(self.active)?.ribbon_groups(); let groups = self.modules.get(self.active)?.ribbon_groups();
let left_offset = compute_layer_combo_left(&groups); let left_offset = compute_layer_combo_left(&groups);
let positioned = container(panel) let positioned = container(panel)
@ -853,7 +866,7 @@ impl Ribbon {
}) })
.width(Length::Fixed(LARGE_W * 2.3)); .width(Length::Fixed(LARGE_W * 2.3));
let top_offset = self.tab_bar_height() + TOOL_BAR_H; let top_offset = self.tab_bar_height() + self.tool_bar_height();
let left_offset = compute_dropdown_left(&groups, open_id); let left_offset = compute_dropdown_left(&groups, open_id);
let positioned = container(panel) let positioned = container(panel)
.align_left(Fill) .align_left(Fill)
@ -891,7 +904,7 @@ impl Ribbon {
}) })
.width(Length::Fixed(200.0)); .width(Length::Fixed(200.0));
let top_offset = self.tab_bar_height() + TOOL_BAR_H; let top_offset = self.tab_bar_height() + self.tool_bar_height();
let groups = self.modules.get(self.active)?.ribbon_groups(); let groups = self.modules.get(self.active)?.ribbon_groups();
let left_offset = compute_prop_combo_left(&groups, PROP_COLOR_ID); let left_offset = compute_prop_combo_left(&groups, PROP_COLOR_ID);
let positioned = container(panel) let positioned = container(panel)
@ -980,7 +993,7 @@ impl Ribbon {
}) })
.width(Length::Fixed(220.0)); .width(Length::Fixed(220.0));
let top_offset = self.tab_bar_height() + TOOL_BAR_H; let top_offset = self.tab_bar_height() + self.tool_bar_height();
let groups = self.modules.get(self.active)?.ribbon_groups(); let groups = self.modules.get(self.active)?.ribbon_groups();
let left_offset = compute_prop_combo_left(&groups, PROP_LINETYPE_ID); let left_offset = compute_prop_combo_left(&groups, PROP_LINETYPE_ID);
let positioned = container(list) let positioned = container(list)
@ -1056,7 +1069,7 @@ impl Ribbon {
}) })
.width(Length::Fixed(width)); .width(Length::Fixed(width));
let top_offset = self.tab_bar_height() + TOOL_BAR_H; let top_offset = self.tab_bar_height() + self.tool_bar_height();
let groups = self.modules.get(self.active)?.ribbon_groups(); let groups = self.modules.get(self.active)?.ribbon_groups();
let left_offset = compute_prop_combo_left(&groups, dd_id); let left_offset = compute_prop_combo_left(&groups, dd_id);
let positioned = container(panel) let positioned = container(panel)
@ -1074,11 +1087,13 @@ impl Ribbon {
} }
} }
/// Render one ribbon tool row for the active module's groups. When `compact`, /// Render the active module's ribbon panels as a list of group elements (with
/// large tools/dropdowns are drawn as small icon columns so the whole row is /// 1px dividers between them). When `compact`, large tools/dropdowns are drawn
/// narrower; the combo groups (layer / properties / style) always stay full. /// 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)] #[allow(clippy::too_many_arguments)]
fn build_tool_row<'a>( fn build_tool_groups<'a>(
compact: bool, compact: bool,
groups: &[RibbonGroup], groups: &[RibbonGroup],
active_tool: &Option<String>, active_tool: &Option<String>,
@ -1092,7 +1107,7 @@ fn build_tool_row<'a>(
active_linetype: &'a str, active_linetype: &'a str,
active_lineweight: LineWeight, active_lineweight: LineWeight,
style_ctx: &StyleContext, style_ctx: &StyleContext,
) -> Element<'a, Message> { ) -> Vec<Element<'a, Message>> {
let mut widgets: Vec<Element<Message>> = Vec::new(); let mut widgets: Vec<Element<Message>> = Vec::new();
let mut first_group = true; let mut first_group = true;
@ -1101,7 +1116,7 @@ fn build_tool_row<'a>(
widgets.push( widgets.push(
container(text("")) container(text(""))
.width(1) .width(1)
.height(Fill) .height(Length::Fixed(TOOL_BAR_H))
.style(|_: &Theme| container::Style { .style(|_: &Theme| container::Style {
background: Some(Background::Color(BORDER_DARK)), background: Some(Background::Color(BORDER_DARK)),
..Default::default() ..Default::default()
@ -1169,15 +1184,12 @@ fn build_tool_row<'a>(
.align_x(iced::Center) .align_x(iced::Center)
.spacing(0) .spacing(0)
.padding([3u16, 4]) .padding([3u16, 4])
.height(Length::Fill) .height(Length::Fixed(TOOL_BAR_H))
.into(), .into(),
); );
} }
widgets widgets
.into_iter()
.fold(row![].spacing(0).height(Length::Fill), |r, w| r.push(w))
.into()
} }
impl Default for Ribbon { impl Default for Ribbon {

View file

@ -621,6 +621,7 @@ impl<'a> From<WrapFlow<'a>> for Element<'a, Message> {
pub struct DensitySwap<'a> { pub struct DensitySwap<'a> {
variants: Vec<Element<'a, Message>>, variants: Vec<Element<'a, Message>>,
chosen: Cell<usize>, chosen: Cell<usize>,
height_out: Option<Arc<AtomicU32>>,
} }
impl<'a> DensitySwap<'a> { impl<'a> DensitySwap<'a> {
@ -628,8 +629,16 @@ impl<'a> DensitySwap<'a> {
Self { Self {
variants, variants,
chosen: Cell::new(0), chosen: Cell::new(0),
height_out: None,
} }
} }
/// Report the chosen variant's height (bits of an `f32`) so callers can
/// anchor overlays below a possibly-taller (wrapped) tool area.
pub fn report_height(mut self, out: Arc<AtomicU32>) -> Self {
self.height_out = Some(out);
self
}
} }
impl<'a> Widget<Message, Theme, Renderer> for DensitySwap<'a> { impl<'a> Widget<Message, Theme, Renderer> for DensitySwap<'a> {
@ -643,7 +652,7 @@ impl<'a> Widget<Message, Theme, Renderer> for DensitySwap<'a> {
} }
fn size(&self) -> Size<Length> { fn size(&self) -> Size<Length> {
Size::new(Length::Shrink, Length::Fill) Size::new(Length::Shrink, Length::Shrink)
} }
fn layout( fn layout(
@ -654,10 +663,10 @@ impl<'a> Widget<Message, Theme, Renderer> for DensitySwap<'a> {
) -> 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, limits.max().height)); layout::Limits::new(Size::ZERO, Size::new(f32::INFINITY, f32::INFINITY));
// Widest-first: keep the first variant whose natural width fits; else the // Widest-first: keep the first variant whose natural width fits; else the
// last (narrowest). // last (which is expected to wrap to fit any width).
let mut pick = self.variants.len().saturating_sub(1); let mut pick = self.variants.len().saturating_sub(1);
for (i, v) in self.variants.iter_mut().enumerate() { for (i, v) in self.variants.iter_mut().enumerate() {
let n = v.as_widget_mut().layout(&mut tree.children[i], renderer, &natural); let n = v.as_widget_mut().layout(&mut tree.children[i], renderer, &natural);
@ -673,6 +682,9 @@ impl<'a> Widget<Message, Theme, Renderer> for DensitySwap<'a> {
.as_widget_mut() .as_widget_mut()
.layout(&mut tree.children[pick], renderer, limits); .layout(&mut tree.children[pick], renderer, limits);
let sz = node.size(); let sz = node.size();
if let Some(out) = &self.height_out {
out.store(sz.height.to_bits(), Ordering::Relaxed);
}
layout::Node::with_children(sz, vec![node]) layout::Node::with_children(sz, vec![node])
} }