refactor(ui): adopt iced layout widgets

This commit is contained in:
Hakan Seven 2026-07-30 15:40:51 +03:00
commit 5116369dec
6 changed files with 123 additions and 266 deletions

View file

@ -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"

View file

@ -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<String, iced::widget::image::Handle>,
discussions: &'a [crate::discussions::DiscussionEntry],
discussions_loading: bool,
recents: &'a [std::path::PathBuf],
thumbs: &'a std::collections::HashMap<
std::path::PathBuf,
Option<iced::widget::image::Handle>,
>,
recent_limit: usize,
recent_limit_input: &'a str,
action_width_out: std::sync::Arc<std::sync::atomic::AtomicU32>,
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)

View file

@ -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<Message, Theme, Renderer> 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<Length> {
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<overlay::Element<'b, Message, Theme, Renderer>> {
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<Floating<'a>> 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<Message, Theme, Renderer> 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()
}

View file

@ -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()),
),
)
}
}

View file

@ -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.

View file

@ -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<String>,
@ -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)