Files
OneCAD-Draft/src/ui/ribbon/mod.rs
T
Karim Jerbi daa26007f9 feat(block-editor): add functional Visibility ribbon controls
Add the current-state selector, Visibility States dialog and mode/show/hide
controls to the contextual ribbon, using shared icons, tooltips, modal UI
and adaptive flyouts. Disable the controls until a Visibility parameter exists.

Stage state creation, rename, deletion, current-state changes and reordering
until OK. Support copied, show-all and hide-all new states in one undo step,
preserve native graph metadata, and restore current-state identity on undo/redo.

Implement nonsaved BVMODE and display-only reveal during BVSHOW selection.
Cancelling selection leaves saved visibility flags and history unchanged.
Keep the existing command-line state workflows available.

Validation: 6 targeted tests; 277 block, 31 app dock, 35 ribbon and 12 history
checks passed (overlapping filters). All 17 local DWG fixtures passed in-memory
DWG/DXF preservation checks with unchanged original checksums.
2026-10-15 15:08:21 +01:00

1844 lines
70 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.
// 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::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use rustc_hash::FxHashMap as HashMap;
use codec::types::{Color as AcadColor, LineWeight};
use iced::widget::{button, column, container, mouse_area, row, scrollable, 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::{linetype_display_name, lw_options, LinetypeItem};
mod draw_panel;
pub(crate) mod widgets;
pub(crate) use draw_panel::tools as panel_tools;
mod color_dropdown;
mod context_tools;
mod modify_panel;
mod visibility_panel;
pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, UnderlayContext};
use widgets::{StyleContext, *};
pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID};
mod collapse;
mod keytips;
mod locate;
use crate::t;
use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow};
pub use collapse::CollapseMode;
use collapse::{CollapsePanels, Panel};
pub use keytips::{
active as keytips_active, key_pressed as keytip_key_pressed,
key_released as keytip_key_released, pointer_pressed as keytip_pointer_pressed, KeyTipInput,
KeyTipStep,
};
pub(crate) fn tooltip_content(text: String) -> Element<'static, Message> {
widgets::make_tip(text)
}
pub(crate) fn tooltip_style(theme: &Theme) -> container::Style {
widgets::tip_style(theme)
}
/// A line over the items of a split button whose face names the chosen item.
fn dropdown_header(id: &str) -> Option<&'static str> {
(id == "IMPORT_FILE").then_some("Import File — the face runs the last one chosen")
}
// ── Ribbon state ───────────────────────────────────────────────────────────
pub struct Ribbon {
modules: Vec<Box<dyn CadModule>>,
active: usize,
active_tool: Option<String>,
pub wireframe: bool,
pub ortho_mode: bool,
/// ViewCube (NAVVCUBE) visibility — drives the View Cube button highlight.
pub show_viewcube: bool,
/// UCS icon (UCSICON) visibility — drives the UCS Icon button highlight.
pub show_ucs_icon: bool,
/// Properties panel (PROPERTIES) visibility — drives the Properties button highlight.
pub show_properties: bool,
/// The Count palette is open (View › Palettes › Count).
pub show_count_palette: bool,
/// Sheet Set Manager palette shown (SSMSTATE).
pub show_sheet_set: bool,
/// Graphic Attributes panel visibility — drives its ribbon button highlight.
pub show_graphic_attributes: bool,
pub show_authoring_palettes: bool,
/// File tabs (FILETAB) visibility — drives the File Tabs button highlight.
pub show_file_tabs: bool,
/// Layout tabs (LAYOUTTAB) visibility — drives the Layout Tabs button highlight.
pub show_layout_tabs: bool,
pub open_dropdown: Option<String>,
/// Title of the collapsed panel whose flyout is currently open, if any.
pub collapsed_open: Option<String>,
/// Last tool used from each panel (panel title → tool id). A collapsed panel
/// shows this tool on its button, defaulting to the panel's first tool.
last_panel_tool: HashMap<&'static str, &'static str>,
last_cmd: HashMap<&'static str, &'static str>,
pub active_layer: String,
pub layer_infos: Vec<LayerInfo>,
/// Live filter for the layer dropdown's search box (#343). Cleared on
/// close so the next open starts unfiltered.
pub layer_filter: String,
/// Active object color — ACI / ByLayer / ByBlock.
pub active_color: AcadColor,
/// Colour-book identity (`BOOK$NAME`) of a colour picked from a colour
/// book, with the colour it was picked as; see `active_color_name`.
pub active_color_book: Option<(AcadColor, String)>,
/// 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<LinetypeItem>,
/// 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<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,
/// 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<AtomicU32>,
/// Measured tool-area height (TOOL_BAR_H at full density, shorter once every
/// panel is collapsed). Written by `CollapsePanels`, read when anchoring
/// dropdowns below the ribbon.
tool_bar_h: Arc<AtomicU32>,
/// User-chosen panel density (persisted). `Auto` sizes by window width.
collapse_mode: CollapseMode,
/// Set by `CollapsePanels` when the tool row is in its tight state; the mode
/// selector hides itself then to give the cramped tab row its space back.
collapse_tight: Arc<AtomicBool>,
/// The selected PDF underlay's switches while only underlays are selected.
underlay_ctx: Option<UnderlayContext>,
/// Only xrefs are selected.
xref_ctx: bool,
/// Only point clouds are selected: whether the first shows its crops.
point_cloud_ctx: Option<bool>,
/// A block editor session is open: the tab that was active before the
/// Block Editor tab took over.
block_editing: Option<usize>,
block_testing: Option<usize>,
/// XDWGFADECTL as the Reference slide-out shows it (negative = off).
pub xref_fade: i32,
/// Open key tips (Alt).
keytips: Option<keytips::State>,
}
/// 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,
/// Layout with an active viewport: (its Layer Manager column, frozen there).
pub vp_frozen: Option<(usize, bool)>,
}
/// The colour of layer `name` in the ribbon's layer list.
pub(super) fn layer_color(layers: &[LayerInfo], name: &str) -> Option<Color> {
layers
.iter()
.find(|layer| layer.name == name)
.map(|layer| layer.color)
}
/// 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 {
/// The current layer's colour: what ByLayer looks like for new objects.
pub fn active_layer_color(&self) -> Option<Color> {
layer_color(&self.layer_infos, &self.active_layer)
}
/// The colour-book identity of the active colour, while the active colour
/// is still the one picked from the book.
pub fn active_color_name(&self) -> Option<String> {
self.active_color_book
.as_ref()
.filter(|(color, _)| *color == self.active_color)
.map(|(_, name)| name.clone())
}
pub fn new() -> Self {
Self {
modules: all_ribbon_modules(),
active: 0,
active_tool: None,
wireframe: false,
ortho_mode: true,
show_viewcube: true,
show_ucs_icon: true,
show_properties: true,
show_count_palette: false,
show_sheet_set: false,
show_graphic_attributes: false,
show_authoring_palettes: false,
show_file_tabs: true,
show_layout_tabs: 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.
active_layer: String::new(),
layer_infos: vec![],
layer_filter: String::new(),
active_color: AcadColor::ByLayer,
active_color_book: None,
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())),
collapse_mode: CollapseMode::default(),
collapse_tight: Arc::new(AtomicBool::new(false)),
underlay_ctx: None,
xref_ctx: false,
point_cloud_ctx: None,
block_editing: None,
block_testing: None,
xref_fade: 50,
keytips: None,
}
}
/// Change the tool-panel density. Persistence is handled by the caller via
/// the consolidated app config (`save_config`).
pub fn set_collapse_mode(&mut self, mode: CollapseMode) {
self.collapse_mode = mode;
}
/// The current tool-panel density (for saving into the app config).
pub fn collapse_mode(&self) -> CollapseMode {
self.collapse_mode
}
/// 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<String>,
active_text: &str,
dim: Vec<String>,
active_dim: &str,
mleader: Vec<String>,
active_mleader: &str,
table: Vec<String>,
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<LayerInfo>, active: &str) {
self.active_layer = active.to_string();
self.layer_infos = infos;
}
pub fn set_available_linetypes(&mut self, items: Vec<LinetypeItem>) {
self.available_linetypes = items;
}
/// Show the Block Editor tab while a block is being edited, and return to
/// the previous tab when the editing ends.
pub fn set_block_editing(&mut self, editing: bool) {
let Some(index) = self
.modules
.iter()
.position(|module| module.id() == "block_editor")
else {
return;
};
match (editing, self.block_editing) {
(true, None) => {
self.block_editing = Some(self.active);
self.active = index;
}
(false, Some(previous)) => {
self.block_editing = None;
if self.active == index {
self.active = previous.min(self.modules.len().saturating_sub(1));
}
}
_ => {}
}
}
pub fn set_block_testing(&mut self, testing: bool) {
if testing != self.block_testing.is_some() {
self.close_dropdown();
let _ = self.hide_keytips();
}
let Some(index) = self.modules.iter().position(|module| module.id() == "draw") else {
return;
};
match (testing, self.block_testing) {
(true, None) => {
self.block_testing = Some(self.active);
self.active = index;
}
(false, Some(previous)) => {
self.block_testing = None;
if self.active == index {
self.active = previous.min(self.modules.len().saturating_sub(1));
}
}
_ => {}
}
}
/// Contextual panels share their normal tab with the drawing tools.
fn groups_for(&self, index: usize) -> impl Iterator<Item = &RibbonGroup> {
let testing = self.block_testing.is_some();
self.modules.get(index).into_iter().flat_map(move |module| {
module
.ribbon_groups()
.iter()
.filter(move |group| module.id() != "draw" || group.title != "Close" || testing)
})
}
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<Box<dyn CadModule>>) {
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());
}
#[cfg(test)]
pub(crate) fn active_tool(&self) -> Option<&str> {
self.active_tool.as_deref()
}
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 set_viewcube(&mut self, on: bool) {
self.show_viewcube = on;
}
pub fn set_ucs_icon(&mut self, on: bool) {
self.show_ucs_icon = on;
}
pub fn set_properties(&mut self, on: bool) {
self.show_properties = on;
}
pub fn set_count_palette(&mut self, on: bool) {
self.show_count_palette = on;
}
pub fn set_sheet_set(&mut self, on: bool) {
self.show_sheet_set = on;
}
pub fn set_graphic_attributes(&mut self, on: bool) {
self.show_graphic_attributes = on;
}
pub fn set_file_tabs(&mut self, on: bool) {
self.show_file_tabs = on;
}
pub fn set_layout_tabs(&mut self, on: bool) {
self.show_layout_tabs = on;
}
/// Snapshot of every ribbon toggle's live state, for the render path.
/// The Block Palette highlight is threaded in from the app's authoritative
/// `show_block_palette` rather than a ribbon copy, so it can't drift from
/// the panel's true visibility.
fn toggle_state(&self, show_block_palette: bool) -> widgets::ToggleState {
use widgets::ToggleState;
ToggleState {
ortho_mode: self.ortho_mode,
show_viewcube: self.show_viewcube,
show_ucs_icon: self.show_ucs_icon,
show_properties: self.show_properties,
show_count_palette: self.show_count_palette,
show_graphic_attributes: self.show_graphic_attributes,
show_authoring_palettes: self.show_authoring_palettes,
show_block_palette,
show_sheet_set: self.show_sheet_set,
show_file_tabs: self.show_file_tabs,
show_layout_tabs: self.show_layout_tabs,
}
}
/// One panel in its four densities; `lead` adds controls before the
/// group's own tools.
fn panel<'a>(
&'a self,
g: &'a RibbonGroup,
ts: widgets::ToggleState,
style_ctx: &StyleContext<'_>,
lead: &dyn Fn(bool) -> Vec<Element<'a, Message>>,
) -> Panel<'a> {
let group = |compact: bool| {
render_group(
compact,
g,
lead(compact),
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
style_ctx,
)
};
let button = |tight: bool| {
collapse_button(
g,
self.last_panel_tool.get(g.title).copied(),
&self.active_tool,
&self.open_dropdown,
&self.last_cmd,
ts,
&self.layer_infos,
&self.active_layer,
self.active_color,
&self.active_linetype,
self.active_lineweight,
style_ctx,
tight,
)
};
Panel {
id: g.title.to_string(),
elements: [group(false), group(true), button(false), button(true)],
}
}
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());
if draw_panel::owns_dropdown(id) {
self.collapsed_open = None;
}
}
}
pub fn close_dropdown(&mut self) {
self.open_dropdown = None;
self.collapsed_open = None;
self.layer_filter.clear();
}
pub fn escape_extension(&mut self) -> bool {
let Some(id) = self.open_dropdown.as_deref() else {
return false;
};
let Some(parent) = draw_panel::parent_panel(id) else {
return false;
};
if id == parent {
self.close_dropdown();
} else {
self.open_dropdown = Some(parent.to_string());
}
true
}
/// 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<usize> {
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.close_dropdown();
}
/// Show `cmd` as the current item of a dropdown that mirrors a drawing
/// setting, without running it.
pub fn set_dropdown_current(&mut self, dropdown_id: &'static str, cmd: &'static str) {
if self.last_cmd.get(dropdown_id) != Some(&cmd) {
self.last_cmd.insert(dropdown_id, cmd);
}
}
// ── View ──────────────────────────────────────────────────────────────
pub fn view(
&self,
is_paper: bool,
is_start: bool,
undo_count: usize,
redo_count: usize,
show_block_palette: bool,
visibility: Option<crate::ui::window::block_visibility::RibbonContext>,
) -> Element<'_, Message> {
// ── Quick-access file commands + undo/redo, one merged flow ────────
let lead = iced::widget::Row::with_children(vec![
quick_access_btn(crate::ui::icons::DOC_NEW, "New", "QNEW").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(TOP_HIST_GAP)
.align_y(iced::Center)
.wrap()
.vertical_spacing(0.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.
// A running command whose button is on another tab tints that tab's
// header with a faint wash of the button's blue, pointing at where the
// button lives without competing with the selected tab's outline.
let holding_tab = self.tab_holding_active_tool();
let tab_items = self.modules.iter().enumerate().fold(
Vec::<Element<'_, Message>>::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"
|| (module.id() == "block_editor" && self.block_editing.is_none())
{
return acc;
}
let is_active = i == self.active;
let is_contextual = matches!(module.id(), "layout" | "block_editor");
let holds_tool = holding_tab == Some(i);
let btn = container(
button(
text(crate::i18n::ribbon_module_title(
module.id(),
module.title(),
))
.size(12),
)
.on_press(Message::RibbonSelectTab(i))
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let accent = if is_contextual {
palette.warning.base
} else {
palette.primary.base
};
let pair = match (is_active, status) {
(true, _) => palette.background.weakest,
(false, _) if holds_tool => palette.primary.weak,
(false, button::Status::Hovered) => {
if is_contextual {
palette.warning.weak
} else {
palette.background.weak
}
}
_ => palette.background.base,
};
button::Style {
background: (is_active
|| holds_tool
|| matches!(status, button::Status::Hovered))
.then_some(Background::Color(if holds_tool && !is_active {
pair.color.scale_alpha(0.35)
} else {
pair.color
})),
text_color: if is_active {
pair.text
} else if holds_tool {
palette.background.base.text
} else if is_contextual {
accent.color
} else {
palette.background.base.text.scale_alpha(0.72)
},
border: Border {
color: if is_active {
accent.color
} 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: &Theme| container::Style {
border: Border {
color: if is_active {
if is_contextual {
theme.palette().warning.base.color
} else {
theme.palette().primary.base.color
}
} else {
Color::TRANSPARENT
},
width: if is_active { 2.0 } else { 0.0 },
radius: 0.0.into(),
},
..Default::default()
});
acc.push(keytips::anchor_with(|| format!("tab:{}", module.id()), btn));
acc
},
);
// Tabs may squeeze their gaps to fit before wrapping: from the normal 6px
// down to -12px on a narrow (e.g. phone) tab row, tucking neighbours into
// each other's 14px side padding without overlapping the labels. When the
// row has room the gap stays 6px, so wide/desktop layouts are unchanged.
let tabs = WrapFlow::new(tab_items)
.spacing_x(6.0)
.min_spacing_x(-12.0)
.row_h(28.0);
// Panel-density selector, pinned to the right edge of the tab row: a bare
// ▾ button that opens a list of modes (see `dropdown_overlay`). `Auto`
// sizes panels to the window; the others force one density. The choice is
// persisted (see `Ribbon::set_collapse_mode`). It hides itself once the
// tool row is tight, giving the cramped tab row its space back.
let dd_open = self.open_dropdown.as_deref() == Some(COLLAPSE_MODE_ID);
let mode_btn = button(crate::ui::icons::themed_arrow_down(10.0))
.on_press(Message::ToggleRibbonDropdown(COLLAPSE_MODE_ID.to_string()))
.style(move |theme: &Theme, status| top_hist_btn_style(theme, true, dd_open, status))
.height(24)
.padding([2, 8]);
let mode_dd = PosReport::new(COLLAPSE_MODE_ID, mode_btn);
let mut tab_row = row![container(
WrapBar::new(lead.into(), tabs.into())
.spacing(6.0)
.report_height(self.tab_bar_h.clone()),
)
.width(Length::Fill)]
.spacing(6.0)
.align_y(iced::Center);
if !self.collapse_tight.load(Ordering::Relaxed) {
tab_row = tab_row.push(mode_dd);
}
let tab_bar = container(tab_row)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
..Default::default()
})
.padding(Padding {
right: 6.0,
..Padding::ZERO
})
.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 self.modules.get(effective_active).is_some() {
let groups = self.groups_for(effective_active);
let style_ctx = StyleContext {
text_style_names: &self.text_style_names,
active_text_style: &self.active_text_style,
dim_style_names: &self.dim_style_names,
active_dim_style: &self.active_dim_style,
mleader_style_names: &self.mleader_style_names,
active_mleader_style: &self.active_mleader_style,
table_style_names: &self.table_style_names,
active_table_style: &self.active_table_style,
};
// 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 ts = self.toggle_state(show_block_palette);
let panels: Vec<Panel<'_>> = groups
.map(|g| {
if self.modules[effective_active].id() == "block_editor"
&& g.title == "Visibility"
{
visibility_panel::panel(visibility.clone())
} else {
self.panel(g, ts, &style_ctx, &|_| Vec::new())
}
})
.collect();
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
.report_height(self.tool_bar_h.clone())
.report_tight(self.collapse_tight.clone())
.mode(self.collapse_mode)
.into()
} else {
text("").into()
};
let tool_bar: Element<'_, Message> = container(tool_area)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weakest.color)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
})
.width(Length::Fill)
.into();
let tool_bar = if is_start {
// The Start tab has no drawing context. One theme-aware shield
// fades and blocks only the tool area; the tabs and application
// controls above it remain available and clearly identifiable.
let shield = mouse_area(
container(iced::widget::Space::new())
.width(Fill)
.height(Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.strongest.color.scale_alpha(0.58),
)),
..Default::default()
}),
)
.on_press(Message::CloseRibbonDropdown)
.interaction(iced::mouse::Interaction::Idle);
let shield = iced::widget::tooltip(
shield,
make_tip(t!("Open or create a drawing to use ribbon commands.").into_owned()),
iced::widget::tooltip::Position::Bottom,
)
.gap(6.0)
.delay(std::time::Duration::from_millis(400))
.style(tip_style);
iced::widget::stack![tool_bar, iced::widget::opaque(shield)].into()
} else {
tool_bar
};
keytips::ribbon_area(column![tab_bar, tool_bar])
}
// ── Dropdown overlay ──────────────────────────────────────────────────
/// Place `panel` (`w` wide) under dropdown `id`, closing on a click
/// outside it.
pub fn place_dropdown<'a>(
&self,
id: &str,
panel: Element<'a, Message>,
w: f32,
win_w: f32,
) -> Element<'a, Message> {
let (align_right, h_pad, top) = self.dd_anchor(id, w, win_w);
dropdown_backdrop(position_ribbon_dropdown(panel, align_right, h_pad, top))
}
pub fn dropdown_overlay(
&self,
undo_labels: &[String],
redo_labels: &[String],
win: (f32, f32),
is_start: bool,
recent_colors: &[AcadColor],
) -> Option<Element<'_, Message>> {
if is_start {
return None;
}
let open_id = self.open_dropdown.as_deref()?;
if draw_panel::owns_dropdown(open_id) {
return Some(draw_panel::overlay(self, open_id, win));
}
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<Element<Message>> = labels
.iter()
.enumerate()
.map(|(idx, label)| {
let step = idx + 1;
button(text(label.clone()).size(11))
.on_press(if is_undo {
Message::UndoMany(step)
} else {
Message::RedoMany(step)
})
.style(popup_row_style)
.width(Fill)
.padding([5, 10])
.into()
})
.collect();
let panel = container(column(rows))
.style(popup_panel_style)
.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 == COLLAPSE_MODE_ID {
const W: f32 = 150.0;
let current = self.collapse_mode;
let rows: Vec<Element<Message>> = CollapseMode::ALL
.iter()
.map(|&m| {
button(
row![
crate::ui::icons::themed_check_cell(m == current),
text(t!(m.label())).size(11),
]
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::SetRibbonCollapseMode(m))
.style(popup_row_style)
.width(Fill)
.padding([5, 10])
.into()
})
.collect();
let panel = container(column(rows))
.style(popup_panel_style)
.width(Length::Fixed(W));
let (align_right, h_pad, top) = self.dd_anchor(open_id, W, 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, recent_colors);
}
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 groups = self.groups_for(self.active);
let mut items_list: Option<Vec<(&'static str, &'static str, IconKind)>> = 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::LabeledDropdown {
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<Element<Message>> = items
.iter()
.map(|(cmd, label, item_icon)| {
let is_current = *cmd == last_cmd;
let disabled = widgets::is_disabled_item(cmd);
let checkmark: Element<'_, Message> =
crate::ui::icons::themed_check_cell(is_current);
let icon_el: Element<Message> = container(make_icon(*item_icon, 20.0))
.width(Length::Fixed(20.0))
.into();
let label_el = text(t!(*label))
.size(11)
.wrapping(iced::advanced::text::Wrapping::None)
.style(move |theme: &Theme| iced::widget::text::Style {
color: (!is_current || disabled)
.then_some(theme.palette().background.base.text.scale_alpha(0.72)),
});
let label_el = keytips::label_anchor(
|| keytips::label_id(&keytips::item_anchor(dd_id, cmd)),
label_el,
);
let item = button(
row![checkmark, icon_el, label_el]
.spacing(4)
.align_y(iced::Center),
)
.on_press_maybe((!disabled).then_some(Message::DropdownSelectItem {
dropdown_id: dd_id,
cmd: *cmd,
}))
.style(popup_row_style)
.width(Fill)
.padding([4, 10]);
keytips::anchor_with(|| keytips::item_anchor(dd_id, cmd), item)
})
.collect();
let labels: Vec<String> = items
.iter()
.map(|(_, label, _)| t!(*label).to_string())
.collect();
let header = dropdown_header(dd_id).map(|header| t!(header).into_owned());
let mut rows = rows;
if let Some(header) = &header {
rows.insert(
0,
container(text(header.clone()).size(10).style(muted_text_style))
.padding([4, 10])
.into(),
);
}
let panel_w = crate::ui::style::common::dropdown_popup_width(
labels.iter().map(|s| s.as_str()),
11.0,
68.0,
// A header wraps rather than widening the menu.
if header.is_some() { 240.0 } else { 190.0 },
);
let panel = container(column(rows))
.style(popup_panel_style)
.width(Length::Fixed(panel_w));
let (align_right, h_pad, top) = self.dd_anchor(open_id, panel_w, win.0);
Some(dropdown_backdrop(position_ribbon_dropdown(
panel.into(),
align_right,
h_pad,
top,
)))
}
fn layer_combo_overlay(&self, win: (f32, f32)) -> Option<Element<'_, Message>> {
// 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::semantic(bytes, 14.0))
.on_press(msg)
.style(popup_row_style)
.padding([2, 4])
.into()
};
let filter = self.layer_filter.to_lowercase();
let rows: Vec<Element<Message>> = self
.layer_infos
.iter()
.filter(|info| filter.is_empty() || info.name.to_lowercase().contains(&filter))
.map(|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: &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);
let vis = icon_btn(
crate::ui::icons::layer_visible(lv),
Message::LayerToggleVisible(info.name.clone()),
);
let freeze = icon_btn(
crate::ui::icons::layer_freeze(lf),
Message::LayerToggleFreeze(info.name.clone()),
);
let lock = icon_btn(
crate::ui::icons::layer_lock(ll),
Message::LayerToggleLock(info.name.clone()),
);
// Freeze in the current viewport only (#1466).
let vp_freeze = info.vp_frozen.map(|(column, frozen)| {
icon_btn(
crate::ui::icons::layer_vp_freeze(frozen),
Message::LayerToggleVpFreeze(info.name.clone(), column),
)
});
let checkmark: Element<'_, Message> =
crate::ui::icons::themed_check_cell(is_active);
let label = text(&info.name).size(11).style(move |theme: &Theme| {
iced::widget::text::Style {
color: (!is_active)
.then_some(theme.palette().background.base.text.scale_alpha(0.72)),
}
});
// 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(popup_row_style)
.width(Fill)
.padding([4, 4]);
container(
row![checkmark, vis, freeze]
.push(vp_freeze)
.push(lock)
.push(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 = rows.len().max(1);
let list_h = (row_count as f32 * 26.0).min(420.0);
// Search box (#343): filters the list live as the user types.
let search = iced::widget::text_input(t!("Search layers…").as_ref(), &self.layer_filter)
.on_input(Message::RibbonLayerFilterChanged)
.size(11)
.padding([4, 6]);
let state_manager = button(
row![
crate::ui::icons::themed_arrow_right(9.0),
text(t!("Layer State Manager…")).size(11),
]
.spacing(7)
.align_y(iced::Center),
)
.on_press(Message::LayerStateManagerOpen)
.style(popup_row_style)
.width(Fill)
.padding([6, 10]);
let panel = container(
column![
container(search).padding([4, 4]),
scrollable(column(rows)).height(Length::Fixed(list_h)),
container(state_manager)
.width(Fill)
.padding([3, 4])
.style(|theme: &Theme| container::Style {
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
}),
]
.spacing(2),
)
.style(popup_panel_style)
.width(Length::Fixed(300.0));
let (align_right, h_pad, top) = self.dd_anchor(LAYER_COMBO_ID, 300.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<Element<'_, Message>> {
let groups = self.groups_for(self.active);
// 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,
active_text_style: &self.active_text_style,
dim_style_names: &self.dim_style_names,
active_dim_style: &self.active_dim_style,
mleader_style_names: &self.mleader_style_names,
active_mleader_style: &self.active_mleader_style,
table_style_names: &self.table_style_names,
active_table_style: &self.active_table_style,
};
let active = ctx.active_for(style_key).to_string();
let mut rows: Vec<Element<Message>> = ctx
.names_for(style_key)
.iter()
.map(|name| {
let is_sel = name.as_str() == active.as_str();
let n = name.clone();
let checkmark: Element<Message> = crate::ui::icons::themed_check_cell(is_sel);
button(
row![
checkmark,
text(name.clone()).size(11).style(move |theme: &Theme| {
iced::widget::text::Style {
color: (!is_sel).then_some(
theme.palette().background.base.text.scale_alpha(0.72),
),
}
}),
]
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::RibbonStyleChanged {
key: style_key,
name: n,
})
.style(popup_row_style)
.width(Fill)
.padding([4, 10])
.into()
})
.collect();
if let Some(mgr) = manager_cmd {
rows.push(
button(text(t!("Manage…")).size(11))
.on_press(Message::Command(mgr.to_string()))
.style(popup_row_style)
.width(Fill)
.padding([4, 10])
.into(),
);
}
let panel = container(column(rows))
.style(popup_panel_style)
.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),
recent_colors: &[AcadColor],
) -> Option<Element<'_, Message>> {
let panel = color_dropdown::color_dropdown_panel(
self.active_color,
recent_colors,
self.active_layer_color(),
);
let (align_right, h_pad, top) =
self.dd_anchor(PROP_COLOR_ID, color_dropdown::PANEL_W, win.0);
Some(dropdown_backdrop(position_ribbon_dropdown(
panel,
align_right,
h_pad,
top,
)))
}
fn prop_linetype_overlay(&self, win: (f32, f32)) -> Option<Element<'_, Message>> {
let active_lt = &self.active_linetype;
let mut items: Vec<LinetypeItem> = 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<Element<Message>> = items
.into_iter()
.map(|lt| {
let is_cur = lt.name == *active_lt;
let check: Element<'_, Message> = crate::ui::icons::themed_check_cell(is_cur);
let name_col = text(linetype_display_name(&lt.name))
.size(11)
.style(move |theme: &Theme| iced::widget::text::Style {
color: (!is_cur)
.then_some(theme.palette().background.base.text.scale_alpha(0.72)),
})
.width(Length::Fixed(90.0));
let art_col = text(lt.art.clone()).size(9).style(muted_text_style);
let name = lt.name.clone();
button(
row![check, name_col, art_col]
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::RibbonLinetypeChanged(name))
.style(popup_row_style)
.width(Fill)
.padding([4, 6])
.into()
})
.collect();
let list = container(scrollable(column(rows)).height(Length::Fixed(200.0)))
.style(popup_panel_style)
.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<Element<'_, Message>> {
let active_lw = self.active_lineweight;
let rows: Vec<Element<Message>> = lw_options()
.into_iter()
.map(|item| {
let is_cur = item.0 == active_lw;
let label = item.to_string();
let check: Element<'_, Message> = crate::ui::icons::themed_check_cell(is_cur);
button(
row![
check,
text(label).size(11).style(move |theme: &Theme| {
iced::widget::text::Style {
color: (!is_cur).then_some(
theme.palette().background.base.text.scale_alpha(0.72),
),
}
})
]
.spacing(5)
.align_y(iced::Center),
)
.on_press(Message::RibbonLineweightChanged(item.0))
.style(popup_row_style)
.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<Element<'a, Message>>,
dd_id: &str,
width: f32,
win: (f32, f32),
) -> Option<Element<'a, Message>> {
let panel = container(column(rows))
.style(popup_panel_style)
.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,
lead: Vec<Element<'a, Message>>,
active_tool: &Option<String>,
open_dd: &Option<String>,
last_cmd: &HashMap<&'static str, &'static str>,
state: widgets::ToggleState,
layer_infos: &'a [LayerInfo],
active_layer: &'a str,
active_color: AcadColor,
active_linetype: &'a str,
active_lineweight: LineWeight,
style_ctx: &StyleContext<'_>,
) -> Element<'a, Message> {
let mut items_row: Vec<Element<Message>> = lead;
let mut small_buf: Vec<Element<Message>> = Vec::new();
let ctx = widgets::RenderCtx {
active_tool,
open_dd,
last_cmd,
state,
layer_infos,
active_layer,
active_color,
active_linetype,
active_lineweight,
style_ctx,
compact,
};
for item in &group.tools {
let is_large = match item {
RibbonItem::LargeTool(_) | RibbonItem::LargeDropdown { .. } => !compact,
RibbonItem::ToolGrid { .. }
| 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, &ctx));
} else {
small_buf.push(render_small(item, active_tool, open_dd, last_cmd, state));
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,
draw_panel::group_title(
group.title,
&group.tools.iter().filter_map(item_id).collect::<Vec<_>>(),
open_dd,
active_tool.as_deref(),
),
]
.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::LabeledTool(t) | RibbonItem::LargeTool(t) => Some(t.id),
RibbonItem::Dropdown { id, .. }
| RibbonItem::LabeledDropdown { 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())
}
/// The icon of a panel's first tool-like item. Shown on the tightest collapse
/// button; digs into the composite groups so Layers/Properties/Styles panels
/// still get a representative icon.
fn first_tool_icon(group: &RibbonGroup) -> Option<IconKind> {
group.tools.iter().find_map(|it| match it {
RibbonItem::Tool(t) | RibbonItem::LabeledTool(t) | RibbonItem::LargeTool(t) => Some(t.icon),
RibbonItem::Dropdown { icon, .. }
| RibbonItem::LabeledDropdown { icon, .. }
| RibbonItem::LargeDropdown { icon, .. } => Some(*icon),
RibbonItem::PropertiesGroup { match_prop } => Some(match_prop.icon),
RibbonItem::LayerComboGroup { row2, .. } => row2.first().map(|t| t.icon),
RibbonItem::StyleComboGroup { rows, .. } => {
rows.first().and_then(|r| r.first()).map(|t| t.icon)
}
RibbonItem::ToolGrid { columns } => columns
.first()
.and_then(|column| column.first())
.map(|tool| tool.icon),
})
}
/// A collapsed panel. In the COLLAPSED step: a live representative-tool button
/// (updates to the last-used tool) plus a title + ▾ opener for the full flyout.
/// In the tighter `compact` (TIGHT) step: just the first tool's icon + ▾, a pure
/// flyout opener with no direct tool run. Height is natural content height
/// (shorter than a full 3-row panel), so `CollapsePanels` can shrink the ribbon.
#[allow(clippy::too_many_arguments)]
fn collapse_button<'a>(
group: &RibbonGroup,
last_used: Option<&str>,
active_tool: &Option<String>,
open_dd: &Option<String>,
last_cmd: &HashMap<&'static str, &'static str>,
state: widgets::ToggleState,
layer_infos: &'a [LayerInfo],
active_layer: &'a str,
active_color: AcadColor,
active_linetype: &'a str,
active_lineweight: LineWeight,
style_ctx: &StyleContext<'_>,
// When set, this is the TIGHT step (reached once even the all-collapsed row
// overflows): the whole panel becomes a single non-running button — the first
// tool's icon + a ▾ that opens the tools flyout. Nothing runs directly here.
compact: bool,
) -> Element<'a, Message> {
let title = group.title;
let localized_title = t!(title).into_owned();
// A collapsed panel lights its opener while one of its hidden tools runs,
// the way a dropdown lights while one of its items does.
let active = active_tool.as_deref();
let holds = active.is_some_and(|id| locate::group_holds(group, id));
let opener_style = move |lit: bool| {
move |theme: &Theme, status| {
if lit {
tool_btn_style(theme, true, status)
} else {
button::subtle(theme, status)
}
}
};
// Tightest form: one button = the panel's FIRST tool icon + its title + ▾.
// Clicking opens the flyout listing every tool; no tool runs directly at this
// density (you pick one from the dropdown), so the tightest row is pure openers.
if compact {
let icon: Element<'_, Message> = match first_tool_icon(group) {
Some(ik) => make_icon(ik, SMALL_ICON),
None => text("").into(),
};
let content = button(
column![
icon,
row![
keytips::label_anchor(
|| keytips::label_id(&keytips::collapsed_anchor(title)),
text(localized_title.clone())
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
),
crate::ui::icons::themed_secondary_arrow_down(8.0),
]
.spacing(3)
.width(Fill)
.align_y(iced::Center),
]
.align_x(iced::Center)
.spacing(2)
.width(Fill),
)
.on_press(Message::ToggleRibbonPanel(title.to_string()))
.style(opener_style(holds))
.width(Fill)
.padding([3, 5]);
return keytips::anchor_with(
|| keytips::collapsed_anchor(title),
automatic_large_button(localized_title, content.into()),
);
}
// Collapsed (not yet tight): a large representative-tool face — a live button
// that runs the last-used tool — above a title + ▾ opener for the full flyout.
// For a Properties panel the representative is its Match button.
let rep = representative(group, last_used);
// The face lights itself when it is the running tool.
let face_lit = rep
.zip(active)
.is_some_and(|(item, id)| locate::item_holds(item, id));
let face: Element<'_, Message> = match rep {
Some(RibbonItem::PropertiesGroup { match_prop }) => render_large(
&RibbonItem::LargeTool(match_prop.clone()),
&widgets::RenderCtx {
active_tool,
open_dd,
last_cmd,
state,
layer_infos,
active_layer,
active_color,
active_linetype,
active_lineweight,
style_ctx,
compact: false,
},
),
Some(item) => render_large(
item,
&widgets::RenderCtx {
active_tool,
open_dd,
last_cmd,
state,
layer_infos,
active_layer,
active_color,
active_linetype,
active_lineweight,
style_ctx,
compact: false,
},
),
None => text("").into(),
};
let opener = button(
row![
keytips::label_anchor(
|| keytips::label_id(&keytips::collapsed_anchor(title)),
text(localized_title.clone())
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
),
crate::ui::icons::themed_secondary_arrow_down(8.0),
]
.spacing(3)
.width(Fill)
.align_y(iced::Center),
)
.on_press(Message::ToggleRibbonPanel(title.to_string()))
.style(opener_style(holds && !face_lit))
.width(Fill)
.padding([1, 4]);
let opener = keytips::anchor_with(
|| keytips::collapsed_anchor(title),
automatic_large_button(localized_title, opener.into()),
);
// The large face fills a fixed slot so a collapsed panel is shorter than a full
// 3-row panel, letting `CollapsePanels` shrink the ribbon row.
let face_box = container(face)
.height(Length::Fixed(COLLAPSED_FACE_H))
.align_y(iced::Center);
column![face_box, opener]
.align_x(iced::Center)
.spacing(2)
.padding([3u16, 4])
.into()
}
impl Default for Ribbon {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::widgets::is_active_tool;
use super::Ribbon;
#[test]
fn block_test_close_panel_extends_draw_only_while_testing() {
let mut ribbon = Ribbon::default();
let draw = ribbon
.modules
.iter()
.position(|module| module.id() == "draw")
.unwrap();
assert!(!ribbon
.modules
.iter()
.any(|module| module.id() == "block_test"));
let ordinary: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect();
assert!(!ordinary.contains(&"Close"));
ribbon.set_block_editing(true);
ribbon.set_block_editing(false);
ribbon.set_block_testing(true);
assert_eq!(ribbon.active, draw);
let testing: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect();
assert_eq!(&testing[..ordinary.len()], ordinary.as_slice());
assert_eq!(testing.last(), Some(&"Close"));
ribbon.toggle_collapsed_panel("Close");
ribbon.set_block_testing(false);
assert!(ribbon.collapsed_open.is_none());
assert_eq!(
ribbon
.groups_for(draw)
.map(|group| group.title)
.collect::<Vec<_>>(),
ordinary
);
ribbon.set_block_editing(true);
assert_eq!(ribbon.modules[ribbon.active].id(), "block_editor");
}
#[test]
fn block_palette_highlight_follows_the_app_state_passed_to_the_view() {
// The Block Palette button highlight is derived from the app's
// authoritative `show_block_palette`, threaded into the view at render
// time: the ribbon keeps no second copy that a BLOCKPALETTE / close
// handler must remember to keep in sync.
let ribbon = Ribbon::default();
let on = ribbon.toggle_state(true);
assert!(is_active_tool("BLOCKPALETTE", &None, &on));
let off = ribbon.toggle_state(false);
assert!(!is_active_tool("BLOCKPALETTE", &None, &off));
}
#[test]
fn escape_returns_from_an_extension_submenu_then_closes_the_panel() {
let mut ribbon = Ribbon::default();
ribbon.open_dropdown = Some("DRAWORDER_FRONT".to_string());
assert!(ribbon.escape_extension());
assert_eq!(ribbon.open_dropdown.as_deref(), Some("modify_extension"));
assert!(ribbon.escape_extension());
assert_eq!(ribbon.open_dropdown, None);
}
}