From 39043e6e56e3d2831c79e5ff4bb08d569c1d22ee Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 5 Jul 2026 18:51:54 +0300 Subject: [PATCH] feat(ui): anchor every ribbon dropdown and status-bar popup to its widget, flipping at window edges All ribbon dropdowns (tool dropdowns, undo/redo history, and the layer / colour / linetype / lineweight / style combos) and all status-bar popups (scale, units, OSNAP, isolate, filter, customization menu, layout list) now open directly at their widget instead of at a layout-estimated position, and flip to the other side when the panel would cross a window edge. Each dropdown/pill widget is wrapped in a transparent PosReport that records its screen bounds into a thread-local map on draw. The overlays read those bounds: - Ribbon: Ribbon::dd_anchor(id, panel_w, win_w) returns (align_right, h_pad, top); position_ribbon_dropdown places the panel below the widget, flipping right-aligned near the right edge. - Status bar: popup builders take (pill_bounds, win_size) and call popup::position_statusbar_popup / popup_anchor, opening just above the pill and flipping left/right at the window edge. The window size is now tracked (win_size, set on WindowResized). The old layout estimators compute_dropdown_left / compute_layer_combo_left / compute_prop_combo_left / compute_history_dropdown_left and osnap_popup_right_offset are deleted. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 3 + src/app/update/mod.rs | 1 + src/app/view/mod.rs | 30 +++- src/ui/popup/isolate_popup.rs | 21 +-- src/ui/popup/mod.rs | 73 +++++++++ src/ui/popup/scale_popup.rs | 16 +- src/ui/popup/selection_filter_popup.rs | 16 +- src/ui/popup/snap_popup.rs | 27 +--- src/ui/popup/units_popup.rs | 20 +-- src/ui/ribbon/mod.rs | 214 +++++++++++-------------- src/ui/ribbon/widgets.rs | 126 ++------------- src/ui/statusbar/mod.rs | 104 ++++++------ src/ui/statusbar/statusbar_menu.rs | 42 ++--- 13 files changed, 310 insertions(+), 383 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 9e8ec72e0..7eb50a1a5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -227,6 +227,8 @@ pub(super) struct OpenCADStudio { status_bar: StatusBar, cursor_pos: Point, vp_size: (f32, f32), + /// Full window size (w, h); used to anchor status-bar popups to their pill. + win_size: (f32, f32), snapper: Snapper, snap_popup_open: bool, scale_popup_open: bool, @@ -1962,6 +1964,7 @@ impl OpenCADStudio { status_bar: StatusBar::new(), cursor_pos: Point::ORIGIN, vp_size: (1280.0, 720.0), + win_size: (1280.0, 720.0), snapper: Snapper::default(), snap_popup_open: false, scale_popup_open: false, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 8579a0e00..aa367d186 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1302,6 +1302,7 @@ impl OpenCADStudio { Message::WindowResized(w, h) => { self.vp_size = ((w - 440.0).max(200.0), h); + self.win_size = (w, h); Task::none() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index f6bd901d6..ee4e25f38 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1262,13 +1262,14 @@ impl OpenCADStudio { .width(Fill) .height(Fill); + let win = self.win_size; + let sb_pill = crate::ui::wrap_bar::dropdown_bounds; + let snap_layer: Element<'_, Message> = if self.snap_popup_open { crate::ui::popup::snap_popup::snap_popup_overlay( &self.snapper, - crate::ui::statusbar::osnap_popup_right_offset( - &self.statusbar_config, - tab.scene.viewport_count(), - ), + sb_pill(crate::ui::statusbar::SB_OSNAP_ID), + win, ) } else { iced::widget::Space::new().width(0).height(0).into() @@ -1281,13 +1282,19 @@ impl OpenCADStudio { tab.scene.annotation_scale, tab.scene.first_viewport_scale(), tab.scene.scale_list(), + sb_pill(crate::ui::statusbar::SB_SCALE_ID), + win, ) } else { iced::widget::Space::new().width(0).height(0).into() }; let statusbar_menu_layer: Element<'_, Message> = if self.statusbar_menu_open { - crate::ui::statusbar::statusbar_menu::statusbar_menu_overlay(&self.statusbar_config) + crate::ui::statusbar::statusbar_menu::statusbar_menu_overlay( + &self.statusbar_config, + sb_pill(crate::ui::statusbar::SB_MENU_ID), + win, + ) } else { iced::widget::Space::new().width(0).height(0).into() }; @@ -1296,13 +1303,19 @@ impl OpenCADStudio { crate::ui::statusbar::statusbar_menu::layout_list_overlay( &tab.scene.layout_names(), &tab.scene.current_layout, + sb_pill(crate::ui::statusbar::SB_LAYOUTLIST_ID), + win, ) } else { iced::widget::Space::new().width(0).height(0).into() }; let units_layer: Element<'_, Message> = if self.units_popup_open { - crate::ui::popup::units_popup::units_popup_overlay(tab.scene.document.header.insertion_units) + crate::ui::popup::units_popup::units_popup_overlay( + tab.scene.document.header.insertion_units, + sb_pill(crate::ui::statusbar::SB_UNITS_ID), + win, + ) } else { iced::widget::Space::new().width(0).height(0).into() }; @@ -1311,6 +1324,8 @@ impl OpenCADStudio { crate::ui::popup::isolate_popup::isolate_popup_overlay( !tab.scene.selected.is_empty(), tab.scene.is_isolation_active(), + sb_pill(crate::ui::statusbar::SB_ISOLATE_ID), + win, ) } else { iced::widget::Space::new().width(0).height(0).into() @@ -1326,6 +1341,8 @@ impl OpenCADStudio { crate::ui::popup::selection_filter_popup::selection_filter_popup_overlay( types, &tab.scene.selection_filter, + sb_pill(crate::ui::statusbar::SB_FILTER_ID), + win, ) } else { iced::widget::Space::new().width(0).height(0).into() @@ -1336,6 +1353,7 @@ impl OpenCADStudio { .dropdown_overlay( &history_dropdown_labels(&self.tabs[self.active_tab].history.undo_stack), &history_dropdown_labels(&self.tabs[self.active_tab].history.redo_stack), + self.win_size, ) .unwrap_or_else(|| iced::widget::Space::new().width(0).height(0).into()); diff --git a/src/ui/popup/isolate_popup.rs b/src/ui/popup/isolate_popup.rs index 3f815c0fe..a112dad9b 100644 --- a/src/ui/popup/isolate_popup.rs +++ b/src/ui/popup/isolate_popup.rs @@ -4,7 +4,7 @@ //! in the viewport right-click menu. use iced::widget::{button, column, container, mouse_area, row, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; +use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; use crate::app::Message; @@ -13,7 +13,12 @@ use crate::app::Message; /// /// - `has_selection`: enables Isolate / Hide (they act on the selection). /// - `isolation_active`: enables End Isolation (something is hidden). -pub fn isolate_popup_overlay(has_selection: bool, isolation_active: bool) -> Element<'static, Message> { +pub fn isolate_popup_overlay( + has_selection: bool, + isolation_active: bool, + pill: Option, + win: (f32, f32), +) -> Element<'static, Message> { let rows = column![ action_row( "Isolate Objects", @@ -44,17 +49,7 @@ pub fn isolate_popup_overlay(has_selection: bool, isolation_active: bool) -> Ele }) .width(Length::Fixed(160.0)); - let positioned = container(panel) - .align_right(Fill) - .align_bottom(Fill) - .padding(Padding { - bottom: 27.0, - right: 4.0, - top: 0.0, - left: 0.0, - }) - .width(Fill) - .height(Fill); + let positioned = super::position_statusbar_popup(panel.into(), pill, win, 160.0, true); mouse_area(positioned) .on_press(Message::CloseIsolatePopup) diff --git a/src/ui/popup/mod.rs b/src/ui/popup/mod.rs index db156c6bd..17186fd28 100644 --- a/src/ui/popup/mod.rs +++ b/src/ui/popup/mod.rs @@ -4,3 +4,76 @@ pub mod scale_popup; pub mod selection_filter_popup; pub mod snap_popup; pub mod units_popup; + +use iced::widget::container; +use iced::{Element, Fill, Padding, Rectangle}; + +use crate::app::Message; + +/// Decide a status-bar popup's horizontal anchor and paddings from its pill's +/// screen bounds. Returns `(align_right, horizontal_pad, bottom_pad)`. +/// +/// The popup opens just above the pill. Horizontally it prefers `prefer_right` +/// (right edge aligned to the pill's right, growing left) but flips to the other +/// side when that direction would cross the window edge. +pub fn popup_anchor( + pill: Option, + win: (f32, f32), + popup_w: f32, + prefer_right: bool, +) -> (bool, f32, f32) { + let Some(b) = pill else { + // No recorded position yet — fall back to the bottom-right corner. + return (prefer_right, 4.0, 27.0); + }; + let bottom = (win.1 - b.y).max(0.0); + let right_pad = (win.0 - (b.x + b.width)).max(4.0); + let left_pad = b.x.max(4.0); + + if prefer_right { + // Right-aligned popup grows left; flip left-aligned if it would pass the + // left window edge. + if (b.x + b.width) - popup_w < 2.0 { + (false, left_pad, bottom) + } else { + (true, right_pad, bottom) + } + } else { + // Left-aligned popup grows right; flip right-aligned if it would pass the + // right window edge. + if b.x + popup_w > win.0 - 2.0 { + (true, right_pad, bottom) + } else { + (false, left_pad, bottom) + } + } +} + +/// Wrap a status-bar popup `panel` in a full-window container positioned just +/// above its pill, flipping horizontally to stay on screen. +pub fn position_statusbar_popup<'a>( + panel: Element<'a, Message>, + pill: Option, + win: (f32, f32), + popup_w: f32, + prefer_right: bool, +) -> Element<'a, Message> { + let (align_right, h_pad, bottom) = popup_anchor(pill, win, popup_w, prefer_right); + let pad = Padding { + top: 0.0, + bottom, + left: if align_right { 0.0 } else { h_pad }, + right: if align_right { h_pad } else { 0.0 }, + }; + let c = container(panel) + .align_bottom(Fill) + .width(Fill) + .height(Fill) + .padding(pad); + let c = if align_right { + c.align_right(Fill) + } else { + c.align_left(Fill) + }; + c.into() +} diff --git a/src/ui/popup/scale_popup.rs b/src/ui/popup/scale_popup.rs index 980cb577f..1a2803e00 100644 --- a/src/ui/popup/scale_popup.rs +++ b/src/ui/popup/scale_popup.rs @@ -3,7 +3,7 @@ //! Rendered as a floating overlay above the status bar, same pattern as snap_popup. use iced::widget::{button, column, container, mouse_area, row, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; +use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; use crate::app::Message; @@ -20,6 +20,8 @@ pub fn scale_popup_overlay( current_anno_scale: f32, viewport_scale: Option, file_scales: Vec<(String, f32, f64)>, + pill: Option, + win: (f32, f32), ) -> Element<'static, Message> { let rows: Vec> = file_scales .into_iter() @@ -52,17 +54,7 @@ pub fn scale_popup_overlay( }) .width(Length::Fixed(120.0)); - let positioned = container(panel) - .align_right(Fill) - .align_bottom(Fill) - .padding(Padding { - bottom: 27.0, - right: 4.0, - top: 0.0, - left: 0.0, - }) - .width(Fill) - .height(Fill); + let positioned = super::position_statusbar_popup(panel.into(), pill, win, 120.0, true); mouse_area(positioned) .on_press(Message::CloseScalePopup) diff --git a/src/ui/popup/selection_filter_popup.rs b/src/ui/popup/selection_filter_popup.rs index f0e15b468..b86b616c5 100644 --- a/src/ui/popup/selection_filter_popup.rs +++ b/src/ui/popup/selection_filter_popup.rs @@ -5,7 +5,7 @@ use rustc_hash::FxHashSet as HashSet; use iced::widget::{button, column, container, mouse_area, row, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; +use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; use crate::app::Message; @@ -17,6 +17,8 @@ use crate::app::Message; pub fn selection_filter_popup_overlay( types: Vec, excluded: &HashSet, + pill: Option, + win: (f32, f32), ) -> Element<'static, Message> { // "Select All / Clear All" header, mirroring the OSNAP popup: Select All // clears every exclusion, Clear All excludes every present type. @@ -70,17 +72,7 @@ pub fn selection_filter_popup_overlay( }) .width(Length::Fixed(180.0)); - let positioned = container(panel) - .align_right(Fill) - .align_bottom(Fill) - .padding(Padding { - bottom: 27.0, - right: 4.0, - top: 0.0, - left: 0.0, - }) - .width(Fill) - .height(Fill); + let positioned = super::position_statusbar_popup(panel.into(), pill, win, 180.0, true); mouse_area(positioned) .on_press(Message::CloseSelectionFilterPopup) diff --git a/src/ui/popup/snap_popup.rs b/src/ui/popup/snap_popup.rs index 0db20ff22..37d7ff3b6 100644 --- a/src/ui/popup/snap_popup.rs +++ b/src/ui/popup/snap_popup.rs @@ -4,7 +4,7 @@ //! inserted into the view stack when `snap_popup_open` is true. use iced::widget::{button, column, container, mouse_area, row, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; +use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; use crate::app::Message; use crate::snap::{SnapType, Snapper, ALL_SNAP_MODES}; @@ -12,7 +12,11 @@ use crate::snap::{SnapType, Snapper, ALL_SNAP_MODES}; /// Returns a full-screen overlay element: /// - a transparent click-catcher that closes the popup on outside click /// - the popup panel pinned to the bottom-right (above the status bar) -pub fn snap_popup_overlay<'a>(snapper: &'a Snapper, right_offset: f32) -> Element<'a, Message> { +pub fn snap_popup_overlay<'a>( + snapper: &'a Snapper, + pill: Option, + win: (f32, f32), +) -> Element<'a, Message> { // ── Panel content ───────────────────────────────────────────────────── let all_on = snapper.all_on(); let none_on = snapper.none_on(); @@ -55,23 +59,8 @@ pub fn snap_popup_overlay<'a>(snapper: &'a Snapper, right_offset: f32) -> Elemen }) .width(Length::Fixed(PANEL_W)); - // ── Position above the status bar, anchored by its bottom-LEFT corner at - // `right_offset` from the window's right edge (the Osnap button), so it - // opens up and to the right of the button. `align_right` positions by the - // panel's right edge, so pad by the anchor minus the panel width; clamp so - // a narrow bar can't push it off-screen. (#248) - let right = (right_offset - PANEL_W).max(4.0); - let positioned = container(panel) - .align_right(Fill) - .align_bottom(Fill) - .padding(Padding { - bottom: 27.0, - right, - top: 0.0, - left: 0.0, - }) - .width(Fill) - .height(Fill); + // Anchored just above its pill, flipping horizontally to stay on screen. + let positioned = super::position_statusbar_popup(panel.into(), pill, win, PANEL_W, true); // ── Click-catcher: closes popup on any outside click ────────────────── mouse_area(positioned) diff --git a/src/ui/popup/units_popup.rs b/src/ui/popup/units_popup.rs index fc23d7aac..17421569b 100644 --- a/src/ui/popup/units_popup.rs +++ b/src/ui/popup/units_popup.rs @@ -2,7 +2,7 @@ //! floating overlay above the status bar, same pattern as the scale picker. use iced::widget::{button, column, container, mouse_area, row, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; +use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; use crate::app::Message; @@ -37,7 +37,11 @@ pub fn unit_short(code: i16) -> &'static str { /// Full-screen overlay: transparent click-catcher + units list pinned /// bottom-right, above the status bar. -pub fn units_popup_overlay(current: i16) -> Element<'static, Message> { +pub fn units_popup_overlay( + current: i16, + pill: Option, + win: (f32, f32), +) -> Element<'static, Message> { let rows: Vec> = UNITS .iter() .map(|&(code, label)| unit_row(label, code == current, Message::SetDrawingUnits(code))) @@ -55,17 +59,7 @@ pub fn units_popup_overlay(current: i16) -> Element<'static, Message> { }) .width(Length::Fixed(140.0)); - let positioned = container(panel) - .align_right(Fill) - .align_bottom(Fill) - .padding(Padding { - bottom: 27.0, - right: 4.0, - top: 0.0, - left: 0.0, - }) - .width(Fill) - .height(Fill); + let positioned = super::position_statusbar_popup(panel.into(), pill, win, 140.0, true); mouse_area(positioned) .on_press(Message::CloseUnitsPopup) diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 2fd6acb5a..127ea7803 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -91,6 +91,33 @@ fn dropdown_backdrop<'a>(positioned: Element<'a, Message>) -> Element<'a, Messag .into() } +/// Position a ribbon dropdown `panel` in a full-window container just below its +/// widget, growing left or right per `align_right` (see [`Ribbon::dd_anchor`]). +fn position_ribbon_dropdown<'a>( + panel: Element<'a, Message>, + align_right: bool, + h_pad: f32, + top: f32, +) -> Element<'a, Message> { + let pad = Padding { + top, + bottom: 0.0, + left: if align_right { 0.0 } else { h_pad }, + right: if align_right { h_pad } else { 0.0 }, + }; + let c = container(panel) + .align_top(Fill) + .width(Fill) + .height(Fill) + .padding(pad); + let c = if align_right { + c.align_right(Fill) + } else { + c.align_left(Fill) + }; + c.into() +} + impl Ribbon { pub fn new() -> Self { Self { @@ -140,14 +167,22 @@ impl Ribbon { f32::from_bits(self.tool_bar_h.load(Ordering::Relaxed)) } - /// (top, left) screen offset at which to anchor the open dropdown `id`'s - /// overlay — just below its widget. Falls back to below the whole ribbon - /// when the widget's position hasn't been recorded yet. - fn dd_anchor(&self, id: &str) -> (f32, f32) { - if let Some(b) = crate::ui::wrap_bar::dropdown_bounds(id) { - (b.y + b.height, b.x) - } else { - (self.tab_bar_height() + self.tool_bar_height(), 0.0) + /// (align_right, horizontal_pad, top) to anchor the open dropdown `id`'s + /// overlay just below its widget. It grows rightward from the widget's left + /// edge, but flips to right-aligned (growing left) when a panel of `panel_w` + /// would cross the right window edge `win_w`. Falls back to below the whole + /// ribbon when the widget's position hasn't been recorded yet. + fn dd_anchor(&self, id: &str, panel_w: f32, win_w: f32) -> (bool, f32, f32) { + match crate::ui::wrap_bar::dropdown_bounds(id) { + Some(b) => { + let top = b.y + b.height; + if b.x + panel_w > win_w - 2.0 { + (true, (win_w - (b.x + b.width)).max(4.0), top) + } else { + (false, b.x.max(0.0), top) + } + } + None => (false, 0.0, self.tab_bar_height() + self.tool_bar_height()), } } @@ -561,6 +596,7 @@ impl Ribbon { &self, undo_labels: &[String], redo_labels: &[String], + win: (f32, f32), ) -> Option> { let open_id = self.open_dropdown.as_deref()?; @@ -607,39 +643,30 @@ impl Ribbon { }) .width(Length::Fixed(170.0)); - let (top, left) = self.dd_anchor(open_id); - let positioned = container(panel) - .align_left(Fill) - .align_top(Fill) - .padding(Padding { - top, - left, - ..Default::default() - }) - .width(Fill) - .height(Fill); + let (align_right, h_pad, top) = self.dd_anchor(open_id, 170.0, win.0); + let positioned = position_ribbon_dropdown(panel.into(), align_right, h_pad, top); - return Some(dropdown_backdrop(positioned.into())); + return Some(dropdown_backdrop(positioned)); } if open_id == LAYER_COMBO_ID { - return self.layer_combo_overlay(); + return self.layer_combo_overlay(win); } if open_id == PROP_COLOR_ID { - return self.prop_color_overlay(); + return self.prop_color_overlay(win); } if open_id == PROP_LINETYPE_ID { - return self.prop_linetype_overlay(); + return self.prop_linetype_overlay(win); } if open_id == PROP_LW_ID { - return self.prop_lw_overlay(); + return self.prop_lw_overlay(win); } // Style combo dropdowns (annotate tab) float as overlays so the list // isn't clipped by the fixed ribbon-row height, the way the Draw-tab // dropdowns already are. (#153) - if let Some(ov) = self.style_combo_overlay(open_id) { + if let Some(ov) = self.style_combo_overlay(open_id, win) { return Some(ov); } @@ -732,22 +759,16 @@ impl Ribbon { }) .width(Length::Fixed(190.0)); - let (top_offset, left_offset) = self.dd_anchor(open_id); - let positioned = container(panel) - .align_left(Fill) - .align_top(Fill) - .padding(Padding { - top: top_offset, - left: left_offset, - ..Default::default() - }) - .width(Fill) - .height(Fill); - - Some(dropdown_backdrop(positioned.into())) + let (align_right, h_pad, top) = self.dd_anchor(open_id, 190.0, win.0); + Some(dropdown_backdrop(position_ribbon_dropdown( + panel.into(), + align_right, + h_pad, + top, + ))) } - fn layer_combo_overlay(&self) -> Option> { + fn layer_combo_overlay(&self, win: (f32, f32)) -> Option> { // A toggle icon (visible / freeze / lock) is its own button so a click // on it flips that state instead of bubbling up to the row's // make-active handler (#133). @@ -854,21 +875,10 @@ impl Ribbon { }) .width(Length::Fixed(220.0)); - let top_offset = self.tab_bar_height() + self.tool_bar_height(); - let groups = self.modules.get(self.active)?.ribbon_groups(); - let left_offset = compute_layer_combo_left(&groups); - let positioned = container(panel) - .align_left(Fill) - .align_top(Fill) - .padding(Padding { - top: top_offset, - left: left_offset, - ..Default::default() - }) - .width(Fill) - .height(Fill); + let (align_right, h_pad, top) = self.dd_anchor(LAYER_COMBO_ID, 220.0, win.0); + let positioned = position_ribbon_dropdown(panel.into(), align_right, h_pad, top); - Some(dropdown_backdrop(positioned.into())) + Some(dropdown_backdrop(positioned)) } /// Floating popup for an annotate-tab style combo (text / dimension / @@ -876,7 +886,7 @@ impl Ribbon { /// style combo. Built as an overlay — like the layer combo and the /// Draw-tab dropdowns — so the list grows to fit its entries instead of /// being clipped to the ribbon-row height. (#153) - fn style_combo_overlay(&self, open_id: &str) -> Option> { + fn style_combo_overlay(&self, open_id: &str, win: (f32, f32)) -> Option> { let groups = self.modules.get(self.active)?.ribbon_groups(); // Locate the open style combo; capture its style key + manager command. @@ -978,23 +988,16 @@ impl Ribbon { }) .width(Length::Fixed(LARGE_W * 2.3)); - let top_offset = self.tab_bar_height() + self.tool_bar_height(); - let left_offset = compute_dropdown_left(&groups, open_id); - let positioned = container(panel) - .align_left(Fill) - .align_top(Fill) - .padding(Padding { - top: top_offset, - left: left_offset, - ..Default::default() - }) - .width(Fill) - .height(Fill); - - Some(dropdown_backdrop(positioned.into())) + let (align_right, h_pad, top) = self.dd_anchor(open_id, LARGE_W * 2.3, win.0); + Some(dropdown_backdrop(position_ribbon_dropdown( + panel.into(), + align_right, + h_pad, + top, + ))) } - fn prop_color_overlay(&self) -> Option> { + fn prop_color_overlay(&self, win: (f32, f32)) -> Option> { let picker = crate::ui::color_select::color_list( crate::ui::color_select::ColorExtras { by_layer: true, @@ -1016,24 +1019,16 @@ impl Ribbon { }) .width(Length::Fixed(200.0)); - let top_offset = self.tab_bar_height() + self.tool_bar_height(); - let groups = self.modules.get(self.active)?.ribbon_groups(); - let left_offset = compute_prop_combo_left(&groups, PROP_COLOR_ID); - let positioned = container(panel) - .align_left(Fill) - .align_top(Fill) - .padding(Padding { - top: top_offset, - left: left_offset, - ..Default::default() - }) - .width(Fill) - .height(Fill); - - Some(dropdown_backdrop(positioned.into())) + let (align_right, h_pad, top) = self.dd_anchor(PROP_COLOR_ID, 200.0, win.0); + Some(dropdown_backdrop(position_ribbon_dropdown( + panel.into(), + align_right, + h_pad, + top, + ))) } - fn prop_linetype_overlay(&self) -> Option> { + fn prop_linetype_overlay(&self, win: (f32, f32)) -> Option> { let active_lt = &self.active_linetype; let mut items: Vec = vec![ @@ -1105,24 +1100,16 @@ impl Ribbon { }) .width(Length::Fixed(220.0)); - let top_offset = self.tab_bar_height() + self.tool_bar_height(); - let groups = self.modules.get(self.active)?.ribbon_groups(); - let left_offset = compute_prop_combo_left(&groups, PROP_LINETYPE_ID); - let positioned = container(list) - .align_left(Fill) - .align_top(Fill) - .padding(Padding { - top: top_offset, - left: left_offset, - ..Default::default() - }) - .width(Fill) - .height(Fill); - - Some(dropdown_backdrop(positioned.into())) + let (align_right, h_pad, top) = self.dd_anchor(PROP_LINETYPE_ID, 220.0, win.0); + Some(dropdown_backdrop(position_ribbon_dropdown( + list.into(), + align_right, + h_pad, + top, + ))) } - fn prop_lw_overlay(&self) -> Option> { + fn prop_lw_overlay(&self, win: (f32, f32)) -> Option> { let active_lw = self.active_lineweight; let rows: Vec> = lw_options() .into_iter() @@ -1160,7 +1147,7 @@ impl Ribbon { }) .collect(); - self.prop_overlay_positioned(rows, PROP_LW_ID, 140.0) + self.prop_overlay_positioned(rows, PROP_LW_ID, 140.0, win) } fn prop_overlay_positioned<'a>( @@ -1168,6 +1155,7 @@ impl Ribbon { rows: Vec>, dd_id: &str, width: f32, + win: (f32, f32), ) -> Option> { let panel = container(column(rows)) .style(|_: &Theme| container::Style { @@ -1181,21 +1169,13 @@ impl Ribbon { }) .width(Length::Fixed(width)); - let top_offset = self.tab_bar_height() + self.tool_bar_height(); - let groups = self.modules.get(self.active)?.ribbon_groups(); - let left_offset = compute_prop_combo_left(&groups, dd_id); - let positioned = container(panel) - .align_left(Fill) - .align_top(Fill) - .padding(Padding { - top: top_offset, - left: left_offset, - ..Default::default() - }) - .width(Fill) - .height(Fill); - - Some(dropdown_backdrop(positioned.into())) + let (align_right, h_pad, top) = self.dd_anchor(dd_id, width, win.0); + Some(dropdown_backdrop(position_ribbon_dropdown( + panel.into(), + align_right, + h_pad, + top, + ))) } } diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index e00abe014..9af2f88a0 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -12,7 +12,7 @@ use iced::widget::{button, column, container, row, svg, text, tooltip}; use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; use crate::app::Message; -use crate::modules::{IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef}; +use crate::modules::{IconKind, ModuleEvent, RibbonItem, StyleKey, ToolDef}; use crate::ui::wrap_bar::PosReport; use crate::ui::icons; use crate::ui::properties::{acad_color_display, LwItem}; @@ -744,9 +744,13 @@ pub(super) fn render_large<'a>( let tools_row3 = make_tool_row(row3); container( - column![combo_btn, tools_row2, tools_row3] - .spacing(3) - .align_x(iced::Left), + column![ + PosReport::new(LAYER_COMBO_ID, combo_btn), + tools_row2, + tools_row3 + ] + .spacing(3) + .align_x(iced::Left), ) .width(Length::Fixed(COMBO_W)) .height(Fill) @@ -864,9 +868,13 @@ pub(super) fn render_large<'a>( let lw_row = prop_row(LwItem(active_lineweight).to_string(), PROP_LW_ID, None); let combos = container( - column![color_row, lt_row, lw_row] - .spacing(2) - .align_x(iced::Left), + column![ + PosReport::new(PROP_COLOR_ID, color_row), + PosReport::new(PROP_LINETYPE_ID, lt_row), + PosReport::new(PROP_LW_ID, lw_row), + ] + .spacing(2) + .align_x(iced::Left), ) .height(Fill) .align_y(iced::Center) @@ -968,7 +976,7 @@ pub(super) fn render_large<'a>( }; let mut col_items: Vec> = - vec![container(row![combo_btn, items_panel].spacing(0)) + vec![container(row![PosReport::new(*combo_id, combo_btn), items_panel].spacing(0)) .width(Fill) .into()]; for row_tools in rows { @@ -990,108 +998,6 @@ pub(super) fn render_large<'a>( } } -// ── Dropdown position helpers ────────────────────────────────────────────── - -/// Calculate the left pixel offset of a dropdown button inside the ribbon tool area. -pub(super) fn compute_dropdown_left(groups: &[RibbonGroup], open_id: &str) -> f32 { - let sum_with_spacing = |widths: &[f32]| -> f32 { - widths - .iter() - .enumerate() - .map(|(i, &w)| if i == 0 { w } else { 2.0 + w }) - .sum::() - }; - let next_item_x = |widths: &[f32]| -> f32 { - if widths.is_empty() { - 0.0 - } else { - sum_with_spacing(widths) + 2.0 - } - }; - - let mut x = 0.0f32; - - for (g_idx, group) in groups.iter().enumerate() { - if g_idx > 0 { - x += 1.0; - } - x += 4.0; - - let mut row_widths: Vec = Vec::new(); - let mut small_col_w: f32 = 0.0; - let mut small_col_n: usize = 0; - - for item in &group.tools { - let is_large = matches!( - item, - RibbonItem::LargeTool(_) - | RibbonItem::LargeDropdown { .. } - | RibbonItem::LayerComboGroup { .. } - | RibbonItem::PropertiesGroup { .. } - | RibbonItem::StyleComboGroup { .. } - ); - let id: &str = match item { - RibbonItem::LargeTool(t) => t.id, - RibbonItem::LargeDropdown { id, .. } => *id, - RibbonItem::Tool(t) => t.id, - RibbonItem::Dropdown { id, .. } => *id, - RibbonItem::LayerComboGroup { .. } => LAYER_COMBO_ID, - RibbonItem::PropertiesGroup { match_prop } => match_prop.id, - RibbonItem::StyleComboGroup { combo_id, .. } => combo_id, - }; - let item_w = match item { - RibbonItem::LargeTool(_) | RibbonItem::LargeDropdown { .. } => LARGE_W, - RibbonItem::LayerComboGroup { .. } => LARGE_W * 2.5, - RibbonItem::PropertiesGroup { .. } => LARGE_W + 4.0 + 130.0, - RibbonItem::StyleComboGroup { .. } => LARGE_W * 2.3, - RibbonItem::Dropdown { .. } => SMALL_W + ARROW_W, - _ => SMALL_W, - }; - - if is_large { - if small_col_n > 0 { - row_widths.push(small_col_w); - small_col_w = 0.0; - small_col_n = 0; - } - if id == open_id { - return x + next_item_x(&row_widths); - } - row_widths.push(item_w); - } else { - if id == open_id { - return x + next_item_x(&row_widths); - } - small_col_w = small_col_w.max(item_w); - small_col_n += 1; - if small_col_n == 3 { - row_widths.push(small_col_w); - small_col_w = 0.0; - small_col_n = 0; - } - } - } - - if small_col_n > 0 { - row_widths.push(small_col_w); - } - x += sum_with_spacing(&row_widths) + 4.0; - } - - 60.0 -} - -pub(super) fn compute_layer_combo_left(groups: &[RibbonGroup]) -> f32 { - compute_dropdown_left(groups, LAYER_COMBO_ID) -} - -/// Left offset for a Properties combo dropdown. -pub(super) fn compute_prop_combo_left(groups: &[RibbonGroup], _dd_id: &str) -> f32 { - let base = compute_dropdown_left(groups, "MATCHPROP"); - base + LARGE_W + 4.0 -} - - // ── Message helpers ──────────────────────────────────────────────────────── #[allow(dead_code)] diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index fca037a83..5a2163bf1 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -21,7 +21,16 @@ pub const LAYOUT_RENAME_INPUT_ID: &str = "layout_rename_input"; use crate::app::Message; use crate::snap::Snapper; use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill}; -use crate::ui::wrap_bar::{WrapBar, WrapFlow}; +use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow}; + +// PosReport ids for anchoring each status-bar popup directly to its pill. +pub const SB_OSNAP_ID: &str = "SB_OSNAP"; +pub const SB_SCALE_ID: &str = "SB_SCALE"; +pub const SB_UNITS_ID: &str = "SB_UNITS"; +pub const SB_ISOLATE_ID: &str = "SB_ISOLATE"; +pub const SB_FILTER_ID: &str = "SB_FILTER"; +pub const SB_MENU_ID: &str = "SB_MENU"; +pub const SB_LAYOUTLIST_ID: &str = "SB_LAYOUTLIST"; #[derive(Clone, Default)] pub struct StatusBar { @@ -189,7 +198,13 @@ impl StatusBar { ); } if vis(StatusPill::Osnap) { - pills.push(osnap_btn(osnap_active, snapper.snap_enabled, popup_open).into()); + pills.push( + PosReport::new( + SB_OSNAP_ID, + osnap_btn(osnap_active, snapper.snap_enabled, popup_open), + ) + .into(), + ); } if vis(StatusPill::Space) { pills.push( @@ -201,16 +216,19 @@ impl StatusBar { ); } if vis(StatusPill::Scale) { - pills.push(scale_element); + pills.push(PosReport::new(SB_SCALE_ID, scale_element).into()); } if vis(StatusPill::Units) { pills.push( - tip( - units_btn( - crate::ui::popup::units_popup::unit_short(insertion_units), - units_popup_open, + PosReport::new( + SB_UNITS_ID, + tip( + units_btn( + crate::ui::popup::units_popup::unit_short(insertion_units), + units_popup_open, + ), + "Drawing Units (INSUNITS)\nClick to change", ), - "Drawing Units (INSUNITS)\nClick to change", ) .into(), ); @@ -230,9 +248,12 @@ impl StatusBar { } if vis(StatusPill::Isolate) { pills.push( - tip( - toggle_pill(crate::ui::icons::ST_ISOLATE, isolation_active, Message::ToggleIsolatePopup), - "Isolate Objects\nClick for Isolate / Hide / End", + PosReport::new( + SB_ISOLATE_ID, + tip( + toggle_pill(crate::ui::icons::ST_ISOLATE, isolation_active, Message::ToggleIsolatePopup), + "Isolate Objects\nClick for Isolate / Hide / End", + ), ) .into(), ); @@ -248,13 +269,16 @@ impl StatusBar { } if vis(StatusPill::SelFilter) { pills.push( - tip( - toggle_pill( - crate::ui::icons::ST_FILTER, - selection_filter_active, - Message::ToggleSelectionFilterPopup, + PosReport::new( + SB_FILTER_ID, + tip( + toggle_pill( + crate::ui::icons::ST_FILTER, + selection_filter_active, + Message::ToggleSelectionFilterPopup, + ), + "Selection Filtering\nLimit which object types can be picked", ), - "Selection Filtering\nLimit which object types can be picked", ) .into(), ); @@ -288,9 +312,12 @@ impl StatusBar { } // Customization handle: opens the pill show/hide menu. pills.push( - tip( - customize_btn(), - "Customization\nShow or hide status-bar items", + PosReport::new( + SB_MENU_ID, + tip( + customize_btn(), + "Customization\nShow or hide status-bar items", + ), ) .into(), ); @@ -301,7 +328,7 @@ impl StatusBar { // wrap in their own flow; WrapBar stacks the two areas so a wrapped tab // never shares a row with a pill. let mut left: Vec> = Vec::new(); - left.push(menu_btn.into()); + left.push(PosReport::new(SB_LAYOUTLIST_ID, menu_btn).into()); if show_layout_tabs { for name in layouts { let is_active = name == current_layout; @@ -408,41 +435,6 @@ fn tip_node<'a>(content: Element<'a, Message>, body: Element<'a, Message>) -> El /// A status-bar toggle, drawn as a tinted icon (issue #216: the old size-10 /// text labels were too small to read). The name lives in the tooltip each /// call site already wraps it with. -/// Approximate distance (px) from the window's right edge to the right edge of -/// the Osnap button — used to anchor the OSNAP settings popup above the button -/// instead of at the window corner (#248). The status-bar pills that follow -/// Osnap are summed by estimated width (icon pills are fixed; labelled pills are -/// approximate), honouring the user's per-pill visibility. iced gives the view -/// no real layout metrics, so this positions the popup near the button rather -/// than pixel-exact. -pub fn osnap_popup_right_offset(config: &StatusBarConfig, viewport_count: usize) -> f32 { - const SP: f32 = 2.0; // right_status row spacing between pills - const TOGGLE: f32 = 33.0; // icon toggle pill (17px icon + [4,7] pad + border) - // Pills after Osnap, in bar order, with estimated widths. - let after = [ - (StatusPill::Space, 58.0), - (StatusPill::Scale, 92.0), - (StatusPill::Units, 42.0), - (StatusPill::Transparency, TOGGLE), - (StatusPill::Isolate, TOGGLE), - (StatusPill::QuickProps, TOGGLE), - (StatusPill::SelFilter, TOGGLE), - (StatusPill::SelCycle, TOGGLE), - (StatusPill::Vp, 46.0), - (StatusPill::CleanScreen, TOGGLE), - ]; - // Bar right margin + the always-present customization button. - let mut w = 6.0 + 30.0 + SP; - for (pill, pw) in after { - let shown = - config.is_visible(pill) && !(matches!(pill, StatusPill::Vp) && viewport_count == 0); - if shown { - w += pw + SP; - } - } - w -} - fn toggle_pill(icon: &'static [u8], active: bool, msg: Message) -> Element<'static, Message> { let color = if active { OSNAP_ON_TEXT } else { OSNAP_OFF_TEXT }; button(crate::ui::icons::tinted(icon, 17.0, color)) diff --git a/src/ui/statusbar/statusbar_menu.rs b/src/ui/statusbar/statusbar_menu.rs index 29ee32530..d078bae0c 100644 --- a/src/ui/statusbar/statusbar_menu.rs +++ b/src/ui/statusbar/statusbar_menu.rs @@ -3,14 +3,18 @@ //! above the status bar, same pattern as the scale picker. use iced::widget::{button, column, container, mouse_area, row, scrollable, text}; -use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; +use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; use crate::app::Message; use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill}; /// Full-screen overlay: transparent click-catcher + the menu panel pinned to /// the bottom-right, just above the status bar. -pub fn statusbar_menu_overlay(config: &StatusBarConfig) -> Element<'static, Message> { +pub fn statusbar_menu_overlay( + config: &StatusBarConfig, + pill: Option, + win: (f32, f32), +) -> Element<'static, Message> { let rows: Vec> = StatusPill::ALL .iter() .map(|&pill| { @@ -34,17 +38,8 @@ pub fn statusbar_menu_overlay(config: &StatusBarConfig) -> Element<'static, Mess }) .width(Length::Fixed(200.0)); - let positioned = container(panel) - .align_right(Fill) - .align_bottom(Fill) - .padding(Padding { - bottom: 27.0, - right: 4.0, - top: 0.0, - left: 0.0, - }) - .width(Fill) - .height(Fill); + let positioned = + crate::ui::popup::position_statusbar_popup(panel.into(), pill, win, 200.0, true); mouse_area(positioned) .on_press(Message::CloseStatusBarMenu) @@ -54,7 +49,12 @@ pub fn statusbar_menu_overlay(config: &StatusBarConfig) -> Element<'static, Mess /// Dropdown listing Model + every paper layout, opened from the leftmost /// hamburger. Pinned bottom-left just above the status bar; a click selects a /// layout (and closes), an outside click just closes. -pub fn layout_list_overlay<'a>(layouts: &[String], current: &str) -> Element<'a, Message> { +pub fn layout_list_overlay<'a>( + layouts: &[String], + current: &str, + pill: Option, + win: (f32, f32), +) -> Element<'a, Message> { let rows: Vec> = layouts .iter() .map(|name| layout_row(name.clone(), name == current)) @@ -73,17 +73,9 @@ pub fn layout_list_overlay<'a>(layouts: &[String], current: &str) -> Element<'a, .width(Length::Fixed(200.0)) .max_height(360.0); - let positioned = container(panel) - .align_left(Fill) - .align_bottom(Fill) - .padding(Padding { - bottom: 27.0, - left: 4.0, - top: 0.0, - right: 0.0, - }) - .width(Fill) - .height(Fill); + // Hamburger is on the left → prefer left-aligned (grows right). + let positioned = + crate::ui::popup::position_statusbar_popup(panel.into(), pill, win, 200.0, false); mouse_area(positioned) .on_press(Message::CloseLayoutList)