From 16d3fd80f8e164e99027cb6a5f738a7ca465bdd8 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Thu, 15 Oct 2026 11:51:02 +0100 Subject: [PATCH] feat(block-editor): add authoring palette UI Add a dockable Block Editor palette with selectable Parameters, Actions, Parameter Sets and Constraints pages, reusing shared dock controls, icons and ribbon-style hover bubbles. Parameters, Actions, Parameter Sets and Block Properties Table are UI catalogs only. Their entries remain disabled; this commit does not add parameter placement, dynamic action behavior, parameter-set authoring or block properties table functionality. The Constraints page connects its geometric and dimensional entries to existing ribbon commands. It reuses their implementations and adds no new constraint behavior. Keep the palette specific to BEDIT and retain its placement and selected page across closing/reopening and Test Block. Opening, closing, switching pages and clicking disabled entries preserve the document, undo history and any running command. Validation: 7 palette tests, 57 dock checks and 46 ribbon checks passed (overlapping groups); rendered pages inspected and native debug build passed. --- src/app/commands/display.rs | 5 + src/app/commands/mod.rs | 11 +- src/app/dock/tests.rs | 297 +++++++++++++++++++++ src/app/dock/update.rs | 26 ++ src/app/dock/view.rs | 51 +--- src/app/mod.rs | 6 + src/app/update/mod.rs | 6 + src/modules/block_editor/mod.rs | 10 + src/modules/parametric/mod.rs | 8 +- src/modules/parametric/smooth.rs | 11 + src/ui/dock.rs | 45 +++- src/ui/ribbon/keytips.rs | 1 + src/ui/ribbon/mod.rs | 3 + src/ui/ribbon/widgets.rs | 2 + src/ui/window/authoring_palettes.rs | 382 ++++++++++++++++++++++++++++ src/ui/window/mod.rs | 1 + 16 files changed, 818 insertions(+), 47 deletions(-) create mode 100644 src/ui/window/authoring_palettes.rs diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 2a3bdb869..eb23461ba 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -628,6 +628,11 @@ impl OpenCADStudio { return Some(Task::done(Message::ToggleQuickProperties)); } + "BAUTHORPALETTE" | "BAUTHORPALETTECLOSE" => { + let open = cmd == "BAUTHORPALETTE" && !self.show_authoring_palettes; + return Some(self.dock_set_open(crate::ui::dock::PanelId::AuthoringPalettes, open)); + } + // ── TOOLPALETTES — not yet implemented ─────────────────────────────── "TOOLPALETTES" => { self.command_line.push_info( diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index d2739f9e3..d06c17d7f 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -447,7 +447,14 @@ pub fn is_transparent(cmd: &str) -> bool { is_spacemouse_command(cmd) || matches!( cmd, - "ORTHO" | "GRID" | "SNAP" | "POLAR" | "OSNAP" | "DSETTINGS" + "ORTHO" + | "GRID" + | "SNAP" + | "POLAR" + | "OSNAP" + | "DSETTINGS" + | "BAUTHORPALETTE" + | "BAUTHORPALETTECLOSE" ) } @@ -983,6 +990,8 @@ inventory::submit!(crate::command::CommandRegistration { "STYLE", "STYLESMANAGER", "TABLESTYLE", + "BAUTHORPALETTE", + "BAUTHORPALETTECLOSE", "TOOLPALETTES", "TP", "TS", diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index fd894a02c..cff677f78 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -691,3 +691,300 @@ fn floating_menu_docks_and_allow_docking_keeps_a_drag_floating() { Some(DockSide::Left) ); } + +fn authoring_app() -> OpenCADStudio { + use codec::{ + entities::{Insert, Line}, + types::Vector3, + EntityType, + }; + let mut app = dock_app(); + let i = app.active_tab; + app.tabs[i].scene.define_block_raw( + "AUTHOR", + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))], + ); + let reference = app.tabs[i] + .scene + .document + .add_entity(EntityType::Insert(Insert::new("AUTHOR", Vector3::ZERO))) + .unwrap(); + let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", reference.value())); + app +} + +#[test] +fn authoring_palettes_ribbon_toggle_and_dock_close_leave_document_and_history_unchanged() { + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = authoring_app(); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + let history = app.tabs[i].history.undo_stack.len(); + let click = || Message::RibbonToolClick { + tool_id: "BAUTHORPALETTE".into(), + event: crate::modules::ModuleEvent::Command("BAUTHORPALETTE".into()), + }; + let _ = app.update(click()); + assert!(app.dock_panel_visible(PanelId::AuthoringPalettes)); + assert!(app.ribbon.show_authoring_palettes); + assert!(app.dock.is_placed(PanelId::AuthoringPalettes)); + let _ = app.update(click()); + assert!(!app.dock_panel_visible(PanelId::AuthoringPalettes)); + assert!(!app.ribbon.show_authoring_palettes); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let _ = app.on_dock(DockMsg::Close(PanelId::AuthoringPalettes)); + assert!(!app.show_authoring_palettes); + assert!(!app.ribbon.show_authoring_palettes); + assert_eq!(app.tabs[i].scene.document, before); + assert_eq!(app.tabs[i].history.undo_stack.len(), history); + assert!(!app.block_edit_dirty(i)); +} + +#[test] +fn authoring_palettes_follow_editor_context_and_keep_the_docked_placement() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let _ = app.on_dock(DockMsg::DockTo(PanelId::AuthoringPalettes, DockSide::Left)); + let placement = app.dock.location(PanelId::AuthoringPalettes); + let _ = app.dispatch_command("BTESTBLOCK"); + assert!(!app.dock_panel_visible(PanelId::AuthoringPalettes)); + let _ = app.dispatch_command("BCLOSETESTBLOCK"); + assert!(app.dock_panel_visible(PanelId::AuthoringPalettes)); + let _ = app.dispatch_command("BTESTBLOCK"); + assert!(!app.dock_panel_visible(PanelId::AuthoringPalettes)); + let _ = app.dispatch_command("BAUTHORPALETTECLOSE"); + let _ = app.dispatch_command("BAUTHORPALETTE"); + assert!(!app.show_authoring_palettes); + assert!(!app.dock_panel_visible(PanelId::AuthoringPalettes)); + let _ = app.dispatch_command("BCLOSETESTBLOCK"); + let _ = app.dispatch_command("BAUTHORPALETTE"); + assert!(app.dock_panel_visible(PanelId::AuthoringPalettes)); + assert_eq!(app.dock.location(PanelId::AuthoringPalettes), placement); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert!(!app.dock_panel_visible(PanelId::AuthoringPalettes)); + let _ = app.dispatch_command("BAUTHORPALETTECLOSE"); + let _ = app.dispatch_command("BAUTHORPALETTE"); + assert!(!app.show_authoring_palettes); +} + +#[test] +fn authoring_palettes_toggle_keeps_the_running_drawing_command() { + let mut app = authoring_app(); + let _ = app.dispatch_command("LINE"); + assert!(app.tabs[app.active_tab].active_cmd.is_some()); + let _ = app.update(Message::RibbonToolClick { + tool_id: "BAUTHORPALETTE".into(), + event: crate::modules::ModuleEvent::Command("BAUTHORPALETTE".into()), + }); + assert!(app.tabs[app.active_tab].active_cmd.is_some()); + let _ = app.dispatch_command("BAUTHORPALETTECLOSE"); + assert!(app.tabs[app.active_tab].active_cmd.is_some()); +} + +#[test] +fn authoring_palettes_catalog_in_real_view_has_no_parameter_placement_action() { + with_stack(|| { + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let point = find_in_view(&app, "Basepoint"); + let before = app.tabs[app.active_tab].scene.document.clone(); + drive_view( + &mut app, + point, + &[ + iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)), + iced::Event::Mouse(iced::mouse::Event::ButtonReleased( + iced::mouse::Button::Left, + )), + ], + ); + assert_eq!(app.tabs[app.active_tab].scene.document, before); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + }); +} + +/// Locate rotated category controls by their stable widget IDs. +fn authoring_control(app: &OpenCADStudio, id: &'static str) -> iced::Point { + use iced_test::core::widget; + use iced_test::runtime::user_interface::{Cache, UserInterface}; + use iced_test::Selector; + let mut renderer = headless_renderer(); + let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, Cache::default(), &mut renderer); + let mut find = widget::Id::new(id).find(); + ui.operate(&renderer, &mut widget::operation::black_box(&mut find)); + match widget::Operation::finish(&find) { + widget::operation::Outcome::Some(Some(target)) => { + target.visible_bounds().expect("visible").center() + } + _ => panic!("{id} not found"), + } +} + +fn authoring_click(app: &mut OpenCADStudio, point: iced::Point) { + drive_view( + app, + point, + &[ + iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)), + iced::Event::Mouse(iced::mouse::Event::ButtonReleased( + iced::mouse::Button::Left, + )), + ], + ); +} + +#[test] +fn authoring_palettes_real_tabs_switch_catalogs_and_unavailable_rows_stay_inert() { + with_stack(|| { + use crate::ui::window::authoring_palettes::Tab; + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + let history = app.tabs[i].history.undo_stack.len(); + let _ = app.dispatch_command("LINE"); + for (tab, row) in [ + (Tab::Actions, "Block Properties Table"), + (Tab::ParameterSets, "Point Move"), + (Tab::Parameters, "Basepoint"), + ] { + let point = authoring_control(&app, tab.widget_id()); + authoring_click(&mut app, point); + assert_eq!(app.authoring_palette_tab, tab); + authoring_capture(&app, tab.widget_id()); + let point = find_in_view(&app, row); + authoring_click(&mut app, point); + if tab == Tab::ParameterSets { + let mut simulator = iced_test::Simulator::with_size( + iced::Settings::default(), + VIEW_SIZE, + app.view_main(), + ); + simulator.point_at(point); + simulator.simulate([iced::Event::Mouse(iced::mouse::Event::WheelScrolled { + delta: iced::mouse::ScrollDelta::Lines { x: 0.0, y: -100.0 }, + })]); + simulator + .click("Lookup Set") + .expect("last set is reachable by scrolling"); + authoring_snapshot(&mut simulator, &app.active_theme, "parameter-sets-bottom"); + let messages = simulator.into_messages().collect::>(); + for message in messages { + let _ = app.update(message); + } + } + assert_eq!( + app.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.name()), + Some("LINE") + ); + } + assert_eq!(app.tabs[i].scene.document, before); + assert_eq!(app.tabs[i].history.undo_stack.len(), history); + assert!(!app.block_edit_dirty(i)); + }); +} + +#[test] +fn authoring_palettes_constraints_start_the_existing_commands_from_real_rows() { + with_stack(|| { + use crate::ui::window::authoring_palettes::Tab; + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let point = authoring_control(&app, Tab::Constraints.widget_id()); + authoring_click(&mut app, point); + assert_eq!(app.authoring_palette_tab, Tab::Constraints); + authoring_capture(&app, Tab::Constraints.widget_id()); + // The palette row is distinct from the ribbon's Coincident button. + let point = authoring_control(&app, "authoring-tool-CCONSTRAINT"); + authoring_click(&mut app, point); + assert_eq!( + app.tabs[app.active_tab] + .active_cmd + .as_ref() + .map(|command| command.name()), + Some("GCCOINCIDENT") + ); + let messages = { + let mut simulator = iced_test::Simulator::with_size( + iced::Settings::default(), + VIEW_SIZE, + app.view_main(), + ); + simulator.point_at(point); + simulator.simulate([iced::Event::Mouse(iced::mouse::Event::WheelScrolled { + delta: iced::mouse::ScrollDelta::Lines { x: 0.0, y: -100.0 }, + })]); + simulator + .click(iced_test::core::widget::Id::new( + "authoring-tool-DCDIAMETER", + )) + .expect("last dimensional constraint is reachable by scrolling"); + authoring_snapshot(&mut simulator, &app.active_theme, "constraints-bottom"); + simulator.into_messages().collect::>() + }; + for message in messages { + let _ = app.update(message); + } + assert_eq!( + app.tabs[app.active_tab] + .active_cmd + .as_ref() + .map(|command| command.name()), + Some("DCDIAMETER") + ); + assert!(!app.block_edit_dirty(app.active_tab)); + }); +} + +#[test] +fn authoring_palettes_selected_page_survives_test_block_and_close_and_ignores_hidden_input() { + use crate::ui::window::authoring_palettes::Tab; + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let _ = app.update(Message::AuthoringPaletteTabSelected(Tab::ParameterSets)); + let _ = app.dispatch_command("BTESTBLOCK"); + let _ = app.update(Message::AuthoringPaletteTabSelected(Tab::Constraints)); + assert_eq!(app.authoring_palette_tab, Tab::ParameterSets); + let _ = app.dispatch_command("BCLOSETESTBLOCK"); + assert_eq!(app.authoring_palette_tab, Tab::ParameterSets); + let _ = app.dispatch_command("BAUTHORPALETTECLOSE"); + let _ = app.update(Message::AuthoringPaletteTabSelected(Tab::Actions)); + assert_eq!(app.authoring_palette_tab, Tab::ParameterSets); + let _ = app.dispatch_command("BAUTHORPALETTE"); + assert_eq!(app.authoring_palette_tab, Tab::ParameterSets); +} + +/// Optional rendered evidence when reviewing palette layout changes. +fn authoring_capture(app: &OpenCADStudio, name: &str) { + if std::env::var_os("OCS_AUTHORING_PALETTE_CAPTURES").is_none() { + return; + } + let mut simulator = + iced_test::Simulator::with_size(iced::Settings::default(), VIEW_SIZE, app.view_main()); + authoring_snapshot(&mut simulator, &app.active_theme, name); +} + +fn authoring_snapshot( + simulator: &mut iced_test::Simulator<'_, Message>, + theme: &iced::Theme, + name: &str, +) { + let Some(directory) = std::env::var_os("OCS_AUTHORING_PALETTE_CAPTURES") else { + return; + }; + let path = std::path::PathBuf::from(directory).join(format!("{name}.png")); + assert!(simulator + .snapshot(theme) + .expect("render palette") + .matches_image(path) + .expect("write palette evidence")); +} diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index cc2c81112..e77857c45 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -435,6 +435,16 @@ impl OpenCADStudio { ) -> iced::Task { use crate::ui::dock::{DockDrag, PanelId}; let mut task = iced::Task::none(); + if open + && id == PanelId::AuthoringPalettes + && self.tabs[self.active_tab] + .active_block_edit_session() + .is_none() + { + self.command_line + .push_error(crate::t!("Open the Block Editor to use Authoring Palettes.").as_ref()); + return task; + } if open { if !self.dock_panel_visible(id) { match id { @@ -448,6 +458,12 @@ impl OpenCADStudio { self.save_config(); } PanelId::BlockPalette => self.open_blocks_palette(None), + PanelId::AuthoringPalettes => { + self.show_authoring_palettes = true; + self.ribbon.show_authoring_palettes = true; + self.dock_open_at_default(id); + self.save_config(); + } PanelId::ExternalReferences => { self.show_external_references = true; self.refresh_xref_manager(); @@ -473,6 +489,10 @@ impl OpenCADStudio { self.show_block_palette = false; self.block_palette.placing = None; } + PanelId::AuthoringPalettes => { + self.show_authoring_palettes = false; + self.ribbon.show_authoring_palettes = false; + } PanelId::ExternalReferences => self.show_external_references = false, PanelId::Browser => self.show_browser = false, PanelId::NodeGraph => self.show_node_graph = false, @@ -562,6 +582,12 @@ impl OpenCADStudio { match id { PanelId::Properties => self.show_properties, PanelId::BlockPalette => self.show_block_palette, + PanelId::AuthoringPalettes => { + self.show_authoring_palettes + && self.tabs[self.active_tab] + .active_block_edit_session() + .is_some() + } PanelId::ExternalReferences => self.show_external_references, PanelId::Browser => self.show_browser, PanelId::NodeGraph => self.show_node_graph, diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index 991eebab6..c70612986 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -36,6 +36,10 @@ impl OpenCADStudio { // Checked = open and docked on this edge. let checked: Vec<_> = crate::ui::dock::PanelId::ALL .iter() + .filter(|p| { + **p != crate::ui::dock::PanelId::AuthoringPalettes + || tab.active_block_edit_session().is_some() + }) .map(|p| { let here = self.dock.location(*p).map(|(s, _)| s) == Some(side); (*p, here && self.dock_panel_visible(*p)) @@ -397,6 +401,13 @@ impl OpenCADStudio { ), ) } + crate::ui::dock::PanelId::AuthoringPalettes => { + crate::ui::window::authoring_palettes::view( + self.authoring_palette_tab, + width, + chrome, + ) + } crate::ui::dock::PanelId::BlockPalette => { crate::ui::window::block_palette::view(&self.block_palette, width, chrome) } @@ -886,44 +897,6 @@ fn dock_icon_ghost(icons: &[crate::ui::dock::PanelId]) -> Element<'static, Messa .into() } -/// Canvas that draws a floating title bar's label, reading upward from the -/// bar's bottom end. -struct VBarLabel { - text: String, -} - -impl canvas::Program for VBarLabel { - type State = (); - - fn draw( - &self, - _state: &(), - renderer: &iced::Renderer, - theme: &Theme, - bounds: iced::Rectangle, - _cursor: iced::advanced::mouse::Cursor, - ) -> Vec { - let mut frame = canvas::Frame::new(renderer, bounds.size()); - frame.with_save(|frame| { - frame.translate(iced::Vector::new(bounds.width / 2.0, bounds.height / 2.0)); - frame.rotate(iced::Radians(-std::f32::consts::FRAC_PI_2)); - // After the rotation the text runs along local +x (upward), so - // the bar's bottom end sits at -height/2. - frame.fill_text(canvas::Text { - content: self.text.clone(), - position: iced::Point::new(-bounds.height / 2.0, 0.0), - color: theme.palette().background.base.text.scale_alpha(0.72), - size: iced::Pixels(13.0), - align_x: iced::advanced::text::Alignment::Left, - align_y: iced::alignment::Vertical::Center, - shaping: iced::advanced::text::Shaping::Advanced, - ..Default::default() - }); - }); - vec![frame.into_geometry()] - } -} - /// Width of a floating panel's vertical title bar. const DOCK_FLOAT_BAR_W: f32 = 28.0; @@ -974,7 +947,7 @@ fn floating_title_bar( } else { iced::widget::tooltip::Position::Right }; - let title = canvas(VBarLabel { + let title = canvas(crate::ui::dock::VerticalLabel { text: t!(id.title()).into_owned(), }) .width(Fill) diff --git a/src/app/mod.rs b/src/app/mod.rs index dcc194222..22ed7bf44 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -948,6 +948,9 @@ pub(super) struct OpenCADStudio { graphic_attributes: crate::ui::window::graphic_attributes::GraphicAttributesState, /// Docked Insert Block panel visibility. pub(crate) show_block_palette: bool, + /// Authoring palette preference, visible only in a Block Editor session. + show_authoring_palettes: bool, + authoring_palette_tab: crate::ui::window::authoring_palettes::Tab, /// Node graph overlay over the viewport. pub(crate) show_node_graph: bool, /// Entity the Properties handlers act on instead of the selection, set @@ -4148,6 +4151,7 @@ pub enum Message { /// A dock chrome interaction (grab/resize/pin/hover/dock move) on a side /// panel. Dock(crate::ui::dock::DockMsg), + AuthoringPaletteTabSelected(crate::ui::window::authoring_palettes::Tab), /// Open the paper-layout batch output dialog. PrintAllOpen, /// Toggle one paper layout in the batch. @@ -4780,6 +4784,8 @@ impl OpenCADStudio { show_graphic_attributes: false, graphic_attributes: Default::default(), show_block_palette: false, + show_authoring_palettes: false, + authoring_palette_tab: Default::default(), show_node_graph: false, property_target_override: None, graph_undo_open: false, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 75f0de896..0e2792ccd 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -10876,6 +10876,12 @@ impl OpenCADStudio { Message::BlockPalette(m) => self.on_block_palette(m), Message::GraphicAttributes(m) => self.on_graphic_attributes(m), Message::Dock(m) => self.on_dock(m), + Message::AuthoringPaletteTabSelected(tab) => { + if self.dock_panel_visible(crate::ui::dock::PanelId::AuthoringPalettes) { + self.authoring_palette_tab = tab; + } + Task::none() + } Message::PrintAllOpen => self.on_print_all_open(), Message::PrintAllToggle(name) => { if let Some((_, selected)) = self diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 09b61c71e..2ac305a8b 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -82,6 +82,16 @@ impl CadModule for BlockEditorModule { // Parametric blocks are driven by the same constraints and // parameters as the Parametric tab. groups.extend(ParametricModule.ribbon_groups().iter().cloned()); + if let Some(manage) = groups.iter_mut().find(|group| group.title == "Manage") { + manage.tools.insert( + 0, + command( + "BAUTHORPALETTE", + "Authoring Palettes", + include_bytes!("../../../assets/icons/tool_palettes.svg"), + ), + ); + } groups }) } diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 9da688dd8..9cfd56ee2 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -42,7 +42,7 @@ pub use perpendicular_command::{PerpendicularConstraintCommand, PerpendicularPic pub use point_on_entity::{ center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, }; -pub use smooth::SmoothConstraintCommand; +pub use smooth::{smooth_tool, SmoothConstraintCommand}; pub use symmetric_command::SymmetricConstraintCommand; pub use tangent_command::TangentConstraintCommand; pub use tools::{ @@ -89,11 +89,7 @@ impl CadModule for ParametricModule { RibbonItem::LargeTool(tangent::tool()), RibbonItem::LargeTool(colinear::tool()), RibbonItem::LargeTool(perpendicular::tool()), - RibbonItem::LargeTool(command( - "GCSMOOTH", - "Smooth", - include_bytes!("../../../assets/icons/constrain/smooth.svg"), - )), + RibbonItem::LargeTool(smooth_tool()), RibbonItem::LargeTool(concentric_tool::tool()), RibbonItem::LargeTool(horizontal::tool()), RibbonItem::LargeTool(symmetric::tool()), diff --git a/src/modules/parametric/smooth.rs b/src/modules/parametric/smooth.rs index b75cc02cc..9e39f164d 100644 --- a/src/modules/parametric/smooth.rs +++ b/src/modules/parametric/smooth.rs @@ -5,6 +5,17 @@ use crate::command::{CadCommand, CmdResult}; use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; use crate::t; +pub fn smooth_tool() -> crate::modules::ToolDef { + crate::modules::ToolDef { + id: "GCSMOOTH", + label: "Smooth", + icon: crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/constrain/smooth.svg" + )), + event: crate::modules::ModuleEvent::Command("GCSMOOTH".into()), + } +} + #[derive(Clone, Copy)] struct SmoothTarget { endpoint: ParametricRef, diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 098738cd6..87eb8016c 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -106,6 +106,8 @@ pub enum PanelId { Properties, GraphicAttributes, BlockPalette, + /// Block Editor authoring tools. + AuthoringPalettes, ExternalReferences, /// Outline of the drawing's origin planes, open sketch and solid bodies. Browser, @@ -123,10 +125,11 @@ pub enum PanelId { impl PanelId { /// Every panel, in the order the pallet menu lists them. - pub const ALL: [PanelId; 10] = [ + pub const ALL: [PanelId; 11] = [ PanelId::Properties, PanelId::GraphicAttributes, PanelId::BlockPalette, + PanelId::AuthoringPalettes, PanelId::ExternalReferences, PanelId::Browser, PanelId::NodeGraph, @@ -142,6 +145,7 @@ impl PanelId { PanelId::Properties => "Properties", PanelId::GraphicAttributes => "Graphic Attributes", PanelId::BlockPalette => "Block Palette", + PanelId::AuthoringPalettes => "Authoring Palettes", PanelId::ExternalReferences => "External References", PanelId::Browser => "Browser", PanelId::NodeGraph => "Node Graph", @@ -160,6 +164,7 @@ impl PanelId { include_bytes!("../../assets/icons/graphic_attributes.svg") } PanelId::BlockPalette => include_bytes!("../../assets/icons/blocks/insert.svg"), + PanelId::AuthoringPalettes => include_bytes!("../../assets/icons/tool_palettes.svg"), PanelId::ExternalReferences => crate::ui::icons::FOLDER_OPEN, PanelId::Browser => include_bytes!("../../assets/icons/content_browser.svg"), PanelId::NodeGraph => crate::ui::icons::NODE_GRAPH, @@ -176,6 +181,7 @@ impl PanelId { PanelId::Properties => 250.0, PanelId::GraphicAttributes => 250.0, PanelId::BlockPalette => 260.0, + PanelId::AuthoringPalettes => 260.0, PanelId::ExternalReferences => 460.0, PanelId::Browser => 230.0, PanelId::NodeGraph => 220.0, @@ -1234,6 +1240,43 @@ pub fn frame<'a>( .into() } +/// Rotated label shared by floating dock title bars and palette categories. +pub(crate) struct VerticalLabel { + pub text: String, +} + +impl iced::widget::canvas::Program for VerticalLabel { + type State = (); + + fn draw( + &self, + _state: &(), + renderer: &iced::Renderer, + theme: &Theme, + bounds: iced::Rectangle, + _cursor: iced::advanced::mouse::Cursor, + ) -> Vec { + let mut frame = iced::widget::canvas::Frame::new(renderer, bounds.size()); + frame.with_save(|frame| { + frame.translate(iced::Vector::new(bounds.width / 2.0, bounds.height / 2.0)); + frame.rotate(iced::Radians(-std::f32::consts::FRAC_PI_2)); + // After the rotation the text runs along local +x (upward), so + // the bar's bottom end sits at -height/2. + frame.fill_text(iced::widget::canvas::Text { + content: self.text.clone(), + position: iced::Point::new(-bounds.height / 2.0, 0.0), + color: theme.palette().background.base.text.scale_alpha(0.72), + size: iced::Pixels(13.0), + align_x: iced::advanced::text::Alignment::Left, + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + }); + vec![frame.into_geometry()] + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/ribbon/keytips.rs b/src/ui/ribbon/keytips.rs index b5f917ad7..cb674772d 100644 --- a/src/ui/ribbon/keytips.rs +++ b/src/ui/ribbon/keytips.rs @@ -479,6 +479,7 @@ const CODES: &[(&str, &str, &str)] = &[ ("draw", "Close", "TC"), ("draw", "BCLOSETESTBLOCK", "CT"), ("block_editor", "ATTDEF", "AD"), + ("block_editor", "BAUTHORPALETTE", "AP"), ("block_editor", "AUTOCONSTRAIN", "AC"), ("block_editor", "CCONSTRAINT", "CI"), ("block_editor", "PCONSTRAINT", "PA"), diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 91778e89d..0426e4cfc 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -70,6 +70,7 @@ pub struct Ribbon { 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. @@ -221,6 +222,7 @@ impl Ribbon { 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, @@ -471,6 +473,7 @@ impl Ribbon { 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, diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 75d18bbd7..5aadfcbc1 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -39,6 +39,7 @@ pub(super) struct ToggleState { pub show_properties: bool, pub show_count_palette: bool, pub show_graphic_attributes: bool, + pub show_authoring_palettes: bool, pub show_block_palette: bool, pub show_sheet_set: bool, pub show_file_tabs: bool, @@ -372,6 +373,7 @@ pub(super) fn is_active_tool(id: &str, active_tool: &Option, state: &Tog "PROPERTIES" => state.show_properties, "COUNTLIST" => state.show_count_palette, "GRAPHICATTRIBUTES" => state.show_graphic_attributes, + "BAUTHORPALETTE" => state.show_authoring_palettes, "BLOCKPALETTE" => state.show_block_palette, "SHEETSET" => state.show_sheet_set, "FILETAB" => state.show_file_tabs, diff --git a/src/ui/window/authoring_palettes.rs b/src/ui/window/authoring_palettes.rs new file mode 100644 index 000000000..677ea0e58 --- /dev/null +++ b/src/ui/window/authoring_palettes.rs @@ -0,0 +1,382 @@ +//! The Block Editor's authoring catalogs, using the shared dock chrome. + +use crate::app::Message; +use crate::modules::{parametric, IconKind, ToolDef}; +use crate::ui::dock::{self, Chrome, PanelId, VerticalLabel}; +use crate::ui::icons; +use crate::ui::ribbon::{tooltip_content, tooltip_style}; +use iced::widget::{ + button, canvas, column, container, rule, row, scrollable, stack, text, tooltip, Space, +}; +use iced::{Background, Border, Element, Fill, Theme}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Tab { + #[default] + Parameters, + Actions, + ParameterSets, + Constraints, +} + +impl Tab { + pub const ALL: [Self; 4] = [ + Self::Parameters, + Self::Actions, + Self::ParameterSets, + Self::Constraints, + ]; + + fn label(self) -> &'static str { + match self { + Self::Parameters => "Parameters", + Self::Actions => "Actions", + Self::ParameterSets => "Parameter Sets", + Self::Constraints => "Constraints", + } + } + + pub(crate) fn widget_id(self) -> &'static str { + match self { + Self::Parameters => "authoring-palette-parameters", + Self::Actions => "authoring-palette-actions", + Self::ParameterSets => "authoring-palette-parameter-sets", + Self::Constraints => "authoring-palette-constraints", + } + } +} + +const PARAMETERS: &[(&str, &[u8])] = &[ + ("Point", include_bytes!("../../../assets/icons/point.svg")), + ( + "Linear", + include_bytes!("../../../assets/icons/dim_linear.svg"), + ), + ( + "Polar", + include_bytes!("../../../assets/icons/array_polar.svg"), + ), + ("XY", include_bytes!("../../../assets/icons/move.svg")), + ( + "Rotation", + include_bytes!("../../../assets/icons/rotate.svg"), + ), + ( + "Alignment", + include_bytes!("../../../assets/icons/modify_align.svg"), + ), + ("Flip", include_bytes!("../../../assets/icons/mirror.svg")), + ( + "Visibility", + include_bytes!("../../../assets/icons/constrain/show.svg"), + ), + ( + "Lookup", + include_bytes!("../../../assets/icons/data_extract.svg"), + ), + ( + "Basepoint", + include_bytes!("../../../assets/icons/base_point.svg"), + ), +]; + +const ACTIONS: &[(&str, &[u8])] = &[ + ("Move", include_bytes!("../../../assets/icons/move.svg")), + ("Scale", include_bytes!("../../../assets/icons/scale.svg")), + ( + "Stretch", + include_bytes!("../../../assets/icons/stretch.svg"), + ), + ( + "Polar Stretch", + include_bytes!("../../../assets/icons/array_polar.svg"), + ), + ("Rotate", include_bytes!("../../../assets/icons/rotate.svg")), + ("Flip", include_bytes!("../../../assets/icons/mirror.svg")), + ( + "Array", + include_bytes!("../../../assets/icons/array_rect.svg"), + ), + ( + "Lookup", + include_bytes!("../../../assets/icons/data_extract.svg"), + ), + ( + "Block Properties Table", + include_bytes!("../../../assets/icons/table.svg"), + ), +]; + +const PARAMETER_SETS: &[(&str, &[u8])] = &[ + ( + "Point Move", + include_bytes!("../../../assets/icons/point.svg"), + ), + ( + "Linear Move", + include_bytes!("../../../assets/icons/dim_linear.svg"), + ), + ( + "Linear Stretch", + include_bytes!("../../../assets/icons/dim_linear.svg"), + ), + ( + "Linear Array", + include_bytes!("../../../assets/icons/dim_linear.svg"), + ), + ( + "Linear Move Pair", + include_bytes!("../../../assets/icons/dim_linear.svg"), + ), + ( + "Linear Stretch Pair", + include_bytes!("../../../assets/icons/dim_linear.svg"), + ), + ( + "Polar Move", + include_bytes!("../../../assets/icons/array_polar.svg"), + ), + ( + "Polar Stretch", + include_bytes!("../../../assets/icons/array_polar.svg"), + ), + ( + "Polar Array", + include_bytes!("../../../assets/icons/array_polar.svg"), + ), + ( + "Polar Move Pair", + include_bytes!("../../../assets/icons/array_polar.svg"), + ), + ( + "Polar Stretch Pair", + include_bytes!("../../../assets/icons/array_polar.svg"), + ), + ("XY Move", include_bytes!("../../../assets/icons/move.svg")), + ( + "XY Move Pair", + include_bytes!("../../../assets/icons/move.svg"), + ), + ( + "XY Move Box Set", + include_bytes!("../../../assets/icons/move.svg"), + ), + ( + "XY Stretch Box Set", + include_bytes!("../../../assets/icons/move.svg"), + ), + ( + "XY Array Box Set", + include_bytes!("../../../assets/icons/move.svg"), + ), + ( + "Rotation Set", + include_bytes!("../../../assets/icons/rotate.svg"), + ), + ( + "Flip Set", + include_bytes!("../../../assets/icons/mirror.svg"), + ), + ( + "Visibility Set", + include_bytes!("../../../assets/icons/constrain/show.svg"), + ), + ( + "Lookup Set", + include_bytes!("../../../assets/icons/data_extract.svg"), + ), +]; + +/// Catalog rows remain inert until their authoring commands exist. +fn catalog(tab: Tab) -> Element<'static, Message> { + let (entries, unavailable) = match tab { + Tab::Parameters => ( + PARAMETERS, + crate::t!("Parameter authoring is not available yet."), + ), + Tab::Actions => (ACTIONS, crate::t!("Action authoring is not available yet.")), + Tab::ParameterSets => ( + PARAMETER_SETS, + crate::t!("Parameter set authoring is not available yet."), + ), + Tab::Constraints => return constraints(), + }; + column(entries.iter().map(|(label, icon)| { + let base = icons::semantic_disabled(icon, 28.0); + let icon = if tab != Tab::Parameters && *label != "Block Properties Table" { + stack![ + base, + container(icons::semantic_disabled( + include_bytes!("../../../assets/icons/ui/bolt.svg"), + 14.0 + )) + .width(28) + .height(28) + .align_right(Fill) + .align_top(Fill), + ] + .into() + } else { + base + }; + tool_row(label, icon, None, &unavailable) + })) + .spacing(2) + .into() +} + +fn tool_row( + label: &str, + icon: Element<'static, Message>, + message: Option, + description: &str, +) -> Element<'static, Message> { + let enabled = message.is_some(); + let entry = button( + row![icon, text(crate::t!(label).into_owned()).size(12)] + .spacing(8) + .align_y(iced::Center), + ) + .on_press_maybe(message) + .padding([4, 6]) + .height(40) + .width(Fill) + .style(move |theme: &Theme, status| { + if enabled { + button::text(theme, status) + } else { + button::Style { + text_color: theme.palette().background.base.text.scale_alpha(0.55), + ..Default::default() + } + } + }); + tooltip( + entry, + tooltip_content(format!("{}\n{}", crate::t!(label), description)), + tooltip::Position::Right, + ) + .gap(6.0) + .delay(std::time::Duration::from_millis(400)) + .style(tooltip_style) + .into() +} + +fn constraint_row(tool: ToolDef) -> Element<'static, Message> { + let icon = match tool.icon { + IconKind::Svg(bytes) => icons::semantic(bytes, 28.0), + IconKind::Glyph(glyph) => text(glyph).size(28).into(), + }; + let label = if tool.id == "LCONSTRAINT" { + "Collinear" + } else { + tool.label + }; + let description = match &tool.event { + crate::modules::ModuleEvent::Command(command) => command.clone(), + _ => tool.id.to_owned(), + }; + container(tool_row( + label, + icon, + Some(Message::RibbonToolClick { + tool_id: tool.id.into(), + event: tool.event, + }), + &description, + )) + .id(format!("authoring-tool-{}", tool.id)) + .into() +} + +fn constraints() -> Element<'static, Message> { + // Reuse the ribbon definitions, including their command events and icons. + let geometric = [ + parametric::coincident_tool::tool(), + parametric::perpendicular::tool(), + parametric::parallel::tool(), + parametric::tangent::tool(), + parametric::horizontal::tool(), + parametric::vertical::tool(), + parametric::colinear::tool(), + parametric::concentric_tool::tool(), + parametric::smooth_tool(), + parametric::symmetric::tool(), + parametric::equal::tool(), + parametric::fixed::tool(), + ]; + let dimensional = [ + parametric::dimensional_tools::aligned(), + parametric::dimensional_tools::horizontal(), + parametric::dimensional_tools::vertical(), + parametric::dimensional_tools::angular(), + parametric::dimensional_tools::radius(), + parametric::dimensional_tools::diameter(), + ]; + column![ + container(text(crate::t!("Geometric Constraints")).size(11)).padding([10, 6]), + column(geometric.into_iter().map(constraint_row)).spacing(2), + container(rule::horizontal(1)).padding([8, 6]), + container(text(crate::t!("Constraint Parameters")).size(11)).padding([10, 6]), + column(dimensional.into_iter().map(constraint_row)).spacing(2), + ] + .into() +} + +pub fn view(selected_tab: Tab, width: f32, chrome: Chrome) -> Element<'static, Message> { + let tabs = column(Tab::ALL.into_iter().map(|category| { + let label = category.label(); + let selected = category == selected_tab; + container( + button( + canvas(VerticalLabel { + text: crate::t!(label).into_owned(), + }) + .width(16) + .height((label.len() as f32 * 7.0) + 8.0), + ) + .on_press(Message::AuthoringPaletteTabSelected(category)) + .padding([6, 4]) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + button::Style { + background: Some(Background::Color( + if selected || status == button::Status::Hovered { + palette.background.base.color + } else { + palette.background.weak.color + }, + )), + border: Border { + color: if selected { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } + }), + ) + .id(category.widget_id()) + .into() + })) + .spacing(2) + .push(Space::new().height(Fill)); + dock::frame( + dock::title_bar( + PanelId::AuthoringPalettes, + crate::t!("Authoring Palettes").into_owned(), + chrome, + ), + row![ + scrollable(catalog(selected_tab)).width(Fill).height(Fill), + tabs + ] + .spacing(4) + .height(Fill), + width, + ) +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index b12716252..19ff475f5 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -3,6 +3,7 @@ pub mod alias_editor; pub mod annotation_data; pub mod attdef_dialog; pub mod attribute_editor; +pub mod authoring_palettes; pub mod auto_constrain_settings; pub mod block_definition; pub mod block_edit_picker;