diff --git a/crates/oa-desktop/src/behavior/topology/layout_tests.rs b/crates/oa-desktop/src/behavior/topology/layout_tests.rs new file mode 100644 index 00000000..04cc0d0c --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/layout_tests.rs @@ -0,0 +1,138 @@ +use super::*; +use iced::advanced::{Layout, layout, widget}; +use iced::{Rectangle, Vector}; + +#[derive(Default)] +struct Geometry { + scrolls: Vec<(Option, Rectangle, Vector)>, + actions: Option, +} +impl widget::Operation for Geometry { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn widget::Operation)) { + operate(self); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if id == Some(&widget::Id::new("test-process-actions")) { + self.actions = Some(bounds); + } + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + _: Rectangle, + translation: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + self.scrolls.push((id.cloned(), bounds, translation)); + } +} +fn workspace(pending: bool) -> iced::Element<'static, Message, iced::Theme, ()> { + let diagram = scrollable(iced::widget::Space::new().width(500).height(1200)) + .id("test-process-page") + .height(Fill) + .into(); + let form = if pending { + editor_panel( + container(iced::widget::Space::new().height(32)) + .id("test-process-actions") + .into(), + iced::widget::Space::new().height(1400).into(), + ) + } else { + iced::widget::Space::new().height(0).into() + }; + panes(diagram, form, pending) +} +fn geometry( + view: &mut iced::Element<'_, Message, iced::Theme, ()>, + tree: &mut widget::Tree, + size: Size, +) -> (layout::Node, Geometry) { + let node = view + .as_widget_mut() + .layout(tree, &(), &layout::Limits::new(Size::ZERO, size)); + let mut geometry = Geometry::default(); + view.as_widget_mut() + .operate(tree, Layout::new(&node), &(), &mut geometry); + (node, geometry) +} + +#[test] +fn direct_process_pending_editor_uses_available_height_and_keeps_actions_visible() { + // Actual available center-pane sizes at the minimum/default supported windows. + // Space represents arbitrarily long diagram/form content; the production + // pane/card builders and real Iced layout/scroll operations are exercised. + for size in [Size::new(500., 570.), Size::new(840., 770.)] { + let mut view = workspace(false); + let mut tree = widget::Tree::new(view.as_widget()); + let (node, _) = geometry(&mut view, &mut tree, size); + let mut scroll = widget::operation::scrollable::scroll_to::<()>( + widget::Id::new("test-process-page"), + widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(180.), + }, + ); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &(), &mut scroll); + view = workspace(true); + tree.diff(view.as_widget()); + let (node, before) = geometry(&mut view, &mut tree, size); + let page = before + .scrolls + .iter() + .find(|s| s.0 == Some(widget::Id::new("test-process-page"))) + .unwrap(); + let form = before + .scrolls + .iter() + .find(|s| s.0 == Some(widget::Id::new("process-form"))) + .unwrap(); + assert_eq!(node.size(), size); + assert!( + form.1.height >= 200., + "usable form viewport: {size:?} {:?}", + form.1 + ); + assert!(form.1.y >= page.1.y + page.1.height); + assert!(form.1.y + form.1.height <= size.height); + assert_eq!(page.2.y, 180., "opening a form retains the page scroll"); + let actions = before.actions.unwrap(); + assert!(actions.y >= page.1.y + page.1.height); + assert!(actions.y + actions.height <= form.1.y); + let mut scroll = widget::operation::scrollable::scroll_to::<()>( + widget::Id::new("process-form"), + widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(600.), + }, + ); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &(), &mut scroll); + let (_, after) = geometry(&mut view, &mut tree, size); + assert_eq!( + after.actions, + Some(actions), + "Create/Cancel stay pinned during form scrolling" + ); + assert_eq!( + after.scrolls[0].2.y, 180., + "form scrolling does not move the diagram page" + ); + assert!( + after + .scrolls + .iter() + .any(|s| s.0 == Some(widget::Id::new("process-form")) && s.2.y == 600.) + ); + view = workspace(false); + tree.diff(view.as_widget()); + let (_, closed) = geometry(&mut view, &mut tree, size); + assert_eq!( + closed.scrolls[0].2.y, 180., + "cancel retains the page scroll" + ); + assert_eq!(closed.scrolls[0].1.height, size.height); + } +} diff --git a/crates/oa-desktop/src/behavior/topology/view.rs b/crates/oa-desktop/src/behavior/topology/view.rs index d2d7c917..96483d04 100644 --- a/crates/oa-desktop/src/behavior/topology/view.rs +++ b/crates/oa-desktop/src/behavior/topology/view.rs @@ -305,6 +305,7 @@ pub(super) fn view<'a>( button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))), ); } + let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into(); if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) { let mut form = column![text(d.tool.title()).size(16)].spacing(8); if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) { @@ -473,21 +474,65 @@ pub(super) fn view<'a>( button("Cancel").on_press(event(Event::Cancel)) ] .spacing(7); - out = out.push( - container( - column![ - actions, - scrollable(form).height((app.window_size.height - 520.).clamp(150., 260.)) - ] - .spacing(8), - ) - .padding(12) - .style(style::card), - ); + form_panel = editor_panel(actions.into(), form.into()); } - super::super::scenario_scroll::observe_topology( - context, - scenario::ScrollArea::Page, - scrollable(out.padding(18)), + panes( + super::super::scenario_scroll::observe_topology( + context, + scenario::ScrollArea::Page, + scrollable(out.padding(18)), + ), + form_panel, + pending, ) } + +/// Keep the diagram mounted while an independent editor uses the remaining +/// workspace height. The editor must never be clipped by the page scrollable. +fn panes<'a, R: iced::advanced::Renderer + 'a>( + diagram: iced::Element<'a, Message, iced::Theme, R>, + form: iced::Element<'a, Message, iced::Theme, R>, + pending: bool, +) -> iced::Element<'a, Message, iced::Theme, R> { + column![ + container(diagram).width(Fill).height(if pending { + iced::Length::FillPortion(1) + } else { + Fill + }), + container(form).width(Fill).height(if pending { + iced::Length::FillPortion(1) + } else { + iced::Length::Shrink + }), + ] + .width(Fill) + .height(Fill) + .into() +} + +fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>( + actions: iced::Element<'a, Message, iced::Theme, R>, + fields: iced::Element<'a, Message, iced::Theme, R>, +) -> iced::Element<'a, Message, iced::Theme, R> { + container( + container(column![actions, scrollable(fields).id("process-form").height(Fill)].spacing(8)) + .width(Fill) + .height(Fill) + .padding(12) + .style(style::card), + ) + .width(Fill) + .height(Fill) + .padding(iced::Padding { + top: 0., + right: 18., + bottom: 18., + left: 18., + }) + .into() +} + +#[cfg(all(test, debug_assertions))] +#[path = "layout_tests.rs"] +mod layout_tests;