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 <noreply@anthropic.com>
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

+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 ────────────────────────────────────────────────────────