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:
parent
ff36a1bdac
commit
56e48b8d0f
7 changed files with 1019 additions and 189 deletions
|
|
@ -1403,6 +1403,8 @@ pub enum Message {
|
||||||
SnapClearAll,
|
SnapClearAll,
|
||||||
/// Toggle a ribbon dropdown open/closed.
|
/// Toggle a ribbon dropdown open/closed.
|
||||||
ToggleRibbonDropdown(String),
|
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).
|
/// Close any open ribbon dropdown (click-catcher outside the panel).
|
||||||
CloseRibbonDropdown,
|
CloseRibbonDropdown,
|
||||||
/// User selected a specific item from a ribbon dropdown.
|
/// User selected a specific item from a ribbon dropdown.
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@ impl OpenCADStudio {
|
||||||
|
|
||||||
|
|
||||||
pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEvent) -> Task<Message> {
|
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);
|
self.ribbon.activate_tool(&tool_id);
|
||||||
match event {
|
match event {
|
||||||
ModuleEvent::Command(cmd) => return self.dispatch_command(&cmd),
|
ModuleEvent::Command(cmd) => return self.dispatch_command(&cmd),
|
||||||
|
|
|
||||||
|
|
@ -1655,6 +1655,10 @@ impl OpenCADStudio {
|
||||||
self.ribbon.toggle_dropdown(&id);
|
self.ribbon.toggle_dropdown(&id);
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
Message::ToggleRibbonPanel(id) => {
|
||||||
|
self.ribbon.toggle_collapsed_panel(&id);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
Message::CloseRibbonDropdown => {
|
Message::CloseRibbonDropdown => {
|
||||||
self.ribbon.close_dropdown();
|
self.ribbon.close_dropdown();
|
||||||
Task::none()
|
Task::none()
|
||||||
|
|
|
||||||
394
src/ui/ribbon/collapse.rs
Normal file
394
src/ui/ribbon/collapse.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,8 @@ use crate::ui::properties::{lw_options, LinetypeItem};
|
||||||
|
|
||||||
mod widgets;
|
mod widgets;
|
||||||
use widgets::{StyleContext, *};
|
use widgets::{StyleContext, *};
|
||||||
|
mod collapse;
|
||||||
|
use collapse::{CollapsePanels, Panel};
|
||||||
use crate::ui::wrap_bar::{DensitySwap, WrapBar, WrapFlow};
|
use crate::ui::wrap_bar::{DensitySwap, WrapBar, WrapFlow};
|
||||||
|
|
||||||
// ── Ribbon state ───────────────────────────────────────────────────────────
|
// ── Ribbon state ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -33,6 +35,11 @@ pub struct Ribbon {
|
||||||
pub wireframe: bool,
|
pub wireframe: bool,
|
||||||
pub ortho_mode: bool,
|
pub ortho_mode: bool,
|
||||||
pub open_dropdown: Option<String>,
|
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>,
|
last_cmd: HashMap<&'static str, &'static str>,
|
||||||
pub layer_names: Vec<String>,
|
pub layer_names: Vec<String>,
|
||||||
pub active_layer: String,
|
pub active_layer: String,
|
||||||
|
|
@ -93,6 +100,8 @@ impl Ribbon {
|
||||||
wireframe: false,
|
wireframe: false,
|
||||||
ortho_mode: true,
|
ortho_mode: true,
|
||||||
open_dropdown: None,
|
open_dropdown: None,
|
||||||
|
collapsed_open: None,
|
||||||
|
last_panel_tool: HashMap::default(),
|
||||||
last_cmd: HashMap::default(),
|
last_cmd: HashMap::default(),
|
||||||
// Empty until a document is open — populated by sync_ribbon_layers.
|
// Empty until a document is open — populated by sync_ribbon_layers.
|
||||||
layer_names: vec![],
|
layer_names: vec![],
|
||||||
|
|
@ -131,6 +140,17 @@ impl Ribbon {
|
||||||
f32::from_bits(self.tool_bar_h.load(Ordering::Relaxed))
|
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(
|
pub fn set_styles(
|
||||||
&mut self,
|
&mut self,
|
||||||
text: Vec<String>,
|
text: Vec<String>,
|
||||||
|
|
@ -209,6 +229,34 @@ impl Ribbon {
|
||||||
}
|
}
|
||||||
pub fn close_dropdown(&mut self) {
|
pub fn close_dropdown(&mut self) {
|
||||||
self.open_dropdown = None;
|
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.
|
/// Returns the index of the Layout module in the modules list.
|
||||||
|
|
@ -386,9 +434,10 @@ impl Ribbon {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Adaptive tool area, widest→narrowest:
|
// Adaptive tool area, widest→narrowest:
|
||||||
// 1. full — full-size panels on one row
|
// 1. full — full-size panels on one row
|
||||||
// 2. compact — large buttons shrink to icon-only columns
|
// 2. compact — large buttons shrink to icon-only columns
|
||||||
// 3. wrapped — compact panels flex-wrap onto extra rows
|
// 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.
|
// DensitySwap shows the widest variant that fits the width.
|
||||||
let build = |compact: bool| {
|
let build = |compact: bool| {
|
||||||
build_tool_groups(
|
build_tool_groups(
|
||||||
|
|
@ -419,9 +468,72 @@ impl Ribbon {
|
||||||
r.push(w)
|
r.push(w)
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let wrapped: Element<'_, Message> =
|
|
||||||
WrapFlow::new(build(true)).spacing_x(0.0).row_h(TOOL_BAR_H).into();
|
let panels: Vec<Panel<'_>> = groups
|
||||||
DensitySwap::new(vec![full, compact, wrapped])
|
.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())
|
.report_height(self.tool_bar_h.clone())
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -495,12 +607,13 @@ impl Ribbon {
|
||||||
})
|
})
|
||||||
.width(Length::Fixed(170.0));
|
.width(Length::Fixed(170.0));
|
||||||
|
|
||||||
|
let (top, left) = self.dd_anchor(open_id);
|
||||||
let positioned = container(panel)
|
let positioned = container(panel)
|
||||||
.align_left(Fill)
|
.align_left(Fill)
|
||||||
.align_top(Fill)
|
.align_top(Fill)
|
||||||
.padding(Padding {
|
.padding(Padding {
|
||||||
top: 28.0,
|
top,
|
||||||
left: compute_history_dropdown_left(open_id),
|
left,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
|
|
@ -619,8 +732,7 @@ impl Ribbon {
|
||||||
})
|
})
|
||||||
.width(Length::Fixed(190.0));
|
.width(Length::Fixed(190.0));
|
||||||
|
|
||||||
let top_offset = self.tab_bar_height() + self.tool_bar_height();
|
let (top_offset, left_offset) = self.dd_anchor(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)
|
||||||
.align_top(Fill)
|
.align_top(Fill)
|
||||||
|
|
@ -1113,85 +1225,223 @@ fn build_tool_groups<'a>(
|
||||||
|
|
||||||
for group in groups {
|
for group in groups {
|
||||||
if !first_group {
|
if !first_group {
|
||||||
widgets.push(
|
widgets.push(tool_divider());
|
||||||
container(text(""))
|
|
||||||
.width(1)
|
|
||||||
.height(Length::Fixed(TOOL_BAR_H))
|
|
||||||
.style(|_: &Theme| container::Style {
|
|
||||||
background: Some(Background::Color(BORDER_DARK)),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
first_group = false;
|
first_group = false;
|
||||||
|
widgets.push(render_group(
|
||||||
let mut items_row: Vec<Element<Message>> = Vec::new();
|
compact,
|
||||||
let mut small_buf: Vec<Element<Message>> = Vec::new();
|
group,
|
||||||
|
active_tool,
|
||||||
for item in &group.tools {
|
open_dd,
|
||||||
let is_large = match item {
|
last_cmd,
|
||||||
RibbonItem::LargeTool(_) | RibbonItem::LargeDropdown { .. } => !compact,
|
wireframe,
|
||||||
RibbonItem::LayerComboGroup { .. }
|
ortho_mode,
|
||||||
| RibbonItem::PropertiesGroup { .. }
|
layer_infos,
|
||||||
| RibbonItem::StyleComboGroup { .. } => true,
|
active_layer,
|
||||||
_ => false,
|
active_color,
|
||||||
};
|
active_linetype,
|
||||||
|
active_lineweight,
|
||||||
if is_large {
|
style_ctx,
|
||||||
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
|
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 {
|
impl Default for Ribbon {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::modules::{IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef};
|
use crate::modules::{IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef};
|
||||||
|
use crate::ui::wrap_bar::PosReport;
|
||||||
use crate::ui::icons;
|
use crate::ui::icons;
|
||||||
use crate::ui::properties::{acad_color_display, LwItem};
|
use crate::ui::properties::{acad_color_display, LwItem};
|
||||||
|
|
||||||
|
|
@ -453,10 +454,11 @@ pub(super) fn render_small<'a>(
|
||||||
.delay(Duration::from_millis(400))
|
.delay(Duration::from_millis(400))
|
||||||
.style(tip_style);
|
.style(tip_style);
|
||||||
|
|
||||||
row![icon_with_tip, arr_with_tip]
|
PosReport::new(
|
||||||
.spacing(0)
|
*id,
|
||||||
.height(ROW_H)
|
row![icon_with_tip, arr_with_tip].spacing(0).height(ROW_H),
|
||||||
.into()
|
)
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => text("").into(),
|
_ => text("").into(),
|
||||||
|
|
@ -465,6 +467,109 @@ pub(super) fn render_small<'a>(
|
||||||
|
|
||||||
// ── Large item renderer ────────────────────────────────────────────────────
|
// ── 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).
|
/// Render a full-height large button (LargeTool, LargeDropdown, LayerCombo, StyleCombo).
|
||||||
pub(super) fn render_large<'a>(
|
pub(super) fn render_large<'a>(
|
||||||
item: &RibbonItem,
|
item: &RibbonItem,
|
||||||
|
|
@ -481,7 +586,9 @@ pub(super) fn render_large<'a>(
|
||||||
style_ctx: &StyleContext,
|
style_ctx: &StyleContext,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
match item {
|
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 active = is_active_tool(t.id, active_tool, wireframe, ortho_mode);
|
||||||
let event = t.event.clone();
|
let event = t.event.clone();
|
||||||
let tool_id = t.id.to_string();
|
let tool_id = t.id.to_string();
|
||||||
|
|
@ -517,100 +624,27 @@ pub(super) fn render_large<'a>(
|
||||||
icon,
|
icon,
|
||||||
items,
|
items,
|
||||||
default,
|
default,
|
||||||
} => {
|
} => render_large_dropdown(
|
||||||
let active = active_tool.as_deref() == Some(*id)
|
*id,
|
||||||
|| items
|
*icon,
|
||||||
.iter()
|
Some(*label),
|
||||||
.any(|(cmd, _, _)| active_tool.as_deref() == Some(*cmd));
|
items,
|
||||||
let dd_open = open_dd.as_deref() == Some(*id);
|
*default,
|
||||||
let last = last_cmd.get(id).copied().unwrap_or(*default);
|
active_tool,
|
||||||
let cur_icon = last_cmd
|
open_dd,
|
||||||
.get(id)
|
last_cmd,
|
||||||
.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
|
// A plain Dropdown renders large too (used by a collapsed panel whose
|
||||||
.get(id)
|
// representative tool is a dropdown).
|
||||||
.copied()
|
RibbonItem::Dropdown {
|
||||||
.and_then(|cmd| {
|
id,
|
||||||
items
|
icon,
|
||||||
.iter()
|
items,
|
||||||
.find(|(c, _, _)| *c == cmd)
|
default,
|
||||||
.map(|(_, lbl, _)| *lbl)
|
} => render_large_dropdown(
|
||||||
})
|
*id, *icon, None, items, *default, active_tool, open_dd, last_cmd,
|
||||||
.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()
|
|
||||||
}
|
|
||||||
|
|
||||||
RibbonItem::LayerComboGroup { row2, row3 } => {
|
RibbonItem::LayerComboGroup { row2, row3 } => {
|
||||||
const COMBO_W: f32 = LARGE_W * 2.5;
|
const COMBO_W: f32 = LARGE_W * 2.5;
|
||||||
|
|
@ -953,8 +987,6 @@ pub(super) fn render_large<'a>(
|
||||||
})
|
})
|
||||||
.into()
|
.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
|
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 ────────────────────────────────────────────────────────
|
// ── Message helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,12 @@
|
||||||
//! The measured total height is written to `height_out` (if set) so callers can
|
//! The measured total height is written to `height_out` (if set) so callers can
|
||||||
//! anchor overlays below the possibly-taller bar.
|
//! anchor overlays below the possibly-taller bar.
|
||||||
|
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::cell::Cell;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rustc_hash::FxHashMap;
|
||||||
|
|
||||||
use iced::advanced::layout::{self, Layout};
|
use iced::advanced::layout::{self, Layout};
|
||||||
use iced::advanced::widget::{self, Widget};
|
use iced::advanced::widget::{self, Widget};
|
||||||
use iced::advanced::{mouse, overlay, renderer, Clipboard, Shell};
|
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;
|
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> {
|
pub struct WrapBar<'a> {
|
||||||
lead: Element<'a, Message>,
|
lead: Element<'a, Message>,
|
||||||
/// Optional Fill filler occupying the gap between `lead` and `trail`.
|
/// 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue