// 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> = 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 = ::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 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 { 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> { 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, pub active_text_style: String, pub dim_style_names: Vec, pub active_dim_style: String, pub mleader_style_names: Vec, pub active_mleader_style: String, pub table_style_names: Vec, 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>, out: &mut Vec>, ) { 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, 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, open_dd: &Option, 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, open_dd: &Option, 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, open_dd: &Option, 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 { let btns: Vec> = 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 = 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| { 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 = iced::widget::Space::new().width(0).height(0).into(); // ── tool rows below combo ── let make_tool_row = |tools: &[ToolDef]| -> Element { let btns: Vec> = 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 = 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> = 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, ) -> 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, } }