// Ribbon — tab bar + 3-row tool area. // // Button sizes: // LargeTool / LargeDropdown — full ribbon height (3 rows), icon + label [+ ▾] // Tool / Dropdown — 1-row height, icon only [+ ▾ on right] // // Dropdown items within a group are collected into columns of 3 rows. use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; use rustc_hash::FxHashMap as HashMap; use acadrust::types::{Color as AcadColor, LineWeight}; use iced::widget::{button, column, container, mouse_area, row, scrollable, svg, text}; use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; use crate::app::Message; use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem}; use crate::plugin::all_ribbon_modules; use crate::ui::properties::{lw_options, LinetypeItem}; mod widgets; use widgets::{StyleContext, *}; mod collapse; use collapse::{CollapsePanels, Panel}; use crate::ui::wrap_bar::{WrapBar, WrapFlow}; // ── Ribbon state ─────────────────────────────────────────────────────────── pub struct Ribbon { modules: Vec>, active: usize, active_tool: Option, pub wireframe: bool, pub ortho_mode: bool, pub open_dropdown: Option, /// Title of the collapsed panel whose flyout is currently open, if any. pub collapsed_open: Option, /// 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>, pub layer_names: Vec, pub active_layer: String, pub layer_infos: Vec, /// Active object color — ACI / ByLayer / ByBlock. pub active_color: AcadColor, /// Active linetype override ("ByLayer", "Continuous", …). pub active_linetype: String, /// Active lineweight. pub active_lineweight: LineWeight, /// Linetypes loaded from the current document (with ASCII art). pub available_linetypes: Vec, /// Whether the full ACI palette is expanded inside the color picker overlay. pub prop_color_palette_open: bool, // ── Style selector state ────────────────────────────────────────────── 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, /// Measured tab-bar height (28 on one row, 56 when tabs wrap). Written by /// the `WrapBar` widget during layout, read when anchoring dropdowns. tab_bar_h: Arc, /// Measured tool-area height (TOOL_BAR_H on one row, taller when the panels /// wrap). Written by the `DensitySwap` widget, read when anchoring dropdowns. tool_bar_h: Arc, } /// Per-layer display data shown in the ribbon layer dropdown. #[derive(Clone, Debug)] pub struct LayerInfo { pub name: String, pub color: Color, pub visible: bool, pub frozen: bool, pub locked: bool, } /// Full-screen backdrop for an open ribbon dropdown: closes the dropdown when /// the user clicks outside the panel. (Cursor motion leaking to the viewport /// beneath is blocked in `on_viewport_move`, not here — iced 0.14's mouse_area /// can only capture button presses, never CursorMoved. #227.) fn dropdown_backdrop<'a>(positioned: Element<'a, Message>) -> Element<'a, Message> { mouse_area(positioned) .on_press(Message::CloseRibbonDropdown) .into() } /// Position a ribbon dropdown `panel` in a full-window container just below its /// widget, growing left or right per `align_right` (see [`Ribbon::dd_anchor`]). fn position_ribbon_dropdown<'a>( panel: Element<'a, Message>, align_right: bool, h_pad: f32, top: f32, ) -> Element<'a, Message> { let pad = Padding { top, bottom: 0.0, left: if align_right { 0.0 } else { h_pad }, right: if align_right { h_pad } else { 0.0 }, }; let c = container(panel) .align_top(Fill) .width(Fill) .height(Fill) .padding(pad); let c = if align_right { c.align_right(Fill) } else { c.align_left(Fill) }; c.into() } impl Ribbon { pub fn new() -> Self { Self { modules: all_ribbon_modules(), active: 0, active_tool: None, wireframe: false, ortho_mode: true, open_dropdown: None, collapsed_open: None, last_panel_tool: HashMap::default(), last_cmd: HashMap::default(), // Empty until a document is open — populated by sync_ribbon_layers. layer_names: vec![], active_layer: String::new(), layer_infos: vec![], active_color: AcadColor::ByLayer, active_linetype: "ByLayer".to_string(), active_lineweight: LineWeight::ByLayer, available_linetypes: vec![LinetypeItem { name: "Continuous".to_string(), art: String::new(), }], prop_color_palette_open: false, // Empty until a document is open — the Annotate style dropdowns // are populated from the active document by sync_ribbon_styles. text_style_names: vec![], active_text_style: String::new(), dim_style_names: vec![], active_dim_style: String::new(), mleader_style_names: vec![], active_mleader_style: String::new(), table_style_names: vec![], active_table_style: String::new(), tab_bar_h: Arc::new(AtomicU32::new(28.0f32.to_bits())), tool_bar_h: Arc::new(AtomicU32::new(TOOL_BAR_H.to_bits())), } } /// Current tab-bar height as last measured by the `WrapBar` widget. fn tab_bar_height(&self) -> f32 { f32::from_bits(self.tab_bar_h.load(Ordering::Relaxed)) } /// Current tool-area height as last measured by the `DensitySwap` widget. fn tool_bar_height(&self) -> f32 { f32::from_bits(self.tool_bar_h.load(Ordering::Relaxed)) } /// (align_right, horizontal_pad, top) to anchor the open dropdown `id`'s /// overlay just below its widget. It grows rightward from the widget's left /// edge, but flips to right-aligned (growing left) when a panel of `panel_w` /// would cross the right window edge `win_w`. Falls back to below the whole /// ribbon when the widget's position hasn't been recorded yet. fn dd_anchor(&self, id: &str, panel_w: f32, win_w: f32) -> (bool, f32, f32) { match crate::ui::wrap_bar::dropdown_bounds(id) { Some(b) => { let top = b.y + b.height; if b.x + panel_w > win_w - 2.0 { (true, (win_w - (b.x + b.width)).max(4.0), top) } else { (false, b.x.max(0.0), top) } } None => (false, 0.0, self.tab_bar_height() + self.tool_bar_height()), } } pub fn set_styles( &mut self, text: Vec, active_text: &str, dim: Vec, active_dim: &str, mleader: Vec, active_mleader: &str, table: Vec, active_table: &str, ) { self.text_style_names = text; self.active_text_style = active_text.to_string(); self.dim_style_names = dim; self.active_dim_style = active_dim.to_string(); self.mleader_style_names = mleader; self.active_mleader_style = active_mleader.to_string(); self.table_style_names = table; self.active_table_style = active_table.to_string(); } pub fn set_layers(&mut self, infos: Vec, active: &str) { self.active_layer = active.to_string(); self.layer_names = infos.iter().map(|l| l.name.clone()).collect(); self.layer_infos = infos; } pub fn set_available_linetypes(&mut self, items: Vec) { self.available_linetypes = items; } pub fn select(&mut self, index: usize) { if index < self.modules.len() { self.active = index; } } /// Replace the tab list (e.g. after a plugin is enabled/disabled in the /// Plugin Manager). Clamps the active tab so it stays in range. pub fn set_modules(&mut self, modules: Vec>) { self.modules = modules; if self.active >= self.modules.len() { self.active = self.modules.len().saturating_sub(1); } self.active_tool = None; self.open_dropdown = None; } pub fn activate_tool(&mut self, id: &str) { self.active_tool = Some(id.to_string()); } pub fn deactivate_tool(&mut self) { self.active_tool = None; } /// Clear `active_tool` only when it currently equals `id`. Used by the /// window-close path to deactivate the tool that owned a popup window /// without disturbing a different tool the user picked in the /// meantime. See #40. pub fn deactivate_tool_if(&mut self, id: &str) { if self.active_tool.as_deref() == Some(id) { self.active_tool = None; } } pub fn set_wireframe(&mut self, w: bool) { self.wireframe = w; } pub fn set_ortho(&mut self, ortho: bool) { self.ortho_mode = ortho; } pub fn toggle_dropdown(&mut self, id: &str) { if self.open_dropdown.as_deref() == Some(id) { self.open_dropdown = None; } else { self.open_dropdown = Some(id.to_string()); } } pub fn close_dropdown(&mut self) { 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. #[allow(dead_code)] // layout-tab helpers; not yet wired pub fn layout_module_index(&self) -> Option { self.modules.iter().position(|m| m.id() == "layout") } /// Returns true if the currently active tab is the Layout module. #[allow(dead_code)] pub fn active_is_layout(&self) -> bool { self.modules .get(self.active) .map(|m| m.id() == "layout") .unwrap_or(false) } pub fn select_dropdown_item(&mut self, dropdown_id: &'static str, cmd: &'static str) { self.last_cmd.insert(dropdown_id, cmd); self.open_dropdown = None; } // ── View ────────────────────────────────────────────────────────────── pub fn view( &self, is_paper: bool, undo_count: usize, redo_count: usize, ) -> Element<'_, Message> { // ── Quick-access file commands + undo/redo, one merged flow ──────── let lead = WrapFlow::new(vec![ quick_access_btn(crate::ui::icons::DOC_NEW, "New", "NEW").into(), quick_access_btn(crate::ui::icons::FOLDER_OPEN, "Open", "OPEN").into(), quick_access_btn(crate::ui::icons::SAVE, "Save", "SAVE").into(), quick_access_btn(crate::ui::icons::FILE_EXPORT, "Save As", "SAVEAS").into(), quick_access_btn(crate::ui::icons::PRINT, "Print", "PRINT").into(), render_history_control("Undo", UNDO_HISTORY_ID, undo_count, &self.open_dropdown).into(), render_history_control("Redo", REDO_HISTORY_ID, redo_count, &self.open_dropdown).into(), ]) .spacing_x(TOP_HIST_GAP) .row_h(28.0); // The quick-access flow and the tabs flow each flex-wrap; WrapBar stacks // them so a wrapped tab never shares a row with a quick-access button. let tab_items = self.modules.iter().enumerate().fold( Vec::>::new(), |mut acc, (i, module)| { // The Layout module no longer has a ribbon tab — its paper-space // tools live in the right-edge side toolbar (see ui::side_toolbar). if module.id() == "layout" { return acc; } let is_active = i == self.active; let is_contextual = module.id() == "layout"; let accent = if is_contextual { ACCENT_GOLD } else { ACCENT_BLUE }; let text_inactive = if is_contextual { Color { r: 0.90, g: 0.72, b: 0.30, a: 1.0, } } else { Color { r: 0.75, g: 0.75, b: 0.75, a: 1.0, } }; let hover_bg = if is_contextual { Color { r: 0.28, g: 0.24, b: 0.12, a: 1.0, } } else { Color { r: 0.25, g: 0.25, b: 0.25, a: 1.0, } }; let btn = container( button(text(module.title()).size(12)) .on_press(Message::RibbonSelectTab(i)) .style(move |_: &Theme, status| button::Style { background: Some(Background::Color(match (is_active, status) { (true, _) => RIBBON_BG, (false, button::Status::Hovered) => hover_bg, _ => Color::TRANSPARENT, })), text_color: if is_active { Color::WHITE } else { text_inactive }, border: Border { color: if is_active { accent } else { Color::TRANSPARENT }, width: if is_active { 2.0 } else { 0.0 }, radius: 0.0.into(), }, shadow: iced::Shadow::default(), snap: false, }) .padding([5, 14]), ) .style(move |_: &Theme| container::Style { border: Border { color: if is_active { accent } else { Color::TRANSPARENT }, width: if is_active { 2.0 } else { 0.0 }, radius: 0.0.into(), }, ..Default::default() }); acc.push(btn.into()); acc }, ); let tabs = WrapFlow::new(tab_items).spacing_x(6.0).row_h(28.0); let tab_bar = container( WrapBar::new(lead.into(), tabs.into()) .spacing(6.0) .report_height(self.tab_bar_h.clone()), ) .style(|_: &Theme| container::Style { background: Some(Background::Color(TOPBAR_BG)), ..Default::default() }) .width(Length::Fill); // ── Tool area ───────────────────────────────────────────────────── let effective_active = if !is_paper && self .modules .get(self.active) .map(|m| m.id() == "layout") .unwrap_or(false) { 0 } else { self.active }; let tool_area: Element<'_, Message> = if let Some(module) = self.modules.get(effective_active) { let groups = module.ribbon_groups(); let style_ctx = StyleContext { text_style_names: self.text_style_names.clone(), active_text_style: self.active_text_style.clone(), dim_style_names: self.dim_style_names.clone(), active_dim_style: self.active_dim_style.clone(), mleader_style_names: self.mleader_style_names.clone(), active_mleader_style: self.active_mleader_style.clone(), table_style_names: self.table_style_names.clone(), active_table_style: self.active_table_style.clone(), }; // Adaptive tool area: panels sit on one row; when they don't all // fit they degrade from the right — a panel's large buttons first // shrink to compact icon columns, then it collapses to a ▾ flyout // button. See `CollapsePanels`. let panels: Vec> = groups .iter() .map(|g| Panel { id: g.title.to_string(), full: 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, ), compact: 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(); CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H, BORDER_DARK) .into() } else { text("").into() }; let tool_bar = container(tool_area) .style(|_: &Theme| container::Style { background: Some(Background::Color(RIBBON_BG)), border: Border { color: BORDER_DARK, width: 1.0, radius: 0.0.into(), }, ..Default::default() }) .width(Length::Fill); column![tab_bar, tool_bar].into() } // ── Dropdown overlay ────────────────────────────────────────────────── pub fn dropdown_overlay( &self, undo_labels: &[String], redo_labels: &[String], win: (f32, f32), ) -> Option> { let open_id = self.open_dropdown.as_deref()?; if open_id == UNDO_HISTORY_ID || open_id == REDO_HISTORY_ID { let is_undo = open_id == UNDO_HISTORY_ID; let labels = if is_undo { undo_labels } else { redo_labels }; if labels.is_empty() { return None; } let rows: Vec> = labels .iter() .enumerate() .map(|(idx, label)| { let step = idx + 1; button(text(label.clone()).size(11).color(LABEL_ON)) .on_press(if is_undo { Message::UndoMany(step) } else { Message::RedoMany(step) }) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => ROW_HOVER, _ => Color::TRANSPARENT, })), ..Default::default() }) .width(Fill) .padding([5, 10]) .into() }) .collect(); let panel = container(column(rows)) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: PANEL_BORDER, width: 1.0, radius: 3.0.into(), }, ..Default::default() }) .width(Length::Fixed(170.0)); let (align_right, h_pad, top) = self.dd_anchor(open_id, 170.0, win.0); let positioned = position_ribbon_dropdown(panel.into(), align_right, h_pad, top); return Some(dropdown_backdrop(positioned)); } if open_id == LAYER_COMBO_ID { return self.layer_combo_overlay(win); } if open_id == PROP_COLOR_ID { return self.prop_color_overlay(win); } if open_id == PROP_LINETYPE_ID { return self.prop_linetype_overlay(win); } if open_id == PROP_LW_ID { return self.prop_lw_overlay(win); } // Style combo dropdowns (annotate tab) float as overlays so the list // isn't clipped by the fixed ribbon-row height, the way the Draw-tab // dropdowns already are. (#153) if let Some(ov) = self.style_combo_overlay(open_id, win) { return Some(ov); } let module = self.modules.get(self.active)?; let groups = module.ribbon_groups(); let mut items_list: Option> = None; let mut dd_default = ""; let mut dd_id: &'static str = ""; 'outer: for group in groups { for item in &group.tools { let (id, items, default) = match item { RibbonItem::Dropdown { id, items, default, .. } => (*id, items, *default), RibbonItem::LargeDropdown { id, items, default, .. } => (*id, items, *default), _ => continue, }; if id == open_id { items_list = Some(items.clone()); dd_default = default; dd_id = id; break 'outer; } } } let items = items_list?; let last_cmd = self.last_cmd.get(dd_id).copied().unwrap_or(dd_default); let rows: Vec> = items .iter() .map(|(cmd, label, item_icon)| { let is_current = *cmd == last_cmd; let checkmark: Element<'_, Message> = container(if is_current { crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) } else { iced::widget::Space::new().width(0).into() }) .width(Length::Fixed(14.0)) .into(); let icon_el: Element = match *item_icon { IconKind::Glyph(s) => text(s) .size(13) .color(ICON_COLOR) .width(Length::Fixed(20.0)) .into(), IconKind::Svg(bytes) => { let handle = svg::Handle::from_memory(bytes); svg(handle).width(20).height(20).into() } }; let label_el = text(*label) .size(11) .color(if is_current { LABEL_ON } else { LABEL_OFF }); button( row![checkmark, icon_el, label_el] .spacing(4) .align_y(iced::Center), ) .on_press(Message::DropdownSelectItem { dropdown_id: dd_id, cmd: *cmd, }) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => ROW_HOVER, _ => Color::TRANSPARENT, })), ..Default::default() }) .width(Fill) .padding([4, 10]) .into() }) .collect(); let panel = container(column(rows)) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: PANEL_BORDER, width: 1.0, radius: 3.0.into(), }, ..Default::default() }) .width(Length::Fixed(190.0)); let (align_right, h_pad, top) = self.dd_anchor(open_id, 190.0, win.0); Some(dropdown_backdrop(position_ribbon_dropdown( panel.into(), align_right, h_pad, top, ))) } fn layer_combo_overlay(&self, win: (f32, f32)) -> Option> { // A toggle icon (visible / freeze / lock) is its own button so a click // on it flips that state instead of bubbling up to the row's // make-active handler (#133). let icon_btn = |bytes: &'static [u8], msg: Message| -> Element<'_, Message> { button(crate::ui::icons::raw(bytes, 14.0)) .on_press(msg) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => ROW_HOVER, _ => Color::TRANSPARENT, })), ..Default::default() }) .padding([2, 4]) .into() }; let rows: Vec> = self .layer_infos .iter() .enumerate() .map(|(index, info)| { let is_active = info.name == self.active_layer; let lc = info.color; let lv = info.visible; let lf = info.frozen; let ll = info.locked; let name = info.name.clone(); let swatch = container(text("")) .style(move |_: &Theme| container::Style { background: Some(Background::Color(lc)), border: Border { color: SWATCH_BORDER, width: 1.0, radius: 1.0.into(), }, ..Default::default() }) .width(12) .height(12); let vis = icon_btn( crate::ui::icons::layer_visible(lv), Message::LayerToggleVisible(index), ); let freeze = icon_btn( crate::ui::icons::layer_freeze(lf), Message::LayerToggleFreeze(index), ); let lock = icon_btn( crate::ui::icons::layer_lock(ll), Message::LayerToggleLock(index), ); let checkmark: Element<'_, Message> = container(if is_active { crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) } else { iced::widget::Space::new().width(0).into() }) .width(Length::Fixed(14.0)) .into(); let label = text(&info.name) .size(11) .color(if is_active { LABEL_ON } else { LABEL_OFF }); // The swatch + label area selects the layer as active; the // icon buttons above handle their own toggles. let select = button(row![swatch, label].spacing(5).align_y(iced::Center)) .on_press(Message::RibbonLayerChanged(name)) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => ROW_HOVER, _ => Color::TRANSPARENT, })), ..Default::default() }) .width(Fill) .padding([4, 4]); container( row![checkmark, vis, freeze, lock, select] .spacing(5) .align_y(iced::Center), ) .padding([0, 4]) .into() }) .collect(); // Cap the panel height and make the list scrollable so a long layer // list stays reachable instead of running off the bottom of the // screen (#227). Short lists shrink to fit; longer ones scroll. let row_count = self.layer_infos.len().max(1); let list_h = (row_count as f32 * 26.0).min(420.0); let panel = container(scrollable(column(rows)).height(Length::Fixed(list_h))) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: PANEL_BORDER, width: 1.0, radius: 3.0.into(), }, ..Default::default() }) .width(Length::Fixed(220.0)); let (align_right, h_pad, top) = self.dd_anchor(LAYER_COMBO_ID, 220.0, win.0); let positioned = position_ribbon_dropdown(panel.into(), align_right, h_pad, top); Some(dropdown_backdrop(positioned)) } /// Floating popup for an annotate-tab style combo (text / dimension / /// multileader / table style). Returns `None` when `open_id` is not a /// style combo. Built as an overlay — like the layer combo and the /// Draw-tab dropdowns — so the list grows to fit its entries instead of /// being clipped to the ribbon-row height. (#153) fn style_combo_overlay(&self, open_id: &str, win: (f32, f32)) -> Option> { let groups = self.modules.get(self.active)?.ribbon_groups(); // Locate the open style combo; capture its style key + manager command. let mut found: Option<(crate::modules::StyleKey, Option<&'static str>)> = None; 'outer: for group in groups { for item in &group.tools { if let RibbonItem::StyleComboGroup { style_key, combo_id, manager_cmd, .. } = item { if *combo_id == open_id { found = Some((*style_key, *manager_cmd)); break 'outer; } } } } let (style_key, manager_cmd) = found?; let ctx = StyleContext { text_style_names: self.text_style_names.clone(), active_text_style: self.active_text_style.clone(), dim_style_names: self.dim_style_names.clone(), active_dim_style: self.active_dim_style.clone(), mleader_style_names: self.mleader_style_names.clone(), active_mleader_style: self.active_mleader_style.clone(), table_style_names: self.table_style_names.clone(), active_table_style: self.active_table_style.clone(), }; let active = ctx.active_for(style_key).to_string(); let row_style = |_: &Theme, status: button::Status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => ROW_HOVER, _ => Color::TRANSPARENT, })), ..Default::default() }; let mut rows: Vec> = ctx .names_for(style_key) .iter() .map(|name| { let is_sel = name.as_str() == active.as_str(); let n = name.clone(); let checkmark: Element = container(if is_sel { crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) } else { iced::widget::Space::new().width(0).into() }) .width(Length::Fixed(14.0)) .into(); button( row![ checkmark, text(name.clone()).size(11).color(if is_sel { LABEL_ON } else { LABEL_OFF }), ] .spacing(4) .align_y(iced::Center), ) .on_press(Message::RibbonStyleChanged { key: style_key, name: n, }) .style(row_style) .width(Fill) .padding([4, 10]) .into() }) .collect(); if let Some(mgr) = manager_cmd { rows.push( button(text("Manage…").size(11).color(LABEL_ON)) .on_press(Message::Command(mgr.to_string())) .style(row_style) .width(Fill) .padding([4, 10]) .into(), ); } let panel = container(column(rows)) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: PANEL_BORDER, width: 1.0, radius: 3.0.into(), }, ..Default::default() }) .width(Length::Fixed(LARGE_W * 2.3)); let (align_right, h_pad, top) = self.dd_anchor(open_id, LARGE_W * 2.3, win.0); Some(dropdown_backdrop(position_ribbon_dropdown( panel.into(), align_right, h_pad, top, ))) } fn prop_color_overlay(&self, win: (f32, f32)) -> Option> { let picker = crate::ui::color_select::color_list( crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, }, Message::RibbonColorChanged, Message::OpenColorWindow(crate::app::ColorPickTarget::Ribbon), ); let panel = container(picker) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: PANEL_BORDER, width: 1.0, radius: 3.0.into(), }, ..Default::default() }) .width(Length::Fixed(200.0)); let (align_right, h_pad, top) = self.dd_anchor(PROP_COLOR_ID, 200.0, win.0); Some(dropdown_backdrop(position_ribbon_dropdown( panel.into(), align_right, h_pad, top, ))) } fn prop_linetype_overlay(&self, win: (f32, f32)) -> Option> { let active_lt = &self.active_linetype; let mut items: Vec = vec![ LinetypeItem { name: "ByLayer".to_string(), art: String::new(), }, LinetypeItem { name: "ByBlock".to_string(), art: String::new(), }, ]; for lt in &self.available_linetypes { if lt.name != "ByLayer" && lt.name != "ByBlock" { items.push(lt.clone()); } } let rows: Vec> = items .into_iter() .map(|lt| { let is_cur = lt.name == *active_lt; let check: Element<'_, Message> = container(if is_cur { crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) } else { iced::widget::Space::new().width(0).into() }) .width(Length::Fixed(14.0)) .into(); let name_col = text(lt.name.clone()) .size(11) .color(if is_cur { LABEL_ON } else { LABEL_OFF }) .width(Length::Fixed(90.0)); let art_col = text(lt.art.clone()).size(9).color(Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0, }); let name = lt.name.clone(); button( row![check, name_col, art_col] .spacing(4) .align_y(iced::Center), ) .on_press(Message::RibbonLinetypeChanged(name)) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => ROW_HOVER, _ => Color::TRANSPARENT, })), ..Default::default() }) .width(Fill) .padding([4, 6]) .into() }) .collect(); let list = container(scrollable(column(rows)).height(Length::Fixed(200.0))) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: PANEL_BORDER, width: 1.0, radius: 3.0.into(), }, ..Default::default() }) .width(Length::Fixed(220.0)); let (align_right, h_pad, top) = self.dd_anchor(PROP_LINETYPE_ID, 220.0, win.0); Some(dropdown_backdrop(position_ribbon_dropdown( list.into(), align_right, h_pad, top, ))) } fn prop_lw_overlay(&self, win: (f32, f32)) -> Option> { let active_lw = self.active_lineweight; let rows: Vec> = lw_options() .into_iter() .map(|item| { let is_cur = item.0 == active_lw; let label = item.to_string(); let check: Element<'_, Message> = container(if is_cur { crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) } else { iced::widget::Space::new().width(0).into() }) .width(Length::Fixed(14.0)) .into(); button( row![ check, text(label) .size(11) .color(if is_cur { LABEL_ON } else { LABEL_OFF }) ] .spacing(5) .align_y(iced::Center), ) .on_press(Message::RibbonLineweightChanged(item.0)) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered | button::Status::Pressed => ROW_HOVER, _ => Color::TRANSPARENT, })), ..Default::default() }) .width(Fill) .padding([4, 8]) .into() }) .collect(); self.prop_overlay_positioned(rows, PROP_LW_ID, 140.0, win) } fn prop_overlay_positioned<'a>( &'a self, rows: Vec>, dd_id: &str, width: f32, win: (f32, f32), ) -> Option> { let panel = container(column(rows)) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: PANEL_BORDER, width: 1.0, radius: 3.0.into(), }, ..Default::default() }) .width(Length::Fixed(width)); let (align_right, h_pad, top) = self.dd_anchor(dd_id, width, win.0); Some(dropdown_backdrop(position_ribbon_dropdown( panel.into(), align_right, h_pad, top, ))) } } /// 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, open_dd: &Option, 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> = Vec::new(); let mut small_buf: Vec> = 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, compact, )); } 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), RibbonItem::PropertiesGroup { match_prop } => Some(match_prop.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, open_dd: &Option, 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. For a Properties panel the // representative is its Match button, rendered as a large tool. let face: Element<'_, Message> = match representative(group, last_used) { Some(RibbonItem::PropertiesGroup { match_prop }) => render_large( &RibbonItem::LargeTool(match_prop.clone()), active_tool, open_dd, last_cmd, wireframe, ortho_mode, layer_infos, active_layer, active_color, active_linetype, active_lineweight, style_ctx, false, ), 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, false, ), 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 { fn default() -> Self { Self::new() } }