From ac8215c1b728c1be9316c96e5f0a784b230caa1f Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:13:45 +0300 Subject: [PATCH 1/3] Center Draw popup beneath its title text --- src/ui/ribbon/draw_panel.rs | 16 ++++------------ src/ui/ribbon/mod.rs | 4 ++-- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index d444d8f05..0f11f9ba8 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -16,7 +16,7 @@ use crate::t; use crate::ui::{icons, wrap_bar::PosReport}; const PANEL_ID: &str = "draw_extension"; -const GROUP_ID: &str = "draw_extension_group"; +const TITLE_ID: &str = "draw_extension_title"; const SCALE: f32 = 0.7; const CELL: f32 = 36.0 * SCALE; const GAP: f32 = 3.0 * SCALE; @@ -125,14 +125,6 @@ pub(super) fn owns_dropdown(id: &str) -> bool { .any(|tool| tool.command == id && !tool.options.is_empty()) } -pub(super) fn group_anchor<'a>(title: &str, content: Element<'a, Message>) -> Element<'a, Message> { - if title == "Draw" { - PosReport::new(GROUP_ID, content).into() - } else { - content - } -} - pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Element<'a, Message> { if title != "Draw" || TOOLS.is_empty() { return container(text(t!(title)).size(9).style(muted_text_style)) @@ -148,7 +140,7 @@ pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Ele PosReport::new( PANEL_ID, button( - row![text(t!(title)).size(9), arrow] + row![PosReport::new(TITLE_ID, text(t!(title)).size(9)), arrow] .spacing(4) .align_y(iced::Center), ) @@ -197,9 +189,9 @@ fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element<'a, Message> { 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); - let anchor = crate::ui::wrap_bar::dropdown_bounds(GROUP_ID); + let anchor = crate::ui::wrap_bar::dropdown_bounds(TITLE_ID); let left = anchor - .map_or(x, |b| b.x) + .map_or(x, |b| b.x + (b.width - width) / 2.0) .clamp(0.0, (win.0 - width).max(0.0)); let top = anchor_top.min((win.1 - 80.0).max(0.0)); let available_height = (win.1 - top - 4.0).max(1.0); diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 7ffc5ec7b..e0f2b2c49 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -1370,7 +1370,7 @@ fn render_group<'a>( r.push(e) }); - draw_panel::group_anchor(group.title, column![ + column![ tools_el, draw_panel::group_title(group.title, open_dd), ] @@ -1378,7 +1378,7 @@ fn render_group<'a>( .spacing(0) .padding([3u16, 4]) .height(Length::Fixed(TOOL_BAR_H)) - .into()) + .into() } /// The top-level command id of a ribbon item, if it has one. From 75cc49d629d3f099909913c4caa16bb7399ddfdc Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:27:59 +0300 Subject: [PATCH 2/3] Return from Draw submenus with Escape and place split tools last --- src/app/update/command.rs | 3 +++ src/ui/ribbon/draw_panel.rs | 22 +++++++++++----------- src/ui/ribbon/mod.rs | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 23df084a7..e82e3cc98 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -890,6 +890,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } pub(super) fn on_command_escape(&mut self) -> Task { + if self.ribbon.escape_extension() { + return Task::none(); + } // Esc drops an unconsumed one-shot snap override and closes // its menu (#337). Falls through — Esc keeps its usual effect. self.snap_override_popup = None; diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 0f11f9ba8..8f985b9c9 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -15,7 +15,7 @@ use crate::modules::IconKind; use crate::t; use crate::ui::{icons, wrap_bar::PosReport}; -const PANEL_ID: &str = "draw_extension"; +pub(super) const PANEL_ID: &str = "draw_extension"; const TITLE_ID: &str = "draw_extension_title"; const SCALE: f32 = 0.7; const CELL: f32 = 36.0 * SCALE; @@ -54,16 +54,6 @@ const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/ray.svg"), options: &[], }, - Tool { - command: "MULTIPOINT", - label: "Multiple Points", - icon: include_bytes!("../../../assets/icons/multipoint.svg"), - options: &[ - ("POINT", "Single Point"), - ("MULTIPOINT", "Multiple Points"), - ("DDPTYPE", "Point Style"), - ], - }, Tool { command: "DIVIDE", label: "Divide", @@ -106,6 +96,16 @@ const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/donut.svg"), options: &[], }, + Tool { + command: "MULTIPOINT", + label: "Multiple Points", + icon: include_bytes!("../../../assets/icons/multipoint.svg"), + options: &[ + ("POINT", "Single Point"), + ("MULTIPOINT", "Multiple Points"), + ("DDPTYPE", "Point Style"), + ], + }, Tool { command: "REVCLOUD", label: "Revision Cloud", diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index e0f2b2c49..f0938716f 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -351,6 +351,21 @@ impl Ribbon { self.layer_filter.clear(); } + pub fn escape_extension(&mut self) -> bool { + let Some(id) = self.open_dropdown.as_deref() else { + return false; + }; + if !draw_panel::owns_dropdown(id) { + return false; + } + if id == draw_panel::PANEL_ID { + self.close_dropdown(); + } else { + self.open_dropdown = Some(draw_panel::PANEL_ID.to_string()); + } + true + } + /// Toggle the flyout of a collapsed ribbon panel (identified by its title). pub fn toggle_collapsed_panel(&mut self, id: &str) { if self.collapsed_open.as_deref() == Some(id) { From 09c1910f6af022b3a17df8f625d2fcbbc3b6ef2a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:30:48 +0300 Subject: [PATCH 3/3] Share centered extension panels with Modify tool contributions --- build.rs | 22 +++++++++++ src/ui/ribbon/draw_panel.rs | 70 +++++++++++++++++++++++------------ src/ui/ribbon/mod.rs | 9 +++-- src/ui/ribbon/modify_panel.rs | 4 ++ 4 files changed, 78 insertions(+), 27 deletions(-) create mode 100644 src/ui/ribbon/modify_panel.rs diff --git a/build.rs b/build.rs index fd50c581c..85ce9861f 100644 --- a/build.rs +++ b/build.rs @@ -4,6 +4,7 @@ use std::path::Path; fn main() { + generate_modify_tools(); let version = std::env::var("CARGO_PKG_VERSION").expect("Cargo package version"); let parts: Vec<&str> = version.split('.').collect(); let app_version = if parts.len() == 3 && parts[0].len() == 4 @@ -81,3 +82,24 @@ fn main() { } } } + +fn generate_modify_tools() { + let root = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let directory = root.join("src/ui/ribbon/modify_tools"); + println!("cargo:rerun-if-changed=src/ui/ribbon/modify_tools"); + let mut paths: Vec<_> = match std::fs::read_dir(&directory) { + Ok(entries) => entries.map(|entry| entry.expect("Modify tool entry").path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "rs")).collect(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => panic!("Cannot read Modify tools: {error}"), + }; + paths.sort(); + let mut source = String::from("pub(super) const TOOLS: &[Tool] = &[\n"); + for path in paths { + println!("cargo:rerun-if-changed={}", path.display()); + source.push_str(&format!("include!({:?}),\n", path.to_string_lossy())); + } + source.push_str("];\n"); + let output = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); + std::fs::write(output.join("modify_panel_tools.rs"), source).expect("Write Modify tool registry"); +} diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 8f985b9c9..a4d30522a 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -22,11 +22,11 @@ const CELL: f32 = 36.0 * SCALE; const GAP: f32 = 3.0 * SCALE; const OPTION_HEIGHT: f32 = 30.0 * SCALE; -struct Tool { - command: &'static str, - label: &'static str, - icon: &'static [u8], - options: &'static [(&'static str, &'static str)], +pub(super) struct Tool { + pub command: &'static str, + pub label: &'static str, + pub icon: &'static [u8], + pub options: &'static [(&'static str, &'static str)], } const TOOLS: &[Tool] = &[ @@ -118,43 +118,63 @@ const TOOLS: &[Tool] = &[ }, ]; +#[derive(Clone, Copy)] +struct Panel { + id: &'static str, + title_id: &'static str, + title: &'static str, + tools: &'static [Tool], +} + +const PANELS: &[Panel] = &[ + Panel { id: PANEL_ID, title_id: TITLE_ID, title: "Draw", tools: TOOLS }, + Panel { id: "modify_extension", title_id: "modify_extension_title", title: "Modify", tools: super::modify_panel::TOOLS }, +]; + +fn panel_for_dropdown(id: &str) -> Option { + PANELS.iter().copied().find(|panel| { + panel.id == id || panel.tools.iter().any(|tool| tool.command == id && !tool.options.is_empty()) + }) +} + pub(super) fn owns_dropdown(id: &str) -> bool { - id == PANEL_ID - || TOOLS - .iter() - .any(|tool| tool.command == id && !tool.options.is_empty()) + panel_for_dropdown(id).is_some() +} + +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) -> Element<'a, Message> { - if title != "Draw" || TOOLS.is_empty() { + let Some(panel) = PANELS.iter().find(|panel| panel.title == title && !panel.tools.is_empty()) else { return container(text(t!(title)).size(9).style(muted_text_style)) .padding([1, 4]) .into(); - } - let expanded = open.as_deref().is_some_and(owns_dropdown); + }; + let expanded = open.as_deref().and_then(parent_panel) == Some(panel.id); let arrow = if expanded { icons::themed_arrow_up(7.0) } else { icons::themed_arrow_down(7.0) }; PosReport::new( - PANEL_ID, + panel.id, button( - row![PosReport::new(TITLE_ID, text(t!(title)).size(9)), arrow] + row![PosReport::new(panel.title_id, text(t!(title)).size(9)), arrow] .spacing(4) .align_y(iced::Center), ) - .on_press(Message::ToggleRibbonDropdown(PANEL_ID.to_string())) + .on_press(Message::ToggleRibbonDropdown(panel.id.to_string())) .style(move |theme: &Theme, status| tool_btn_style(theme, expanded, status)) .padding([1, 4]), ) .into() } -fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { +fn tool_button(tool: &Tool, active: bool, panel_id: &'static str) -> Element<'static, Message> { let face = button(make_icon(IconKind::Svg(tool.icon), 23.0 * SCALE)) .on_press(Message::DropdownSelectItem { - dropdown_id: PANEL_ID, + dropdown_id: panel_id, cmd: tool.command, }) .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) @@ -187,22 +207,26 @@ fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { } 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"); + 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); - let anchor = crate::ui::wrap_bar::dropdown_bounds(TITLE_ID); + let (_, x, anchor_top) = ribbon.dd_anchor(panel.id, width, win.0); + let anchor = crate::ui::wrap_bar::dropdown_bounds(panel.title_id); let left = anchor .map_or(x, |b| b.x + (b.width - width) / 2.0) .clamp(0.0, (win.0 - width).max(0.0)); let top = anchor_top.min((win.1 - 80.0).max(0.0)); let available_height = (win.1 - top - 4.0).max(1.0); let cols = (((width - 12.0 + GAP) / (CELL + GAP)).floor() as usize).clamp(1, 7); - let option_tool = TOOLS + let option_tool = tools .iter() .find(|tool| tool.command == id && !tool.options.is_empty()); let content_height = option_tool.map_or_else( - || TOOLS.len().div_ceil(cols) as f32 * (CELL + GAP) - GAP + 12.0, + || tools.len().div_ceil(cols) as f32 * (CELL + GAP) - GAP + 12.0, |tool| tool.options.len() as f32 * OPTION_HEIGHT, ); + let ordered: Vec<&Tool> = tools.iter().filter(|tool| tool.options.is_empty()) + .chain(tools.iter().filter(|tool| !tool.options.is_empty())).collect(); let contents: Element<'static, Message> = if let Some(tool) = option_tool { column( tool.options @@ -224,13 +248,13 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element .into() } else { column( - TOOLS + ordered .chunks(cols) .map(|tools| { row(tools .iter() .map(|tool| { - tool_button(tool, ribbon.active_tool.as_deref() == Some(tool.command)) + tool_button(tool, ribbon.active_tool.as_deref() == Some(tool.command), panel.id) }) .collect::>()) .spacing(GAP) diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index f0938716f..feed284f9 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -22,6 +22,7 @@ use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; mod widgets; mod draw_panel; +mod modify_panel; use widgets::{StyleContext, *}; mod collapse; use collapse::{CollapsePanels, Panel}; @@ -355,13 +356,13 @@ impl Ribbon { let Some(id) = self.open_dropdown.as_deref() else { return false; }; - if !draw_panel::owns_dropdown(id) { + let Some(parent) = draw_panel::parent_panel(id) else { return false; - } - if id == draw_panel::PANEL_ID { + }; + if id == parent { self.close_dropdown(); } else { - self.open_dropdown = Some(draw_panel::PANEL_ID.to_string()); + self.open_dropdown = Some(parent.to_string()); } true } diff --git a/src/ui/ribbon/modify_panel.rs b/src/ui/ribbon/modify_panel.rs new file mode 100644 index 000000000..d04410b12 --- /dev/null +++ b/src/ui/ribbon/modify_panel.rs @@ -0,0 +1,4 @@ +//! Independently registered commands in the Modify extension. +use super::draw_panel::Tool; + +include!(concat!(env!("OUT_DIR"), "/modify_panel_tools.rs"));