Files
OneCAD-Draft/src/ui/ribbon/widgets.rs
T
Karim Jerbi fbab4c4934 feat(ribbon): Block Palette button reflects panel open state
The Insert tab's Block Palette button now behaves like the View tab toggles
(UCS Icon, View Cube, Ortho, Properties, File Tabs, Layout Tabs): it stays
highlighted while the block palette panel is open and clears when closed.

The Ribbon gains a show_block_palette flag with a set_block_palette setter,
fed into ToggleState and mapped in is_active_tool for the BLOCKPALETTE tool
id. show_block_palette on the app remains the single source of truth; both
mutation paths now mirror it onto the ribbon:

- BLOCKPALETTE / BLOCKSPALETTE command toggles ribbon state alongside the
  panel (src/app/commands/blocks.rs).
- Closing the panel via the dock rail close button clears the ribbon state,
  matching how Close(Properties) already syncs (src/app/update/dialog.rs).
2026-08-14 23:33:45 +01:00

1296 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Shared rendering helpers, button styles, colours, layout constants, and
// free functions used by the Ribbon view/overlay methods.
use rustc_hash::FxHashMap as HashMap;
use std::cell::RefCell;
use std::time::Duration;
use acadrust::types::{Color as AcadColor, LineWeight};
use iced::advanced::{
layout, mouse, overlay, renderer, text as advanced_text, widget, Layout, Shell, Widget,
};
// Ribbon tooltips anchor to the right of their button so the cursor — which
// rests on the button itself — never covers the tip text. (#143)
use iced::widget::tooltip::Position as TipPos;
use iced::widget::{button, column, container, row, text, tooltip};
use iced::{
Background, Border, Color, Element, Event, Fill, Length, Padding, Pixels, Rectangle, Size,
Theme, Vector,
};
use crate::app::Message;
use crate::modules::{IconKind, ModuleEvent, RibbonItem, StyleKey, ToolDef};
use crate::ui::wrap_bar::PosReport;
use crate::ui::icons;
use crate::ui::properties::{acad_color_display, LwItem};
use crate::t;
use super::LayerInfo;
/// Live on/off state of every ribbon toggle button. Passed as a single value
/// through the render path so adding a new toggle only touches `is_active_tool`
/// plus the `Ribbon::toggle_state` builder — not every render-function signature
/// and call site.
#[derive(Clone, Copy)]
pub(super) struct ToggleState {
pub ortho_mode: bool,
pub show_viewcube: bool,
pub show_ucs_icon: bool,
pub show_properties: bool,
pub show_block_palette: bool,
pub show_file_tabs: bool,
pub show_layout_tabs: bool,
}
// ── Layout constants (single source of truth: ROW_H from ui::mod) ─────────
use crate::ui::ROW_H;
/// Icon size inside a 3-row (large) button.
pub(super) const LARGE_ICON: f32 = ROW_H * 1.5;
/// Icon size inside a 1-row (small) button.
pub(super) const SMALL_ICON: f32 = ROW_H * 0.7;
/// Width of a 3-row (large) button.
pub(super) const LARGE_W: f32 = ROW_H * 2.2;
/// Horizontal button padding surrounding a large tool's label.
const LARGE_LABEL_HPAD: f32 = 8.0;
/// Large ribbon labels use at most two lines before their button grows.
const LARGE_LABEL_LINES: f32 = 2.0;
const LARGE_LABEL_SIZE: f32 = 10.0;
/// Width of a 1-row (small) button.
pub(super) const SMALL_W: f32 = ROW_H;
/// Width of the ▾ strip on a small dropdown.
pub(super) const ARROW_W: f32 = ROW_H * 0.4;
/// Height of the ▾ strip at the bottom of a large dropdown.
pub(super) const LARGE_ARR: f32 = ROW_H * 0.55;
/// Total ribbon tool-area height = 3 × ROW_H + 6 px v-padding + 12 px group-label.
pub(super) const TOOL_BAR_H: f32 = 3.0 * ROW_H + 18.0;
/// Height of a collapsed panel button's large representative face (big icon +
/// its label). A collapsed button is this face plus the title opener, so it is
/// shorter than a full 3-row panel — the ribbon height follows it down.
pub(super) const COLLAPSED_FACE_H: f32 = LARGE_ICON + 20.0;
// ── Automatic large-button sizing ────────────────────────────────────────
thread_local! {
/// Ribbon layout runs on every pointer-driven view update. Cache the font-
/// measured width per translated label so the automatic sizing stays cheap.
static LARGE_WIDTH_CACHE: RefCell<HashMap<String, f32>> =
RefCell::new(HashMap::default());
}
fn ribbon_label_bounds(
renderer: &iced::Renderer,
label: &str,
width: f32,
wrapping: advanced_text::Wrapping,
) -> Size {
use advanced_text::{Paragraph as _, Renderer as _};
let paragraph = <iced::Renderer as advanced_text::Renderer>::Paragraph::with_text(
advanced_text::Text {
content: label,
bounds: Size::new(width, f32::INFINITY),
size: Pixels(LARGE_LABEL_SIZE),
line_height: advanced_text::LineHeight::default(),
font: renderer.default_font(),
align_x: advanced_text::Alignment::Center,
align_y: iced::alignment::Vertical::Center,
shaping: advanced_text::Shaping::default(),
wrapping,
ellipsis: advanced_text::Ellipsis::None,
hint_factor: None,
},
);
paragraph.min_bounds()
}
/// Measure the translated label at the normal button width. It wraps first;
/// only labels that would need more than two lines widen their button. The
/// binary search uses the renderer's real font metrics, so locale and UI scale
/// changes do not rely on character-count estimates.
fn measure_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
let base_inner = (LARGE_W - LARGE_LABEL_HPAD).max(1.0);
let line_height = advanced_text::LineHeight::default()
.to_absolute(Pixels(LARGE_LABEL_SIZE))
.0;
let max_label_height = line_height * LARGE_LABEL_LINES + 0.5;
let fits = |width: f32| {
ribbon_label_bounds(
renderer,
label,
width,
advanced_text::Wrapping::WordOrGlyph,
)
.height
<= max_label_height
};
if fits(base_inner) {
return LARGE_W;
}
let natural = ribbon_label_bounds(
renderer,
label,
f32::INFINITY,
advanced_text::Wrapping::None,
)
.width
.max(base_inner);
if !fits(natural) {
return (natural + LARGE_LABEL_HPAD).ceil();
}
let mut low = base_inner;
let mut high = natural;
for _ in 0..10 {
let mid = (low + high) * 0.5;
if fits(mid) {
high = mid;
} else {
low = mid;
}
}
(high + LARGE_LABEL_HPAD).ceil().max(LARGE_W)
}
fn automatic_large_width(renderer: &iced::Renderer, label: &str) -> f32 {
if let Some(width) = LARGE_WIDTH_CACHE.with(|cache| cache.borrow().get(label).copied()) {
return width;
}
let width = measure_large_width(renderer, label);
LARGE_WIDTH_CACHE.with(|cache| {
cache.borrow_mut().insert(label.to_string(), width);
});
width
}
struct AutomaticLargeWidth<'a> {
label: String,
content: Element<'a, Message>,
}
impl Widget<Message, Theme, iced::Renderer> for AutomaticLargeWidth<'_> {
fn tag(&self) -> widget::tree::Tag {
self.content.as_widget().tag()
}
fn state(&self) -> widget::tree::State {
self.content.as_widget().state()
}
fn diff(&mut self, tree: &mut widget::Tree) {
self.content.as_widget_mut().diff(tree);
}
fn size(&self) -> Size<Length> {
Size::new(Length::Shrink, self.content.as_widget().size().height)
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &iced::Renderer,
limits: &layout::Limits,
) -> layout::Node {
let width = automatic_large_width(renderer, &self.label);
self.content.as_widget_mut().layout(
tree,
renderer,
&limits.width(Length::Fixed(width)),
)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &iced::Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &iced::Renderer,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, shell, viewport,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &iced::Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(tree, layout, cursor, viewport, renderer)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut iced::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(tree, renderer, theme, style, layout, cursor, viewport);
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'b>,
renderer: &iced::Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, iced::Renderer>> {
self.content
.as_widget_mut()
.overlay(tree, layout, renderer, viewport, translation)
}
}
pub(super) fn automatic_large_button<'a>(
label: String,
content: Element<'a, Message>,
) -> Element<'a, Message> {
Element::new(AutomaticLargeWidth { label, content })
}
// ── Tab-bar constants ──────────────────────────────────────────────────────
pub(super) const TOP_ARR_W: f32 = 12.0;
pub(super) const TOP_HIST_W: f32 = 28.0;
pub(super) const TOP_HIST_GAP: f32 = 4.0;
// ── Dropdown / combo ID constants ─────────────────────────────────────────
pub(super) const UNDO_HISTORY_ID: &str = "UNDO_HISTORY";
pub(super) const REDO_HISTORY_ID: &str = "REDO_HISTORY";
pub(super) const LAYER_COMBO_ID: &str = "LAYER_COMBO";
/// Dropdown id for the tab-bar panel-density selector.
pub(super) const COLLAPSE_MODE_ID: &str = "COLLAPSE_MODE";
pub(super) const PROP_COLOR_ID: &str = "PROP_COLOR";
pub(super) const PROP_LINETYPE_ID: &str = "PROP_LINETYPE";
pub(super) const PROP_LW_ID: &str = "PROP_LW";
// ── Style context (passed from Ribbon to render_large) ────────────────────
pub(super) struct StyleContext {
pub text_style_names: Vec<String>,
pub active_text_style: String,
pub dim_style_names: Vec<String>,
pub active_dim_style: String,
pub mleader_style_names: Vec<String>,
pub active_mleader_style: String,
pub table_style_names: Vec<String>,
pub active_table_style: String,
}
impl StyleContext {
pub(super) fn names_for(&self, key: StyleKey) -> &[String] {
match key {
StyleKey::TextStyle => &self.text_style_names,
StyleKey::DimStyle => &self.dim_style_names,
StyleKey::MLeaderStyle => &self.mleader_style_names,
StyleKey::TableStyle => &self.table_style_names,
}
}
pub(super) fn active_for(&self, key: StyleKey) -> &str {
match key {
StyleKey::TextStyle => &self.active_text_style,
StyleKey::DimStyle => &self.active_dim_style,
StyleKey::MLeaderStyle => &self.active_mleader_style,
StyleKey::TableStyle => &self.active_table_style,
}
}
}
// ── Layout helpers ─────────────────────────────────────────────────────────
/// Flush up-to-3 small items as a vertical column into the group row.
pub(super) fn flush_small_col<'a>(
buf: &mut Vec<Element<'a, Message>>,
out: &mut Vec<Element<'a, Message>>,
) {
if buf.is_empty() {
return;
}
let col = column(std::mem::take(buf)).spacing(1);
out.push(col.into());
}
pub(super) fn make_icon(icon: IconKind, size: f32) -> Element<'static, Message> {
match icon {
IconKind::Glyph(s) => text(s).size(size * 0.7).into(),
IconKind::Svg(bytes) => icons::semantic(bytes, size),
}
}
pub(super) fn is_active_tool(
id: &str,
active_tool: &Option<String>,
state: &ToggleState,
) -> bool {
match id {
"ORTHO" => state.ortho_mode,
"PERSP" => !state.ortho_mode,
"NAVVCUBE" => state.show_viewcube,
"UCSICON" => state.show_ucs_icon,
"PROPERTIES" => state.show_properties,
"BLOCKPALETTE" => state.show_block_palette,
"FILETAB" => state.show_file_tabs,
"LAYOUTTAB" => state.show_layout_tabs,
id => active_tool.as_deref() == Some(id),
}
}
// ── Button style ───────────────────────────────────────────────────────────
pub(super) fn tool_btn_style(
theme: &Theme,
is_active: bool,
status: button::Status,
) -> button::Style {
let palette = theme.palette();
let pair = match (is_active, status) {
(true, _) => palette.primary.weak,
(_, button::Status::Hovered) => palette.background.weak,
(_, button::Status::Pressed) => palette.primary.weak,
_ => palette.background.base,
};
button::Style {
background: is_active
.then_some(Background::Color(pair.color))
.or_else(|| matches!(status, button::Status::Hovered | button::Status::Pressed)
.then_some(Background::Color(pair.color))),
text_color: pair.text,
border: Border {
radius: 3.0.into(),
color: Color::TRANSPARENT,
width: 0.0,
},
shadow: iced::Shadow::default(),
snap: false,
}
}
pub(super) fn combo_btn_style(
theme: &Theme,
is_open: bool,
status: button::Status,
radius: f32,
) -> button::Style {
let palette = theme.palette();
let pair = if is_open {
palette.primary.weak
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
palette.background.weak
} else {
palette.background.weakest
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
radius: radius.into(),
width: 1.0,
color: if is_open {
palette.primary.base.color
} else {
palette.background.neutral.color
},
},
..Default::default()
}
}
pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button::Style {
let palette = theme.palette();
let pair = if matches!(status, button::Status::Hovered | button::Status::Pressed) {
palette.background.weak
} else {
palette.background.base
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
..Default::default()
}
}
pub(super) fn popup_panel_style(theme: &Theme) -> container::Style {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.base.color)),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
}
pub(super) fn muted_text_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.72)),
}
}
// ── Tooltip helpers ────────────────────────────────────────────────────────
pub(super) fn make_tip(tip: String) -> Element<'static, Message> {
text(tip).size(11).into()
}
pub(super) fn tip_style(theme: &Theme) -> container::Style {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.strong.color)),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
text_color: Some(palette.background.strong.text),
..Default::default()
}
}
// ── Small item renderer ────────────────────────────────────────────────────
/// Render a 1-row small button (Tool or Dropdown).
pub(super) fn render_small<'a>(
item: &RibbonItem,
active_tool: &Option<String>,
open_dd: &Option<String>,
last_cmd: &HashMap<&'static str, &'static str>,
state: ToggleState,
) -> Element<'a, Message> {
match item {
// Large variants render small too, so the ribbon can shrink a panel of
// large buttons to icon-only columns when the width is tight.
RibbonItem::Tool(t) | RibbonItem::LargeTool(t) => {
let active = is_active_tool(t.id, active_tool, &state);
let event = t.event.clone();
let tool_id = t.id.to_string();
let tip_text = format!("{}\n{} {}", t!(t.label), t!("Command:"), t.id);
let btn = button(make_icon(t.icon, SMALL_ICON))
.on_press(Message::RibbonToolClick { tool_id, event })
.style(move |theme: &Theme, status| tool_btn_style(theme, active, status))
.width(Length::Fixed(SMALL_W))
.height(ROW_H)
.padding([4, 4]);
tooltip(btn, make_tip(tip_text), TipPos::Right)
.gap(6.0)
.delay(Duration::from_millis(400))
.style(tip_style)
.into()
}
RibbonItem::Dropdown {
id,
icon,
items,
default,
..
}
| RibbonItem::LargeDropdown {
id,
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);
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 tip_text = format!("{}\n{} {}", t!(cur_label), t!("Command:"), last);
let icon_btn = button(make_icon(cur_icon, SMALL_ICON))
.on_press(Message::RibbonToolClick {
tool_id: last.to_string(),
event: ModuleEvent::Command(last.to_string()),
})
.style(move |theme: &Theme, status| tool_btn_style(theme, active, status))
.width(Length::Fixed(SMALL_W))
.height(ROW_H)
.padding([4, 4]);
let arr_tip = format!("{} {}", t!(cur_label), t!("options"));
let arr_btn = button(
container(icons::themed_arrow_down(8.0))
.width(Fill)
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center),
)
.on_press(Message::ToggleRibbonDropdown(id.to_string()))
.style(move |theme: &Theme, status| {
tool_btn_style(theme, dd_open, status)
})
.width(Length::Fixed(ARROW_W))
.height(ROW_H)
.padding(0);
let icon_with_tip = tooltip(icon_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,
row![icon_with_tip, arr_with_tip].spacing(0).height(ROW_H),
)
.into()
}
_ => text("").into(),
}
}
// ── 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<&'a 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 = t!(explicit_label.unwrap_or(cur_label)).into_owned();
let tip_text = format!("{}\n{} {}", t!(cur_label), t!("Command:"), last);
let arr_tip = format!("{} {}", label, t!("options"));
// The label owns the bottom of the face. The icon's Fill container centers
// it in all remaining space between the button top and the label.
let top_btn = button(
column![
container(make_icon(cur_icon, LARGE_ICON))
.width(Fill)
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center),
text(label.clone())
.size(10)
.width(Fill)
.align_x(iced::Center)
.wrapping(advanced_text::Wrapping::WordOrGlyph),
]
.align_x(iced::Center)
.spacing(0)
.width(Fill)
.height(Fill),
)
.on_press(Message::RibbonToolClick {
tool_id: last.to_string(),
event: ModuleEvent::Command(last.to_string()),
})
.style(move |theme: &Theme, status| tool_btn_style(theme, active, status))
.width(Fill)
.height(Fill)
.padding(Padding {
top: 4.0,
right: 4.0,
bottom: 2.0,
left: 4.0,
});
let arr_btn = button(
container(icons::themed_arrow_down(9.0))
.width(Fill)
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center),
)
.on_press(Message::ToggleRibbonDropdown(id.to_string()))
.style(move |theme: &Theme, status| {
tool_btn_style(theme, dd_open, status)
})
.width(Fill)
.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);
let content = column![top_with_tip, arr_with_tip]
.spacing(0)
.width(Fill)
.height(Fill);
PosReport::new(id, automatic_large_button(label, content.into()))
.into()
}
/// Render a full-height large button (LargeTool, LargeDropdown, LayerCombo, StyleCombo).
pub(super) fn render_large<'a>(
item: &RibbonItem,
active_tool: &Option<String>,
open_dd: &Option<String>,
last_cmd: &HashMap<&'static str, &'static str>,
state: ToggleState,
layer_infos: &'a [LayerInfo],
active_layer: &'a str,
active_color: AcadColor,
active_linetype: &'a str,
active_lineweight: LineWeight,
style_ctx: &StyleContext,
// When compact, the Properties panel's Match button shrinks to a small icon.
compact: bool,
) -> Element<'a, Message> {
match item {
// 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, &state);
let event = t.event.clone();
let tool_id = t.id.to_string();
let label = t!(t.label).into_owned();
let tip_text = format!("{}\n{} {}", label, t!("Command:"), t.id);
let btn = button(
column![
container(make_icon(t.icon, LARGE_ICON))
.width(Fill)
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center),
text(label.clone())
.size(10)
.width(Fill)
.align_x(iced::Center)
.wrapping(advanced_text::Wrapping::WordOrGlyph),
]
.align_x(iced::Center)
.spacing(0)
.width(Fill)
.height(Fill),
)
.on_press(Message::RibbonToolClick { tool_id, event })
.style(move |theme: &Theme, status| tool_btn_style(theme, active, status))
.width(Fill)
.height(Fill)
.padding(Padding {
top: 4.0,
right: 4.0,
bottom: 4.0,
left: 4.0,
});
tooltip(
automatic_large_button(label, btn.into()),
make_tip(tip_text),
TipPos::Right,
)
.gap(6.0)
.delay(Duration::from_millis(400))
.style(tip_style)
.into()
}
RibbonItem::LargeDropdown {
id,
label,
icon,
items,
default,
} => {
render_large_dropdown(
*id,
*icon,
Some(*label),
items,
*default,
active_tool,
open_dd,
last_cmd,
)
}
// 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 TOOL_BUTTON_W: f32 = 26.0;
const TOOL_SPACING: f32 = 2.0;
const GROUP_PADDING: f32 = 8.0;
let tool_count = row2.len().max(row3.len()) as f32;
let tools_w = tool_count * TOOL_BUTTON_W
+ (tool_count - 1.0).max(0.0) * TOOL_SPACING
+ GROUP_PADDING;
let combo_w = (LARGE_W * 2.5).max(tools_w);
let info = layer_infos.iter().find(|l| l.name == active_layer);
let lc = info.map(|l| l.color).unwrap_or(Color::WHITE);
let lv = info.map(|l| l.visible).unwrap_or(true);
let lf = info.map(|l| l.frozen).unwrap_or(false);
let ll = info.map(|l| l.locked).unwrap_or(false);
let is_open = open_dd.as_deref() == Some(LAYER_COMBO_ID);
let vis_icon = icons::semantic(icons::layer_visible(lv), 14.0);
let freeze_icon = icons::semantic(icons::layer_freeze(lf), 14.0);
let lock_icon = icons::semantic(icons::layer_lock(ll), 14.0);
let swatch = container(text(""))
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(lc)),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 1.0.into(),
},
..Default::default()
})
.width(12)
.height(12);
const FIXED_COMBO_W: f32 =
14.0 * 3.0 + 12.0 + 9.0 + 4.0 * 4.0 + 8.0 * 2.0;
let name_w = (combo_w - FIXED_COMBO_W).max(24.0);
// About 6 px per glyph at 11 px. The dropdown itself keeps the
// complete layer name available; only its closed ribbon label is
// shortened to preserve the fixed row height.
let name_budget = ((name_w / 6.0) as usize).max(4);
let active_layer_label =
crate::ui::text_util::elide(active_layer, name_budget);
let combo_btn = button(
row![
vis_icon,
freeze_icon,
lock_icon,
swatch,
container(text(active_layer_label).size(11))
.width(name_w)
.clip(true),
icons::themed_arrow_down(9.0),
]
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::ToggleRibbonDropdown(LAYER_COMBO_ID.to_string()))
.style(move |theme: &Theme, status| {
combo_btn_style(theme, is_open, status, 3.0)
})
.padding([3, 8])
.width(Fill);
let make_tool_row = |tools: &[ToolDef]| -> Element<Message> {
let btns: Vec<Element<Message>> = tools
.iter()
.map(|t| {
let is_active = active_tool.as_deref() == Some(t.id);
let tip = t!(t.label);
let event = t.event.clone();
let icon_el: Element<Message> = make_icon(t.icon, 16.0);
let msg = module_event_to_message(event);
tooltip(
button(icon_el)
.on_press(msg)
.style(move |theme: &Theme, status| {
tool_btn_style(theme, is_active, status)
})
.padding([2, 5]),
make_tip(tip.to_string()),
TipPos::Right,
)
.gap(4.0)
.delay(Duration::from_millis(400))
.style(tip_style)
.into()
})
.collect();
row(btns).spacing(2).align_y(iced::Center).into()
};
let tools_row2 = make_tool_row(row2);
let tools_row3 = make_tool_row(row3);
container(
column![
PosReport::new(LAYER_COMBO_ID, combo_btn),
tools_row2,
tools_row3
]
.spacing(3)
.align_x(iced::Left),
)
.width(Length::Fixed(combo_w))
.height(Fill)
.align_y(iced::Center)
.padding(Padding {
top: 4.0,
bottom: 4.0,
left: 4.0,
right: 4.0,
})
.into()
}
RibbonItem::PropertiesGroup { match_prop } => {
// The Match Properties button is a large icon normally, but shrinks
// to a small icon when the panel is compacted.
let mp_el: Element<'a, Message> = if compact {
render_small(
&RibbonItem::Tool(match_prop.clone()),
active_tool,
open_dd,
last_cmd,
state,
)
} else {
render_large(
&RibbonItem::LargeTool(match_prop.clone()),
active_tool,
open_dd,
last_cmd,
state,
layer_infos,
active_layer,
active_color,
active_linetype,
active_lineweight,
style_ctx,
false,
)
};
const PROP_W: f32 = 130.0;
let prop_row = |label: String, dd_id: &'static str, swatch: Option<Color>| {
let is_open = open_dd.as_deref() == Some(dd_id);
let swatch_el: Element<'a, Message> = if let Some(c) = swatch {
container(text(""))
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(c)),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 1.0.into(),
},
..Default::default()
})
.width(12)
.height(12)
.into()
} else {
iced::widget::Space::new().width(0).into()
};
button(
row![
swatch_el,
container(text(label).size(10))
.width(Fill)
.clip(true),
if is_open {
icons::themed_arrow_up(8.0)
} else {
icons::themed_arrow_down(8.0)
},
]
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::ToggleRibbonDropdown(dd_id.to_string()))
.style(move |theme: &Theme, status| {
combo_btn_style(theme, is_open, status, 2.0)
})
.padding([3, 8])
.width(Length::Fixed(PROP_W))
};
let (color_swatch, _) = acad_color_display(active_color);
let color_row = prop_row(
crate::ui::color_select::color_display_name(active_color),
PROP_COLOR_ID,
Some(color_swatch),
);
let lt_row = prop_row(active_linetype.to_string(), PROP_LINETYPE_ID, None);
let lw_row = prop_row(LwItem(active_lineweight).to_string(), PROP_LW_ID, None);
let combos = container(
column![
PosReport::new(PROP_COLOR_ID, color_row),
PosReport::new(PROP_LINETYPE_ID, lt_row),
PosReport::new(PROP_LW_ID, lw_row),
]
.spacing(2)
.align_x(iced::Left),
)
.height(Fill)
.align_y(iced::Center)
.padding(Padding {
top: 4.0,
bottom: 4.0,
left: 0.0,
right: 4.0,
});
row![mp_el, combos]
.spacing(4)
.align_y(iced::Center)
.height(Fill)
.into()
}
RibbonItem::StyleComboGroup {
style_key,
combo_id,
rows,
..
} => {
const STYLE_COMBO_W: f32 = LARGE_W * 2.3;
let active: String = style_ctx.active_for(*style_key).to_string();
let is_open = open_dd.as_deref() == Some(*combo_id);
// ── combo button ──
let combo_btn = button(
row![
container(text(active.clone()).size(11))
.width(Fill)
.clip(true),
if is_open {
icons::themed_arrow_up(9.0)
} else {
icons::themed_arrow_down(9.0)
},
]
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::ToggleRibbonDropdown(combo_id.to_string()))
.style(move |theme: &Theme, status| {
combo_btn_style(theme, is_open, status, 3.0)
})
.padding([3, 8])
.width(Fill);
// The open style list renders as a floating overlay
// (`Ribbon::style_combo_overlay`) so it isn't clipped by the fixed
// ribbon-row height — matching the Draw-tab dropdowns. (#153)
let items_panel: Element<Message> =
iced::widget::Space::new().width(0).height(0).into();
// ── tool rows below combo ──
let make_tool_row = |tools: &[ToolDef]| -> Element<Message> {
let btns: Vec<Element<Message>> = tools
.iter()
.map(|t| {
let is_active = active_tool.as_deref() == Some(t.id);
let tip = t!(t.label);
let event = t.event.clone();
let icon_el: Element<Message> = make_icon(t.icon, 16.0);
let msg = module_event_to_message(event);
tooltip(
button(icon_el)
.on_press(msg)
.style(move |theme: &Theme, status| {
tool_btn_style(theme, is_active, status)
})
.padding([2, 5]),
make_tip(tip.to_string()),
TipPos::Right,
)
.gap(4.0)
.delay(Duration::from_millis(400))
.style(tip_style)
.into()
})
.collect();
row(btns).spacing(2).align_y(iced::Center).into()
};
let mut col_items: Vec<Element<Message>> =
vec![container(row![PosReport::new(*combo_id, combo_btn), items_panel].spacing(0))
.width(Fill)
.into()];
for row_tools in rows {
col_items.push(make_tool_row(row_tools));
}
container(column(col_items).spacing(3).align_x(iced::Left))
.width(Length::Fixed(STYLE_COMBO_W))
.height(Fill)
.align_y(iced::Center)
.padding(Padding {
top: 4.0,
bottom: 4.0,
left: 4.0,
right: 4.0,
})
.into()
}
}
}
// ── Message helpers ────────────────────────────────────────────────────────
#[allow(dead_code)]
pub fn module_event_to_message(event: ModuleEvent) -> Message {
match event {
ModuleEvent::Command(cmd) => Message::Command(cmd),
ModuleEvent::OpenFileDialog => Message::OpenFile,
ModuleEvent::ClearModels => Message::ClearScene,
ModuleEvent::SetVisualStyle(name) => {
match crate::modules::view::visual_style::mode_for_keyword(&name) {
Some(mode) => Message::SetRenderMode(mode),
// Report through the command line rather than silently doing
// nothing, so a plugin author sees the typo.
None => Message::Command(format!("VISUALSTYLES {name}")),
}
}
ModuleEvent::ToggleLayers => Message::ToggleLayers,
// Needs the tool context + async picker — route through the normal
// ribbon-click handler rather than a direct 1:1 message.
e @ ModuleEvent::PluginFileDialog { .. } => Message::RibbonToolClick {
tool_id: String::new(),
event: e,
},
}
}
// ── History control ────────────────────────────────────────────────────────
/// Quick-access chrome button (New / Open / Save / Save As / Print) in the top
/// strip: an SVG icon that dispatches a command string, with a hover tooltip.
pub(super) fn quick_access_btn<'a>(
icon_bytes: &'static [u8],
label: &'static str,
cmd: &'static str,
) -> Element<'a, Message> {
// The bundled UI SVGs are black-stroked; tint them to a light chrome grey so
// they read on the dark top strip (raw black is invisible there).
let icon = icons::themed(icon_bytes, 16.0);
let btn = button(
container(icon)
.width(Fill)
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center),
)
.on_press(Message::Command(cmd.to_string()))
.style(button::subtle)
.width(Length::Fixed(TOP_HIST_W))
.height(24)
.padding([2, 0]);
tooltip(btn, make_tip(t!(label).into_owned()), TipPos::Bottom)
.gap(6.0)
.delay(Duration::from_millis(400))
.style(tip_style)
.into()
}
pub(super) fn render_history_control<'a>(
label: &'static str,
dropdown_id: &'static str,
count: usize,
open_dropdown: &Option<String>,
) -> Element<'a, Message> {
let dd_open = open_dropdown.as_deref() == Some(dropdown_id);
let active = count > 0;
let main_btn = {
let glyph = if dropdown_id == UNDO_HISTORY_ID {
icons::themed_undo(15.0, active)
} else {
icons::themed_redo(15.0, active)
};
let btn = button(
container(glyph)
.width(Fill)
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center),
)
.style(move |theme: &Theme, status| {
top_hist_btn_style(theme, active, dd_open, status)
})
.width(Length::Fixed(TOP_HIST_W))
.height(24)
.padding([2, 0]);
let btn = if active {
if dropdown_id == UNDO_HISTORY_ID {
btn.on_press(Message::Undo)
} else {
btn.on_press(Message::Redo)
}
} else {
btn
};
tooltip(
btn,
make_tip(format!(
"{}\n{}",
t!(label),
t!("%{count} steps available", count = count)
)),
TipPos::Right,
)
.gap(6.0)
.delay(Duration::from_millis(400))
.style(tip_style)
};
let arrow_btn = {
let btn = button(
container(if active {
icons::themed_arrow_down(8.0)
} else {
icons::themed_disabled_arrow_down(8.0)
})
.width(Fill)
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center),
)
.style(move |theme: &Theme, status| {
top_hist_btn_style(theme, active, dd_open, status)
})
.width(Length::Fixed(TOP_ARR_W))
.height(24)
.padding(0);
let btn = if active {
btn.on_press(Message::ToggleRibbonDropdown(dropdown_id.to_string()))
} else {
btn
};
tooltip(
btn,
make_tip(format!(
"{}",
t!("%{label} history", label = t!(label))
)),
TipPos::Right,
)
.gap(6.0)
.delay(Duration::from_millis(400))
.style(tip_style)
};
PosReport::new(dropdown_id, row![main_btn, arrow_btn].spacing(0)).into()
}
pub(super) fn top_hist_btn_style(
theme: &Theme,
active: bool,
open: bool,
status: button::Status,
) -> button::Style {
let palette = theme.palette();
let pair = match (active, open, status) {
(false, _, _) => palette.background.weakest,
(_, true, _) => palette.primary.weak,
(_, _, button::Status::Hovered) => palette.background.weak,
(_, _, button::Status::Pressed) => palette.primary.weak,
_ => palette.background.base,
};
button::Style {
background: (!active || open || matches!(
status,
button::Status::Hovered | button::Status::Pressed
))
.then_some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
radius: 3.0.into(),
color: Color::TRANSPARENT,
width: 0.0,
},
shadow: iced::Shadow::default(),
snap: false,
}
}