From 5116369decc8b8aa672003ee315e6376ccc85435 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 30 Jul 2026 15:40:51 +0300 Subject: [PATCH] refactor(ui): adopt iced layout widgets --- Cargo.toml | 2 +- src/app/view/mod.rs | 85 ++++++++++---- src/ui/color_select.rs | 249 ++++------------------------------------ src/ui/properties.rs | 27 +++-- src/ui/ribbon/mod.rs | 8 +- src/ui/statusbar/mod.rs | 18 ++- 6 files changed, 123 insertions(+), 266 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 07d72770..85548199 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00 iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } # iced_aw 0.15 compatibility is pending upstream in PR #432. Pin the exact # reviewed pair of commits so Iced and its additional widgets cannot drift. -iced_aw = { git = "https://github.com/tsuza/iced_aw.git", rev = "a9301708a4d008dc17f63cbbbb2b2aa06e0e7352", features = ["menu", "context_menu", "color_picker"] } +iced_aw = { git = "https://github.com/tsuza/iced_aw.git", rev = "a9301708a4d008dc17f63cbbbb2b2aa06e0e7352", features = ["menu", "context_menu", "color_picker", "drop_down"] } bytemuck = { version = "1.25", features = ["derive"] } glam = { version = "0.33", features = ["bytemuck"] } truck-modeling = "0.6" diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 8051e6f8..ef8bb42f 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -150,24 +150,20 @@ impl OpenCADStudio { // viewport draws the layout's own geometry (white sheet + entities + // borders) and the floating content viewports blit on top. let viewport_3d: Element<'_, Message> = if tab.is_start { - responsive(|size| { - start_page_view( - &self.patrons, - &self.videos, - self.videos_loading, - &self.video_thumbs, - &self.discussions, - self.discussions_loading, - &self.recent_files, - &self.recent_thumbs, - self.recent_limit, - &self.recent_limit_input, - size.width, - self.start_action_w.clone(), - self.start_section, - ) - }) - .into() + start_page_view( + &self.patrons, + &self.videos, + self.videos_loading, + &self.video_thumbs, + &self.discussions, + self.discussions_loading, + &self.recent_files, + &self.recent_thumbs, + self.recent_limit, + &self.recent_limit_input, + self.start_action_w.clone(), + self.start_section, + ) } else if is_paper { shader(ViewportPane::model( &tab.scene, @@ -2124,7 +2120,13 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele items.push(new_btn.into()); - container(WrapFlow::new(items).spacing_x(0.0).row_h(30.0)) + container( + Row::with_children(items) + .spacing(0.0) + .align_y(iced::Center) + .wrap() + .vertical_spacing(0.0), + ) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().background.base.color, @@ -2254,6 +2256,43 @@ pub(super) fn collapse_bar<'a>(name: &str, on_press: Message) -> Element<'a, Mes } pub(super) fn start_page_view<'a>( + patrons: &'a [(String, i64)], + videos: &'a [crate::videos::VideoEntry], + videos_loading: bool, + video_thumbs: &'a std::collections::HashMap, + discussions: &'a [crate::discussions::DiscussionEntry], + discussions_loading: bool, + recents: &'a [std::path::PathBuf], + thumbs: &'a std::collections::HashMap< + std::path::PathBuf, + Option, + >, + recent_limit: usize, + recent_limit_input: &'a str, + action_width_out: std::sync::Arc, + active: super::StartSection, +) -> Element<'a, Message> { + responsive(move |size| { + start_page_content( + patrons, + videos, + videos_loading, + video_thumbs, + discussions, + discussions_loading, + recents, + thumbs, + recent_limit, + recent_limit_input, + size.width, + action_width_out.clone(), + active, + ) + }) + .into() +} + +fn start_page_content<'a>( patrons: &'a [(String, i64)], videos: &'a [crate::videos::VideoEntry], videos_loading: bool, @@ -2819,15 +2858,17 @@ pub(super) fn start_page_view<'a>( } }) }; - let tab_bar = WrapFlow::new(vec![ + let tab_bar = Row::with_children(vec![ tab_btn("Recent Files", super::StartSection::Recent).into(), tab_btn("Videos", super::StartSection::Videos).into(), tab_btn("Welcome", super::StartSection::Welcome).into(), tab_btn("Discussions", super::StartSection::Discussions).into(), tab_btn("Supporters", super::StartSection::Supporters).into(), ]) - .spacing_x(6.0) - .row_h(38.0); + .spacing(6.0) + .align_y(iced::Center) + .wrap() + .vertical_spacing(0.0); let section_body: Element<'a, Message> = match active { super::StartSection::Recent => container(recent) .width(Fill) diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index 1d48214d..ccd8c2c7 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -6,11 +6,8 @@ use crate::app::Message; use crate::ui::properties::acad_color_display; use acadrust::types::Color as AcadColor; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::widget::{self, Widget}; -use iced::advanced::{mouse, overlay, renderer, Shell}; use iced::widget::{button, column, container, row, text}; -use iced::{Background, Border, Color, Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vector}; +use iced::{Background, Border, Color, Element, Length, Theme}; /// Which "logical" entries the colour list offers besides the standard ACI /// colours. @@ -127,6 +124,7 @@ pub fn color_selector<'a>( ) -> Element<'a, Message> { let (cur_bg, _) = acad_color_display(current); let cur_name = color_display_name(current); + let on_dismiss = on_toggle.clone(); // Closed button: current swatch + name + caret. let head = button( @@ -162,12 +160,14 @@ pub fn color_selector<'a>( .padding(5) .width(220); - // The popup is shown as a floating overlay (anchored below the button) so - // it doesn't push the surrounding form down. - Element::new(Floating { - base: head.into(), - popup: popup.into(), - }) + // `DropDown` keeps the popup outside the surrounding form layout and + // handles viewport placement, Escape, and outside-click dismissal. + iced_aw::DropDown::new(head, popup, true) + .width(220) + .alignment(iced_aw::drop_down::Alignment::Bottom) + .offset(2.0) + .on_dismiss(on_dismiss) + .into() } fn list_row_style(theme: &Theme, status: button::Status) -> button::Style { @@ -227,224 +227,19 @@ pub fn color_list<'a>( list.into() } -/// Render `base` inline with `popup` floating just below it — the shared -/// dropdown mechanic for the panel's custom dropdowns (colour picker, block -/// Name). Unlike iced's menu overlay it always opens downward. -pub fn floating_below<'a>( +/// Render `base` inline with `popup` in an `iced_aw` dropdown. +pub fn drop_down_below<'a>( base: Element<'a, Message>, popup: Element<'a, Message>, + popup_width: Length, + popup_height: Length, + on_dismiss: Message, ) -> Element<'a, Message> { - Element::new(Floating { base, popup }) -} - -/// A widget that renders `base` inline and `popup` as a floating overlay -/// anchored just below it. -struct Floating<'a> { - base: Element<'a, Message>, - popup: Element<'a, Message>, -} - -impl<'a> Widget for Floating<'a> { - fn diff(&mut self, tree: &mut widget::Tree) { - tree.diff_children(&mut [&mut self.base, &mut self.popup]); - } - - fn size(&self) -> Size { - self.base.as_widget().size() - } - - fn layout( - &mut self, - tree: &mut widget::Tree, - renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - self.base - .as_widget_mut() - .layout(&mut tree.children[0], renderer, limits) - } - - fn update( - &mut self, - tree: &mut widget::Tree, - event: &Event, - layout: Layout<'_>, - cursor: mouse::Cursor, - renderer: &Renderer, - shell: &mut Shell<'_, Message>, - viewport: &Rectangle, - ) { - self.base.as_widget_mut().update( - &mut tree.children[0], - event, - layout, - cursor, - renderer, - shell, - viewport, - ); - } - - fn mouse_interaction( - &self, - tree: &widget::Tree, - layout: Layout<'_>, - cursor: mouse::Cursor, - viewport: &Rectangle, - renderer: &Renderer, - ) -> mouse::Interaction { - self.base.as_widget().mouse_interaction( - &tree.children[0], - layout, - cursor, - viewport, - renderer, - ) - } - - fn operate( - &mut self, - tree: &mut widget::Tree, - layout: Layout<'_>, - renderer: &Renderer, - operation: &mut dyn widget::Operation, - ) { - self.base - .as_widget_mut() - .operate(&mut tree.children[0], layout, renderer, operation); - } - - fn draw( - &self, - tree: &widget::Tree, - renderer: &mut Renderer, - theme: &Theme, - style: &renderer::Style, - layout: Layout<'_>, - cursor: mouse::Cursor, - viewport: &Rectangle, - ) { - self.base.as_widget().draw( - &tree.children[0], - renderer, - theme, - style, - layout, - cursor, - viewport, - ); - } - - fn overlay<'b>( - &'b mut self, - tree: &'b mut widget::Tree, - layout: Layout<'b>, - _renderer: &Renderer, - _viewport: &Rectangle, - translation: Vector, - ) -> Option> { - let bounds = layout.bounds(); - let anchor = Point::new( - bounds.x + translation.x, - bounds.y + bounds.height + translation.y + 2.0, - ); - Some(overlay::Element::new(Box::new(FloatingOverlay { - popup: &mut self.popup, - tree: &mut tree.children[1], - anchor, - }))) - } -} - -impl<'a> From> for Element<'a, Message> { - fn from(f: Floating<'a>) -> Self { - Element::new(f) - } -} - -struct FloatingOverlay<'a, 'b> { - popup: &'b mut Element<'a, Message>, - tree: &'b mut widget::Tree, - anchor: Point, -} - -impl overlay::Overlay for FloatingOverlay<'_, '_> { - fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { - let viewport = Rectangle::with_size(bounds); - let limits = layout::Limits::new(Size::ZERO, viewport.size()); - let node = self - .popup - .as_widget_mut() - .layout(self.tree, renderer, &limits); - let size = node.size(); - let mut x = self.anchor.x; - let mut y = self.anchor.y; - if x + size.width > viewport.width { - x = (viewport.width - size.width).max(0.0); - } - if y + size.height > viewport.height { - // Not enough room below — flip above the anchor. - y = (self.anchor.y - bounds.height.min(0.0) - size.height).max(0.0); - } - layout::Node::with_children(size, vec![node]).translate(Vector::new(x, y)) - } - - fn draw( - &self, - renderer: &mut Renderer, - theme: &Theme, - style: &renderer::Style, - layout: Layout<'_>, - cursor: mouse::Cursor, - ) { - let child = layout.children().next().unwrap(); - self.popup.as_widget().draw( - self.tree, - renderer, - theme, - style, - child, - cursor, - &child.bounds(), - ); - } - - fn update( - &mut self, - event: &Event, - layout: Layout<'_>, - cursor: mouse::Cursor, - renderer: &Renderer, - shell: &mut Shell<'_, Message>, - ) { - let child = layout.children().next().unwrap(); - let vp = child.bounds(); - self.popup.as_widget_mut().update( - self.tree, event, child, cursor, renderer, shell, &vp, - ); - } - - fn operate( - &mut self, - layout: Layout<'_>, - renderer: &Renderer, - operation: &mut dyn widget::Operation, - ) { - let child = layout.children().next().unwrap(); - self.popup - .as_widget_mut() - .operate(self.tree, child, renderer, operation); - } - - fn mouse_interaction( - &self, - layout: Layout<'_>, - cursor: mouse::Cursor, - renderer: &Renderer, - ) -> mouse::Interaction { - let child = layout.children().next().unwrap(); - self.popup - .as_widget() - .mouse_interaction(self.tree, child, cursor, &child.bounds(), renderer) - } + iced_aw::DropDown::new(base, popup, true) + .width(popup_width) + .height(popup_height) + .alignment(iced_aw::drop_down::Alignment::Bottom) + .offset(2.0) + .on_dismiss(on_dismiss) + .into() } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index f1b52090..27065b29 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -26,6 +26,8 @@ const COMBO_PAD_V: f32 = (ROW_H - FONT_SZ * 1.3 - 2.0) / 2.0; // fills combo to const SWATCH_SZ: f32 = ROW_H * 0.54; // ≈14 px color swatch const PATTERN_CARD_W: f32 = 158.0; const PATTERN_PREVIEW_H: f32 = 58.0; +const PATTERN_PICKER_W: f32 = 348.0; +const PATTERN_PICKER_H: f32 = 720.0; use crate::app::Message; use crate::scene::model::object::{PropSection, PropValue}; @@ -862,8 +864,7 @@ impl PropertiesPanel { /// Editable dropdown row (block reference Name): a text field with a caret /// button in one bordered control. Typing + Enter commits through the /// normal PropGeomCommit path (existing name → re-point, new name → - /// rename); the caret opens a floating list of the definitions (always - /// downward, via the shared `floating_below` mechanic) and picking one + /// rename); the caret opens a dropdown list of the definitions and picking one /// applies through PropGeomChoiceChanged. Typed text filters the list. fn render_edit_choice_row<'a>( &'a self, @@ -966,7 +967,13 @@ impl PropertiesPanel { prop_row_widget( label, - crate::ui::color_select::floating_below(head.into(), popup.into()), + crate::ui::color_select::drop_down_below( + head.into(), + popup.into(), + Length::Fixed(200.0), + Length::Shrink, + Message::PropEditChoiceToggle, + ), ) } @@ -1124,19 +1131,25 @@ impl PropertiesPanel { .into() } else { scrollable(grid) - .height(Length::Fixed(300.0)) + .height(Length::Fill) .width(Length::Fill) .into() }; let popup = container(column![search, results].spacing(7)) .style(container::bordered_box) .padding(8) - .width(348) - .height(Length::Fit.max(360.0)); + .width(PATTERN_PICKER_W) + .height(Length::Fixed(PATTERN_PICKER_H)); prop_row_widget( label, - crate::ui::color_select::floating_below(head.into(), popup.into()), + crate::ui::color_select::drop_down_below( + head.into(), + popup.into(), + Length::Fixed(PATTERN_PICKER_W), + Length::Fixed(PATTERN_PICKER_H), + Message::PropHatchPatternPickerToggle(current.to_string()), + ), ) } } diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index a70442ac..2cae9b81 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -393,7 +393,7 @@ impl Ribbon { redo_count: usize, ) -> Element<'_, Message> { // ── Quick-access file commands + undo/redo, one merged flow ──────── - let lead = WrapFlow::new(vec![ + let lead = iced::widget::Row::with_children(vec![ quick_access_btn(crate::ui::icons::DOC_NEW, "New", "NEW", is_start).into(), quick_access_btn(crate::ui::icons::FOLDER_OPEN, "Open", "OPEN", is_start).into(), quick_access_btn(crate::ui::icons::SAVE, "Save", "SAVE", is_start).into(), @@ -402,8 +402,10 @@ impl Ribbon { render_history_control("Undo", UNDO_HISTORY_ID, undo_count, &self.open_dropdown).into(), render_history_control("Redo", REDO_HISTORY_ID, redo_count, &self.open_dropdown).into(), ]) - .spacing_x(TOP_HIST_GAP) - .row_h(28.0); + .spacing(TOP_HIST_GAP) + .align_y(iced::Center) + .wrap() + .vertical_spacing(0.0); // The quick-access flow and the tabs flow each flex-wrap; WrapBar stacks // them so a wrapped tab never shares a row with a quick-access button. diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index a6edc594..0e2cd4d5 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -25,7 +25,7 @@ use crate::app::Message; use crate::snap::Snapper; use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill}; use crate::ui::statusbar::status_menu::Entry as StatusMenuEntry; -use crate::ui::wrap_bar::{WrapBar, WrapFlow}; +use crate::ui::wrap_bar::WrapBar; pub struct StatusMenuData<'a> { pub layout_names: Vec, @@ -412,10 +412,12 @@ impl StatusBar { ) .into(), ); - let right_status = WrapFlow::new(pills) - .spacing_x(2.0) - .row_h(30.0) - .justify_end(true); + let right_status = iced::widget::Row::with_children(pills) + .spacing(2.0) + .align_y(iced::Center) + .wrap() + .vertical_spacing(0.0) + .align_x(iced::alignment::Horizontal::Right); // Left area: hamburger menu + Model/layout tabs in a flex-wrap flow, so // they spill onto lower rows when narrow (no scroll arrows). The pills @@ -462,7 +464,11 @@ impl StatusBar { } left.push(add_btn.into()); } - let left_area = WrapFlow::new(left).spacing_x(2.0).row_h(30.0); + let left_area = iced::widget::Row::with_children(left) + .spacing(2.0) + .align_y(iced::Center) + .wrap() + .vertical_spacing(0.0); let wrap = WrapBar::new(left_area.into(), right_status.into()) .min_row_h(30.0)