Merge main into codex/graphic-attributes-cleanup

Conflicts were additions on both sides, kept together: the Count palette
and Sheet Set toggles next to the Graphic Attributes toggle in the ribbon,
main's new locale groups and catalog entries next to graphic-attributes,
and module declarations. The palette code follows main's crate renames
(acadrust -> codec, cadkernel -> kernel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgkJN8DEojuPTumAXLHotu
This commit is contained in:
Claude committed 2026-10-07 20:11:05 +00:00
commit 7a03e1c43c
625 files changed
+104397 -28053

No files matched your search

+2 -2
View File
@@ -11,7 +11,7 @@ use super::widgets::{muted_text_style, popup_panel_style, popup_row_style};
use crate::app::Message;
use crate::t;
use crate::ui::properties::acad_color_display;
use acadrust::types::Color as AcadColor;
use codec::types::Color as AcadColor;
use iced::widget::{button, column, container, row, text, Space};
use iced::{Background, Border, Element, Length, Padding, Theme};
@@ -82,7 +82,7 @@ pub const QUICK_PICK_GRID: [[(u8, u8, u8); 9]; 5] = [
#[allow(dead_code)]
pub fn aci_1_9() -> [(u8, u8, u8); 9] {
std::array::from_fn(|i| {
acadrust::types::aci_table::aci_to_rgb((i + 1) as u8).unwrap_or((128, 128, 128))
codec::types::aci_table::aci_to_rgb((i + 1) as u8).unwrap_or((128, 128, 128))
})
}
+130
View File
@@ -0,0 +1,130 @@
//! Selection-driven tools: while only underlays of one kind (or only xrefs) are
//! selected their actions show on the right-edge toolbar; they go when the
//! selection changes. Contrast and fade stay in the Properties panel.
use super::*;
use crate::modules::{ModuleEvent, ToolDef};
/// The switches of the selected underlay (the first one) the toolbar shows
/// as on.
#[derive(Clone, Debug, PartialEq)]
pub struct UnderlayContext {
pub kind: codec::entities::UnderlayType,
pub monochrome: bool,
pub shown: bool,
pub snap: bool,
}
fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef {
ToolDef {
id,
label,
icon: IconKind::Svg(icon),
event: ModuleEvent::Command(id.to_string()),
}
}
const CLIP: &[u8] = include_bytes!("../../../assets/icons/xclip.svg");
/// The clip icon with a small red cross at its lower right.
const UNCLIP: &[u8] = include_bytes!("../../../assets/icons/xclip_remove.svg");
/// Underlay tools: display, clipping, options, layers and (PDF only)
/// import.
pub fn pdf_underlay_tools(kind: codec::entities::UnderlayType) -> Vec<ToolDef> {
const FRAMES: &[u8] = include_bytes!("../../../assets/icons/underlay_frames.svg");
let mut tools = vec![
tool("_PDFULMONO", "Display in\nMonochrome", FRAMES),
tool("_PDFULCLIP", "Create Clipping\nBoundary", CLIP),
tool("_PDFULUNCLIP", "Remove\nClipping", UNCLIP),
tool("_PDFULSHOW", "Show\nUnderlay", FRAMES),
tool(
"_PDFULSNAP",
"Enable\nSnap",
include_bytes!("../../../assets/icons/snap_underlays.svg"),
),
tool("EXTERNALREFERENCES", "External\nReferences", crate::ui::icons::FOLDER_OPEN),
tool(
"ULAYERS",
"Edit\nLayers",
include_bytes!("../../../assets/icons/underlay_layers.svg"),
),
];
if kind == codec::entities::UnderlayType::Pdf {
tools.push(tool(
"_PDFULIMPORT",
"Import As\nObjects",
include_bytes!("../../../assets/icons/cui_import.svg"),
));
}
tools
}
/// Point cloud tools: the three crops, showing, inverting and removing
/// them, and the palette.
pub fn point_cloud_tools() -> Vec<ToolDef> {
vec![
tool("_PCCROPRECT", "Rectangular\nCrop", CLIP),
tool("_PCCROPPOLY", "Polygonal\nCrop", CLIP),
tool("_PCCROPCIRC", "Circular\nCrop", CLIP),
tool(
"_PCCROPSHOW",
"Show\nCropping",
include_bytes!("../../../assets/icons/underlay_frames.svg"),
),
tool("_PCCROPINVERT", "Invert\nCropping", CLIP),
tool("_PCUNCROP", "Remove All\nCropping", UNCLIP),
tool("EXTERNALREFERENCES", "External\nReferences", crate::ui::icons::FOLDER_OPEN),
]
}
/// External reference tools: edit, open, clipping and the palette.
pub fn xref_tools() -> Vec<ToolDef> {
vec![
tool(
"_XREFEDIT",
"Edit Reference\nIn-Place",
include_bytes!("../../../assets/icons/edit_block.svg"),
),
tool("_XREFOPEN", "Open\nReference", crate::ui::icons::FOLDER_OPEN),
tool("_XREFCLIP", "Create Clipping\nBoundary", CLIP),
tool("_XREFUNCLIP", "Remove\nClipping", UNCLIP),
tool("EXTERNALREFERENCES", "External\nReferences", crate::ui::icons::FOLDER_OPEN),
]
}
impl Ribbon {
/// The selection context: the selected underlay's switches, or that
/// only xrefs are selected, or neither.
pub fn set_underlay_context(&mut self, context: Option<UnderlayContext>, xref: bool) {
self.underlay_ctx = context;
self.xref_ctx = xref;
}
pub fn underlay_context(&self) -> Option<&UnderlayContext> {
self.underlay_ctx.as_ref()
}
pub fn xref_context(&self) -> bool {
self.xref_ctx
}
/// Only point clouds are selected: whether the first shows its crops.
pub fn set_point_cloud_context(&mut self, context: Option<bool>) {
self.point_cloud_ctx = context;
}
pub fn point_cloud_context(&self) -> Option<bool> {
self.point_cloud_ctx
}
/// Select a tab by its module id.
pub fn select_by_id(&mut self, id: &str) -> bool {
match self.modules.iter().position(|m| m.id() == id) {
Some(index) => {
self.select(index);
true
}
None => false,
}
}
}
+201 -9
View File
@@ -106,26 +106,89 @@ const TOOLS: &[Tool] = &[
#[derive(Clone, Copy)]
struct Panel {
id: &'static str,
/// A command the owning ribbon group must hold, for titles more than one
/// tab uses ("Block" on Home and Insert).
anchor: Option<&'static str>,
title_id: &'static str,
title: &'static str,
tools: &'static [Tool],
}
/// The Reference panel's slide-out: Edit Reference, then the xref fading
/// switch and amount (drawn by `reference_overlay`).
const REFERENCE_TOOLS: &[Tool] = &[Tool {
command: "REFEDIT",
label: "Edit Reference",
icon: include_bytes!("../../../assets/icons/edit_block.svg"),
options: &[],
}];
pub(super) const REFERENCE_PANEL_ID: &str = "reference_extension";
const fn icon_tool(command: &'static str, label: &'static str, icon: &'static [u8]) -> Tool {
Tool { command, label, icon, options: &[] }
}
/// Home > Block slide-out.
const HOME_BLOCK_TOOLS: &[Tool] = &[
icon_tool("ATTDEF", "Define Attributes", include_bytes!("../../../assets/icons/attdef.svg")),
icon_tool("ATTMAN", "Manage Attributes", include_bytes!("../../../assets/icons/attman.svg")),
icon_tool("ATTSYNC", "Synchronize", include_bytes!("../../../assets/icons/attsync.svg")),
icon_tool("BASE", "Set Base Point", include_bytes!("../../../assets/icons/base_point.svg")),
];
/// Insert > Block Definition slide-out.
const BLOCK_DEFINITION_TOOLS: &[Tool] = &[
icon_tool("BASE", "Set Base Point", include_bytes!("../../../assets/icons/base_point.svg")),
icon_tool("ATTSYNC", "Synchronize", include_bytes!("../../../assets/icons/attsync.svg")),
];
const PANELS: &[Panel] = &[
Panel {
id: PANEL_ID,
anchor: None,
title_id: TITLE_ID,
title: "Draw",
tools: TOOLS,
},
Panel {
id: "modify_extension",
anchor: None,
title_id: "modify_extension_title",
title: "Modify",
tools: super::modify_panel::TOOLS,
},
Panel {
id: REFERENCE_PANEL_ID,
anchor: None,
title_id: "reference_extension_title",
title: "Reference",
tools: REFERENCE_TOOLS,
},
Panel {
id: "home_block_extension",
anchor: Some("BEDIT"),
title_id: "home_block_extension_title",
title: "Block",
tools: HOME_BLOCK_TOOLS,
},
Panel {
id: "block_definition_extension",
anchor: None,
title_id: "block_definition_extension_title",
title: "Block Definition",
tools: BLOCK_DEFINITION_TOOLS,
},
];
/// Command, label and icon of every tool in the expanded Draw and Modify
/// panels.
pub(crate) fn tools() -> impl Iterator<Item = (&'static str, &'static str, &'static [u8])> {
PANELS
.iter()
.flat_map(|panel| panel.tools)
.map(|tool| (tool.command, tool.label, tool.icon))
}
fn panel_for_dropdown(id: &str) -> Option<Panel> {
PANELS.iter().copied().find(|panel| {
panel.id == id
@@ -144,22 +207,58 @@ pub(super) fn parent_panel(id: &str) -> Option<&'static str> {
panel_for_dropdown(id).map(|panel| panel.id)
}
pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>) -> Element<'a, Message> {
let Some(panel) = PANELS
.iter()
.find(|panel| panel.title == title && !panel.tools.is_empty())
else {
/// The slide-out under the title of the ribbon group `title`, whose items
/// are `group_ids`.
fn panel_for_group(title: &str, group_ids: &[&'static str]) -> Option<Panel> {
PANELS.iter().copied().find(|panel| {
panel.title == title
&& !panel.tools.is_empty()
&& panel.anchor.is_none_or(|anchor| group_ids.contains(&anchor))
})
}
/// Every command the slide-out of the group `title` offers: each tool and
/// each of its submenu options.
pub(super) fn slide_out_commands(
title: &str,
group_ids: &[&'static str],
) -> impl Iterator<Item = &'static str> {
panel_for_group(title, group_ids)
.into_iter()
.flat_map(|panel| panel.tools)
.flat_map(|tool| {
std::iter::once(tool.command).chain(tool.options.iter().map(|(cmd, _)| *cmd))
})
}
/// A slide-out tool is lit while it or one of its submenu options runs.
fn tool_is_active(tool: &Tool, active_tool: Option<&str>) -> bool {
active_tool.is_some_and(|active| {
tool.command == active || tool.options.iter().any(|(cmd, _)| *cmd == active)
})
}
pub(super) fn group_title<'a>(
title: &'static str,
group_ids: &[&'static str],
open: &Option<String>,
active_tool: Option<&str>,
) -> Element<'a, Message> {
let Some(panel) = panel_for_group(title, group_ids) else {
return container(text(t!(title)).size(9).style(muted_text_style))
.padding([1, 4])
.into();
};
let expanded = open.as_deref().and_then(parent_panel) == Some(panel.id);
// A command running from the closed slide-out lights its title, the way a
// dropdown lights while one of its items runs.
let lit = expanded || panel.tools.iter().any(|tool| tool_is_active(tool, active_tool));
let arrow = if expanded {
icons::themed_arrow_up(GROUP_TITLE_ARROW_SIZE)
} else {
icons::themed_arrow_down(GROUP_TITLE_ARROW_SIZE)
};
PosReport::new(
let title_button: Element<'a, Message> = PosReport::new(
panel.id,
button(
row![
@@ -170,10 +269,41 @@ pub(super) fn group_title<'a>(title: &'static str, open: &Option<String>) -> Ele
.align_y(iced::Center),
)
.on_press(Message::ToggleRibbonDropdown(panel.id.to_string()))
.style(move |theme: &Theme, status| tool_btn_style(theme, expanded, status))
.style(move |theme: &Theme, status| tool_btn_style(theme, lit, status))
.padding([1, 4]),
)
.into()
.into();
if panel.id != REFERENCE_PANEL_ID {
return title_button;
}
// The corner arrow opens the External References palette.
let launcher = tooltip(
// A right arrow turned to point into the corner.
button(
iced::widget::svg(icons::themed_handle(icons::ARROW_LONG_RIGHT))
.width(10)
.height(10)
.rotation(iced::Radians(std::f32::consts::FRAC_PI_4))
.style(|theme: &Theme, _| iced::widget::svg::Style {
color: Some(theme.palette().background.base.text),
}),
)
.on_press(Message::RibbonToolClick {
tool_id: "EXTERNALREFERENCES".to_string(),
event: crate::modules::ModuleEvent::Command("EXTERNALREFERENCES".to_string()),
})
.style(move |theme: &Theme, status| tool_btn_style(theme, false, status))
.padding([0, 3]),
make_tip(format!("{}\n{} EXTERNALREFERENCES", t!("External References"), t!("Command:"))),
tooltip::Position::Bottom,
)
.delay(Duration::from_millis(400))
.style(tip_style);
// Shrink-sized: a Fill here would make the panel claim the whole row.
row![title_button, launcher]
.spacing(6)
.align_y(iced::Center)
.into()
}
fn tool_button(tool: &Tool, active: bool, panel_id: &'static str) -> Element<'static, Message> {
@@ -211,8 +341,70 @@ fn tool_button(tool: &Tool, active: bool, panel_id: &'static str) -> Element<'st
}
}
/// The Reference slide-out: Edit Reference, and xref fading on/off with its
/// amount (XDWGFADECTL; the switch keeps the amount as a negative value).
fn reference_overlay<'a>(ribbon: &Ribbon, panel: Panel, win: (f32, f32)) -> Element<'a, Message> {
let width: f32 = 260.0_f32.min((win.0 - 8.0).max(120.0));
let (_, x, anchor_top) = ribbon.dd_anchor(panel.id, width, win.0);
let left = x.clamp(0.0, (win.0 - width).max(0.0));
let top = anchor_top.min((win.1 - 80.0).max(0.0));
let tool = &panel.tools[0];
let edit = button(
row![
make_icon(IconKind::Svg(tool.icon), 16.0),
text(t!(tool.label)).size(11)
]
.spacing(6)
.align_y(iced::Center),
)
.on_press(Message::DropdownSelectItem {
dropdown_id: panel.id,
cmd: tool.command,
})
.style(popup_row_style)
.width(Fill)
.padding([3, 6]);
let fade = ribbon.xref_fade;
let on = fade > 0;
let switch = button(make_icon(
IconKind::Svg(include_bytes!("../../../assets/icons/underlay_frames.svg")),
16.0,
))
.on_press(Message::XrefFadeToggle)
.style(move |theme: &Theme, status| tool_btn_style(theme, on, status))
.padding(3);
let amount = fade.unsigned_abs().min(90) as u8;
let fading = row![
tooltip(
switch,
make_tip(format!("{}\n{} XDWGFADECTL", t!("Xref fading"), t!("Command:"))),
tooltip::Position::Bottom
)
.delay(Duration::from_millis(400))
.style(tip_style),
text(t!("Xref fading")).size(11),
iced::widget::slider(0..=90u8, amount, Message::XrefFadeSlide)
.on_release(Message::XrefFadeCommit)
.width(Fill),
text(amount.to_string()).size(11).width(22),
]
.spacing(6)
.align_y(iced::Center);
let body = container(column![edit, fading].spacing(4).padding(6))
.width(width)
.style(|theme: &Theme| {
let mut style = popup_panel_style(theme);
style.border.color = theme.palette().primary.base.color;
style
});
dropdown_backdrop(position_ribbon_dropdown(body.into(), false, left, top))
}
pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element<'a, Message> {
let panel = panel_for_dropdown(id).expect("registered ribbon extension");
if panel.id == REFERENCE_PANEL_ID {
return reference_overlay(ribbon, panel, win);
}
let tools = panel.tools;
let width = (7.0 * (CELL + GAP) - GAP + 12.0).min((win.0 - 8.0).max(CELL + 12.0));
let (_, x, anchor_top) = ribbon.dd_anchor(panel.id, width, win.0);
@@ -259,7 +451,7 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element
.map(|tool| {
tool_button(
tool,
ribbon.active_tool.as_deref() == Some(tool.command),
tool_is_active(tool, ribbon.active_tool.as_deref()),
panel.id,
)
})
+283
View File
@@ -0,0 +1,283 @@
//! Where the ribbon offers a command, so a command started from the command
//! line, an alias, a shortcut or Repeat lights the same button a click on it
//! would. Seeing which button runs a typed command is how its place in the
//! ribbon is learned.
use crate::modules::{ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
use super::{draw_panel, item_id, Ribbon};
/// One button of a group: the id its highlight is keyed on, and the command
/// line it dispatches (`None` for a button that raises a non-command event).
type Entry<'a> = (&'static str, Option<&'a str>);
fn tool_entry(t: &ToolDef) -> Entry<'_> {
match &t.event {
ModuleEvent::Command(cmd) => (t.id, Some(cmd.as_str())),
_ => (t.id, None),
}
}
/// The buttons one ribbon item shows: itself, the items of a dropdown, or the
/// small tools under a combo. A dropdown item is keyed on the command it
/// dispatches, which is what the dropdown's highlight looks for.
fn item_entries(item: &RibbonItem) -> Vec<Entry<'_>> {
match item {
RibbonItem::Tool(t) | RibbonItem::LabeledTool(t) | RibbonItem::LargeTool(t) => {
vec![tool_entry(t)]
}
RibbonItem::PropertiesGroup { match_prop } => vec![tool_entry(match_prop)],
RibbonItem::Dropdown { items, .. }
| RibbonItem::LabeledDropdown { items, .. }
| RibbonItem::LargeDropdown { items, .. } => {
items.iter().map(|(cmd, _, _)| (*cmd, Some(*cmd))).collect()
}
RibbonItem::ToolGrid { columns } | RibbonItem::StyleComboGroup { rows: columns, .. } => {
columns.iter().flatten().map(tool_entry).collect()
}
RibbonItem::LayerComboGroup { row2, row3 } => {
row2.iter().chain(row3).map(tool_entry).collect()
}
}
}
/// Every button `group` offers: its items' buttons and the tools of its title
/// slide-out, keyed like dropdown items on the command they dispatch.
pub(super) fn group_entries(group: &RibbonGroup) -> Vec<Entry<'_>> {
let mut out: Vec<_> = group.tools.iter().flat_map(item_entries).collect();
let ids: Vec<_> = group.tools.iter().filter_map(item_id).collect();
out.extend(draw_panel::slide_out_commands(group.title, &ids).map(|cmd| (cmd, Some(cmd))));
out
}
/// Whether `item` shows the button keyed `id`, lit or as the item a lit
/// dropdown holds.
pub(super) fn item_holds(item: &RibbonItem, id: &str) -> bool {
item_entries(item).iter().any(|(entry, _)| *entry == id)
}
/// Whether `group` holds the button keyed `id`.
pub(super) fn group_holds(group: &RibbonGroup, id: &str) -> bool {
group_entries(group).iter().any(|(entry, _)| *entry == id)
}
impl Ribbon {
/// Every button of every tab, with the index of the tab it is on. The
/// tab on show comes first, so a command offered on two tabs lights the
/// button the user can already see.
fn entries(&self) -> impl Iterator<Item = (usize, Entry<'_>)> {
let order = std::iter::once(self.active)
.chain((0..self.modules.len()).filter(move |i| *i != self.active));
order
.filter_map(|i| self.modules.get(i).map(|m| (i, m)))
.flat_map(|(i, module)| {
module
.ribbon_groups()
.iter()
.flat_map(group_entries)
.map(move |entry| (i, entry))
})
}
/// The button id that runs the command line `cmd`: an exact match first,
/// then a button that runs its bare verb (`OFFSET 5` → Offset).
pub(super) fn tool_for_command(&self, cmd: &str) -> Option<&'static str> {
let cmd = cmd.trim();
let verb = cmd.split_whitespace().next().unwrap_or(cmd);
[cmd, verb].into_iter().find_map(|want| {
self.entries()
.find(|(_, (_, runs))| runs.is_some_and(|runs| runs.eq_ignore_ascii_case(want)))
.map(|(_, (id, _))| id)
})
}
/// Light the ribbon button of the command line `cmd` that is starting,
/// however it was started. A button the user just clicked to run `cmd`
/// stays lit, so a command offered on two tabs keeps the clicked one; a
/// command with no button clears the previous command's highlight.
pub fn show_command(&mut self, cmd: &str) {
let clicked = self.active_tool.as_deref().is_some_and(|current| {
self.entries().any(|(_, (id, runs))| {
id == current && runs.is_some_and(|runs| runs.eq_ignore_ascii_case(cmd.trim()))
})
});
if !clicked {
self.active_tool = self.tool_for_command(cmd).map(str::to_string);
}
}
/// The tab holding the lit button, when it is not the tab on show: its
/// header lights instead, pointing at where the button lives. The Layout
/// module has no tab (its tools are on the side toolbar).
pub(super) fn tab_holding_active_tool(&self) -> Option<usize> {
let id = self.active_tool.as_deref()?;
let holds = |i: usize| {
self.modules[i]
.ribbon_groups()
.iter()
.any(|group| group_holds(group, id))
};
if self
.modules
.get(self.active)
.is_some_and(|_| holds(self.active))
{
return None;
}
(0..self.modules.len()).find(|i| self.modules[*i].id() != "layout" && holds(*i))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modules::{CadModule, IconKind};
struct TestTab {
id: &'static str,
groups: Vec<RibbonGroup>,
}
impl CadModule for TestTab {
fn id(&self) -> &'static str {
self.id
}
fn title(&self) -> &'static str {
self.id
}
fn ribbon_groups(&self) -> &[RibbonGroup] {
&self.groups
}
}
fn tool(id: &'static str, cmd: &str) -> ToolDef {
ToolDef {
id,
label: id,
icon: IconKind::Glyph("x"),
event: ModuleEvent::Command(cmd.to_string()),
}
}
/// Home: OFFSET (button id differs from its command), a CIRCLE dropdown,
/// LAYERS (a non-command event). View: ZOOM EXTENTS and a second OFFSET.
fn ribbon() -> Ribbon {
let mut layers = tool("LAYERS", "");
layers.event = ModuleEvent::ToggleLayers;
let home = TestTab {
id: "home",
groups: vec![RibbonGroup {
title: "Modify",
tools: vec![
RibbonItem::LargeTool(tool("OFFSET_TOOL", "OFFSET")),
RibbonItem::Dropdown {
id: "circle_dd",
icon: IconKind::Glyph("o"),
items: vec![
("CIRCLE", "Center, Radius", IconKind::Glyph("o")),
("CIRCLE_2P", "2-Point", IconKind::Glyph("o")),
],
default: "CIRCLE",
},
RibbonItem::Tool(layers),
],
}],
};
let view = TestTab {
id: "view",
groups: vec![RibbonGroup {
title: "Navigate",
tools: vec![
RibbonItem::Tool(tool("ZOOM_EXTENTS", "ZOOM EXTENTS")),
RibbonItem::Tool(tool("OFFSET_VIEW", "OFFSET")),
],
}],
};
let mut ribbon = Ribbon::default();
ribbon.set_modules(vec![Box::new(home), Box::new(view)]);
ribbon
}
#[test]
fn a_command_lights_the_button_that_runs_it_not_one_named_like_it() {
let mut r = ribbon();
r.show_command("OFFSET");
assert_eq!(r.active_tool.as_deref(), Some("OFFSET_TOOL"));
assert_eq!(r.tab_holding_active_tool(), None);
}
#[test]
fn a_dropdown_item_is_keyed_on_its_command_so_the_dropdown_lights() {
let mut r = ribbon();
r.show_command("circle_2p");
assert_eq!(r.active_tool.as_deref(), Some("CIRCLE_2P"));
}
#[test]
fn a_button_on_another_tab_lights_that_tab() {
let mut r = ribbon();
r.show_command("ZOOM EXTENTS");
assert_eq!(r.active_tool.as_deref(), Some("ZOOM_EXTENTS"));
assert_eq!(r.tab_holding_active_tool(), Some(1));
r.select(1);
assert_eq!(r.tab_holding_active_tool(), None);
}
#[test]
fn a_command_on_two_tabs_prefers_the_tab_on_show_then_the_clicked_one() {
let mut r = ribbon();
r.select(1);
r.show_command("OFFSET");
assert_eq!(r.active_tool.as_deref(), Some("OFFSET_VIEW"));
// A clicked button keeps its highlight when its command dispatches,
// even though another button for the same command comes first.
r.select(0);
r.activate_tool("OFFSET_VIEW");
r.show_command("OFFSET");
assert_eq!(r.active_tool.as_deref(), Some("OFFSET_VIEW"));
}
#[test]
fn arguments_fall_back_to_the_bare_verb() {
let mut r = ribbon();
r.show_command("OFFSET 5");
assert_eq!(r.active_tool.as_deref(), Some("OFFSET_TOOL"));
}
#[test]
fn a_command_without_a_button_clears_the_previous_highlight() {
let mut r = ribbon();
r.show_command("OFFSET");
r.show_command("DIST");
assert_eq!(r.active_tool, None);
assert_eq!(r.tab_holding_active_tool(), None);
// A non-command button has no command line to match.
r.show_command("LAYERS");
assert_eq!(r.active_tool, None);
}
#[test]
fn slide_out_tools_and_their_options_belong_to_their_group() {
// The Draw group's title slide-out holds SPLINE; the Modify one holds
// the draw-order submenu.
let draw = RibbonGroup {
title: "Draw",
tools: vec![],
};
assert!(group_holds(&draw, "SPLINE"));
let modify = RibbonGroup {
title: "Modify",
tools: vec![],
};
assert!(group_holds(&modify, "DRAWORDER_BACK"));
}
#[test]
fn the_shipped_ribbon_resolves_its_own_commands() {
let mut r = Ribbon::default();
for cmd in ["LINE", "OFFSET", "SPLINE", "ZOOM EXTENTS"] {
r.show_command(cmd);
assert!(r.active_tool.is_some(), "{cmd} has no ribbon button");
}
}
}
+174 -85
View File
@@ -11,7 +11,7 @@ use std::sync::Arc;
use rustc_hash::FxHashMap as HashMap;
use acadrust::types::{Color as AcadColor, LineWeight};
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};
@@ -20,13 +20,17 @@ use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem};
use crate::plugin::all_ribbon_modules;
use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem};
mod widgets;
pub(crate) mod widgets;
mod draw_panel;
pub(crate) use draw_panel::tools as panel_tools;
mod modify_panel;
mod color_dropdown;
mod context_tools;
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 locate;
use collapse::{CollapsePanels, Panel};
pub use collapse::CollapseMode;
use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow};
@@ -54,6 +58,10 @@ pub struct Ribbon {
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,
/// File tabs (FILETAB) visibility — drives the File Tabs button highlight.
@@ -104,6 +112,14 @@ pub struct Ribbon {
/// 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>,
/// XDWGFADECTL as the Reference slide-out shows it (negative = off).
pub xref_fade: i32,
}
/// Per-layer display data shown in the ribbon layer dropdown.
@@ -114,6 +130,8 @@ pub struct LayerInfo {
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)>,
}
/// Full-screen backdrop for an open ribbon dropdown: closes the dropdown when
@@ -164,6 +182,8 @@ impl Ribbon {
show_viewcube: true,
show_ucs_icon: true,
show_properties: true,
show_count_palette: false,
show_sheet_set: false,
show_graphic_attributes: false,
show_file_tabs: true,
show_layout_tabs: true,
@@ -198,6 +218,10 @@ impl Ribbon {
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,
xref_fade: 50,
}
}
@@ -291,6 +315,10 @@ impl Ribbon {
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;
}
@@ -318,6 +346,12 @@ impl Ribbon {
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;
}
@@ -338,13 +372,64 @@ impl Ribbon {
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_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;
@@ -423,6 +508,14 @@ impl Ribbon {
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(
@@ -435,7 +528,7 @@ impl Ribbon {
) -> 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", "NEW").into(),
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(),
@@ -451,6 +544,10 @@ impl Ribbon {
// 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)| {
@@ -462,6 +559,7 @@ impl Ribbon {
let is_active = i == self.active;
let is_contextual = module.id() == "layout";
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))
@@ -474,6 +572,7 @@ impl Ribbon {
};
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
@@ -485,10 +584,17 @@ impl Ribbon {
};
button::Style {
background: (is_active
|| holds_tool
|| matches!(status, button::Status::Hovered))
.then_some(Background::Color(pair.color)),
.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 {
@@ -530,6 +636,7 @@ impl Ribbon {
},
);
// 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
@@ -609,73 +716,10 @@ impl Ribbon {
// 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
.iter()
.map(|g| {
let ts = self.toggle_state(show_block_palette);
Panel {
id: g.title.to_string(),
elements: [render_group(
false,
g,
&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,
),
render_group(
true,
g,
&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,
),
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,
false,
),
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,
true,
),
],
}
})
.map(|g| 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())
@@ -742,6 +786,19 @@ impl Ribbon {
// ── 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],
@@ -881,6 +938,7 @@ impl Ribbon {
.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> =
@@ -892,7 +950,7 @@ impl Ribbon {
.size(11)
.wrapping(iced::advanced::text::Wrapping::None)
.style(move |theme: &Theme| iced::widget::text::Style {
color: (!is_current).then_some(
color: (!is_current || disabled).then_some(
theme
.palette()
.background
@@ -907,10 +965,10 @@ impl Ribbon {
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::DropdownSelectItem {
.on_press_maybe((!disabled).then_some(Message::DropdownSelectItem {
dropdown_id: dd_id,
cmd: *cmd,
})
}))
.style(popup_row_style)
.width(Fill)
.padding([4, 10])
@@ -957,11 +1015,10 @@ impl Ribbon {
let rows: Vec<Element<Message>> = self
.layer_infos
.iter()
.enumerate()
.filter(|(_, info)| {
.filter(|info| {
filter.is_empty() || info.name.to_lowercase().contains(&filter)
})
.map(|(index, info)| {
.map(|info| {
let is_active = info.name == self.active_layer;
let lc = info.color;
let lv = info.visible;
@@ -984,16 +1041,23 @@ impl Ribbon {
let vis = icon_btn(
crate::ui::icons::layer_visible(lv),
Message::LayerToggleVisible(index),
Message::LayerToggleVisible(info.name.clone()),
);
let freeze = icon_btn(
crate::ui::icons::layer_freeze(lf),
Message::LayerToggleFreeze(index),
Message::LayerToggleFreeze(info.name.clone()),
);
let lock = icon_btn(
crate::ui::icons::layer_lock(ll),
Message::LayerToggleLock(index),
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 =
@@ -1019,7 +1083,10 @@ impl Ribbon {
.padding([4, 4]);
container(
row![checkmark, vis, freeze, lock, select]
row![checkmark, vis, freeze]
.push(vp_freeze)
.push(lock)
.push(select)
.spacing(5)
.align_y(iced::Center),
)
@@ -1328,6 +1395,7 @@ impl Ribbon {
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>,
@@ -1339,7 +1407,7 @@ fn render_group<'a>(
active_lineweight: LineWeight,
style_ctx: &StyleContext<'_>,
) -> Element<'a, Message> {
let mut items_row: Vec<Element<Message>> = Vec::new();
let mut items_row: Vec<Element<Message>> = lead;
let mut small_buf: Vec<Element<Message>> = Vec::new();
let ctx = widgets::RenderCtx {
@@ -1392,7 +1460,12 @@ fn render_group<'a>(
column![
tools_el,
draw_panel::group_title(group.title, open_dd),
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)
@@ -1473,6 +1546,19 @@ fn collapse_button<'a>(
) -> 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
@@ -1490,7 +1576,7 @@ fn collapse_button<'a>(
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::WordOrGlyph)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
crate::ui::icons::themed_secondary_arrow_down(8.0),
]
@@ -1503,7 +1589,7 @@ fn collapse_button<'a>(
.width(Fill),
)
.on_press(Message::ToggleRibbonPanel(title.to_string()))
.style(button::subtle)
.style(opener_style(holds))
.width(Fill)
.padding([3, 5]);
return automatic_large_button(localized_title, content.into());
@@ -1513,6 +1599,8 @@ fn collapse_button<'a>(
// 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(
@@ -1559,7 +1647,7 @@ fn collapse_button<'a>(
.size(9)
.width(Fill)
.align_x(iced::Center)
.wrapping(iced::advanced::text::Wrapping::WordOrGlyph)
.wrapping(iced::advanced::text::Wrapping::Word)
.style(muted_text_style),
crate::ui::icons::themed_secondary_arrow_down(8.0),
]
@@ -1568,7 +1656,7 @@ fn collapse_button<'a>(
.align_y(iced::Center),
)
.on_press(Message::ToggleRibbonPanel(title.to_string()))
.style(button::subtle)
.style(opener_style(holds && !face_lit))
.width(Fill)
.padding([1, 4]);
let opener = automatic_large_button(localized_title, opener.into());
@@ -1621,3 +1709,4 @@ mod tests {
assert_eq!(ribbon.open_dropdown, None);
}
}
+31 -9
View File
@@ -5,7 +5,7 @@ use rustc_hash::FxHashMap as HashMap;
use std::cell::RefCell;
use std::time::Duration;
use acadrust::types::{Color as AcadColor, LineWeight};
use codec::types::{Color as AcadColor, LineWeight};
use iced::advanced::{
layout, mouse, overlay, renderer, text as advanced_text, widget, Layout, Shell, Widget,
};
@@ -37,8 +37,10 @@ pub(super) struct ToggleState {
pub show_viewcube: bool,
pub show_ucs_icon: bool,
pub show_properties: bool,
pub show_count_palette: bool,
pub show_graphic_attributes: bool,
pub show_block_palette: bool,
pub show_sheet_set: bool,
pub show_file_tabs: bool,
pub show_layout_tabs: bool,
}
@@ -95,6 +97,11 @@ thread_local! {
RefCell::new(HashMap::default());
}
/// Dropdown items that report a state and cannot be picked.
pub(super) fn is_disabled_item(cmd: &str) -> bool {
cmd == "FRAMES3"
}
fn ribbon_label_bounds(
renderer: &iced::Renderer,
label: &str,
@@ -304,7 +311,7 @@ pub(super) const TOP_HIST_GAP: f32 = 4.0;
pub(crate) const UNDO_HISTORY_ID: &str = "UNDO_HISTORY";
pub(crate) const REDO_HISTORY_ID: &str = "REDO_HISTORY";
pub(super) const LAYER_COMBO_ID: &str = "LAYER_COMBO";
pub(crate) 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";
@@ -375,8 +382,10 @@ pub(super) fn is_active_tool(
"NAVVCUBE" => state.show_viewcube,
"UCSICON" => state.show_ucs_icon,
"PROPERTIES" => state.show_properties,
"COUNTLIST" => state.show_count_palette,
"GRAPHICATTRIBUTES" => state.show_graphic_attributes,
"BLOCKPALETTE" => state.show_block_palette,
"SHEETSET" => state.show_sheet_set,
"FILETAB" => state.show_file_tabs,
"LAYOUTTAB" => state.show_layout_tabs,
id => active_tool.as_deref() == Some(id),
@@ -443,7 +452,7 @@ pub(super) fn combo_btn_style(
}
}
pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button::Style {
pub(crate) 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
@@ -457,7 +466,7 @@ pub(super) fn popup_row_style(theme: &Theme, status: button::Status) -> button::
}
}
pub(super) fn popup_panel_style(theme: &Theme) -> container::Style {
pub(crate) fn popup_panel_style(theme: &Theme) -> container::Style {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.base.color)),
@@ -667,18 +676,31 @@ pub(super) fn render_small<'a>(
items.iter().find(|(candidate, _, _)| *candidate == cmd)
.map(|(_, _, item_icon)| *item_icon)
}).or_else(|| items.first().map(|(_, _, item_icon)| *item_icon)).unwrap_or(*icon);
let localized_label = t!(*label).into_owned();
let localized_label = if label.is_empty() {
items
.iter()
.find(|(cmd, _, _)| *cmd == last)
.map(|(_, item_label, _)| t!(*item_label).into_owned())
.unwrap_or_default()
} else {
t!(*label).into_owned()
};
let face = row![
container(make_icon(cur_icon, SMALL_ICON)).width(Length::Fixed(SMALL_W)),
text(localized_label.clone()).size(10).wrapping(advanced_text::Wrapping::None),
].spacing(3).align_y(iced::Center);
// A state that is only shown (not chosen) opens the list instead.
let face_btn = button(face)
.on_press(Message::RibbonToolClick {
tool_id: last.to_string(),
event: ModuleEvent::Command(last.to_string()),
.on_press(if is_disabled_item(last) {
Message::ToggleRibbonDropdown(id.to_string())
} else {
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(LABELED_SMALL_W)).height(ROW_H).padding([3, 4]);
.width(Length::Shrink).height(ROW_H).padding([3, 4]);
let arrow = 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()))