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;