From 6969c825a8340980b6a419524ade3bb687a6051f Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Fri, 11 Sep 2026 10:45:44 -0700 Subject: [PATCH] Integrate shared behavior canvas with current form focus and recovery --- crates/oa-desktop/src/app.rs | 181 +- crates/oa-desktop/src/app/process_canvas.rs | 75 +- .../oa-desktop/src/app/reference_authoring.rs | 4 + crates/oa-desktop/src/behavior.rs | 164 +- .../src/behavior/canvas_workspace.rs | 243 ++ .../src/behavior/canvas_workspace/control.rs | 258 +++ .../src/behavior/canvas_workspace/focus.rs | 293 +++ .../src/behavior/canvas_workspace/tests.rs | 756 +++++++ .../src/behavior/canvas_workspace/viewport.rs | 426 ++++ crates/oa-desktop/src/behavior/scenario.rs | 46 +- .../src/behavior/scenario/workspace.rs | 490 ++--- .../src/behavior/scenario/workspace/tests.rs | 1947 ++++++++++------- crates/oa-desktop/src/behavior/topology.rs | 27 +- .../src/behavior/topology/contextual.rs | 6 +- .../src/behavior/topology/direct.rs | 163 +- .../src/behavior/topology/direct_tests.rs | 78 +- .../src/behavior/topology/layout_tests.rs | 1228 ++++++++--- .../oa-desktop/src/behavior/topology/view.rs | 488 ++--- 18 files changed, 4813 insertions(+), 2060 deletions(-) create mode 100644 crates/oa-desktop/src/behavior/canvas_workspace.rs create mode 100644 crates/oa-desktop/src/behavior/canvas_workspace/control.rs create mode 100644 crates/oa-desktop/src/behavior/canvas_workspace/focus.rs create mode 100644 crates/oa-desktop/src/behavior/canvas_workspace/tests.rs create mode 100644 crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 6545df64..13783226 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -319,6 +319,7 @@ pub struct FilePrompt { #[derive(Debug, Clone)] pub enum Message { + BehaviorControl(behavior::canvas_workspace::Intent), Architecture(architecture_authoring::Event), Home(crate::home::Event), CheckpointTick, @@ -453,6 +454,9 @@ pub struct App { menu_sequence: u64, pub canvas_document: Id, pub canvas_epoch: u64, + /// Camera batches survive their own updates, never external presentation changes. + pub camera_epoch: u64, + camera_boundary_epoch: u64, pub selected_bend: Option<(Id, usize)>, pub exact_strings: bool, pub document: Document, @@ -556,6 +560,8 @@ impl App { menu_sequence: 0, canvas_document: Id::new_v4(), canvas_epoch: 0, + camera_epoch: 0, + camera_boundary_epoch: 0, selected_bend: None, exact_strings: false, document, @@ -1033,7 +1039,10 @@ impl App { ) .into(); crate::behavior::scenario::focus::root( - crate::hosted::view::overlay(self, self.reference_overlay(base)), + behavior::canvas_workspace::focus_root( + crate::hosted::view::overlay(self, self.reference_overlay(base)), + behavior::canvas_workspace::Scope::input_owner(self), + ), self.scenario_focus_owner(), ) } @@ -1091,6 +1100,105 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + if let Message::BehaviorControl(intent) = message { + return self.behavior_control(intent); + } + let camera = matches!( + &message, + Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(..)) + | Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(..)) + ); + let before = self.camera_boundary(); + let task = self.update_inner(message); + if !camera && self.camera_boundary() != before { + self.camera_epoch = self.camera_epoch.wrapping_add(1); + self.camera_boundary_epoch = self.canvas_epoch; + } + task + } + fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task { + use behavior::canvas_workspace::{self, Event as Camera, Scope}; + use behavior::scenario::CanvasEvent as Scenario; + use behavior::topology::CanvasEvent as Process; + if Scope::input_owner(self) != Some(intent.scope) { + return Task::none(); + } + let mut message = *intent.message; + let scope = intent.scope; + let valid_scenario = |c: behavior::scenario::Context| { + c.document == scope.document + && c.scenario == scope.owner + && c.revision == scope.revision + && self.camera_toolbar_epoch(c.epoch) + }; + let valid_process = |c: behavior::topology::Context| { + c.document == scope.document + && c.owner == scope.owner + && c.revision == scope.revision + && self.camera_toolbar_epoch(c.epoch) + }; + // This whitelist contains only the production presentation controls. + // A stamped toolbar cannot smuggle an arbitrary authoring command. + match &mut message { + Message::ScenarioCanvas(Scenario::Camera(c, lifetime, Camera::Focus)) + if valid_scenario(*c) && *lifetime == scope.epoch => + { + return canvas_workspace::focus_task(scope, Camera::Focus); + } + Message::TopologyCanvas(Process::Camera(c, lifetime, Camera::Focus)) + if valid_process(*c) && *lifetime == scope.epoch => + { + return canvas_workspace::focus_task(scope, Camera::Focus); + } + Message::ScenarioCanvas( + Scenario::Zoom(c, _) + | Scenario::Choose(c, _) + | Scenario::Focus(c) + | Scenario::Shelf(c, _), + ) if valid_scenario(*c) => { + c.epoch = self.canvas_epoch; + } + Message::TopologyCanvas( + Process::Zoom(c, _) | Process::Choose(c, _) | Process::Focus(c), + ) if valid_process(*c) => { + c.epoch = self.canvas_epoch; + } + Message::Behavior(behavior::Event::Topology(behavior::topology::Event::Shelf(_))) + if self.topology_context().is_some() => {} + _ => return Task::none(), + } + let task = self.update(message); + // Reapplying Fit or a tool also ends a held gesture. Focus is stamped + // after the action, for Iced's final-view ordering. + if self.camera_epoch == scope.epoch { + self.camera_epoch = self.camera_epoch.wrapping_add(1); + self.camera_boundary_epoch = self.canvas_epoch; + } + let focus = Scope::input_owner(self).map_or_else(Task::none, |scope| { + canvas_workspace::focus_task(scope, Camera::ControlFocus(intent.name)) + }); + Task::batch([task, focus]) + } + fn camera_toolbar_epoch(&self, epoch: u64) -> bool { + epoch >= self.camera_boundary_epoch && epoch <= self.canvas_epoch + } + fn camera_boundary(&self) -> impl PartialEq + use<> { + ( + self.canvas_epoch, + self.canvas_document, + self.document.revision(), + crate::behavior::selected_owner(self), + self.page, + self.window_size, + crate::behavior::canvas_workspace::keyboard_enabled(self), + self.behavior.has_pending(), + self.draft_dirty(), + self.source_dirty(), + self.behavior.topology.camera_boundary(), + self.behavior.scenario.camera_boundary(), + ) + } + fn update_inner(&mut self, message: Message) -> Task { if let Some(task) = self.reference_message(&message) { return task; } @@ -1400,6 +1508,7 @@ impl App { } fn handle(&mut self, message: Message) -> Task { match message { + Message::BehaviorControl(intent) => return self.behavior_control(intent), Message::Architecture(event) => return self.architecture_event(event), Message::Home(event) => return self.home_event(event), Message::CheckpointTick => return self.checkpoint_tick(), @@ -1464,6 +1573,49 @@ impl App { } return Task::none(); } + if let CanvasEvent::Camera(_, lifetime, input) = event { + use crate::behavior::canvas_workspace::{self, Event}; + if lifetime != self.camera_epoch + || !self.scenario_context().is_some_and(|c| { + c.document == context.document + && c.scenario == context.scenario + && c.revision == context.revision + }) + { + return Task::none(); + } + let scope = canvas_workspace::Scope { + document: context.document, + owner: context.scenario, + revision: context.revision, + epoch: lifetime, + }; + if self.behavior.has_pending() + || self.draft_dirty() + || self.source_dirty() + || !canvas_workspace::keyboard_enabled(self) + { + return Task::none(); + } + match input { + Event::Focus + | Event::Traverse(_) + | Event::ControlFocus(_) + | Event::ToggleFocus + | Event::Leave => { + return canvas_workspace::focus_task(scope, input); + } + Event::CancelGesture => { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1) + } + input => { + if self.behavior.scenario.camera(input) { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + } + } + return Task::none(); + } if self.scenario_context() != Some(context) { return Task::none(); } @@ -1477,9 +1629,11 @@ impl App { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } CanvasEvent::Zoom(_, scale) => { - if self.behavior.scenario.zoom(scale) { + let mut state = std::mem::take(&mut self.behavior.scenario); + if state.camera_zoom(self.project(), context.scenario, scale) { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } + self.behavior.scenario = state; } CanvasEvent::OpenUse(_, occurrence) => { if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() @@ -1601,13 +1755,22 @@ impl App { .. } => { let mut state = std::mem::take(&mut self.behavior.scenario); - let result = state.begin_occurrence( - self.project(), - context, - tool, - slot, - lifeline, - ); + let result = if tool == scenario::Tool::Lifeline { + state + .update( + self.project(), + scenario::Event::Start(context.scenario, tool), + ) + .map(|_| ()) + } else { + state.begin_occurrence( + self.project(), + context, + tool, + slot, + lifeline, + ) + }; self.behavior.scenario = state; match result { Ok(()) => { diff --git a/crates/oa-desktop/src/app/process_canvas.rs b/crates/oa-desktop/src/app/process_canvas.rs index 211a15bf..4a48ce3c 100644 --- a/crates/oa-desktop/src/app/process_canvas.rs +++ b/crates/oa-desktop/src/app/process_canvas.rs @@ -15,18 +15,15 @@ impl App { epoch: self.canvas_epoch, }) } - pub(super) fn reveal_process_selection(&self) -> Task { - let Some(context) = self.topology_context() else { - return Task::none(); - }; - let Some(offset) = - self.behavior - .topology - .selection_offset(self.project(), context.owner, self.selected) - else { - return Task::none(); - }; - iced::widget::operation::scroll_to("process-canvas", offset) + pub(super) fn reveal_process_selection(&mut self) -> Task { + if let Some(context) = self.topology_context() { + let mut state = std::mem::take(&mut self.behavior.topology); + if state.reveal_selection(self.project(), context.owner, self.selected) { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + self.behavior.topology = state; + } + Task::none() } pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task { let context = event.context(); @@ -52,6 +49,47 @@ impl App { } return Task::none(); } + if let CanvasEvent::Camera(_, lifetime, input) = event { + use crate::behavior::canvas_workspace::{self, Event}; + if lifetime != self.camera_epoch + || !self.topology_context().is_some_and(|c| { + c.document == context.document + && c.owner == context.owner + && c.revision == context.revision + }) + { + return Task::none(); + } + let scope = canvas_workspace::Scope { + document: context.document, + owner: context.owner, + revision: context.revision, + epoch: lifetime, + }; + if self.behavior.has_pending() + || self.draft_dirty() + || self.source_dirty() + || !canvas_workspace::keyboard_enabled(self) + { + return Task::none(); + } + match input { + Event::Focus + | Event::Traverse(_) + | Event::ControlFocus(_) + | Event::ToggleFocus + | Event::Leave => { + return canvas_workspace::focus_task(scope, input); + } + Event::CancelGesture => self.canvas_epoch = self.canvas_epoch.wrapping_add(1), + input => { + if self.behavior.topology.camera(input) { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + } + } + return Task::none(); + } if self.topology_context() != Some(context) { return Task::none(); } @@ -81,6 +119,7 @@ impl App { .first() .copied() .unwrap_or(endpoint.occurrence), + resolved.defining_sequence, ); self.behavior.active_owner = Some(resolved.defining_sequence); self.select(endpoint.occurrence); @@ -118,14 +157,18 @@ impl App { } } CanvasEvent::Choose(_, tool) => { - if self.behavior.has_pending() || !self.apply() { + if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { + self.status = + "Apply or cancel pending edits before choosing a process tool.".into(); return Task::none(); } self.behavior.topology.choose(tool); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } CanvasEvent::Zoom(_, zoom) => { - self.behavior.topology.zoom(zoom); + let mut state = std::mem::take(&mut self.behavior.topology); + state.camera_zoom(self.project(), context.owner, zoom); + self.behavior.topology = state; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } CanvasEvent::Expand(_, id) => { @@ -149,7 +192,9 @@ impl App { .get(&target) .is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) { - self.behavior.topology.remember(context.owner, occurrence); + self.behavior + .topology + .remember(context.owner, occurrence, target); self.behavior.active_owner = Some(target); } self.canvas_epoch = self.canvas_epoch.wrapping_add(1); diff --git a/crates/oa-desktop/src/app/reference_authoring.rs b/crates/oa-desktop/src/app/reference_authoring.rs index 47942c5a..c8820787 100644 --- a/crates/oa-desktop/src/app/reference_authoring.rs +++ b/crates/oa-desktop/src/app/reference_authoring.rs @@ -49,6 +49,10 @@ pub(super) struct State { } impl App { + pub(crate) fn reference_input_active(&self) -> bool { + self.reference_authoring.opened.is_some() + } + fn reference_draft(&self, host: &Host, path: &str) -> Option<&Draft> { match host { Host::Inspector diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index db74fc61..54e18056 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -14,6 +14,7 @@ use iced::{ }; use oa_core::*; use std::collections::BTreeMap; +pub(crate) mod canvas_workspace; #[cfg(test)] mod contextual_source_tests; pub(crate) mod creation; @@ -40,6 +41,7 @@ mod scenario_scroll { pub(super) fn observe<'a, R: renderer::Renderer + 'a>( context: scenario::Context, area: scenario::ScrollArea, + process: bool, restore: Option, content: impl Into>, ) -> Element<'a, Message, Theme, R> { @@ -47,43 +49,8 @@ mod scenario_scroll { content: content.into(), context, area, - process: false, + process, restore, - reveal: None, - }) - } - pub(super) fn observe_canvas<'a, R: renderer::Renderer + 'a>( - context: scenario::Context, - restore: Option, - reveal: Option, - content: impl Into>, - ) -> Element<'a, Message, Theme, R> { - Element::new(Observer { - content: content.into(), - context, - area: scenario::ScrollArea::Canvas, - process: false, - restore, - reveal, - }) - } - pub(super) fn observe_topology<'a>( - context: topology::Context, - area: scenario::ScrollArea, - content: impl Into>, - ) -> Element<'a, Message> { - Element::new(Observer { - content: content.into(), - context: scenario::Context { - document: context.document, - scenario: context.owner, - revision: context.revision, - epoch: context.epoch, - }, - area, - process: true, - restore: None, - reveal: None, }) } struct Observer<'a, R = Renderer> { @@ -92,14 +59,12 @@ mod scenario_scroll { area: scenario::ScrollArea, process: bool, restore: Option, - reveal: Option, } type Report = Option<(bool, Id, Id, Vector)>; struct ObservedScroll { identity: (Id, Id), report: Report, restored: Option, - revealed: Option<(Id, scenario::Slot)>, } #[derive(Default)] struct Translation(Option); @@ -138,41 +103,6 @@ mod scenario_scroll { self.applied = true; } } - struct RevealTranslation { - request: scenario::ScrollReveal, - applied: bool, - } - impl widget::Operation for RevealTranslation { - fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {} - fn scrollable( - &mut self, - _: Option<&widget::Id>, - bounds: Rectangle, - _: Rectangle, - translation: Vector, - state: &mut dyn widget::operation::Scrollable, - ) { - let reveal = self.request; - if !reveal.y.is_finite() || !reveal.padding.is_finite() || bounds.height <= 0. { - return; - } - let padding = reveal.padding.max(0.).min(bounds.height / 2.); - let y = if reveal.y - padding < translation.y { - (reveal.y - padding).max(0.) - } else if reveal.y + padding > translation.y + bounds.height { - (reveal.y + padding - bounds.height).max(0.) - } else { - translation.y - }; - // Preserve horizontal position and reveal only once per invocation - // and explicit destination. Later ordinary scrolling stays ordinary. - state.scroll_to(widget::operation::scrollable::AbsoluteOffset { - x: None, - y: Some(y), - }); - self.applied = true; - } - } impl Observer<'_, R> { fn report( &mut self, @@ -200,27 +130,6 @@ mod scenario_scroll { ); operation.applied.then_some(request.token) }); - if let Some(reveal) = self.reveal.filter(|request| { - !self.process - && receipt.is_none() - && tree.state.downcast_ref::().revealed - != Some((request.token, request.slot)) - }) { - let mut operation = RevealTranslation { - request: reveal, - applied: false, - }; - self.content.as_widget_mut().operate( - &mut tree.children[0], - layout, - renderer, - &mut operation, - ); - if operation.applied { - tree.state.downcast_mut::().revealed = - Some((reveal.token, reveal.slot)); - } - } let mut offset = Translation::default(); self.content.as_widget_mut().operate( &mut tree.children[0], @@ -281,7 +190,6 @@ mod scenario_scroll { identity: (self.context.document, self.context.scenario), report: None, restored: None, - revealed: None, }) } fn children(&self) -> Vec { @@ -415,10 +323,9 @@ mod scenario_scroll { fn observer(height: f32, context: scenario::Context) -> Observer<'static, ()> { Observer { context, - area: scenario::ScrollArea::Canvas, + area: scenario::ScrollArea::Page, process: false, restore: None, - reveal: None, content: scrollable(iced::widget::Space::new().width(200.).height(height)) .id("observer-test") .width(200.) @@ -486,7 +393,7 @@ mod scenario_scroll { std::time::Instant::now(), )) }; - for area in [ScrollArea::Canvas, ScrollArea::Page] { + for area in [ScrollArea::Page] { for returned_height in [600., 250.] { let (project, [a, _, _], [use_id, _, _]) = navigation_fixture(); let mut app = App::with_project(project.clone()); @@ -1235,6 +1142,7 @@ struct Scene { selected: Id, scale: f32, scroll_offset: iced::Vector, + camera_pan: iced::Vector, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, pending_placement: Option<(scenario::Slot, Option)>, lanes: BTreeMap, @@ -1289,6 +1197,7 @@ impl Scene { let mut scene = Self { scale: 1., scroll_offset: iced::Vector::ZERO, + camera_pan: iced::Vector::ZERO, boxes: vec![], lines: vec![], size: Size::new(700., 200.), @@ -1951,9 +1860,12 @@ impl canvas::Program for Scene { } _ => cursor, }; - let world = cursor - .position_in(bounds) - .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); + let world = cursor.position_in(bounds).map(|p| { + Point::new( + (p.x - self.camera_pan.x) / self.scale, + (p.y - self.camera_pan.y) / self.scale, + ) + }); match event { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { if !visible { @@ -1980,7 +1892,7 @@ impl canvas::Program for Scene { return Some(Action::request_redraw().and_capture()); } if let CanvasTool::Add(tool) = tool { - if !tool.canvas_occurrence() { + if !tool.canvas_occurrence() && tool != scenario::Tool::Lifeline { return None; } state.press = Some(SequencePress::Occurrence { @@ -1990,10 +1902,18 @@ impl canvas::Program for Scene { }); return Some(Action::request_redraw().and_capture()); } - if self.reorder_handle().is_some_and(|r| r.contains(point)) { + if let Some(occurrence) = self + .hit(point) + .filter(|id| self.rows.contains_key(id)) + .or_else(|| { + self.reorder_handle() + .filter(|r| r.contains(point)) + .map(|_| self.selected) + }) + { state.press = Some(SequencePress::Reorder { - occurrence: self.selected, - parent: self.rows[&self.selected].0, + occurrence, + parent: self.rows[&occurrence].0, start: cursor.position()? - self.scroll_offset, moved: false, }); @@ -2068,6 +1988,18 @@ impl canvas::Program for Scene { ); } } + SequencePress::Reorder { + occurrence, + moved: false, + .. + } => { + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Select( + context, occurrence, + ))) + .and_capture(), + ); + } SequencePress::Reorder { occurrence, parent, @@ -2105,6 +2037,7 @@ impl canvas::Program for Scene { ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); + f.translate(self.camera_pan); f.scale(self.scale); for b in &self.boxes { let outline = matches!( @@ -2216,9 +2149,12 @@ impl canvas::Program for Scene { } if let Some((context, tool, editable)) = self.interaction { let active = editable && state.context == Some(context) && state.tool == tool; - let point = cursor - .position_in(bounds) - .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); + let point = cursor.position_in(bounds).map(|p| { + Point::new( + (p.x - self.camera_pan.x) / self.scale, + (p.y - self.camera_pan.y) / self.scale, + ) + }); let source = active.then_some(state.source).flatten(); let reorder = active.then_some(state.press).flatten().and_then(|press| { if let SequencePress::Reorder { parent, .. } = press { @@ -2263,6 +2199,18 @@ impl canvas::Program for Scene { } else { None }; + if reorder.is_some() + && slot.is_none() + && let Some(point) = point + { + f.fill_text(Text { + content: "Other operand: move stays in the current sequence".into(), + position: Point::new(point.x + 8., point.y + 12.), + size: (11. / self.scale).into(), + color: Color::from_rgb(0.75, 0.2, 0.15), + ..Default::default() + }); + } if let Some(slot) = slot { f.stroke( &Path::line( diff --git a/crates/oa-desktop/src/behavior/canvas_workspace.rs b/crates/oa-desktop/src/behavior/canvas_workspace.rs new file mode 100644 index 00000000..32b81926 --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace.rs @@ -0,0 +1,243 @@ +//! Shared presentation and input contract; contains no model commands. +use crate::app::{App, Message}; +use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip}; +use iced::{Element, Length::Fill, Point, Theme, Vector}; +use oa_core::Id; +mod control; +mod focus; +mod viewport; +pub use control::Control; +pub use viewport::Viewport; + +pub fn keyboard_enabled(app: &crate::app::App) -> bool { + !app.busy + && !app.hosted.visible + && app.workflow.is_none() + && app.file_prompt.is_none() + && !app.delete_pending + && app.pending.is_none() + && app.model_menu.is_none() + && !app.reference_input_active() +} + +pub fn controls<'a>( + scope: Scope, + keyboard: bool, + scale: f32, + details: Message, + focus: Option, + zoom: impl Fn(Option) -> Message, + publish: impl Fn(Event) -> Message + Copy + 'a, +) -> Element<'a, Message> { + let control = |name: &'static str, label: &'static str, message: Option| { + let message = message.map(|message| intent(scope, name, message)); + Control::new( + button(text(label).size(12)) + .padding(6) + .on_press_maybe(message.clone()), + scope, + name, + keyboard, + message, + publish, + ) + }; + row![ + control("Details", "Details", Some(details)), + tooltip(control("Canvas", "Canvas", focus), + container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card), + tooltip::Position::Bottom), + control("Fit", "Fit", Some(zoom(None))), + control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))), + text(format!("{:.0}%", scale * 100.)).size(12), + control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))), + control("ActualSize", "100%", Some(zoom(Some(1.)))), + ].spacing(4).align_y(iced::Center).into() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Scope { + pub document: Id, + pub owner: Id, + pub revision: u64, + pub epoch: u64, +} +impl Scope { + pub fn current(app: &App) -> Option { + if let Some(context) = app.scenario_context() { + Some(Self { + document: context.document, + owner: context.scenario, + revision: context.revision, + epoch: app.camera_epoch, + }) + } else { + let context = app.topology_context()?; + Some(Self { + document: context.document, + owner: context.owner, + revision: context.revision, + epoch: app.camera_epoch, + }) + } + } + pub fn input_owner(app: &App) -> Option { + if keyboard_enabled(app) + && !app.behavior.has_pending() + && !app.draft_dirty() + && !app.source_dirty() + { + Self::current(app) + } else { + None + } + } + + pub fn control_id(self, name: &str) -> iced::advanced::widget::Id { + iced::advanced::widget::Id::from(format!( + "behavior-control:{}:{}:{}:{name}", + self.document, self.owner, self.revision + )) + } +} +/// Only a live presentation intent may carry a toolbar action across its own pan. +#[derive(Debug, Clone)] +pub struct Intent { + pub scope: Scope, + pub name: &'static str, + pub message: Box, +} +pub fn intent(scope: Scope, name: &'static str, message: Message) -> Message { + Message::BehaviorControl(Intent { + scope, + name, + message: Box::new(message), + }) +} + +#[derive(Debug, Clone, Copy)] +pub enum Event { + CancelGesture, + Pan(Vector), + Zoom { factor: f32, at: Point, base: f32 }, + Focus, + Traverse(bool), + ControlFocus(&'static str), + ToggleFocus, + Leave, +} + +pub use focus::{focus_task, root as focus_root}; +/// Fold relative input in event order. The base scale is used only on leaving Fit. +pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: Event) -> bool { + match event { + Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => { + let next = *pan + delta; + if next.x.is_finite() && next.y.is_finite() && next != *pan { + *pan = next; + true + } else { + false + } + } + Event::Zoom { factor, at, base } + if factor.is_finite() + && factor > 0. + && base.is_finite() + && base > 0. + && at.x.is_finite() + && at.y.is_finite() => + { + let old = if *fit { base } else { zoom.unwrap_or(base) }; + let next = (old * factor).clamp(0.05, 3.); + if old == next { + return false; + } + let anchor = Vector::new(at.x, at.y); + let next_pan = anchor - (anchor - *pan) * (next / old); + if !next_pan.x.is_finite() || !next_pan.y.is_finite() { + return false; + } + *pan = next_pan; + *zoom = Some(next); + *fit = false; + true + } + _ => false, + } +} + +/// Constant-width labelled palette. Arming a tool never reallocates the canvas. +pub fn palette<'a>( + scope: Scope, + keyboard: bool, + publish: impl Fn(Event) -> Message + Copy + 'a, + entries: impl IntoIterator)>, +) -> Element<'a, Message> { + let mut tools = column![].spacing(3); + for (glyph, label, selected, message) in entries { + let message = message.map(|message| intent(scope, glyph, message)); + tools = tools.push(tooltip( + container(Control::new( + button(text(glyph).size(12)) + .width(34) + .height(30) + .padding(3) + .style(if selected { + button::primary + } else { + crate::style::nav + }) + .on_press_maybe(message.clone()), + scope, + glyph, + keyboard, + message, + publish, + )) + .id(format!("behavior-tool:{glyph}")), + container(text(label).size(12)) + .padding(7) + .style(crate::style::card), + tooltip::Position::Left, + )); + } + container(scrollable(tools).id(scope.control_id("palette-scroll"))) + .width(40) + .height(Fill) + .padding(3) + .style(crate::style::panel) + .into() +} + +/// Header and palette are permanent. Details/editors overlay the same viewport. +pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>( + header: Element<'a, Message, Theme, R>, + palette: Element<'a, Message, Theme, R>, + canvas: Element<'a, Message, Theme, R>, + editor: Option>, +) -> Element<'a, Message, Theme, R> { + let mut body = stack![canvas]; + if let Some(editor) = editor { + body = body.push( + container(opaque( + container(editor) + .width(300) + .height(Fill) + .style(crate::style::card), + )) + .align_right(Fill) + .height(Fill) + .padding(8), + ); + } + column![header, row![body, palette].height(Fill)] + .spacing(6) + .padding(8) + .width(Fill) + .height(Fill) + .into() +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/oa-desktop/src/behavior/canvas_workspace/control.rs b/crates/oa-desktop/src/behavior/canvas_workspace/control.rs new file mode 100644 index 00000000..6e199cca --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace/control.rs @@ -0,0 +1,258 @@ +//! A real focusable keyboard target around the existing styled pointer button. +use super::{Event as CameraEvent, Scope, focus::Member}; +use iced::advanced::{ + Clipboard, Layout, Shell, Widget, layout, overlay, renderer, + widget::{Operation, Tree, operation, tree}, +}; +use iced::{Border, Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse}; + +#[derive(Default)] +struct Input { + identity: Option, + focused: bool, +} +impl operation::Focusable for Input { + fn is_focused(&self) -> bool { + self.focused + } + fn focus(&mut self) { + self.focused = true; + } + fn unfocus(&mut self) { + self.focused = false; + } +} +pub struct Control<'a, Message, R = iced::Renderer> +where + R: renderer::Renderer, +{ + content: Element<'a, Message, Theme, R>, + scope: Scope, + name: &'static str, + enabled: bool, + activate: Option, + publish: Box Message + 'a>, +} +impl<'a, Message, R: renderer::Renderer> Control<'a, Message, R> { + pub fn new( + content: impl Into>, + scope: Scope, + name: &'static str, + enabled: bool, + activate: Option, + publish: impl Fn(CameraEvent) -> Message + 'a, + ) -> Self { + Self { + content: content.into(), + scope, + name, + enabled: enabled && activate.is_some(), + activate, + publish: Box::new(publish), + } + } +} +impl Widget for Control<'_, Message, R> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + fn state(&self) -> tree::State { + tree::State::new(Input { + identity: Some(self.scope.control_id(self.name)), + focused: false, + }) + } + fn children(&self) -> Vec { + vec![Tree::new(self.content.as_widget())] + } + fn diff(&self, tree: &mut Tree) { + let state = tree.state.downcast_mut::(); + let identity = self.scope.control_id(self.name); + if state.identity.as_ref() != Some(&identity) || !self.enabled { + *state = Input { + identity: Some(identity), + focused: false, + }; + } + tree.diff_children(std::slice::from_ref(&self.content)); + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, + operation: &mut dyn Operation, + ) { + let id = self.scope.control_id(self.name); + operation.container(Some(&id), layout.bounds()); + if self.enabled { + operation.custom(Some(&id), layout.bounds(), &mut Member(self.scope)); + operation.focusable( + Some(&id), + layout.bounds(), + tree.state.downcast_mut::(), + ); + } + operation.traverse(&mut |operation| { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + operation, + ); + }); + } + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if shell.is_event_captured() { + return; + } + let state = tree.state.downcast_mut::(); + if self.enabled { + if let Event::Keyboard(keyboard::Event::KeyPressed { + key, + modifiers, + repeat, + .. + }) = event + && state.focused + { + use keyboard::{Key, key::Named}; + let action = match key.as_ref() { + Key::Named(Named::Tab) => { + Some((self.publish)(CameraEvent::Traverse(modifiers.shift()))) + } + Key::Named(Named::Escape) => Some((self.publish)(CameraEvent::Leave)), + Key::Named(Named::F6) if modifiers.is_empty() && !repeat => { + Some((self.publish)(CameraEvent::ToggleFocus)) + } + Key::Named(Named::Enter | Named::Space) if modifiers.is_empty() && !repeat => { + self.activate.clone() + } + _ => None, + }; + if let Some(action) = action { + shell.publish(action); + shell.capture_event(); + shell.request_redraw(); + return; + } + } + if matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + ) { + if !layout + .bounds() + .intersection(viewport) + .is_some_and(|bounds| cursor.is_over(bounds)) + { + state.focused = false; + } + } + } + if matches!(event, Event::Window(iced::window::Event::Unfocused)) { + state.focused = false; + } + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + fn draw( + &self, + tree: &Tree, + renderer: &mut R, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + if self.enabled && tree.state.downcast_ref::().focused { + renderer.fill_quad( + renderer::Quad { + bounds: layout.bounds(), + border: Border { + color: crate::style::INK, + width: 2., + ..Default::default() + }, + ..Default::default() + }, + iced::Color::TRANSPARENT, + ); + } + } + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + fn overlay<'a>( + &'a mut self, + tree: &'a mut Tree, + layout: Layout<'a>, + renderer: &R, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} +impl<'a, Message: Clone + 'a, R: renderer::Renderer + 'a> From> + for Element<'a, Message, Theme, R> +{ + fn from(control: Control<'a, Message, R>) -> Self { + Element::new(control) + } +} diff --git a/crates/oa-desktop/src/behavior/canvas_workspace/focus.rs b/crates/oa-desktop/src/behavior/canvas_workspace/focus.rs new file mode 100644 index 00000000..52ce9dd2 --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace/focus.rs @@ -0,0 +1,293 @@ +//! Explicit canvas entry; ordinary initial Tab continues to belong to Inspector. +use super::{Event, Scope}; +use iced::advanced::widget::{Id, Operation, Tree, operation}; +use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer}; +use iced::{Element, Event as InputEvent, Length, Rectangle, Size, Theme, Vector, mouse}; + +pub(super) struct Member(pub Scope); + +pub fn focus_task(scope: Scope, event: Event) -> iced::Task { + iced::advanced::widget::operate(Collect { + scope, + valid: false, + event, + members: vec![], + focused: None, + palette: None, + }) +} +struct Collect { + scope: Scope, + valid: bool, + event: Event, + members: Vec<(Id, Rectangle)>, + focused: Option, + palette: Option<(Rectangle, Rectangle)>, +} +impl Operation for Collect { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + visit(self); + } + fn custom(&mut self, id: Option<&Id>, bounds: Rectangle, value: &mut dyn std::any::Any) { + if let Some(owner) = value.downcast_ref::() { + self.valid = owner.0 == Some(self.scope); + } + if self.valid + && value + .downcast_ref::() + .is_some_and(|m| m.0 == self.scope) + && let Some(id) = id + { + self.members.push((id.clone(), bounds)); + } + } + fn focusable(&mut self, id: Option<&Id>, _: Rectangle, value: &mut dyn operation::Focusable) { + if value.is_focused() { + self.focused = self + .members + .iter() + .position(|(member, _)| Some(member) == id) + .or(self.focused); + } + } + fn scrollable( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + content: Rectangle, + _: Vector, + _: &mut dyn operation::Scrollable, + ) { + if id == Some(&self.scope.control_id("palette-scroll")) { + self.palette = Some((bounds, content)); + } + } + fn finish(&self) -> operation::Outcome { + let canvas = self.scope.control_id("viewport"); + // An enabled viewport witnesses the live lifetime. Hidden/dirty/modal + // owners expose no members; a delayed request cannot clear their focus. + if !self.valid || !self.members.iter().any(|(id, _)| *id == canvas) { + return operation::Outcome::None; + } + let target = match self.event { + Event::Focus => Some(canvas), + Event::ControlFocus(name) => Some(self.scope.control_id(name)), + Event::ToggleFocus if self.focused.is_none() => Some(canvas), + Event::ToggleFocus | Event::Leave if self.focused.is_some() => None, + Event::Traverse(back) => { + let Some(current) = self.focused else { + return operation::Outcome::None; + }; + let next = if back { + (current + self.members.len() - 1) % self.members.len() + } else { + (current + 1) % self.members.len() + }; + Some(self.members[next].0.clone()) + } + _ => return operation::Outcome::None, + }; + if target + .as_ref() + .is_some_and(|target| !self.members.iter().any(|(id, _)| id == target)) + { + return operation::Outcome::None; + } + let reveal = target.as_ref().and_then(|target| { + let (_, field) = self.members.iter().find(|(id, _)| id == target)?; + let (bounds, content) = self.palette?; + // Only the palette has member controls inside this scrollable. + (field.x >= bounds.x && field.x < bounds.x + bounds.width).then(|| { + (field.center_y() - bounds.y - bounds.height / 2.) + .clamp(0., (content.height - bounds.height).max(0.)) + }) + }); + operation::Outcome::Chain(Box::new(Apply { + scope: self.scope, + valid: false, + target, + members: self.members.iter().map(|(id, _)| id.clone()).collect(), + palette: self.scope.control_id("palette-scroll"), + reveal, + })) + } +} +struct Apply { + scope: Scope, + valid: bool, + target: Option, + members: Vec, + palette: Id, + reveal: Option, +} +impl Operation for Apply { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + visit(self); + } + fn custom(&mut self, _: Option<&Id>, _: Rectangle, value: &mut dyn std::any::Any) { + if let Some(owner) = value.downcast_ref::() { + self.valid = owner.0 == Some(self.scope); + } + } + fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) { + if !self.valid { + return; + } + if self + .target + .as_ref() + .is_some_and(|target| Some(target) == id) + { + state.focus(); + } else if self.target.is_some() || id.is_some_and(|id| self.members.contains(id)) { + state.unfocus(); + } + } + fn scrollable( + &mut self, + id: Option<&Id>, + _: Rectangle, + _: Rectangle, + _: Vector, + state: &mut dyn operation::Scrollable, + ) { + if self.valid + && id == Some(&self.palette) + && let Some(y) = self.reveal + { + state.scroll_to(operation::scrollable::AbsoluteOffset { + x: None, + y: Some(y), + }); + } + } +} + +// Every Collect/Apply pass observes current ownership before touching any field. +struct Owner(Option); + +struct Root<'a, Message, R: renderer::Renderer> { + owner: Option, + content: Element<'a, Message, Theme, R>, +} + +pub fn root<'a, Message: 'a, R: renderer::Renderer + 'a>( + content: impl Into>, + owner: Option, +) -> Element<'a, Message, Theme, R> { + Element::new(Root { + owner, + content: content.into(), + }) +} + +impl Widget for Root<'_, Message, R> { + fn children(&self) -> Vec { + vec![Tree::new(self.content.as_widget())] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, + operation: &mut dyn Operation, + ) { + operation.custom(None, layout.bounds(), &mut Owner(self.owner)); + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &InputEvent, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut R, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &R, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} diff --git a/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs b/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs new file mode 100644 index 00000000..dd863aa1 --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs @@ -0,0 +1,756 @@ +use super::*; +use crate::app::{App, Message}; +use iced::advanced::{Layout, Shell, layout, widget}; +use iced::futures::{ + StreamExt, + stream::{BoxStream, SelectAll}, +}; +use iced::{Rectangle, Size, mouse}; +use iced_runtime::{ + Action, + user_interface::{Cache, UserInterface}, +}; +use std::collections::HashMap; + +type Pending = SelectAll>>; + +// iced_winit drains messages while collecting immediately ready actions. A +// pending Task stream survives until its widget operation sends its result. +fn enqueue(task: iced::Task, ready: &mut Vec>, pending: &mut Pending) { + if let Some(mut stream) = iced_runtime::task::into_stream(task) { + let waker = iced::futures::task::noop_waker(); + let mut context = std::task::Context::from_waker(&waker); + loop { + match stream.poll_next_unpin(&mut context) { + std::task::Poll::Ready(Some(action)) => ready.push(action), + std::task::Poll::Ready(None) => break, + std::task::Poll::Pending => { + pending.push(stream); + break; + } + } + } + } +} + +#[derive(Default)] +pub(crate) struct Geometry { + pub containers: HashMap, + pub focused: Vec<(Option, Rectangle, bool)>, + pub text_inputs: Vec, + pub scrolls: HashMap, +} +impl widget::Operation for Geometry { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + state: &mut dyn widget::operation::Focusable, + ) { + self.focused.push((id.cloned(), bounds, state.is_focused())); + } + fn text_input( + &mut self, + _: Option<&widget::Id>, + bounds: Rectangle, + _: &mut dyn widget::operation::TextInput, + ) { + self.text_inputs.push(bounds); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if let Some(id) = id { + self.containers.insert(id.clone(), bounds); + } + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + _: Rectangle, + offset: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + if let Some(id) = id { + self.scrolls.insert(id.clone(), (bounds, offset)); + } + } +} +impl Geometry { + pub fn bounds(&self, id: &str) -> Rectangle { + self.containers[&widget::Id::from(id.to_owned())] + } +} + +/// Mount the actual App root, production widgets and renderer. Events are +/// delivered in order with Iced's final-batch cursor, then fed through App::update. +/// This deliberately does not seed any private canvas gesture state. +pub(crate) struct Mounted { + pub renderer: iced::Renderer, + cache: Cache, + pub operations: usize, +} +impl Mounted { + pub async fn unshown() -> Self { + use iced::advanced::renderer::Headless; + let renderer = ::new(iced::Font::DEFAULT, 16.into(), None) + .await + .expect("production mounted tests require the supported WGPU headless renderer"); + Self { + renderer, + cache: Cache::new(), + operations: 0, + } + } + pub async fn new(app: &mut App) -> Self { + let mut mounted = Self::unshown().await; + mounted.settle(app).await; + mounted + } + pub fn geometry(&mut self, app: &App) -> Geometry { + let mut geometry = Geometry::default(); + self.operate(app, &mut geometry); + geometry + } + /// One operation pass, including every nested overlay, via installed Iced. + pub fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) { + let mut ui = UserInterface::build( + app.view(), + app.window_size, + std::mem::take(&mut self.cache), + &mut self.renderer, + ); + ui.operate(&self.renderer, operation); + self.cache = ui.into_cache(); + } + pub async fn settle(&mut self, app: &mut App) { + for _ in 0..3 { + self.batch( + app, + &[iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + ))], + mouse::Cursor::Unavailable, + ) + .await; + } + } + pub async fn batch( + &mut self, + app: &mut App, + events: &[iced::Event], + cursor: mouse::Cursor, + ) -> Vec { + let messages = self.input(app, events, cursor); + let mut ready = vec![]; + let mut pending = Pending::new(); + for message in messages.iter().cloned() { + enqueue(app.update(message), &mut ready, &mut pending); + } + self.drain(app, ready, pending).await; + messages + } + pub fn input( + &mut self, + app: &App, + events: &[iced::Event], + cursor: mouse::Cursor, + ) -> Vec { + let mut messages = vec![]; + let mut ui = UserInterface::build( + app.view(), + app.window_size, + std::mem::take(&mut self.cache), + &mut self.renderer, + ); + let _ = ui.update( + events, + cursor, + &mut self.renderer, + &mut iced::advanced::clipboard::Null, + &mut messages, + ); + self.cache = ui.into_cache(); + messages + } + pub async fn perform(&mut self, app: &mut App, task: iced::Task) { + let mut ready = vec![]; + let mut pending = Pending::new(); + enqueue(task, &mut ready, &mut pending); + self.drain(app, ready, pending).await; + } + async fn drain( + &mut self, + app: &mut App, + mut ready: Vec>, + mut pending: Pending, + ) { + loop { + let mut outputs = vec![]; + if !ready.is_empty() { + // Build only after all App messages in this cycle have run. + let mut ui = UserInterface::build( + app.view(), + app.window_size, + std::mem::take(&mut self.cache), + &mut self.renderer, + ); + for action in ready.drain(..) { + match action { + Action::Output(message) => outputs.push(message), + Action::Widget(mut operation) => { + self.operations += 1; + loop { + ui.operate(&self.renderer, &mut *operation); + match operation.finish() { + widget::operation::Outcome::Chain(next) => operation = next, + widget::operation::Outcome::None + | widget::operation::Outcome::Some(()) => break, + } + } + } + action => { + panic!("Unexpected native action in mounted App harness: {action:?}") + } + } + } + self.cache = ui.into_cache(); + } + for message in outputs { + enqueue(app.update(message), &mut ready, &mut pending); + } + if !ready.is_empty() { + continue; + } + let Some(action) = pending.next().await else { + break; + }; + ready.push(action); + let waker = iced::futures::task::noop_waker(); + let mut context = std::task::Context::from_waker(&waker); + while let std::task::Poll::Ready(Some(action)) = pending.poll_next_unpin(&mut context) { + ready.push(action); + } + } + } + pub fn pixels(&mut self, app: &App) -> Vec { + use iced::advanced::renderer::Headless; + let mut ui = UserInterface::build( + app.view(), + app.window_size, + std::mem::take(&mut self.cache), + &mut self.renderer, + ); + // update initializes the nested overlay layout used by draw. + let mut messages = vec![]; + let _ = ui.update( + &[], + mouse::Cursor::Unavailable, + &mut self.renderer, + &mut iced::advanced::clipboard::Null, + &mut messages, + ); + assert!( + messages.is_empty(), + "An empty draw preparation must not drop App messages" + ); + ui.draw( + &mut self.renderer, + &crate::style::theme(), + &iced::advanced::renderer::Style { + text_color: crate::style::INK, + }, + mouse::Cursor::Unavailable, + ); + self.cache = ui.into_cache(); + self.renderer.screenshot( + Size::new(app.window_size.width as u32, app.window_size.height as u32), + 1., + crate::style::BG, + ) + } + pub async fn click(&mut self, app: &mut App, point: Point) -> Vec { + self.batch( + app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ], + mouse::Cursor::Available(point), + ) + .await + } + pub async fn pan(&mut self, app: &mut App, start: Point, end: Point) -> Vec { + self.batch( + app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: start }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)), + iced::Event::Mouse(mouse::Event::CursorMoved { position: end }), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)), + ], + mouse::Cursor::Available(end), + ) + .await + } + pub fn scroll(&mut self, app: &App, id: &str, offset: f32) { + let mut operation = widget::operation::scrollable::scroll_to::<()>( + widget::Id::from(id.to_owned()), + widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(offset), + }, + ); + self.operate(app, &mut operation); + } +} + +pub(crate) fn key(named: iced::keyboard::key::Named) -> iced::Event { + use iced::keyboard::{self, Key}; + iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(named), + modified_key: Key::Named(named), + physical_key: keyboard::key::Physical::Unidentified( + keyboard::key::NativeCode::Unidentified, + ), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }) +} + +#[test] +fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs() { + for scale in [0.05, 0.5, 1., 2.] { + let mut pan = Vector::new(-130., 75.); + let mut zoom = Some(scale); + let mut fit = false; + let at = Point::new(171., 93.); + let world = (at - Point::ORIGIN - pan) / scale; + change( + &mut pan, + &mut zoom, + &mut fit, + Event::Zoom { + factor: 1.25, + at, + base: scale, + }, + ); + let after = (at - Point::ORIGIN - pan) / zoom.unwrap(); + assert!((after.x - world.x).abs() < 0.001 && (after.y - world.y).abs() < 0.001); + let before = (pan, zoom, fit); + for input in [ + Event::Pan(Vector::new(f32::INFINITY, 0.)), + Event::Zoom { + factor: f32::NAN, + at, + base: scale, + }, + Event::Zoom { + factor: 0., + at, + base: scale, + }, + ] { + assert!(!change(&mut pan, &mut zoom, &mut fit, input)); + assert_eq!((pan, zoom, fit), before); + } + } +} + +#[test] +fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() { + use iced::widget::Space; + let scope = Scope { + document: Id::new_v4(), + owner: Id::new_v4(), + revision: 2, + epoch: 0, + }; + let mut view: Element<'_, Event, Theme, ()> = Viewport::new( + Space::new().width(Fill).height(Fill), + scope, + 1., + true, + |e| e, + ) + .into(); + let mut tree = widget::Tree::new(view.as_widget()); + let node = view + .as_widget_mut() + .layout( + &mut tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(400., 300.)), + ) + .move_to(Point::new(30., 80.)); + let start = Point::new(90., 160.); + let middle = Point::new(125., 175.); + let end = Point::new(145., 185.); + let mut messages = vec![]; + for event in [ + mouse::Event::CursorMoved { position: start }, + mouse::Event::ButtonPressed(mouse::Button::Middle), + mouse::Event::CursorMoved { position: middle }, + mouse::Event::CursorMoved { position: end }, + mouse::Event::ButtonReleased(mouse::Button::Middle), + ] { + view.as_widget_mut().update( + &mut tree, + &iced::Event::Mouse(event), + Layout::new(&node), + mouse::Cursor::Available(end), + &(), + &mut iced::advanced::clipboard::Null, + &mut Shell::new(&mut messages), + &Rectangle::with_size(Size::new(700., 600.)), + ); + } + assert!(matches!(messages.first(), Some(Event::CancelGesture))); + let total = messages + .iter() + .fold(Vector::ZERO, |total, event| match event { + Event::Pan(delta) => total + *delta, + _ => total, + }); + assert_eq!(total, end - start); +} + +pub(crate) fn scope(app: &App) -> Scope { + if let Some(context) = app.scenario_context() { + Scope { + document: context.document, + owner: context.scenario, + revision: context.revision, + epoch: app.camera_epoch, + } + } else { + let context = app.topology_context().expect("mounted behavior owner"); + Scope { + document: context.document, + owner: context.owner, + revision: context.revision, + epoch: app.camera_epoch, + } + } +} +pub(crate) fn focused(mounted: &mut Mounted, app: &App, name: &str) -> bool { + let id = scope(app).control_id(name); + mounted + .geometry(app) + .focused + .iter() + .any(|(member, _, focused)| member.as_ref() == Some(&id) && *focused) +} +pub(crate) async fn tab_to(mounted: &mut Mounted, app: &mut App, name: &str) { + for _ in 0..32 { + if focused(mounted, app, name) { + return; + } + mounted + .batch( + app, + &[key(iced::keyboard::key::Named::Tab)], + mouse::Cursor::Unavailable, + ) + .await; + } + panic!("Keyboard traversal did not reach {name}"); +} +pub(crate) fn camera_delta(messages: &[Message]) -> Message { + messages.iter().find(|m| matches!(m, + Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(_, _, Event::Pan(delta))) + | Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(_, _, Event::Pan(delta))) if *delta != Vector::ZERO + )).expect("actual nonzero camera delta from a mounted gesture").clone() +} +pub(crate) async fn press_middle(mounted: &mut Mounted, app: &mut App, point: Point) { + mounted + .batch( + app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)), + ], + mouse::Cursor::Available(point), + ) + .await; +} +pub(crate) async fn release_middle( + mounted: &mut Mounted, + app: &mut App, + point: Point, +) -> Vec { + mounted + .batch( + app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)), + ], + mouse::Cursor::Available(point), + ) + .await +} +pub(crate) fn assert_drawn_focus(before: &[u8], after: &[u8], size: Size, bounds: Rectangle) { + let width = size.width as usize; + assert_eq!(before.len(), width * size.height as usize * 4); + assert_eq!(after.len(), before.len()); + let mut changed = 0; + for y in bounds.y.ceil() as usize..(bounds.y + bounds.height).floor() as usize { + for x in bounds.x.ceil() as usize..(bounds.x + bounds.width).floor() as usize { + if x as f32 - bounds.x > 2. + && bounds.x + bounds.width - x as f32 > 2. + && y as f32 - bounds.y > 2. + && bounds.y + bounds.height - y as f32 > 2. + { + continue; + } + let start = (y * width + x) * 4; + if before[start..start + 3] != after[start..start + 3] { + changed += 1; + } + } + } + assert!( + changed >= bounds.width.min(bounds.height) as usize, + "Focus must change visible border pixels, changed={changed}, bounds={bounds:?}" + ); +} + +pub(crate) fn character(value: &str) -> iced::Event { + let mut event = key(iced::keyboard::key::Named::Space); + if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key, + modified_key, + text, + .. + }) = &mut event + { + *key = iced::keyboard::Key::Character(value.into()); + *modified_key = key.clone(); + *text = Some(value.into()); + } + event +} + +/// Inspect the action without stripping the lifetime from retained packets. +pub(crate) fn intent_action(message: &Message) -> &Message { + match message { + Message::BehaviorControl(intent) => &intent.message, + message => message, + } +} + +/// Deliberately retain a returned Chain to test the stronger ownership invariant. +/// Native Iced runs Chain passes synchronously; this is not a native interleaving. +pub(crate) async fn retained_focus_apply( + mounted: &mut Mounted, + app: &mut App, +) -> Box { + let entry = mounted.input( + app, + &[key(iced::keyboard::key::Named::F6)], + mouse::Cursor::Unavailable, + ); + assert_eq!( + entry.len(), + 1, + "actual initial F6 must emit one canvas focus request" + ); + let mut stream = iced_runtime::task::into_stream(app.update(entry[0].clone())) + .expect("F6 returns a widget Task"); + let Some(Action::Widget(mut operation)) = stream.next().await else { + panic!("F6 first action must be Collect"); + }; + mounted.operate(app, &mut *operation); + match operation.finish() { + widget::operation::Outcome::Chain(apply) => apply, + _ => panic!("Live Collect must produce its guarded Apply pass"), + } +} + +pub(crate) async fn overflow_last_tool(mounted: &mut Mounted, app: &mut App, last: &'static str) { + use iced::keyboard::key::Named; + let geometry = mounted.geometry(app); + let last_bounds = geometry.containers[&scope(app).control_id(last)]; + let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")]; + assert_eq!(offset.y, 0.); + assert!( + last_bounds.y >= rail.y + rail.height, + "last tool starts fully below the overflowing palette: tool={last_bounds:?}, rail={rail:?}" + ); + mounted + .batch(app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + tab_to(mounted, app, last).await; + let geometry = mounted.geometry(app); + let mut visible = geometry.containers[&scope(app).control_id(last)]; + let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")]; + assert!(offset.y > 0., "keyboard traversal must scroll the palette"); + visible.x -= offset.x; + visible.y -= offset.y; + assert!( + visible.y >= rail.y && visible.y + visible.height <= rail.y + rail.height + 0.5, + "the entire last tool is revealed: tool={visible:?}, rail={rail:?}" + ); + let messages = mounted + .batch(app, &[key(Named::Enter)], mouse::Cursor::Unavailable) + .await; + assert!( + messages.iter().any( + |message| matches!(message, Message::BehaviorControl(intent) if intent.name == last) + ), + "the revealed last tool is keyboard activated" + ); + assert!(focused(mounted, app, last)); + let on = mounted.pixels(app); + mounted + .batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + let off = mounted.pixels(app); + assert_drawn_focus(&off, &on, app.window_size, visible); +} + +pub(crate) async fn retained_toolbar_boundaries( + mounted: &mut Mounted, + app: &mut App, + tool: &'static str, +) { + use iced::keyboard::key::Named; + for boundary in ["Chooser", "Dirty", "Picker"] { + let mut packets = vec![]; + for name in ["Fit", tool, "Details", "Panels", "Canvas"] { + packets.push(capture_toolbar(mounted, app, name)); + } + let lifetime = app.camera_epoch; + let epoch = app.canvas_epoch; + let process = app.behavior.topology.camera_boundary(); + let scenario = app.behavior.scenario.camera_boundary(); + match boundary { + "Chooser" => { + let draft = app.draft.as_ref().unwrap(); + let id = crate::forms::reference_chooser::trigger::field_id( + draft.reference_generation(), + "/parent", + ); + let bounds = mounted + .geometry(app) + .focused + .iter() + .find(|(field, _, _)| field.as_ref() == Some(&id)) + .expect("real Inspector parent reference trigger") + .1; + let request = draft.reference_open(app.project(), "/parent", bounds); + let task = app.update(Message::FieldReferenceOpen(request)); + mounted.perform(app, task).await; + assert!(app.reference_input_active()); + mounted + .batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + assert!(!app.reference_input_active()); + } + "Dirty" => { + let task = app.update(Message::Field( + "/name".into(), + "Unsaved camera boundary".into(), + )); + mounted.perform(app, task).await; + assert!(app.draft_dirty()); + // Every retained packet is also rejected while the owner is dirty. + for packet in packets.iter().cloned() { + let task = app.update(packet); + mounted.perform(app, task).await; + } + assert!(app.draft_dirty()); + let task = app.update(Message::RevertProperties); + mounted.perform(app, task).await; + assert!(!app.draft_dirty()); + } + "Picker" => { + // Public transitions, with cancellation injected at the external + // picker boundary. Never poll the native dialog future here. + let picker = app.update(Message::Save(true)); + if cfg!(target_os = "linux") { + assert!(app.file_prompt.is_some()); + mounted.perform(app, picker).await; + let task = app.update(Message::CancelFile); + mounted.perform(app, task).await; + } else { + assert!(app.busy, "Save As enters the real native-picker busy state"); + drop(picker); + for packet in packets.iter().cloned() { + let task = app.update(packet); + mounted.perform(app, task).await; + } + let task = app.update(Message::SavePath(None)); + mounted.perform(app, task).await; + assert!(!app.busy); + } + } + _ => unreachable!(), + } + assert_ne!( + app.camera_epoch, lifetime, + "{boundary} changes the presentation lifetime" + ); + assert_eq!( + app.canvas_epoch, epoch, + "{boundary} cycle leaves semantic context unchanged" + ); + let focus_before = mounted + .geometry(app) + .focused + .into_iter() + .filter(|(_, _, value)| *value) + .collect::>(); + let current_lifetime = app.camera_epoch; + for packet in packets { + let task = app.update(packet); + mounted.perform(app, task).await; + } + assert_eq!( + app.camera_epoch, current_lifetime, + "{boundary} retained intents cannot start a new lifetime" + ); + assert!( + app.behavior.topology.camera_boundary() == process, + "{boundary} retained toolbar packet changed process presentation" + ); + assert!( + app.behavior.scenario.camera_boundary() == scenario, + "{boundary} retained toolbar packet changed scenario presentation" + ); + assert_eq!( + mounted + .geometry(app) + .focused + .into_iter() + .filter(|(_, _, value)| *value) + .collect::>(), + focus_before, + "{boundary} retained toolbar packet stole field focus" + ); + } +} + +pub(crate) fn capture_toolbar(mounted: &mut Mounted, app: &App, name: &'static str) -> Message { + let point = mounted.geometry(app).containers[&scope(app).control_id(name)].center(); + mounted + .input( + app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ], + mouse::Cursor::Available(point), + ) + .into_iter() + .find(|message| matches!(message, Message::BehaviorControl(intent) if intent.name == name)) + .expect("actual production toolbar packet") +} diff --git a/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs b/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs new file mode 100644 index 00000000..58d46463 --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs @@ -0,0 +1,426 @@ +use super::{Event as CameraEvent, Scope}; +use iced::advanced::{ + Clipboard, Layout, Shell, Widget, layout, overlay, renderer, + widget::{Operation, Tree, operation, tree}, +}; +use iced::{Border, Element, Event, Length, Point, Rectangle, Size, Theme, Vector}; +use iced::{keyboard, mouse}; + +#[derive(Default)] +struct Input { + scope: Option, + focused: bool, + pointer: Option, + pan: Option<(mouse::Button, Point)>, + space: bool, + bounds: Option, +} +impl operation::Focusable for Input { + fn is_focused(&self) -> bool { + self.focused + } + fn focus(&mut self) { + self.focused = true; + } + fn unfocus(&mut self) { + self.focused = false; + self.pan = None; + self.space = false; + } +} +pub struct Viewport<'a, Message, R = iced::Renderer> +where + R: renderer::Renderer, +{ + content: Element<'a, Message, Theme, R>, + scope: Scope, + scale: f32, + keyboard: bool, + blank_pan: bool, + publish: Box Message + 'a>, +} +impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> { + pub fn new( + content: impl Into>, + scope: Scope, + scale: f32, + keyboard: bool, + publish: impl Fn(CameraEvent) -> Message + 'a, + ) -> Self { + Self { + content: content.into(), + scope, + scale, + keyboard, + blank_pan: false, + publish: Box::new(publish), + } + } + pub fn pan_background(mut self, enabled: bool) -> Self { + self.blank_pan = enabled; + self + } +} +impl Widget for Viewport<'_, Message, R> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + fn state(&self) -> tree::State { + tree::State::new(Input { + scope: Some(self.scope), + ..Default::default() + }) + } + fn children(&self) -> Vec { + vec![Tree::new(self.content.as_widget())] + } + fn diff(&self, tree: &mut Tree) { + let state = tree.state.downcast_mut::(); + if state.scope.is_none_or(|s| { + (s.document, s.owner, s.revision) + != (self.scope.document, self.scope.owner, self.scope.revision) + }) { + *state = Input::default(); + } + if state + .scope + .is_some_and(|scope| scope.epoch != self.scope.epoch) + || !self.keyboard + { + state.pan = None; + state.space = false; + } + if !self.keyboard { + state.focused = false; + } + state.scope = Some(self.scope); + tree.diff_children(std::slice::from_ref(&self.content)); + } + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Fill) + } + fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, + operation: &mut dyn Operation, + ) { + if self.keyboard { + let id = self.scope.control_id("viewport"); + operation.custom( + Some(&id), + layout.bounds(), + &mut super::focus::Member(self.scope), + ); + operation.focusable( + Some(&id), + layout.bounds(), + tree.state.downcast_mut::(), + ); + } + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if shell.is_event_captured() { + return; + } + let state = tree.state.downcast_mut::(); + let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds()); + state.bounds = Some(layout.bounds()); + if changed_bounds || !self.keyboard { + state.pan = None; + state.space = false; + } + if !self.keyboard { + state.focused = false; + } + if let Event::Mouse(mouse::Event::CursorMoved { position }) = event { + state.pointer = Some(*position); + } + let pointer = match event { + Event::Mouse( + mouse::Event::ButtonPressed(_) + | mouse::Event::ButtonReleased(_) + | mouse::Event::WheelScrolled { .. }, + ) => state.pointer.or_else(|| cursor.position()), + Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position), + _ => cursor.position().or(state.pointer), + }; + let visible = layout.bounds().intersection(viewport); + let inside = pointer.is_some_and(|p| visible.is_some_and(|v| v.contains(p))) + && cursor.position().is_some(); + if !self.keyboard { + state.space = false; + } + let mut cancel = changed_bounds; + let mut camera = None; + let mut capture = false; + match event { + Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) => { + state.pan = None; + state.space = false; + state.focused = false; + } + Event::Mouse(mouse::Event::CursorLeft) => { + state.pan = None; + state.pointer = None; + } + Event::Mouse(mouse::Event::ButtonPressed(button)) => { + if state.pan.is_some_and(|(held, _)| held != *button) || !inside { + state.pan = None; + state.space = false; + cancel = true; + } + state.focused = inside && self.keyboard; + if inside + && self.keyboard + && (*button == mouse::Button::Middle + || (*button == mouse::Button::Left && state.space)) + { + state.pan = pointer.map(|p| (*button, p)); + cancel = true; + camera = Some(CameraEvent::CancelGesture); + capture = true; + } + } + Event::Mouse(mouse::Event::CursorMoved { position }) => { + if let Some((button, previous)) = state.pan { + state.pan = Some((button, *position)); + camera = Some(CameraEvent::Pan(*position - previous)); + capture = true; + } + } + Event::Mouse(mouse::Event::ButtonReleased(button)) => { + if let Some((held, previous)) = state.pan + && held == *button + { + state.pan = None; + camera = pointer + .filter(|_| inside) + .map(|p| CameraEvent::Pan(p - previous)); + capture = true; + } + } + Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside && state.focused => { + state.pan = None; + cancel = true; + let y = match delta { + mouse::ScrollDelta::Lines { y, .. } => *y, + mouse::ScrollDelta::Pixels { y, .. } => *y / 40., + }; + if y != 0. { + let at = pointer.unwrap() - Vector::new(layout.bounds().x, layout.bounds().y); + camera = Some(CameraEvent::Zoom { + factor: 1.1_f32.powf(y.clamp(-8., 8.)), + at, + base: self.scale, + }); + capture = true; + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::F6), + modifiers, + repeat: false, + .. + }) if self.keyboard && modifiers.is_empty() => { + camera = Some(CameraEvent::ToggleFocus); + capture = true; + cancel = true; + } + Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) + if state.focused && self.keyboard => + { + use keyboard::{Key, key::Named}; + match key.as_ref() { + Key::Named(Named::Space) => { + state.space = true; + cancel = true; + camera = Some(CameraEvent::CancelGesture); + capture = true; + } + Key::Named(Named::Tab) => { + camera = Some(CameraEvent::Traverse(modifiers.shift())); + capture = true; + } + Key::Named(Named::Escape) => { + state.pan = None; + state.space = false; + camera = Some(CameraEvent::Leave); + capture = true; + } + Key::Named(key) if modifiers.is_empty() => { + let delta = match key { + Named::ArrowLeft => Some(Vector::new(60., 0.)), + Named::ArrowRight => Some(Vector::new(-60., 0.)), + Named::ArrowUp => Some(Vector::new(0., 60.)), + Named::ArrowDown => Some(Vector::new(0., -60.)), + _ => None, + }; + camera = delta.map(CameraEvent::Pan); + capture = camera.is_some(); + cancel = capture; + } + _ => {} + } + } + Event::Keyboard(keyboard::Event::KeyReleased { key, .. }) + if *key == keyboard::Key::Named(keyboard::key::Named::Space) => + { + state.space = false; + } + _ => {} + } + if cancel { + self.content.as_widget_mut().update( + &mut tree.children[0], + &Event::Window(iced::window::Event::Unfocused), + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + if let Some(event) = camera { + shell.publish((self.publish)(event)); + } + if capture { + shell.capture_event(); + shell.request_redraw(); + return; + } + if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) { + return; + } + let child_cursor = if inside { + pointer.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available) + } else { + mouse::Cursor::Unavailable + }; + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + child_cursor, + renderer, + clipboard, + shell, + viewport, + ); + // A semantic hit or armed creation tool gets first refusal. In Select, + // an otherwise unused blank press starts the same camera gesture. + if self.blank_pan + && self.keyboard + && inside + && !shell.is_event_captured() + && matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + ) + { + state.pan = pointer.map(|p| (mouse::Button::Left, p)); + shell.publish((self.publish)(CameraEvent::CancelGesture)); + shell.capture_event(); + shell.request_redraw(); + } + } + fn draw( + &self, + tree: &Tree, + renderer: &mut R, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + renderer.with_layer(layout.bounds(), |renderer| { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + }); + if tree.state.downcast_ref::().focused && self.keyboard { + renderer.fill_quad( + renderer::Quad { + bounds: layout.bounds(), + border: Border { + color: crate::style::TEAL, + width: 1., + ..Default::default() + }, + ..Default::default() + }, + iced::Color::TRANSPARENT, + ); + } + } + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + if tree.state.downcast_ref::().pan.is_some() { + mouse::Interaction::Grabbing + } else { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + } + fn overlay<'a>( + &'a mut self, + tree: &'a mut Tree, + layout: Layout<'a>, + renderer: &R, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} +impl<'a, Message: 'a, R: renderer::Renderer + 'a> From> + for Element<'a, Message, Theme, R> +{ + fn from(viewport: Viewport<'a, Message, R>) -> Self { + Element::new(viewport) + } +} diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index fe573b04..ab8bc786 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -102,6 +102,7 @@ pub enum CanvasEvent { Viewport(Context, Size), FormShown(Context, Id), Zoom(Context, Option), + Camera(Context, u64, super::canvas_workspace::Event), Focus(Context), Shelf(Context, Shelf), OpenUse(Context, Id), @@ -142,6 +143,7 @@ impl CanvasEvent { | Self::Viewport(context, _) | Self::FormShown(context, _) | Self::Zoom(context, _) + | Self::Camera(context, ..) | Self::Focus(context) | Self::Shelf(context, _) | Self::OpenUse(context, _) @@ -238,13 +240,6 @@ pub(super) struct ScrollRestore { pub token: Id, pub offset: iced::Vector, } -#[derive(Clone, Copy)] -pub(super) struct ScrollReveal { - pub token: Id, - pub slot: Slot, - pub y: f32, - pub padding: f32, -} #[derive(PartialEq, Eq)] struct CallChoiceContext { document: Id, @@ -296,6 +291,18 @@ struct Draft { choice_page: usize, } impl State { + pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> { + ( + self.canvas_tool, + self.presentation.focused, + self.presentation.shelf, + self.presentation.viewport, + self.presentation.pan, + self.presentation.zoom, + self.presentation.fit, + ) + } + pub(super) fn pending_name(&self) -> Option<&str> { self.draft.as_ref().map(|d| d.name.as_str()) } @@ -336,9 +343,31 @@ impl State { pub(crate) fn viewport(&mut self, size: Size) -> bool { self.presentation.viewport(size) } + pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool { + let p = &mut self.presentation; + super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event) + } pub(crate) fn zoom(&mut self, scale: Option) -> bool { self.presentation.zoom(scale) } + pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option) -> bool { + let size = Scene::sequence(p, owner, owner).size; + let old = self.presentation.scale(size); + if let Some(next) = zoom { + let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.)); + self.camera(super::canvas_workspace::Event::Zoom { + factor: next / old, + at: Point::new(viewport.width / 2., viewport.height / 2.), + base: old, + }) + } else { + self.zoom(None); + let fitted = self.presentation.scale(size); + self.zoom(Some(fitted)); + self.presentation.pan = iced::Vector::ZERO; + true + } + } pub(crate) fn focused(&self) -> bool { self.presentation.focused } @@ -406,7 +435,7 @@ impl State { owner: frame.owner, token: Id::new_v4(), offsets: frame.offsets, - pending: [true; 2], + pending: [false, true], }); } pub(super) fn scroll_restore( @@ -518,6 +547,7 @@ impl State { self.scroll_offsets[index] = offset; changed } + #[cfg(test)] fn scroll_offset(&self) -> iced::Vector { // The former page scroll now belongs to a sibling outline shelf. self.scroll_offsets[0] diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index e643b949..089ea4f3 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -17,6 +17,7 @@ pub(super) struct Presentation { pub(super) viewport: Option, pub(super) focused: bool, pub(super) shelf: Option, + pub(super) pan: iced::Vector, pub(super) zoom: Option, pub(super) fit: bool, } @@ -46,23 +47,19 @@ impl Presentation { } fn fit_scale(&self, scene: Size) -> Option { let viewport = self.viewport?; - // This is the measured inner Scrollable, never a window-height estimate. - // Leave room for the overlay scrollbars on both axes. + // Fit uses the actual mounted viewport with a small visual gutter. Some( ((viewport.width - 16.).max(1.) / scene.width.max(1.)) .min((viewport.height - 16.).max(1.) / scene.height.max(1.)), ) } - fn scale(&self, scene: Size) -> f32 { + pub(super) fn scale(&self, scene: Size) -> f32 { if self.fit { self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.) } else { self.zoom.unwrap_or(1.) } } - fn fit_limited(&self, scene: Size) -> bool { - self.fit && self.fit_scale(scene).is_some_and(|scale| scale < MIN_SCALE) - } } pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> { @@ -78,258 +75,182 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(); let mut scene = Scene::sequence(p, owner, app.selected); let scale = presentation.scale(scene.size); - let limited = presentation.fit_limited(scene.size); scene.scale = scale; + scene.camera_pan = presentation.pan; scene.interaction = Some((context, tool, clean)); - // The outline is a sibling shelf, not an ancestor of this canvas. - scene.scroll_offset = state.scroll_offset(); scene.pending_placement = state.pending_placement(owner); - let reveal = state.form_session().and_then(|token| { - let (slot, _) = scene.pending_placement?; - let marker = scene.slots.iter().find(|marker| marker.slot == slot)?; - Some(ScrollReveal { - token, - slot, - y: marker.y * scale, - padding: (24. * scale).max(12.), - }) - }); - let size = Size::new(scene.size.width * scale, scene.size.height * scale); - let canvas = canvas_pane( - context, - state.scroll_restore(context, ScrollArea::Canvas), - reveal, - canvas::Canvas::new(scene) - .width(size.width) - .height(size.height) - .into(), - ); + let scope = super::super::canvas_workspace::Scope { + document: context.document, + owner, + revision: context.revision, + epoch: app.camera_epoch, + }; + let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app); + let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e)); + let canvas = container( + sensor( + super::super::canvas_workspace::Viewport::new( + canvas::Canvas::new(scene).width(Fill).height(Fill), + scope, + scale, + keyboard, + camera, + ) + .pan_background(tool == CanvasTool::Select), + ) + .key((context.document, owner)) + .on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))) + .on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))), + ) + .id("scenario-viewport") + .width(Fill) + .height(Fill) + .style(style::card) + .into(); let mut navigation = row![ out.width(Fill), - button( - text(if presentation.focused { - "Restore panels" - } else { - "Focus canvas" - }) - .size(12) + super::super::canvas_workspace::Control::new( + button( + text(if presentation.focused { + "Restore panels" + } else { + "Focus canvas" + }) + .size(12) + ) + .padding(6) + .on_press(super::super::canvas_workspace::intent( + scope, + "Panels", + Message::ScenarioCanvas(CanvasEvent::Focus(context)) + )), + scope, + "Panels", + keyboard, + Some(super::super::canvas_workspace::intent( + scope, + "Panels", + Message::ScenarioCanvas(CanvasEvent::Focus(context)) + )), + camera ) - .padding(6) - .on_press(Message::ScenarioCanvas(CanvasEvent::Focus(context))) - ] - .spacing(6) - .align_y(iced::Center); - if state.can_back(p, context) { - navigation = navigation.push( - button(text("Back to caller").size(12)) - .padding(6) - .on_press_maybe( - clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))), - ), - ); - } - let mut header = column![ - navigation, - row![ - button(text("Tools").size(12)) - .padding(6) - .style(if presentation.shelf == Some(Shelf::Tools) { - button::primary - } else { - style::nav - }) - .on_press(Message::ScenarioCanvas(CanvasEvent::Shelf( - context, - Shelf::Tools - ))), - button(text("Selection").size(12)) - .padding(6) - .style(if presentation.shelf == Some(Shelf::Selection) { - button::primary - } else { - style::nav - }) - .on_press(Message::ScenarioCanvas(CanvasEvent::Shelf( - context, - Shelf::Selection - ))), - button(text("Fit overview").size(12)) - .padding(6) - .on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(context, None))), - button(text("−").size(12)) - .padding(6) - .on_press(Message::ScenarioCanvas(CanvasEvent::Zoom( - context, - Some(scale / 1.25) - ))), - text(format!("{:.0}%", scale * 100.)).size(12), - button(text("+").size(12)) - .padding(6) - .on_press(Message::ScenarioCanvas(CanvasEvent::Zoom( - context, - Some(scale * 1.25) - ))), - button(text("100%").size(12)) - .padding(6) - .on_press(Message::ScenarioCanvas(CanvasEvent::Zoom( - context, - Some(1.) - ))) - ] - .spacing(6) - .align_y(iced::Center) - .wrap(), - text(if pending { - "Pending form · the highlighted sequence position is retained".to_owned() - } else if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) { - "Start with a lifeline representing existing or explicitly new work / participants." - .into() - } else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) { - "Choose a first message, action or another tool; no occurrence is selected yet.".into() - } else { - let guidance = match tool { - CanvasTool::Message(_) => { - "Click a source position, then click or drag to a target · Esc cancels" - } - CanvasTool::Add(Tool::Action | Tool::Observation) => { - "Click a highlighted position · a lifeline click preselects that lifeline" - } - CanvasTool::Add(Tool::Reuse | Tool::Fragment) => { - "Click a highlighted sequence position to open its form" - } - _ => "Select a lifeline or occurrence · drag its ↕ handle to reorder", - }; - format!("{tool} · {guidance}") - }) - .size(11) ] .spacing(6); - if !pending { - if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) { - header = header.push( - row![ - text(format!("No lifelines · {:?}", viewpoint(p, owner))).size(12), - button("Add first lifeline").on_press_maybe(clean.then_some({ - Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Lifeline)) - })) - ] - .spacing(8), - ); - } else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) { - header = header.push( - row![ - text("No occurrences yet").size(12), - button("Add first message").on_press_maybe(clean.then_some({ - Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Message)) - })), - button("Add first action").on_press_maybe(clean.then_some({ - Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Action)) - })) - ] - .spacing(8), - ); - } + if state.can_back(p, context) { + navigation = + navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe( + clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))), + )); } - if limited { - header = header - .push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11)); + let header = column![ + navigation, + super::super::canvas_workspace::controls( + scope, + keyboard, + scale, + Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)), + clean.then_some(Message::ScenarioCanvas(CanvasEvent::Camera( + context, + scope.epoch, + super::super::canvas_workspace::Event::Focus + ))), + move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)), + camera + ) + ] + .spacing(4); + let entries = [ + ("S", "Select / reorder occurrences", CanvasTool::Select), + ( + "L", + "Lifeline: click to choose represented work or participant", + CanvasTool::Add(Tool::Lifeline), + ), + ( + "C/R", + "Call and correlated reply", + CanvasTool::Message(MessageStyle::CallReply), + ), + ( + "M", + "Asynchronous message", + CanvasTool::Message(MessageStyle::Asynchronous), + ), + ( + "C", + "Synchronous call", + CanvasTool::Message(MessageStyle::Call), + ), + ( + "R", + "Reply to existing call", + CanvasTool::Message(MessageStyle::Reply), + ), + ( + "+L", + "Create participant", + CanvasTool::Message(MessageStyle::Create), + ), + ( + "-L", + "Delete participant", + CanvasTool::Message(MessageStyle::Delete), + ), + ("T", "Local timer", CanvasTool::Message(MessageStyle::Timer)), + ( + "A", + "Action: click a sequence position", + CanvasTool::Add(Tool::Action), + ), + ( + "St", + "State observation: click a sequence position", + CanvasTool::Add(Tool::Observation), + ), + ( + "ref", + "Reuse scenario: click a sequence position", + CanvasTool::Add(Tool::Reuse), + ), + ( + "alt", + "Fragment: click a sequence position", + CanvasTool::Add(Tool::Fragment), + ), + ]; + let palette = super::super::canvas_workspace::palette( + scope, + keyboard, + camera, + entries.into_iter().map(|(glyph, label, choice)| { + ( + glyph, + label, + choice == tool, + clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose( + context, choice, + ))), + ) + }), + ); + let mut details = column![ + text(format!("Selected: {}", p.named(app.selected))).size(12), + button(text("Properties").size(12)) + .on_press_maybe(clean.then_some(Message::EditProperties(app.selected))) + ] + .spacing(6); + if referenced_scenario(p, owner, app.selected).is_some() { + details = details.push(button(text("Open definition").size(12)).on_press_maybe( + clean.then_some(Message::ScenarioCanvas(CanvasEvent::OpenUse( + context, + app.selected, + ))), + )); } - let details = match presentation.shelf { - Some(Shelf::Tools) => { - let choose = move |tool| Message::ScenarioCanvas(CanvasEvent::Choose(context, tool)); - let tool_picker: Element<'_, Message> = if clean { - pick_list( - vec![ - CanvasTool::Select, - CanvasTool::Message(MessageStyle::CallReply), - CanvasTool::Message(MessageStyle::Asynchronous), - CanvasTool::Message(MessageStyle::Call), - CanvasTool::Message(MessageStyle::Reply), - CanvasTool::Message(MessageStyle::Create), - CanvasTool::Message(MessageStyle::Delete), - CanvasTool::Message(MessageStyle::Timer), - CanvasTool::Add(Tool::Action), - CanvasTool::Add(Tool::Observation), - CanvasTool::Add(Tool::Reuse), - CanvasTool::Add(Tool::Fragment), - ], - Some(tool), - choose, - ) - .text_size(12) - .padding(6) - .width(Fill) - .into() - } else { - button(text(format!("{tool} · finish pending edits to change tools")).size(12)) - .padding(6) - .width(Fill) - .into() - }; - column![ - tool_picker, - row![ - button(text("+ Lifeline").size(12)) - .padding(6) - .on_press_maybe( - clean.then_some(event(Event::Start(owner, Tool::Lifeline))) - ), - button(text("Scenario properties").size(12)) - .padding(6) - .on_press_maybe(clean.then_some(Message::EditProperties(owner))), - ] - .spacing(6) - .wrap(), - text(match viewpoint(p, owner) { - ScenarioViewpoint::OperationalActivity => - "Activity scenario · lifelines represent activities", - ScenarioViewpoint::Functional => - "Functional scenario · lifelines represent system functions", - ScenarioViewpoint::ParticipantInteraction => - "Participant scenario · entities, actors or roles", - ScenarioViewpoint::BoundaryInterface => - "Interface scenario · system and external actors", - ScenarioViewpoint::MixedLegacy => "Mixed scenario · work or participants", - }) - .size(11), - ] - .spacing(6) - } - _ => { - let mut details = - column![text(format!("Selected: {}", p.named(app.selected))).size(12)].spacing(6); - let mut navigation = row![].spacing(6); - if referenced_scenario(p, owner, app.selected).is_some() { - navigation = navigation.push( - button(text("Open definition").size(12)) - .padding(6) - .on_press_maybe(clean.then_some(Message::ScenarioCanvas( - CanvasEvent::OpenUse(context, app.selected), - ))), - ); - } - navigation = navigation.push( - button(text("Properties").size(12)) - .padding(6) - .on_press_maybe(clean.then_some(Message::EditProperties(app.selected))), - ); - details = details.push(navigation.wrap()); - if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) { - details.push( - text("Use Tools to add lifelines, then draw messages or insert occurrences.") - .size(12), - ) - } else { - sequence_rows(p, context, owner, details, clean) - } - } - }; let details = details_pane( context, state.scroll_restore(context, ScrollArea::Page), - details.into(), - presentation.shelf.is_some(), - pending, + sequence_rows(p, context, owner, details, clean).into(), ); let mut form_panel = editor_panel( Id::nil(), @@ -647,7 +568,12 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a .into(); } } - workspace_layout(header.into(), details, canvas, form_panel, pending) + let editor = if pending { + Some(form_panel) + } else { + presentation.shelf.map(|_| details) + }; + super::super::canvas_workspace::host(header.into(), palette, canvas, editor) } fn sequence_action<'a>( @@ -661,91 +587,23 @@ fn sequence_action<'a>( .on_press_maybe(enabled.then_some(message)) } -// Permanent header/shelf/body and canvas/form slots. Zero-sized disclosures -// must remain in the tree: Column::push otherwise drops them and remounts siblings. -fn workspace_layout<'a, R: iced::advanced::text::Renderer + 'a>( - header: iced::Element<'a, Message, Theme, R>, - details: iced::Element<'a, Message, Theme, R>, - canvas: iced::Element<'a, Message, Theme, R>, - form: iced::Element<'a, Message, Theme, R>, - pending: bool, -) -> iced::Element<'a, Message, Theme, R> { - let panes = Column::from_vec(vec![ - container(canvas).width(Fill).height(Fill).into(), - container(form) - .width(Fill) - .height(if pending { - Fill - } else { - iced::Length::Fixed(0.) - }) - .clip(true) - .into(), - ]) - .spacing(if pending { 6 } else { 0 }) - .width(Fill) - .height(Fill); - Column::from_vec(vec![header, details, panes.into()]) - .spacing(6) - .padding(8) - .width(Fill) - .height(Fill) - .into() -} - fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>( context: Context, restore: Option, details: iced::Element<'a, Message, Theme, R>, - open: bool, - pending: bool, ) -> iced::Element<'a, Message, Theme, R> { scenario_scroll::observe( context, ScrollArea::Page, + false, restore, scrollable(details) .id("scenario-details") .width(Fill) - .height(if open { - if pending { 72. } else { 100. } - } else { - 0. - }), + .height(Fill), ) } -fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>( - context: Context, - restore: Option, - reveal: Option, - content: iced::Element<'a, Message, Theme, R>, -) -> iced::Element<'a, Message, Theme, R> { - container( - sensor(scenario_scroll::observe_canvas( - context, - restore, - reveal, - scrollable(content) - .id("scenario-canvas") - .direction(scrollable::Direction::Both { - vertical: scrollable::Scrollbar::default(), - horizontal: scrollable::Scrollbar::default(), - }) - .width(Fill) - .height(Fill), - )) - .key((context.document, context.scenario)) - .on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))) - .on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))), - ) - .padding(8) - .width(Fill) - .height(Fill) - .style(style::card) - .into() -} - fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>( session: Id, actions: iced::Element<'a, Message, Theme, R>, diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs index 536e719e..0aa066dd 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs @@ -1,221 +1,415 @@ use super::*; +use crate::behavior::canvas_workspace::tests::{Geometry, Mounted}; use iced::Vector; -use iced::advanced::{Layout, Shell, layout, widget}; +use iced::advanced::{Layout, layout, widget}; -#[derive(Default)] -struct Geometry { - scrolls: BTreeMap, - actions: Option, -} -impl widget::Operation for Geometry { - fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { - visit(self); - } - fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { - if id == Some(&widget::Id::new("scenario-actions")) { - self.actions = Some(bounds); - } - } - fn scrollable( - &mut self, - id: Option<&widget::Id>, - bounds: Rectangle, - _: Rectangle, - translation: Vector, - _: &mut dyn widget::operation::Scrollable, - ) { - for name in ["scenario-canvas", "scenario-details", "scenario-form"] { - if id == Some(&widget::Id::new(name)) { - assert!( - self.scrolls - .insert(name.into(), (bounds, translation)) - .is_none() +#[tokio::test] +async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_through_editing() { + use crate::behavior::canvas_workspace::{self, tests::Mounted}; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project.clone()); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + for size in [Size::new(1100., 700.), Size::new(1440., 900.)] { + app.window_size = size; + mounted.settle(&mut app).await; + for scale in [0.5, 1., 2.] { + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Zoom( + app.scenario_context().unwrap(), + Some(scale), + ))); + let geometry = mounted.geometry(&app); + let bounds = geometry.bounds("scenario-viewport"); + assert!( + !geometry + .scrolls + .contains_key(&widget::Id::new("scenario-canvas")) + ); + assert!( + bounds.height > 350., + "actual Fill viewport at {size:?}: {bounds:?}" + ); + let scene = Scene::sequence(app.project(), f.owner, f.owner); + let slot = scene + .slots + .iter() + .find(|s| s.slot.parent == f.owner && s.slot.before == Some(f.reply)) + .unwrap(); + let expected = slot.slot; + let world = Point::new(scene.lanes[&f.sender], slot.y); + let desired = Vector::new(70., 115.); + let next_pan = desired - Vector::new(world.x, world.y) * scale; + let delta = next_pan - app.behavior.scenario.presentation.pan; + let start = bounds.position() + Vector::new(120., 60.); + let snapshot = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + mounted.pan(&mut app, start, start + delta).await; + let diff = app.behavior.scenario.presentation.pan - next_pan; + assert!(diff.x.hypot(diff.y) < 0.01); + let tool = mounted.geometry(&app).bounds("behavior-tool:A").center(); + mounted.click(&mut app, tool).await; + let messages = mounted.click(&mut app, bounds.position() + desired).await; + assert!( + messages.iter().any( + |m| matches!(m, Message::ScenarioCanvas(CanvasEvent::BeginOccurrence { + tool: Tool::Action, slot, lifeline: Some(line), .. + }) if *slot == expected && *line == f.sender) + ), + "scale {scale}: {messages:?}" + ); + let session = app.behavior.scenario.form_session().unwrap(); + let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation; + let _ = app.update(event(Event::Name(" raw action 雨 ".into()))); + let raw = app.behavior.recovery_snapshot().unwrap(); + mounted.settle(&mut app).await; + let shown = mounted.geometry(&app); + assert_eq!( + app.behavior.scenario.pending_placement(f.owner), + Some((expected, Some(f.sender))) + ); + let retained_pan = app.behavior.scenario.presentation.pan - next_pan; + assert!( + retained_pan.x.hypot(retained_pan.y) < 0.01, + "opening the editor must not move the clicked, already-visible insertion marker" + ); + assert_eq!(shown.bounds("scenario-viewport"), bounds); + let actions = shown.bounds("scenario-actions"); + let form = shown.scrolls[&widget::Id::new("scenario-form")].0; + assert!(actions.y >= bounds.y && actions.y + actions.height <= form.y); + assert!(form.y + form.height <= bounds.y + bounds.height); + assert!(form.height > 250.); + let pan = app.behavior.scenario.presentation.pan; + // Background canvas keys cannot move the camera while the draft owns editing. + mounted + .batch( + &mut app, + &[canvas_workspace::tests::key( + iced::keyboard::key::Named::ArrowDown, + )], + mouse::Cursor::Available(bounds.position() + desired), + ) + .await; + assert_eq!(app.behavior.scenario.presentation.pan, pan); + mounted.scroll(&app, "scenario-form", 500.); + let offset = mounted.geometry(&app).scrolls[&widget::Id::new("scenario-form")].1; + assert_eq!(mounted.geometry(&app).bounds("scenario-actions"), actions); + for shelf in [Shelf::Tools, Shelf::Selection, Shelf::Selection] { + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Shelf( + app.scenario_context().unwrap(), + shelf, + ))); + assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds); + assert_eq!(app.behavior.scenario.form_session(), Some(session)); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().invocation, + invocation + ); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert_eq!( + mounted.geometry(&app).scrolls[&widget::Id::new("scenario-form")].1, + offset ); } + // Width-only responsive changes preserve the actual editor offset, + // identity and camera; the right editor keeps its fixed field width. + let other_width = if size.width == 1100. { 1440. } else { 1100. }; + for resized in [Size::new(other_width, size.height), size] { + let task = app.update(Message::WindowResized(resized)); + mounted.perform(&mut app, task).await; + mounted.settle(&mut app).await; + let resized_geometry = mounted.geometry(&app); + assert_eq!( + resized_geometry.scrolls[&widget::Id::new("scenario-form")].1, + offset + ); + assert_eq!(resized_geometry.bounds("scenario-actions").y, actions.y); + assert_eq!(app.behavior.scenario.form_session(), Some(session)); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().invocation, + invocation + ); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert_eq!(app.behavior.scenario.presentation.pan, pan); + } + let _ = app.update(event(Event::Cancel)); + assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); + // Close the optional details overlay before the next pointer workflow. + app.behavior.scenario.presentation.shelf = None; } } } -type TestView = iced::Element<'static, Message, Theme, ()>; -fn fixture_view(state: &State, context: Context, pending: bool, header: f32) -> TestView { - workspace_layout( - Space::new().height(header).into(), - details_pane( - context, - state.scroll_restore(context, ScrollArea::Page), - Space::new().height(1800.).into(), - state.presentation.shelf.is_some(), - pending, - ), - canvas_pane( - context, - state.scroll_restore(context, ScrollArea::Canvas), - None, - Space::new().width(1800.).height(2400.).into(), - ), - editor_panel( - state.form_session().unwrap_or_default(), - Space::new().height(32.).into(), - Space::new().height(2000.).into(), - ), - pending, - ) -} -#[test] -fn new_scenario_form_resets_only_its_scroll_and_recovery_gets_a_fresh_session() { - let f = super::super::direct_tests::fixture(); - let mut app = App::with_project(f.project); - let _ = app.update(Message::Select(f.owner)); +#[tokio::test] +async fn production_scenario_canvas_pan_invalidates_a_pressed_creation_and_caller_restores_once() { + use super::super::direct_tests::{navigation_event, navigation_fixture}; + use crate::behavior::canvas_workspace::{Event as Camera, tests::Mounted}; + let (p, [owner, target, _], [reference, _, _]) = navigation_fixture(); + let mut app = App::with_project(p); + let _ = app.update(Message::Select(owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); - let _ = app.update(event(Event::Start(f.owner, Tool::Action))); - let first = app.behavior.scenario.form_session().unwrap(); - let size = Size::new(500., 570.); - let mut view = fixture_view( - &app.behavior.scenario, - app.scenario_context().unwrap(), - true, - 132., - ); - let mut tree = widget::Tree::new(view.as_widget()); - let (node, _) = geometry(&mut view, &mut tree, size); - scroll( - &mut view, - &mut tree, - &node, - "scenario-form", - Vector::new(0., 700.), - ); - scroll( - &mut view, - &mut tree, - &node, - "scenario-canvas", - Vector::new(137., 850.), - ); - redraw(&mut view, &mut tree, size, &mut app); - let accepted = app.document.snapshot(); - let _ = app.update(event(Event::Name(" retained raw 雨 ".into()))); - let raw = app.behavior.scenario.recovery_snapshot().unwrap(); - view = fixture_view( - &app.behavior.scenario, - app.scenario_context().unwrap(), - true, - 132., - ); - tree.diff(view.as_widget()); - let (_, same_form) = geometry(&mut view, &mut tree, size); - assert_eq!(same_form.scrolls["scenario-form"].1.y, 700.); - assert_eq!(app.behavior.scenario.form_session(), Some(first)); - - // No intervening closed render: replacing the editing session itself must - // invalidate the old scroll, while preserving the canvas's actual offset. - let _ = app.update(event(Event::Cancel)); - let _ = app.update(event(Event::Start(f.owner, Tool::Observation))); - assert_ne!(app.behavior.scenario.form_session(), Some(first)); - view = fixture_view( - &app.behavior.scenario, - app.scenario_context().unwrap(), - true, - 132., - ); - tree.diff(view.as_widget()); - let (_, next_form) = geometry(&mut view, &mut tree, size); - assert_eq!(next_form.scrolls["scenario-form"].1, Vector::ZERO); - assert_eq!( - next_form.scrolls["scenario-canvas"].1, - Vector::new(137., 850.) - ); - let recovered = State::from_recovery(raw.unwrap()).unwrap(); - assert_ne!(recovered.form_session(), Some(first)); - assert_eq!( - recovered.draft.as_ref().unwrap().name, - " retained raw 雨 " - ); - assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); - assert!(!app.document.can_undo()); -} - -#[test] -fn pending_marker_reveals_in_the_actual_smaller_canvas_once_without_horizontal_or_history_drift() { - let f = super::super::direct_tests::fixture(); - let mut app = App::with_project(f.project); - let _ = app.update(Message::Select(f.owner)); - let _ = app.update(Message::Page(crate::app::Page::Behavior)); - let context = app.scenario_context().unwrap(); - let make = |reveal| -> TestView { - canvas_pane( - context, - None, - reveal, - Space::new().width(1800.).height(2400.).into(), + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let source = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + navigation_event(&mut app, |c| { + CanvasEvent::Choose(c, CanvasTool::Add(Tool::Fragment)) + }); + let point = bounds.position() + Vector::new(80., 120.); + mounted + .batch( + &mut app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ], + mouse::Cursor::Available(point), ) - }; - let mut view = make(None); - let mut tree = widget::Tree::new(view.as_widget()); - let wide = Size::new(500., 500.); - let (node, _) = geometry(&mut view, &mut tree, wide); - scroll( - &mut view, - &mut tree, - &node, - "scenario-canvas", - Vector::new(137., 800.), + .await; + assert!( + !app.behavior.has_pending(), + "a zero-distance camera press cancels semantic creation" ); - redraw(&mut view, &mut tree, wide, &mut app); - let accepted = app.document.snapshot(); - let request = ScrollReveal { - token: Id::new_v4(), - slot: Slot { - parent: f.owner, - before: Some(f.reply), - }, - y: 1100., - padding: 24., - }; - view = make(Some(request)); - tree.diff(view.as_widget()); - let small = Size::new(500., 200.); - redraw(&mut view, &mut tree, small, &mut app); - let (node, shown) = geometry(&mut view, &mut tree, small); - let (bounds, offset) = shown.scrolls["scenario-canvas"]; - assert_eq!(offset.x, 137.); - assert_eq!(offset.y, 1100. + 24. - bounds.height); - assert!(1100. - 24. >= offset.y); - assert!(1100. + 24. <= offset.y + bounds.height); - scroll( - &mut view, - &mut tree, - &node, - "scenario-canvas", - Vector::new(137., 960.), + let emitted = mounted + .pan(&mut app, point, point + Vector::new(-45., 28.)) + .await; + let retained = crate::behavior::canvas_workspace::tests::camera_delta(&emitted); + let camera = ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom, ); - redraw(&mut view, &mut tree, small, &mut app); - view = make(Some(request)); - tree.diff(view.as_widget()); - redraw(&mut view, &mut tree, small, &mut app); + let toolbar = + crate::behavior::canvas_workspace::tests::capture_toolbar(&mut mounted, &app, "Fit"); + navigation_event(&mut app, |c| CanvasEvent::OpenUse(c, reference)); + mounted.settle(&mut app).await; + assert_eq!(app.scenario_context().unwrap().scenario, target); + let lifetime = app.camera_epoch; + navigation_event(&mut app, |c| { + CanvasEvent::Camera(c, lifetime, Camera::Pan(Vector::new(90., -70.))) + }); + navigation_event(&mut app, CanvasEvent::Back); + mounted.settle(&mut app).await; + assert_eq!(app.selected, reference); + assert_eq!(app.scenario_context().unwrap().scenario, owner); assert_eq!( - geometry(&mut view, &mut tree, small).1.scrolls["scenario-canvas"].1, - Vector::new(137., 960.) + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera ); + let task = app.update(retained); + mounted.perform(&mut app, task).await; + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera, + "a camera packet retained before A → B → Back must not move the restored caller" + ); + let lifetime = app.camera_epoch; + let task = app.update(toolbar); + mounted.perform(&mut app, task).await; + assert_eq!( + app.camera_epoch, lifetime, + "A to B and Back cannot revive toolbar intent" + ); + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera + ); + let lifetime = app.camera_epoch; + navigation_event(&mut app, |c| { + CanvasEvent::Camera(c, lifetime, Camera::Pan(Vector::new(10., 15.))) + }); + mounted.settle(&mut app).await; + assert_eq!( + app.behavior.scenario.presentation.pan, + camera.0 + Vector::new(10., 15.) + ); + assert!(std::sync::Arc::ptr_eq(&source, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} - view = make(Some(ScrollReveal { - token: Id::new_v4(), - y: 780., - ..request - })); - tree.diff(view.as_widget()); - redraw(&mut view, &mut tree, small, &mut app); - assert_eq!( - geometry(&mut view, &mut tree, small).1.scrolls["scenario-canvas"].1, - Vector::new(137., 756.) +#[tokio::test] +async fn production_scenario_whole_glyph_drag_moves_one_occurrence_and_rejects_other_operand() { + use crate::behavior::canvas_workspace::tests::Mounted; + let (mut f, branch, first, _, _, _) = super::super::direct_tests::placement_fixture(); + let other = super::super::super::topology::occurrence( + &f.project, + branch, + "Second check", + OccurrenceBody::Action { + action: represented(&f.project, f.sender), + lifeline: Some(f.sender), + }, ); - assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + let second = other.id; + f.project = execute_batch(&f.project, &[Command::CreateElement(other)]).unwrap(); + let mut app = App::with_project(f.project.clone()); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let scene = Scene::sequence(app.project(), f.owner, f.owner); + let glyph = scene + .boxes + .iter() + .find(|b| b.id == first && !b.title.is_empty()) + .unwrap() + .rect; + let end = scene + .slots + .iter() + .find(|s| s.slot.parent == branch && s.slot.before.is_none()) + .unwrap(); + let forbidden = scene + .slots + .iter() + .find(|s| s.slot.parent == f.owner) + .unwrap(); + let world_start = glyph.position() + Vector::new(25., 12.); + let scale = 0.5; + let pan = Vector::new(70. - world_start.x * scale, 260. - world_start.y * scale); + let screen = |point: Point| bounds.position() + pan + Vector::new(point.x, point.y) * scale; + let start = screen(world_start); + let valid = screen(Point::new(world_start.x, end.y)); + let invalid = screen(Point::new(world_start.x, forbidden.y)); + assert!( + bounds.contains(start) && bounds.contains(valid) && bounds.contains(invalid), + "all drag destinations must be visible" + ); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Zoom( + app.scenario_context().unwrap(), + Some(scale), + ))); + let delta = pan - app.behavior.scenario.presentation.pan; + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera( + context, + app.camera_epoch, + crate::behavior::canvas_workspace::Event::Pan(delta), + ))); + let snapshot = app.document.snapshot(); + let before = app.document.bundle().clone(); + let drag = |end| { + [ + iced::Event::Mouse(mouse::Event::CursorMoved { position: start }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + iced::Event::Mouse(mouse::Event::CursorMoved { position: end }), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ] + }; + let messages = mounted + .batch(&mut app, &drag(invalid), mouse::Cursor::Available(invalid)) + .await; + assert!( + !messages + .iter() + .any(|m| matches!(m, Message::ScenarioCanvas(CanvasEvent::Reorder { .. }))) + ); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + let messages = mounted + .batch(&mut app, &drag(valid), mouse::Cursor::Available(valid)) + .await; + assert!(messages.iter().any( + |m| matches!(m, Message::ScenarioCanvas(CanvasEvent::Reorder { occurrence, slot, .. }) + if *occurrence == first && *slot == (Slot { parent: branch, before: None })) + )); + assert_eq!(app.selected, first); + let expected = execute_batch( + &f.project, + &[Command::SetSteps(branch, vec![second, first])], + ) + .unwrap(); + assert_eq!(app.project(), &expected); + let after = app.document.bundle().clone(); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &f.project); + assert_eq!(app.document.bundle(), &before); assert!(!app.document.can_undo()); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected); + assert_eq!(app.document.bundle(), &after); + assert!(!app.document.can_redo()); +} + +#[tokio::test] +async fn production_empty_scenario_lifeline_tool_keeps_staged_source_inside_stable_canvas() { + use crate::behavior::canvas_workspace::tests::Mounted; + let mut project = Project::empty(); + let owners = [Layer::Oa, Layer::Sa].map(|layer| { + project + .add_element(Kind::Scenario, layer, "Empty scenario", None) + .unwrap() + }); + let mut app = App::with_project(project.clone()); + let _ = app.update(Message::Select(owners[0])); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + for owner in owners { + let _ = app.update(Message::Select(owner)); + mounted.settle(&mut app).await; + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let tool = mounted.geometry(&app).bounds("behavior-tool:L").center(); + mounted.click(&mut app, tool).await; + mounted + .click(&mut app, bounds.position() + Vector::new(70., 100.)) + .await; + let draft = app + .behavior + .scenario + .draft + .as_ref() + .expect("canvas lifeline opens its single typed draft"); + assert_eq!(draft.owner, owner); + assert_eq!(draft.tool, Tool::Lifeline); + let invocation = draft.invocation; + let _ = app.update(event(Event::Name(" consumer raw 雨 ".into()))); + let snapshot = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + let _ = app.update(event(Event::Source( + crate::behavior::source_draft::Event::Open(invocation), + ))); + assert!(app.behavior.scenario.source_open()); + mounted.settle(&mut app).await; + assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds); + let _ = app.update(Message::Escape); + assert!(!app.behavior.scenario.source_open()); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().invocation, + invocation + ); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().name, + " consumer raw 雨 " + ); + mounted.settle(&mut app).await; + assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + let _ = app.update(event(Event::Cancel)); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } } #[tokio::test] async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_modals() { - let renderer = focus_renderer().await; let f = super::super::direct_tests::fixture(); let mut app = App::with_project(f.project); let _ = app.update(Message::Select(f.owner)); @@ -224,11 +418,13 @@ async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_moda let _ = app.update(event(Event::Name(" retained 雨 ".into()))); let context = app.scenario_context().unwrap(); let session = app.behavior.scenario.form_session().unwrap(); + let mount = app.behavior.scenario.form_mount().unwrap(); + app.window_size = Size::new(1100., 520.); let raw = app.behavior.recovery_snapshot().unwrap(); let accepted = app.document.snapshot(); for mismatch in 0..4 { let mut stale = context; - let mut token = session; + let mut token = mount; match mismatch { 0 => stale.document = Id::new_v4(), 1 => stale.scenario = Id::new_v4(), @@ -242,13 +438,13 @@ async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_moda } app.hosted.visible = true; let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( - context, session, + context, mount, ))); assert!(iced_runtime::task::into_stream(task).is_none()); app.hosted.visible = false; app.busy = true; let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( - context, session, + context, mount, ))); assert!(iced_runtime::task::into_stream(task).is_none()); app.busy = false; @@ -261,10 +457,10 @@ async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_moda ))); assert_ne!(app.scenario_context(), Some(context)); let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( - context, session, + context, mount, ))); - let mut tree = widget::Tree::new(app.view().as_widget()); - focus_other(&app, &mut tree, &renderer); + let mut mounted = Mounted::unshown().await; + focus_other(&app, &mut mounted); let mut away = widget::operation::scrollable::scroll_to::<()>( widget::Id::new("scenario-form"), widget::operation::scrollable::AbsoluteOffset { @@ -272,17 +468,17 @@ async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_moda y: Some(1000.), }, ); - mounted_operation(&app, &mut tree, &renderer, &mut away); + mounted.operate(&app, &mut away); let mut before_reveal = Geometry::default(); - mounted_operation(&app, &mut tree, &renderer, &mut before_reveal); + mounted.operate(&app, &mut before_reveal); assert!( - before_reveal.scrolls["scenario-form"].1.y > 64., + before_reveal.scrolls[&widget::Id::new("scenario-form")].1.y > 64., "the production parent must actually overflow" ); - run_focus_task(&app, &mut tree, &renderer, task).await; - assert_parent_focused(&app, &mut tree, &renderer); + mounted.perform(&mut app, task).await; + assert_parent_focused(&app, &mut mounted); let mut revealed = ParentBounds::new(app.scenario_focus_owner().unwrap()); - mounted_operation(&app, &mut tree, &renderer, &mut revealed); + mounted.operate(&app, &mut revealed); let name = revealed.name.expect("mounted parent Name"); let viewport = revealed.viewport.expect("actual parent scroll viewport"); assert!( @@ -290,9 +486,10 @@ async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_moda "Name {name:?} must be visible inside {viewport:?}" ); let repeated = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( - context, session, + context, mount, ))); assert!(iced_runtime::task::into_stream(repeated).is_none()); + assert_eq!(app.behavior.scenario.form_session(), Some(session)); assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); assert!(!app.document.can_undo()); @@ -300,38 +497,13 @@ async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_moda let _ = app.update(event(Event::Start(f.owner, Tool::Fragment))); let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( app.scenario_context().unwrap(), - session, + mount, ))); assert!(iced_runtime::task::into_stream(task).is_none()); } // These gates mount the production App, including its modal and scenario // branches. A missing headless backend is a test failure, never a silent skip. const FOCUS_VIEWPORT: Size = Size::new(1100., 700.); -async fn focus_renderer() -> iced::Renderer { - ::new( - iced::Font::default(), - 16.into(), - None, - ) - .await - .expect("mounted focus gates require the headless production renderer") -} -fn mounted_operation( - app: &App, - tree: &mut widget::Tree, - renderer: &iced::Renderer, - operation: &mut dyn widget::Operation, -) { - let mut view = app.view(); - tree.diff(view.as_widget()); - let node = view.as_widget_mut().layout( - tree, - renderer, - &layout::Limits::new(Size::ZERO, FOCUS_VIEWPORT), - ); - view.as_widget_mut() - .operate(tree, Layout::new(&node), renderer, operation); -} async fn focus_action(task: iced::Task) -> Box { use iced::futures::StreamExt; let mut stream = iced_runtime::task::into_stream(task).expect("parent focus task"); @@ -342,21 +514,6 @@ async fn focus_action(task: iced::Task) -> Box { // completion while retaining this delayed action would wait forever. operation } -async fn run_focus_task( - app: &App, - tree: &mut widget::Tree, - renderer: &iced::Renderer, - task: iced::Task, -) { - let mut operation = focus_action(task).await; - loop { - mounted_operation(app, tree, renderer, operation.as_mut()); - match operation.finish() { - widget::operation::Outcome::Chain(next) => operation = next, - _ => break, - } - } -} #[derive(Default, Debug, PartialEq)] struct Focused(Vec<(Option, bool)>); impl widget::Operation for Focused { @@ -372,9 +529,9 @@ impl widget::Operation for Focused { self.0.push((id.cloned(), state.is_focused())); } } -fn focused(app: &App, tree: &mut widget::Tree, renderer: &iced::Renderer) -> Focused { +fn parent_fields(app: &App, mounted: &mut Mounted) -> Focused { let mut result = Focused::default(); - mounted_operation(app, tree, renderer, &mut result); + mounted.operate(app, &mut result); result } struct FocusIndex { @@ -399,28 +556,28 @@ impl widget::Operation for FocusIndex { self.index += 1; } } -fn focus_other(app: &App, tree: &mut widget::Tree, renderer: &iced::Renderer) { +fn focus_other(app: &App, mounted: &mut Mounted) { let parent = app.scenario_focus_owner().map(focus::name_id); - let fields = focused(app, tree, renderer); + let fields = parent_fields(app, mounted); let target = fields .0 .iter() .rposition(|(id, _)| parent.is_none() || *id != parent) .expect("actual tree contains another focusable input"); - mounted_operation(app, tree, renderer, &mut FocusIndex { target, index: 0 }); - assert!(focused(app, tree, renderer).0[target].1); + mounted.operate(app, &mut FocusIndex { target, index: 0 }); + assert!(parent_fields(app, mounted).0[target].1); } -fn focus_source(app: &App, tree: &mut widget::Tree, renderer: &iced::Renderer) { +fn focus_source(app: &App, mounted: &mut Mounted) { let id = widget::Id::new(crate::behavior::source_draft::NAME_INPUT); assert!( - focused(app, tree, renderer) + parent_fields(app, mounted) .0 .iter() .any(|(field, _)| field.as_ref() == Some(&id)) ); let mut operation = widget::operation::focusable::focus::<()>(id.clone()); - mounted_operation(app, tree, renderer, &mut operation); - assert!(focused(app, tree, renderer).0.contains(&(Some(id), true))); + mounted.operate(app, &mut operation); + assert!(parent_fields(app, mounted).0.contains(&(Some(id), true))); } #[derive(Default)] struct ReviewField { @@ -454,18 +611,18 @@ impl widget::Operation for ReviewField { self.index += 1; } } -fn focus_review(app: &App, tree: &mut widget::Tree, renderer: &iced::Renderer) { +fn focus_review(app: &App, mounted: &mut Mounted) { let mut field = ReviewField::default(); - mounted_operation(app, tree, renderer, &mut field); + mounted.operate(app, &mut field); let target = field .target .expect("production Review owns a mounted candidate input"); - mounted_operation(app, tree, renderer, &mut FocusIndex { target, index: 0 }); - assert!(focused(app, tree, renderer).0[target].1); + mounted.operate(app, &mut FocusIndex { target, index: 0 }); + assert!(parent_fields(app, mounted).0[target].1); } -fn assert_parent_focused(app: &App, tree: &mut widget::Tree, renderer: &iced::Renderer) { +fn assert_parent_focused(app: &App, mounted: &mut Mounted) { let expected = focus::name_id(app.scenario_focus_owner().unwrap()); - let fields = focused(app, tree, renderer); + let fields = parent_fields(app, mounted); assert_eq!( fields .0 @@ -533,44 +690,32 @@ impl widget::Operation for ParentBounds { } } } -fn shown_task( - app: &mut App, - tree: &mut widget::Tree, - renderer: &iced::Renderer, -) -> iced::Task { - let mut messages = vec![]; - { - let mut view = app.view(); - tree.diff(view.as_widget()); - let size = FOCUS_VIEWPORT; - let node = - view.as_widget_mut() - .layout(tree, renderer, &layout::Limits::new(Size::ZERO, size)); - view.as_widget_mut().update( - tree, - &iced::Event::Window(iced::window::Event::RedrawRequested( - std::time::Instant::now(), - )), - Layout::new(&node), - mouse::Cursor::Unavailable, - renderer, - &mut iced::advanced::clipboard::Null, - &mut Shell::new(&mut messages), - &Rectangle::with_size(size), - ); - } - let mut task = None; +async fn shown_task(app: &mut App, mounted: &mut Mounted) -> iced::Task { + let messages = mounted.input( + app, + &[iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + ))], + mouse::Cursor::Unavailable, + ); + let mut shown = None; + let mut ready = vec![]; + // Drain the whole App message batch before any operation sees the final UI. + // Retain only the parent sensor's Task for the explicit delayed-focus gates. for message in messages { - let shown = matches!( + let parent = matches!( &message, Message::ScenarioCanvas(CanvasEvent::FormShown(..)) ); - let result = app.update(message); - if shown { - assert!(task.replace(result).is_none()); + let task = app.update(message); + if parent { + assert!(shown.replace(task).is_none()); + } else { + ready.push(task); } } - task.expect("production parent sensor reports its newly mounted identity") + mounted.perform(app, iced::Task::batch(ready)).await; + shown.expect("production parent sensor reports its newly mounted identity") } fn focus_fixture() -> (App, Id, Id) { let mut p = Project::empty(); @@ -616,6 +761,7 @@ fn focus_fixture() -> (App, Id, Id) { }, ); let mut app = App::with_project(p); + app.window_size = FOCUS_VIEWPORT; let _ = app.update(Message::Select(owner)); let _ = app.update(Message::Page(crate::app::Page::Behavior)); let _ = app.update(event(Event::Start(owner, Tool::Lifeline))); @@ -647,20 +793,19 @@ fn open_review(app: &mut App, source: Id) { #[tokio::test] async fn scenario_form_focus_returns_from_back_use_and_escape_after_initial_focus_is_consumed() { use crate::behavior::source_draft as source; - let renderer = focus_renderer().await; for route in 0..3 { let (mut app, _, _) = focus_fixture(); - let mut tree = widget::Tree::new(app.view().as_widget()); - let initial = shown_task(&mut app, &mut tree, &renderer); - run_focus_task(&app, &mut tree, &renderer, initial).await; - assert_parent_focused(&app, &mut tree, &renderer); + let mut mounted = Mounted::unshown().await; + let initial = shown_task(&mut app, &mut mounted).await; + mounted.perform(&mut app, initial).await; + assert_parent_focused(&app, &mut mounted); let first_mount = app.behavior.scenario.form_mount().unwrap(); let session = app.behavior.scenario.form_session(); let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); let accepted = app.document.snapshot(); let bundle = app.document.bundle().clone(); let token = open_source(&mut app); - focus_source(&app, &mut tree, &renderer); // mount the actual child + focus_source(&app, &mut mounted); // mount the actual child match route { 0 => { let _ = app.update(event(Event::Source(source::Event::Edit( @@ -696,9 +841,9 @@ async fn scenario_form_focus_returns_from_back_use_and_escape_after_initial_focu ] { assert_eq!(returned[key], raw[key], "route {route}, raw field {key}"); } - let task = shown_task(&mut app, &mut tree, &renderer); - run_focus_task(&app, &mut tree, &renderer, task).await; - assert_parent_focused(&app, &mut tree, &renderer); + let task = shown_task(&mut app, &mut mounted).await; + mounted.perform(&mut app, task).await; + assert_parent_focused(&app, &mut mounted); assert_eq!( app.behavior.scenario.recovery_snapshot().unwrap().unwrap(), returned @@ -718,11 +863,10 @@ async fn scenario_form_focus_returns_from_back_use_and_escape_after_initial_focu #[tokio::test] async fn scenario_form_focus_saved_action_is_inert_on_replacement_child_review_and_changed_context() { - let renderer = focus_renderer().await; for change in 0..8 { let (mut app, owner, review_source) = focus_fixture(); - let mut tree = widget::Tree::new(app.view().as_widget()); - let task = shown_task(&mut app, &mut tree, &renderer); + let mut mounted = Mounted::unshown().await; + let task = shown_task(&mut app, &mut mounted).await; // Obtain S1's actual Action before changing the live state; stale // FormShown rejection alone would not exercise this race. let mut operation = focus_action(task).await; @@ -755,33 +899,29 @@ async fn scenario_form_focus_saved_action_is_inert_on_replacement_child_review_a } 6 => { open_source(&mut app); - focus_source(&app, &mut tree, &renderer); + focus_source(&app, &mut mounted); let _ = app.update(Message::Escape); // same S1 session, different mount } _ => app.hosted.visible = true, } if change == 1 { - focus_source(&app, &mut tree, &renderer); + focus_source(&app, &mut mounted); } else if change == 2 { - focus_review(&app, &mut tree, &renderer); + focus_review(&app, &mut mounted); } else { - focus_other(&app, &mut tree, &renderer); + focus_other(&app, &mut mounted); } - let before = focused(&app, &mut tree, &renderer); + let before = parent_fields(&app, &mut mounted); let source = app.document.bundle().clone(); let raw = app.behavior.recovery_snapshot().unwrap(); let undo = app.document.can_undo(); let redo = app.document.can_redo(); - mounted_operation(&app, &mut tree, &renderer, operation.as_mut()); + mounted.operate(&app, operation.as_mut()); assert!( matches!(operation.finish(), widget::operation::Outcome::None), "change {change}" ); - assert_eq!( - focused(&app, &mut tree, &renderer), - before, - "change {change}" - ); + assert_eq!(parent_fields(&app, &mut mounted), before, "change {change}"); assert_eq!(app.document.bundle(), &source); assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); assert_eq!( @@ -793,23 +933,22 @@ async fn scenario_form_focus_saved_action_is_inert_on_replacement_child_review_a #[tokio::test] async fn scenario_form_focus_chained_apply_rechecks_live_review_owner() { - let renderer = focus_renderer().await; let (mut app, _, review_source) = focus_fixture(); - let mut tree = widget::Tree::new(app.view().as_widget()); - let task = shown_task(&mut app, &mut tree, &renderer); + let mut mounted = Mounted::unshown().await; + let task = shown_task(&mut app, &mut mounted).await; let mut collect = focus_action(task).await; - mounted_operation(&app, &mut tree, &renderer, collect.as_mut()); + mounted.operate(&app, collect.as_mut()); let widget::operation::Outcome::Chain(mut apply) = collect.finish() else { panic!("current parent produces the guarded apply traversal"); }; open_review(&mut app, review_source); - focus_review(&app, &mut tree, &renderer); - let before = focused(&app, &mut tree, &renderer); + focus_review(&app, &mut mounted); + let before = parent_fields(&app, &mut mounted); let raw = app.behavior.recovery_snapshot().unwrap(); let source = app.document.bundle().clone(); - mounted_operation(&app, &mut tree, &renderer, apply.as_mut()); + mounted.operate(&app, apply.as_mut()); assert!(matches!(apply.finish(), widget::operation::Outcome::None)); - assert_eq!(focused(&app, &mut tree, &renderer), before); + assert_eq!(parent_fields(&app, &mut mounted), before); assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); assert_eq!(app.document.bundle(), &source); assert!(!app.document.can_undo() && !app.document.can_redo()); @@ -853,457 +992,6 @@ async fn scenario_form_focus_missing_exact_target_leaves_other_inputs_untouched( assert_eq!(fields.0, vec![(Some(widget::Id::new("unrelated")), true)]); } -fn geometry(view: &mut TestView, 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) -} -fn scroll( - view: &mut TestView, - tree: &mut widget::Tree, - node: &layout::Node, - name: &'static str, - offset: Vector, -) { - let mut operation = widget::operation::scrollable::scroll_to::<()>( - widget::Id::new(name), - widget::operation::scrollable::AbsoluteOffset { - x: Some(offset.x), - y: Some(offset.y), - }, - ); - view.as_widget_mut() - .operate(tree, Layout::new(node), &(), &mut operation); -} -fn redraw(view: &mut TestView, tree: &mut widget::Tree, size: Size, app: &mut App) { - let (node, _) = geometry(view, tree, size); - let mut messages = vec![]; - view.as_widget_mut().update( - tree, - &iced::Event::Window(iced::window::Event::RedrawRequested( - std::time::Instant::now(), - )), - Layout::new(&node), - mouse::Cursor::Unavailable, - &(), - &mut iced::advanced::clipboard::Null, - &mut Shell::new(&mut messages), - &Rectangle::with_size(size), - ); - for message in messages { - let _ = app.update(message); - } -} - -#[test] -fn scenario_workspace_allocates_remaining_height_and_pins_actions_at_minimum_and_wide_sizes() { - for size in [ - Size::new(500., 570.), - Size::new(1050., 570.), - Size::new(840., 770.), - Size::new(1394., 802.), - ] { - for pending in [false, true] { - for shelf in [None, Some(Shelf::Tools), Some(Shelf::Selection)] { - for header in [68., 100., 132.] { - let mut state = State::default(); - state.presentation.shelf = shelf; - let mut view = fixture_view( - &state, - super::super::direct_tests::context(Id::new_v4()), - pending, - header, - ); - let mut tree = widget::Tree::new(view.as_widget()); - let (node, actual) = geometry(&mut view, &mut tree, size); - assert_eq!(node.size(), size); - let canvas = actual.scrolls["scenario-canvas"].0; - assert!(canvas.x >= 0. && canvas.y >= header); - assert!( - canvas.x + canvas.width <= size.width - && canvas.y + canvas.height <= size.height - ); - assert!( - canvas.height >= if pending { 145. } else { 290. }, - "{size:?} header={header} pending={pending} shelf={shelf:?}: {canvas:?}" - ); - if pending { - let form = actual.scrolls["scenario-form"].0; - let actions = actual.actions.unwrap(); - assert!(form.height >= 100. && form.y + form.height <= size.height); - assert!(actions.y >= canvas.y + canvas.height); - assert!(actions.y + actions.height <= form.y); - scroll( - &mut view, - &mut tree, - &node, - "scenario-form", - Vector::new(0., 600.), - ); - let (_, after) = geometry(&mut view, &mut tree, size); - assert_eq!(after.actions, Some(actions)); - assert_eq!(after.scrolls["scenario-form"].1.y, 600.); - assert_eq!(after.scrolls["scenario-canvas"].1, Vector::ZERO); - } - } - } - } - } -} - -#[test] -fn scenario_workspace_canvas_and_form_scroll_survive_disclosures_focus_width_and_resize() { - let context = super::super::direct_tests::context(Id::new_v4()); - let mut state = State::default(); - let mut view = fixture_view(&state, context, false, 68.); - let mut tree = widget::Tree::new(view.as_widget()); - let (node, _) = geometry(&mut view, &mut tree, Size::new(500., 570.)); - let offset = Vector::new(90., 180.); - scroll(&mut view, &mut tree, &node, "scenario-canvas", offset); - let mut prior_form = false; - for (pending, shelf, header, size) in [ - (false, Some(Shelf::Tools), 100., Size::new(500., 570.)), - (true, Some(Shelf::Selection), 68., Size::new(1050., 570.)), - (true, None, 68., Size::new(1394., 802.)), - (true, Some(Shelf::Tools), 100., Size::new(500., 570.)), - (false, None, 100., Size::new(500., 570.)), - ] { - state.presentation.shelf = shelf; - view = fixture_view(&state, context, pending, header); - tree.diff(view.as_widget()); - let (node, actual) = geometry(&mut view, &mut tree, size); - assert_eq!(actual.scrolls["scenario-canvas"].1, offset); - if pending { - if prior_form { - assert_eq!(actual.scrolls["scenario-form"].1.y, 600.); - } - scroll( - &mut view, - &mut tree, - &node, - "scenario-form", - Vector::new(0., 600.), - ); - let (_, after) = geometry(&mut view, &mut tree, size); - assert_eq!(after.actions, actual.actions); - assert_eq!(after.scrolls["scenario-canvas"].1, offset); - } - prior_form = pending; - } -} - -#[test] -fn scenario_workspace_caller_restores_actual_mounted_canvas_and_shelf_with_camera() { - use super::super::direct_tests::{navigation_event, navigation_fixture}; - let (p, [a, b, _], [reference, _, _]) = navigation_fixture(); - let mut app = App::with_project(p); - let _ = app.update(Message::Select(a)); - let _ = app.update(Message::Page(crate::app::Page::Behavior)); - navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection)); - navigation_event(&mut app, CanvasEvent::Focus); - navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75))); - let source = app.document.snapshot(); - let size = Size::new(1050., 570.); - let mut view = fixture_view( - &app.behavior.scenario, - app.scenario_context().unwrap(), - false, - 68., - ); - let mut tree = widget::Tree::new(view.as_widget()); - redraw(&mut view, &mut tree, size, &mut app); - let (node, _) = geometry(&mut view, &mut tree, size); - let canvas_offset = Vector::new(90., 180.); - scroll( - &mut view, - &mut tree, - &node, - "scenario-canvas", - canvas_offset, - ); - scroll( - &mut view, - &mut tree, - &node, - "scenario-details", - Vector::new(0., 80.), - ); - redraw(&mut view, &mut tree, size, &mut app); - assert_eq!( - app.behavior.scenario.scroll_offset(), - canvas_offset, - "shelf is not a canvas ancestor" - ); - navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference)); - assert_eq!(app.selected, b); - view = fixture_view( - &app.behavior.scenario, - app.scenario_context().unwrap(), - false, - 68., - ); - tree.diff(view.as_widget()); - redraw(&mut view, &mut tree, size, &mut app); - let (_, opened) = geometry(&mut view, &mut tree, size); - assert_eq!(opened.scrolls["scenario-canvas"].1, Vector::ZERO); - navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(2.))); - navigation_event(&mut app, CanvasEvent::Focus); - navigation_event(&mut app, CanvasEvent::Back); - assert_eq!(app.selected, reference); - assert!(app.behavior.scenario.focused()); - assert_eq!( - app.behavior.scenario.presentation.shelf, - Some(Shelf::Selection) - ); - assert_eq!( - app.behavior - .scenario - .presentation - .scale(Size::new(800., 1200.)), - 0.75 - ); - view = fixture_view( - &app.behavior.scenario, - app.scenario_context().unwrap(), - false, - 68., - ); - tree.diff(view.as_widget()); - redraw(&mut view, &mut tree, size, &mut app); - let (node, returned) = geometry(&mut view, &mut tree, size); - assert_eq!(returned.scrolls["scenario-canvas"].1, canvas_offset); - assert_eq!(returned.scrolls["scenario-details"].1, Vector::new(0., 80.)); - for area in [ScrollArea::Canvas, ScrollArea::Page] { - assert!( - app.behavior - .scenario - .scroll_restore(app.scenario_context().unwrap(), area) - .is_none() - ); - } - scroll( - &mut view, - &mut tree, - &node, - "scenario-canvas", - Vector::new(90., 220.), - ); - redraw(&mut view, &mut tree, size, &mut app); - assert_eq!( - geometry(&mut view, &mut tree, size).1.scrolls["scenario-canvas"] - .1 - .y, - 220., - "receipt cannot snap a later gesture back" - ); - assert!(std::sync::Arc::ptr_eq(&source, &app.document.snapshot())); - assert!(!app.document.can_undo()); -} - -#[test] -fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inputs() { - let graph = Size::new(900., 1400.); - let mut presentation = Presentation::default(); - assert_eq!(presentation.scale(graph), 1.); - presentation.zoom(None); - assert_eq!( - presentation.scale(graph), - 1., - "no invented window-height fallback" - ); - assert!(presentation.viewport(Size::new(484., 204.))); - assert!(!presentation.viewport(Size::new(484., 204.))); - let scale = presentation.scale(graph); - assert!(graph.width * scale <= 468. && graph.height * scale <= 188.); - assert!(!presentation.fit_limited(graph)); - for bad in [f32::NAN, f32::INFINITY, 0., -1.] { - assert!(!presentation.zoom(Some(bad))); - assert!(!presentation.viewport(Size::new(bad, 100.))); - assert!(!presentation.viewport(Size::new(100., bad))); - assert_eq!(presentation.scale(graph), scale); - } - assert!(presentation.fit_limited(Size::new(900., 10000.))); - assert!(presentation.zoom(Some(1.25))); - presentation.viewport(Size::new(200., 100.)); - assert_eq!( - presentation.scale(graph), - 1.25, - "manual zoom survives form/viewport resize" - ); -} - -#[test] -fn scenario_workspace_same_size_owner_and_document_changes_remeasure_fit_viewport() { - use super::super::direct_tests::{navigation_event, navigation_fixture}; - let (project, [a, b, _], [reference, _, _]) = navigation_fixture(); - let mut app = App::with_project(project.clone()); - let _ = app.update(Message::Select(a)); - let _ = app.update(Message::Page(crate::app::Page::Behavior)); - let size = Size::new(500., 570.); - let graph = Size::new(1800., 2400.); - let mut view = fixture_view( - &app.behavior.scenario, - app.scenario_context().unwrap(), - false, - 68., - ); - let mut tree = widget::Tree::new(view.as_widget()); - redraw(&mut view, &mut tree, size, &mut app); - let first_viewport = app.behavior.scenario.presentation.viewport.unwrap(); - navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None)); - assert!(app.behavior.scenario.presentation.scale(graph) < 1.); - - navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference)); - assert_eq!(app.scenario_context().unwrap().scenario, b); - assert!(app.behavior.scenario.presentation.viewport.is_none()); - view = fixture_view( - &app.behavior.scenario, - app.scenario_context().unwrap(), - false, - 68., - ); - tree.diff(view.as_widget()); - redraw(&mut view, &mut tree, size, &mut app); - assert_eq!( - app.behavior.scenario.presentation.viewport, - Some(first_viewport) - ); - navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None)); - let fitted = app.behavior.scenario.presentation.scale(graph); - assert!(graph.width * fitted <= first_viewport.width - 16.); - assert!(graph.height * fitted <= first_viewport.height - 16.); - - let prior_document = app.scenario_context().unwrap().document; - app = App::with_project(project); - let _ = app.update(Message::Select(b)); - let _ = app.update(Message::Page(crate::app::Page::Behavior)); - let context = app.scenario_context().unwrap(); - assert_ne!(context.document, prior_document); - assert_eq!( - context.scenario, b, - "semantic UUID is retained across documents" - ); - assert!(app.behavior.scenario.presentation.viewport.is_none()); - let accepted = app.document.snapshot(); - view = fixture_view(&app.behavior.scenario, context, false, 68.); - tree.diff(view.as_widget()); - redraw(&mut view, &mut tree, size, &mut app); - assert_eq!( - app.behavior.scenario.presentation.viewport, - Some(first_viewport) - ); - navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None)); - assert_eq!(app.behavior.scenario.presentation.scale(graph), fitted); - assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); - assert!(!app.document.can_undo()); -} - -#[test] -fn scenario_workspace_open_and_back_restore_owner_and_mounted_scroll_after_preselection() { - use super::super::direct_tests::{navigation_event, navigation_fixture}; - let (p, [a, b, _], [reference, _, _]) = navigation_fixture(); - let mut app = App::with_project(p); - let _ = app.update(Message::Select(a)); - let _ = app.update(Message::Page(crate::app::Page::Behavior)); - navigation_event(&mut app, |ctx| { - CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer)) - }); - navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection)); - navigation_event(&mut app, CanvasEvent::Focus); - navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75))); - let size = Size::new(1050., 570.); - let mut view = fixture_view( - &app.behavior.scenario, - app.scenario_context().unwrap(), - false, - 68., - ); - let mut tree = widget::Tree::new(view.as_widget()); - redraw(&mut view, &mut tree, size, &mut app); - let (node, _) = geometry(&mut view, &mut tree, size); - let offset = Vector::new(90., 180.); - scroll(&mut view, &mut tree, &node, "scenario-canvas", offset); - scroll( - &mut view, - &mut tree, - &node, - "scenario-details", - Vector::new(0., 80.), - ); - redraw(&mut view, &mut tree, size, &mut app); - - let _ = app.update(Message::Command(Command::Rename( - b, - "Renamed definition".into(), - ))); - assert_eq!(app.selected, b); - assert_eq!(app.scenario_context().unwrap().scenario, a); - let before_open = app.document.snapshot(); - navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference)); - assert_eq!(app.selected, b); - assert_eq!(app.scenario_context().unwrap().scenario, b); - assert!(std::sync::Arc::ptr_eq( - &before_open, - &app.document.snapshot() - )); - view = fixture_view( - &app.behavior.scenario, - app.scenario_context().unwrap(), - false, - 68., - ); - tree.diff(view.as_widget()); - redraw(&mut view, &mut tree, size, &mut app); - let (_, opened) = geometry(&mut view, &mut tree, size); - assert_eq!(opened.scrolls["scenario-canvas"].1, Vector::ZERO); - - let _ = app.update(Message::Command(Command::Rename( - reference, - "Renamed caller use".into(), - ))); - assert_eq!(app.selected, reference); - assert_eq!(app.scenario_context().unwrap().scenario, b); - let before_back = app.document.snapshot(); - let revision = app.document.revision(); - navigation_event(&mut app, CanvasEvent::Back); - assert_eq!(app.selected, reference); - assert_eq!(app.scenario_context().unwrap().scenario, a); - assert_eq!( - app.behavior.scenario.canvas_tool(a), - CanvasTool::Message(MessageStyle::Timer) - ); - assert!(app.behavior.scenario.focused()); - assert_eq!( - app.behavior.scenario.presentation.shelf, - Some(Shelf::Selection) - ); - assert_eq!(app.behavior.scenario.presentation.zoom, Some(0.75)); - view = fixture_view( - &app.behavior.scenario, - app.scenario_context().unwrap(), - false, - 68., - ); - tree.diff(view.as_widget()); - redraw(&mut view, &mut tree, size, &mut app); - let (_, restored) = geometry(&mut view, &mut tree, size); - assert_eq!(restored.scrolls["scenario-canvas"].1, offset); - assert_eq!(restored.scrolls["scenario-details"].1, Vector::new(0., 80.)); - assert_eq!(app.behavior.scenario.scroll_offset(), offset); - assert!(app.behavior.scenario.restore.is_none()); - assert!(std::sync::Arc::ptr_eq( - &before_back, - &app.document.snapshot() - )); - assert_eq!(app.document.revision(), revision); - assert!(app.document.can_undo()); - assert!(!app.document.can_redo()); -} - #[test] fn scenario_workspace_viewport_events_are_passive_guarded_and_do_not_apply_raw_forms() { use super::super::direct_tests::{fixture, navigation_event}; @@ -1455,63 +1143,6 @@ fn scenario_workspace_choosing_tools_preserves_valid_and_invalid_pending_editors } } -#[test] -fn scenario_workspace_whole_app_canvas_tree_survives_pending_shelves_and_focus() { - use iced::advanced::widget::tree; - use std::rc::Rc; - fn path_to(tree: &widget::Tree, tag: tree::Tag) -> Option> { - if tree.tag == tag { - return Some(vec![]); - } - tree.children.iter().enumerate().find_map(|(i, child)| { - let mut path = path_to(child, tag)?; - path.insert(0, i); - Some(path) - }) - } - fn at<'a>(mut tree: &'a mut widget::Tree, path: &[usize]) -> &'a mut widget::Tree { - for &index in path { - tree = &mut tree.children[index]; - } - tree - } - let f = super::super::direct_tests::fixture(); - let mut app = App::with_project(f.project); - let _ = app.update(Message::Select(f.call)); - let _ = app.update(Message::Page(crate::app::Page::Behavior)); - let canvas: Element<'_, Message> = - canvas::Canvas::new(Scene::sequence(app.project(), f.owner, f.call)).into(); - let tag = widget::Tree::new(canvas.as_widget()).tag; - drop(canvas); - let mut tree = widget::Tree::new(crate::views::view(&app).as_widget()); - let mut path = path_to(&tree, tag).unwrap(); - path.pop(); - let marker = Rc::new(()); - at(&mut tree, &path).state = tree::State::new(Rc::clone(&marker)); - let _ = app.update(event(Event::Start(f.owner, Tool::Message))); - let _ = app.update(event(Event::Name(" untouched 雨 ".into()))); - let raw = app.behavior.recovery_snapshot().unwrap(); - let accepted = app.document.snapshot(); - for shelf in [Shelf::Tools, Shelf::Selection, Shelf::Selection] { - super::super::direct_tests::navigation_event(&mut app, |ctx| { - CanvasEvent::Shelf(ctx, shelf) - }); - super::super::direct_tests::navigation_event(&mut app, CanvasEvent::Focus); - tree.diff(crate::views::view(&app).as_widget()); - let mut current = path_to(&tree, tag).unwrap(); - current.pop(); - assert_eq!(current, path); - assert_eq!(Rc::strong_count(&marker), 2); - assert!(Rc::ptr_eq( - at(&mut tree, &path).state.downcast_ref::>(), - &marker - )); - assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); - assert_eq!(app.selected, f.call); - assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); - } -} - #[test] fn scenario_workspace_scaled_and_scrolled_scene_keeps_clicked_slots_and_message_endpoints() { let f = super::super::direct_tests::fixture(); @@ -1680,3 +1311,645 @@ fn scenario_workspace_scaled_nested_occurrences_and_reorder_keep_exact_sequence_ ); } } + +#[tokio::test] +async fn production_scenario_camera_external_controls_bounds_and_retained_deltas_cancel_old_drags() +{ + use crate::behavior::canvas_workspace::tests::*; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + for boundary in ["Fit", "A", "Details", "Panels", "Bounds"] { + let toolbar = capture_toolbar(&mut mounted, &app, "Fit"); + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let point = bounds.position() + iced::Vector::new(95., 90.); + let lifetime = app.camera_epoch; + let pan = app.behavior.scenario.presentation.pan; + let first = point + iced::Vector::new(13., 9.); + let last = first + iced::Vector::new(17., -3.); + let emitted = mounted + .batch( + &mut app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)), + iced::Event::Mouse(mouse::Event::CursorMoved { position: first }), + iced::Event::Mouse(mouse::Event::CursorMoved { position: last }), + ], + mouse::Cursor::Available(last), + ) + .await; + assert_eq!(app.behavior.scenario.presentation.pan, pan + (last - point)); + assert_eq!( + app.camera_epoch, lifetime, + "own ordered deltas retain their lifetime" + ); + let retained = camera_delta(&emitted); + if boundary == "Bounds" { + let size = Size::new(app.window_size.width + 90., app.window_size.height + 35.); + let task = app.update(Message::WindowResized(size)); + mounted.perform(&mut app, task).await; + } else { + let id = scope(&app).control_id(boundary); + let at = mounted.geometry(&app).containers[&id].center(); + let clicked = mounted.click(&mut app, at).await; + assert!( + focused(&mut mounted, &app, boundary), + "same-batch pointer activation focuses the resulting live control without F6" + ); + if boundary == "Fit" { + mounted + .batch( + &mut app, + &[key(iced::keyboard::key::Named::Tab)], + mouse::Cursor::Unavailable, + ) + .await; + assert!( + focused(&mut mounted, &app, "ZoomOut"), + "Tab after the real press/release batch continues from Fit" + ); + assert!(clicked.iter().any(|m| matches!( + intent_action(m), + Message::ScenarioCanvas(CanvasEvent::Zoom(_, None)) + ))); + assert_eq!( + app.behavior.scenario.presentation.pan, + iced::Vector::ZERO, + "Fit emitted after this same batch's old pan must still execute" + ); + } + } + mounted.settle(&mut app).await; + assert_ne!( + app.camera_epoch, lifetime, + "{boundary} ends the old input lifetime" + ); + let camera = ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom, + ); + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let released = release_middle( + &mut mounted, + &mut app, + bounds.position() + iced::Vector::new(130., 145.), + ) + .await; + assert!(!released.iter().any(|m| matches!(m, Message::ScenarioCanvas(CanvasEvent::Camera(_, _, crate::behavior::canvas_workspace::Event::Pan(delta))) if *delta != iced::Vector::ZERO)), + "{boundary} must cancel the retained widget drag"); + let task = app.update(retained); + mounted.perform(&mut app, task).await; + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera, + "{boundary} must reject its pre-boundary App delta" + ); + let current_lifetime = app.camera_epoch; + let task = app.update(toolbar); + mounted.perform(&mut app, task).await; + assert_eq!( + app.camera_epoch, current_lifetime, + "{boundary} rejects the retained toolbar lifetime" + ); + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera, + "{boundary} retained Fit cannot alter the camera" + ); + } + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_scenario_keyboard_enters_activates_draws_focus_and_returns_to_inspector() { + use crate::behavior::canvas_workspace::tests::*; + use iced::keyboard::key::Named; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + assert!(!focused(&mut mounted, &app, "viewport")); + let operations = mounted.operations; + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!( + mounted.operations > operations, + "entry executes the returned App widget Task" + ); + assert!(focused(&mut mounted, &app, "viewport")); + let mut backwards = key(Named::Tab); + if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { modifiers, .. }) = + &mut backwards + { + *modifiers = iced::keyboard::Modifiers::SHIFT; + } + mounted + .batch(&mut app, &[backwards], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "ActualSize")); + mounted + .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "viewport")); + assert_eq!( + mounted + .geometry(&app) + .focused + .iter() + .filter(|(_, _, focused)| *focused) + .count(), + 1 + ); + tab_to(&mut mounted, &mut app, "A").await; + let chosen = mounted + .batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable) + .await; + assert!(chosen.iter().any(|m| matches!( + intent_action(m), + Message::ScenarioCanvas(CanvasEvent::Choose(..)) + ))); + assert!( + focused(&mut mounted, &app, "A"), + "tool activation retains its keyboard target" + ); + // The selected palette tool must still have a visibly distinct focus ring. + let tool_on = mounted.pixels(&app); + let geometry = mounted.geometry(&app); + let mut tool_bounds = geometry.containers[&scope(&app).control_id("A")]; + let offset = geometry.scrolls[&scope(&app).control_id("palette-scroll")].1; + tool_bounds.x -= offset.x; + tool_bounds.y -= offset.y; + mounted + .batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + let tool_off = mounted.pixels(&app); + assert_drawn_focus(&tool_off, &tool_on, app.window_size, tool_bounds); + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + // Focus alone changes the visible border of the real production Fit control. + let before = mounted.pixels(&app); + tab_to(&mut mounted, &mut app, "Fit").await; + let id = scope(&app).control_id("Fit"); + let bounds = mounted.geometry(&app).containers[&id]; + let after = mounted.pixels(&app); + assert_drawn_focus(&before, &after, app.window_size, bounds); + let fitted = mounted + .batch(&mut app, &[key(Named::Space)], mouse::Cursor::Unavailable) + .await; + assert!(fitted.iter().any(|m| matches!( + intent_action(m), + Message::ScenarioCanvas(CanvasEvent::Zoom(_, None)) + ))); + assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO); + assert!(focused(&mut mounted, &app, "Fit")); + tab_to(&mut mounted, &mut app, "Canvas").await; + mounted + .batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "viewport")); + mounted + .batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + assert!(!focused(&mut mounted, &app, "viewport")); + mounted + .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) + .await; + assert!( + mounted + .geometry(&app) + .focused + .iter() + .any(|(_, _, focused)| *focused), + "ordinary Tab resumes the production Inspector's own entry policy" + ); + assert!(!focused(&mut mounted, &app, "viewport")); + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "viewport")); + assert_eq!( + mounted + .geometry(&app) + .focused + .iter() + .filter(|(_, _, focused)| *focused) + .count(), + 1, + "explicit canvas entry releases Inspector keyboard ownership" + ); + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!(!focused(&mut mounted, &app, "viewport")); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + // Typing after exit belongs to Inspector. Replaying the prior clean tool + // action must retain those raw fields, never implicitly Apply them. + mounted + .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) + .await; + mounted + .batch(&mut app, &[character("x")], mouse::Cursor::Unavailable) + .await; + assert!(app.draft_dirty()); + let dirty_raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + assert_ne!(dirty_raw, raw); + let camera = ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom, + ); + for message in chosen { + let task = app.update(message); + mounted.perform(&mut app, task).await; + } + let emitted = mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!( + !emitted + .iter() + .any(|m| matches!(m, Message::ScenarioCanvas(CanvasEvent::Camera(..)))) + ); + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera + ); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + dirty_raw + ); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_scenario_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes() { + use crate::behavior::canvas_workspace::tests::*; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1440., 900.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + retained_toolbar_boundaries(&mut mounted, &mut app, "A").await; + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_scenario_keyboard_reveals_and_activates_initially_offscreen_last_tool() { + use crate::behavior::canvas_workspace::tests::*; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 420.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + overflow_last_tool(&mut mounted, &mut app, "alt").await; + assert_eq!( + app.behavior.scenario.canvas_tool(f.owner), + CanvasTool::Add(Tool::Fragment) + ); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +// Successor to the former 2000px synthetic form / scrollable-canvas fixture. +// The real editor clamps the 700px request; that exact measured offset must +// survive same-session edits and reset to zero on replacement without a closed render. +#[tokio::test] +async fn new_scenario_form_resets_only_its_scroll_and_recovery_gets_a_fresh_session() { + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 420.); + let mut mounted = Mounted::new(&mut app).await; + let canvas = mounted.geometry(&app).bounds("scenario-viewport"); + let start = canvas.position() + Vector::new(90., 80.); + mounted + .pan(&mut app, start, start + Vector::new(-137., -850.)) + .await; + let camera = ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom, + ); + assert_eq!(camera.0, Vector::new(-137., -850.)); + let task = app.update(event(Event::Start(f.owner, Tool::Action))); + mounted.perform(&mut app, task).await; + mounted.settle(&mut app).await; + let first = app.behavior.scenario.form_session().unwrap(); + let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation; + mounted.scroll(&app, "scenario-form", 700.); + let offset = mounted.geometry(&app).scrolls[&widget::Id::new("scenario-form")].1; + assert!( + offset.y > 64., + "real Action editor must actually overflow: {offset:?}" + ); + let accepted = app.document.snapshot(); + let source = app.document.bundle().clone(); + let task = app.update(event(Event::Name(" retained raw 雨 ".into()))); + mounted.perform(&mut app, task).await; + let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); + assert_eq!( + mounted.geometry(&app).scrolls[&widget::Id::new("scenario-form")].1, + offset + ); + assert_eq!(app.behavior.scenario.form_session(), Some(first)); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().invocation, + invocation + ); + // No render or widget operation between Cancel and Start. + let cancel = app.update(event(Event::Cancel)); + let start = app.update(event(Event::Start(f.owner, Tool::Observation))); + mounted + .perform(&mut app, iced::Task::batch([cancel, start])) + .await; + assert_ne!(app.behavior.scenario.form_session(), Some(first)); + let replaced = mounted.geometry(&app); + assert_eq!( + replaced.scrolls[&widget::Id::new("scenario-form")].1, + Vector::ZERO + ); + assert_eq!(replaced.bounds("scenario-viewport"), canvas); + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera + ); + let recovered = State::from_recovery(raw.clone()).unwrap(); + assert_ne!(recovered.form_session(), Some(first)); + assert_eq!( + recovered.draft.as_ref().unwrap().name, + " retained raw 雨 " + ); + assert_eq!(recovered.draft.as_ref().unwrap().invocation, invocation); + let recovered_raw = recovered.recovery_snapshot().unwrap().unwrap(); + for field in [ + "name", + "owner", + "parent", + "before", + "target", + "source_origin", + "invocation", + ] { + assert_eq!(recovered_raw[field], raw[field], "recovered {field}"); + } + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inputs() { + let graph = Size::new(900., 1400.); + let mut presentation = Presentation::default(); + assert_eq!(presentation.scale(graph), 1.); + presentation.zoom(None); + assert_eq!( + presentation.scale(graph), + 1., + "no invented window-height fallback" + ); + assert!(presentation.viewport(Size::new(484., 204.))); + assert!(!presentation.viewport(Size::new(484., 204.))); + let scale = presentation.scale(graph); + assert!(graph.width * scale <= 468. && graph.height * scale <= 188.); + assert!(scale > MIN_SCALE); + for bad in [f32::NAN, f32::INFINITY, 0., -1.] { + assert!(!presentation.zoom(Some(bad))); + assert!(!presentation.viewport(Size::new(bad, 100.))); + assert!(!presentation.viewport(Size::new(100., bad))); + assert_eq!(presentation.scale(graph), scale); + } + assert_eq!(presentation.scale(Size::new(900., 10000.)), MIN_SCALE); + assert!(presentation.zoom(Some(1.25))); + presentation.viewport(Size::new(200., 100.)); + assert_eq!( + presentation.scale(graph), + 1.25, + "manual zoom survives form/viewport resize" + ); +} + +#[tokio::test] +async fn scenario_workspace_same_size_owner_and_document_changes_remeasure_fit_viewport() { + use super::super::direct_tests::{navigation_event, navigation_fixture}; + let (project, [a, b, _], [reference, _, _]) = navigation_fixture(); + let mut app = App::with_project(project.clone()); + let _ = app.update(Message::Select(a)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1440., 900.); + let mut mounted = Mounted::new(&mut app).await; + let first = mounted.geometry(&app).bounds("scenario-viewport").size(); + assert_eq!(app.behavior.scenario.presentation.viewport, Some(first)); + navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference)); + assert_eq!(app.scenario_context().unwrap().scenario, b); + assert!(app.behavior.scenario.presentation.viewport.is_none()); + mounted.settle(&mut app).await; + assert_eq!(app.behavior.scenario.presentation.viewport, Some(first)); + let graph = Scene::sequence(app.project(), b, b).size; + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None)); + let fitted = app.behavior.scenario.presentation.scale(graph); + assert!( + graph.width * fitted <= first.width - 16. && graph.height * fitted <= first.height - 16. + ); + assert_eq!(app.behavior.scenario.presentation.pan, Vector::ZERO); + let prior_document = app.scenario_context().unwrap().document; + app = App::with_project(project); + let _ = app.update(Message::Select(b)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1440., 900.); + let context = app.scenario_context().unwrap(); + assert_ne!(context.document, prior_document); + assert_eq!( + context.scenario, b, + "semantic UUID is retained across documents" + ); + assert!(app.behavior.scenario.presentation.viewport.is_none()); + let accepted = app.document.snapshot(); + let source = app.document.bundle().clone(); + mounted.settle(&mut app).await; + assert_eq!(app.behavior.scenario.presentation.viewport, Some(first)); + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None)); + assert_eq!(app.behavior.scenario.presentation.scale(graph), fitted); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +// Real mounted counterpart of both former synthetic caller-scroll fixtures. +#[tokio::test] +async fn scenario_workspace_open_and_back_restore_owner_and_mounted_camera_after_preselection() { + use super::super::direct_tests::{navigation_event, navigation_fixture}; + let (mut p, [a, b, _], [reference, _, _]) = navigation_fixture(); + // Give the real Details overlay enough legitimate rows to test its offset. + for index in 0..30 { + let element = super::super::super::topology::occurrence( + &p, + a, + &format!("Additional use {index}"), + OccurrenceBody::ScenarioUse { + scenario: Some(b), + bindings: vec![], + }, + ); + p = execute_batch(&p, &[Command::CreateElement(element)]).unwrap(); + } + let mut app = App::with_project(p); + let _ = app.update(Message::Select(a)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + navigation_event(&mut app, |ctx| { + CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer)) + }); + navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection)); + navigation_event(&mut app, CanvasEvent::Focus); + let mut mounted = Mounted::new(&mut app).await; + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75))); + let canvas = mounted.geometry(&app).bounds("scenario-viewport"); + let point = canvas.position() + Vector::new(90., 120.); + mounted + .pan(&mut app, point, point + Vector::new(90., 180.)) + .await; + let camera = ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom, + ); + mounted.scroll(&app, "scenario-details", 80.); + mounted.settle(&mut app).await; + let details = mounted.geometry(&app).scrolls[&widget::Id::new("scenario-details")].1; + assert_eq!(details, Vector::new(0., 80.)); + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera, + "Details is not a camera ancestor" + ); + let _ = app.update(Message::Command(Command::Rename( + b, + "Renamed definition".into(), + ))); + assert_eq!(app.selected, b); + assert_eq!(app.scenario_context().unwrap().scenario, a); + let before_open = app.document.snapshot(); + let source_open = app.document.bundle().clone(); + navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference)); + assert_eq!(app.selected, b); + assert_eq!(app.scenario_context().unwrap().scenario, b); + assert!(std::sync::Arc::ptr_eq( + &before_open, + &app.document.snapshot() + )); + assert_eq!(app.document.bundle(), &source_open); + mounted.settle(&mut app).await; + assert_eq!(app.behavior.scenario.presentation.pan, Vector::ZERO); + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(2.))); + navigation_event(&mut app, CanvasEvent::Focus); + let _ = app.update(Message::Command(Command::Rename( + reference, + "Renamed caller use".into(), + ))); + assert_eq!(app.selected, reference); + assert_eq!(app.scenario_context().unwrap().scenario, b); + let before_back = app.document.snapshot(); + let source_back = app.document.bundle().clone(); + let revision = app.document.revision(); + let bindings = app.project().elements[&reference].clone(); + navigation_event(&mut app, CanvasEvent::Back); + assert_eq!(app.selected, reference); + assert_eq!(app.scenario_context().unwrap().scenario, a); + assert_eq!( + app.behavior.scenario.canvas_tool(a), + CanvasTool::Message(MessageStyle::Timer) + ); + assert!(app.behavior.scenario.focused()); + assert_eq!( + app.behavior.scenario.presentation.shelf, + Some(Shelf::Selection) + ); + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera + ); + assert_eq!(app.behavior.scenario.presentation.zoom, Some(0.75)); + mounted.settle(&mut app).await; + assert_eq!( + mounted.geometry(&app).scrolls[&widget::Id::new("scenario-details")].1, + details + ); + assert_eq!( + app.behavior.scenario.scroll_offset(), + Vector::ZERO, + "canvas uses camera pan, not a synthetic Scrollable offset" + ); + for area in [ScrollArea::Canvas, ScrollArea::Page] { + assert!( + app.behavior + .scenario + .scroll_restore(app.scenario_context().unwrap(), area) + .is_none() + ); + } + assert!(app.behavior.scenario.restore.is_none()); + assert!(std::sync::Arc::ptr_eq( + &before_back, + &app.document.snapshot() + )); + assert_eq!(app.document.bundle(), &source_back); + assert_eq!(app.project().elements[&reference], bindings); + assert_eq!(app.document.revision(), revision); + assert!(app.document.can_undo()); + assert!(!app.document.can_redo()); + mounted + .pan(&mut app, point, point + Vector::new(0., 40.)) + .await; + mounted.settle(&mut app).await; + assert_eq!( + app.behavior.scenario.presentation.pan, + camera.0 + Vector::new(0., 40.), + "a consumed restore receipt cannot snap a later gesture back" + ); +} diff --git a/crates/oa-desktop/src/behavior/topology.rs b/crates/oa-desktop/src/behavior/topology.rs index d23c40f2..1fcbce81 100644 --- a/crates/oa-desktop/src/behavior/topology.rs +++ b/crates/oa-desktop/src/behavior/topology.rs @@ -81,9 +81,10 @@ pub struct State { tool: CanvasTool, expanded: BTreeSet, scroll_offsets: [iced::Vector; 2], - return_to: Vec<(Id, Id, Option)>, + return_to: Vec, draft: Option, viewport: Option, + pan: iced::Vector, zoom: Option, fit: bool, pub(crate) focused: bool, @@ -125,6 +126,18 @@ struct Draft { contextual: Option, } impl State { + pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> { + ( + self.tool, + self.focused, + self.shelf, + self.viewport, + self.pan, + self.zoom, + self.fit, + ) + } + pub(super) fn pending_name(&self) -> Option<&str> { self.draft.as_ref().map(|d| d.name.as_str()) } @@ -348,10 +361,12 @@ impl State { ) } + #[cfg(test)] fn canvas_offset(&self) -> iced::Vector { self.scroll_offsets[0] } + #[cfg(test)] fn fit_limited(&self, graph: Size, fallback_width: f32) -> bool { let available = self.fit_area(fallback_width); self.fit && (graph.width * 0.05 > available.width || graph.height * 0.05 > available.height) @@ -970,6 +985,8 @@ struct Graph<'a> { project: &'a Project, interaction: Option<(Context, CanvasTool, bool)>, scroll_offset: iced::Vector, + camera_pan: iced::Vector, + pending_insertion: Option, nodes: Vec, edges: Vec, ordered_paths: Vec, @@ -1302,6 +1319,8 @@ impl<'a> Graph<'a> { project: p, interaction: None, scroll_offset: iced::Vector::ZERO, + camera_pan: iced::Vector::ZERO, + pending_insertion: None, nodes, edges, ordered_paths, @@ -1401,6 +1420,7 @@ impl canvas::Program for Graph<'_> { ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); + f.translate(self.camera_pan); f.scale(self.scale); for pass in PAINT_PASSES { match pass { @@ -1616,7 +1636,10 @@ impl canvas::Program for Graph<'_> { cursor: mouse::Cursor, ) -> mouse::Interaction { if cursor.position_in(bounds).is_some_and(|p| { - let p = Point::new(p.x / self.scale, p.y / self.scale); + let p = Point::new( + (p.x - self.camera_pan.x) / self.scale, + (p.y - self.camera_pan.y) / self.scale, + ); self.nodes.iter().any(|n| n.rect.contains(p)) || self.ordered_paths.iter().any(|path| { path.points.windows(2).any(|pair| { diff --git a/crates/oa-desktop/src/behavior/topology/contextual.rs b/crates/oa-desktop/src/behavior/topology/contextual.rs index d7396f31..7c178649 100644 --- a/crates/oa-desktop/src/behavior/topology/contextual.rs +++ b/crates/oa-desktop/src/behavior/topology/contextual.rs @@ -608,7 +608,11 @@ mod tests { "Authored endpoint is retained for explicit repair" ); assert!(state.prepare_pending(&deleted).is_err()); - state.remember(owner, reference); + state.remember( + owner, + reference, + super::super::direct::referenced(&p, owner, reference).unwrap(), + ); assert_eq!(state.back(&deleted), Some((owner, reference))); assert!(state.selected_context.is_none()); state.draft = None; diff --git a/crates/oa-desktop/src/behavior/topology/direct.rs b/crates/oa-desktop/src/behavior/topology/direct.rs index 88c6e3f7..f2cbf4f7 100644 --- a/crates/oa-desktop/src/behavior/topology/direct.rs +++ b/crates/oa-desktop/src/behavior/topology/direct.rs @@ -60,6 +60,7 @@ pub enum CanvasEvent { Open(Context, Id), Back(Context), Zoom(Context, Option), + Camera(Context, u64, super::super::canvas_workspace::Event), Focus(Context), Viewport(Context, Size), } @@ -77,6 +78,7 @@ impl CanvasEvent { | Self::Back(c) | Self::AppendExchange(c) | Self::Zoom(c, ..) + | Self::Camera(c, ..) | Self::Focus(c) | Self::Viewport(c, ..) => *c, Self::Begin { context, .. } @@ -86,6 +88,19 @@ impl CanvasEvent { } } } +pub(super) struct ReturnFrame { + identity: Option<(Id, Id)>, + owner: Id, + occurrence: Id, + target: Id, + address: Option, + pan: iced::Vector, + zoom: Option, + fit: bool, + viewport: Option, + expanded: BTreeSet, + contextual_expanded: BTreeSet, +} impl State { pub(crate) fn select_context(&mut self, address: oa_core::topology_context::ContextAddress) { self.selected_context = Some(address); @@ -138,6 +153,7 @@ impl State { if self.identity != Some((document, owner)) { self.identity = Some((document, owner)); self.tool = CanvasTool::Select; + self.pan = iced::Vector::ZERO; self.zoom = Some(1.); self.fit = false; self.viewport = None; @@ -162,11 +178,21 @@ impl State { // Choosing how to edit must retain the user's current view. self.tool = tool; } + pub(crate) fn camera(&mut self, event: super::super::canvas_workspace::Event) -> bool { + super::super::canvas_workspace::change(&mut self.pan, &mut self.zoom, &mut self.fit, event) + } pub(crate) fn zoom(&mut self, value: Option) { self.fit = value.is_none(); - self.zoom = value.map(|v| v.clamp(0.5, 2.)); + self.zoom = value.map(|v| v.clamp(0.05, 3.)); } pub(crate) fn viewport(&mut self, size: Size) -> bool { + if !size.width.is_finite() + || !size.height.is_finite() + || size.width <= 0. + || size.height <= 0. + { + return false; + } let changed = self.viewport != Some(size); self.viewport = Some(size); changed @@ -188,24 +214,73 @@ impl State { } true } - pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) { - self.return_to - .push((owner, occurrence, self.selected_context.clone())); + pub(crate) fn remember(&mut self, owner: Id, occurrence: Id, target: Id) { + if self.return_to.len() == 32 { + self.return_to.remove(0); + } + self.return_to.push(ReturnFrame { + identity: self.identity, + owner, + occurrence, + target, + address: self.selected_context.clone(), + pan: self.pan, + zoom: self.zoom, + fit: self.fit, + viewport: self.viewport, + expanded: self.expanded.clone(), + contextual_expanded: self.contextual_expanded.clone(), + }); } pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> { - while let Some((owner, occurrence, address)) = self.return_to.pop() { - if is_contribution(p, owner, occurrence) { - self.selected_context = address.filter(|address| { - address.owner == owner - && oa_core::topology_context::TopologyIndex::new(p) - .resolve(owner, &address.endpoint) - .is_ok() + while let Some(frame) = self.return_to.pop() { + if frame.identity.map(|i| i.0) != self.identity.map(|i| i.0) { + continue; + } + let valid = referenced(p, frame.owner, frame.occurrence) == Some(frame.target) + || if let Some(address) = &frame.address { + oa_core::topology_context::TopologyIndex::new(p) + .resolve(frame.owner, &address.endpoint) + .is_ok_and(|r| r.defining_sequence == frame.target) + } else { + false + }; + if valid && is_contribution(p, frame.owner, frame.occurrence) { + self.identity = frame.identity; + self.selected_context = frame.address.filter(|a| { + oa_core::topology_context::TopologyIndex::new(p) + .resolve(frame.owner, &a.endpoint) + .is_ok() }); - return Some((owner, occurrence)); + self.pan = frame.pan; + self.zoom = frame.zoom; + self.fit = frame.fit; + self.viewport = frame.viewport; + self.expanded = frame.expanded; + self.contextual_expanded = frame.contextual_expanded; + return Some((frame.owner, frame.occurrence)); } } None } + pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option) { + let graph = + Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded); + let old = self.scale(graph.size, 500.); + if let Some(next) = zoom { + let size = self.viewport.unwrap_or(Size::new(500., 300.)); + self.camera(super::super::canvas_workspace::Event::Zoom { + factor: next / old, + at: Point::new(size.width / 2., size.height / 2.), + base: old, + }); + } else { + self.zoom(None); + let fitted = self.scale(graph.size, 500.); + self.zoom(Some(fitted)); + self.pan = iced::Vector::ZERO; + } + } pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option { let owner = self.pending_owner()?; if self @@ -575,9 +650,12 @@ impl Graph<'_> { } _ => cursor, }; - let world = cursor - .position_in(bounds) - .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); + let world = cursor.position_in(bounds).map(|p| { + Point::new( + (p.x - self.camera_pan.x) / self.scale, + (p.y - self.camera_pan.y) / self.scale, + ) + }); let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture()); match event { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => { @@ -730,6 +808,22 @@ impl Graph<'_> { let Some((context, tool, editable)) = self.interaction else { return; }; + if let Some(insertion) = self.pending_insertion { + if let Some(edge) = insertion + .link + .and_then(|id| self.edges.iter().find(|e| e.id == id)) + { + draw_arrow(f, &edge.points, style::TEAL, 3.); + } else if let Some(node) = insertion + .before + .and_then(|id| self.nodes.iter().find(|n| n.id == id)) + { + f.stroke( + &Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()), + Stroke::default().with_color(style::TEAL).with_width(3.), + ); + } + } if !editable { return; } @@ -738,7 +832,12 @@ impl Graph<'_> { .map(|p| p + self.scroll_offset) .or_else(|| cursor.position()) .filter(|p| bounds.contains(*p)) - .map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale)); + .map(|p| { + Point::new( + (p.x - bounds.x - self.camera_pan.x) / self.scale, + (p.y - bounds.y - self.camera_pan.y) / self.scale, + ) + }); let live = state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds); if live && let Some(source) = state.source { @@ -853,12 +952,7 @@ impl Graph<'_> { } impl State { - pub(crate) fn selection_offset( - &self, - p: &Project, - owner: Id, - id: Id, - ) -> Option { + pub(crate) fn reveal_selection(&mut self, p: &Project, owner: Id, id: Id) -> bool { let graph = Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded); let scale = self.scale(graph.size, 500.); @@ -870,10 +964,25 @@ impl State { self.qualified_selection(p, owner) .map_or(n.rect, |a| n.endpoint_rect(&a.endpoint)) }) - .or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?; - Some(scrollable::AbsoluteOffset { - x: (rect.x * scale - 12.).max(0.), - y: (rect.y * scale - 12.).max(0.), - }) + .or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label)); + let Some(rect) = rect else { + return false; + }; + let viewport = self.viewport.unwrap_or(Size::new(500., 300.)); + let x = rect.x * scale + self.pan.x; + let y = rect.y * scale + self.pan.y; + let delta = iced::Vector::new( + if x < 12. || x + rect.width * scale > viewport.width - 12. { + 12. - x + } else { + 0. + }, + if y < 12. || y + rect.height * scale > viewport.height - 12. { + 12. - y + } else { + 0. + }, + ); + self.camera(super::super::canvas_workspace::Event::Pan(delta)) } } diff --git a/crates/oa-desktop/src/behavior/topology/direct_tests.rs b/crates/oa-desktop/src/behavior/topology/direct_tests.rs index 3b7918db..6217aabc 100644 --- a/crates/oa-desktop/src/behavior/topology/direct_tests.rs +++ b/crates/oa-desktop/src/behavior/topology/direct_tests.rs @@ -732,7 +732,7 @@ fn direct_process_readable_default_fit_expansion_and_bounded_choices() { state.zoom(None); assert!(state.scale(Size::new(3262., 220.), 500.) < 0.2); state.zoom(Some(0.1)); - assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.5); + assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.1); state.enter(Id::new_v4(), f.owner); assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.); let mut all = Vec::new(); @@ -1364,82 +1364,6 @@ fn direct_process_palette_preserves_fit_and_manual_camera() { } } -#[test] -fn direct_process_pending_form_diff_retains_the_canvas_scroll_state() { - use iced::advanced::widget::{Tree, tree}; - use std::rc::Rc; - fn path_to(tree: &Tree, tag: tree::Tag) -> Option> { - if tree.tag == tag { - return Some(Vec::new()); - } - tree.children.iter().enumerate().find_map(|(index, child)| { - let mut path = path_to(child, tag)?; - path.insert(0, index); - Some(path) - }) - } - fn at<'a>(mut tree: &'a mut Tree, path: &[usize]) -> &'a mut Tree { - for &index in path { - tree = &mut tree.children[index]; - } - tree - } - for layer in [Layer::Oa, Layer::Sa] { - for size in [Size::new(1440., 900.), Size::new(1100., 700.)] { - for tool in [Tool::Control, Tool::Branch, Tool::Connect] { - let f = fixture(layer); - let mut app = App::with_project(f.p.clone()); - let _ = app.update(Message::Select(f.nodes[1])); - let _ = app.update(Message::Page(crate::app::Page::Behavior)); - app.window_size = size; - let original = app.document.snapshot(); - let graph: Element<'_, Message> = - canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into(); - let canvas_tag = Tree::new(graph.as_widget()).tag; - drop(graph); - let scroll: Element<'_, Message> = scrollable(text("")).into(); - let scroll_tag = Tree::new(scroll.as_widget()).tag; - let mut tree = Tree::new(view::view(&app, f.owner, Column::new()).as_widget()); - let mut scroll_path = path_to(&tree, canvas_tag).unwrap(); - scroll_path.pop(); - assert_eq!(at(&mut tree, &scroll_path).tag, scroll_tag); - // Scrollable::diff treats its local state as opaque. A retained marker - // proves the real Iced diff preserves the state that owns the offsets. - // No layout or input operation runs on this test-only marker state. - let marker = Rc::new(()); - at(&mut tree, &scroll_path).state = tree::State::new(Rc::clone(&marker)); - let _ = app.update(Message::Behavior(super::super::Event::Topology( - Event::Start(f.owner, tool, Some(f.nodes[1])), - ))); - assert!(app.behavior.has_pending()); - tree.diff(view::view(&app, f.owner, Column::new()).as_widget()); - assert_eq!( - Rc::strong_count(&marker), - 2, - "form open: {layer:?} {size:?} {tool:?}" - ); - let mut current_path = path_to(&tree, canvas_tag).unwrap(); - current_path.pop(); - assert_eq!(current_path, scroll_path); - assert!(Rc::ptr_eq( - at(&mut tree, &scroll_path).state.downcast_ref::>(), - &marker, - )); - let _ = app.update(Message::Behavior(super::super::Event::Topology( - Event::Cancel, - ))); - tree.diff(view::view(&app, f.owner, Column::new()).as_widget()); - assert_eq!( - Rc::strong_count(&marker), - 2, - "form cancel: {layer:?} {size:?} {tool:?}" - ); - assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &original)); - } - } - } -} - #[test] fn direct_process_scaled_control_and_link_gestures_use_visible_positions() { for layer in [Layer::Oa, Layer::Sa] { diff --git a/crates/oa-desktop/src/behavior/topology/layout_tests.rs b/crates/oa-desktop/src/behavior/topology/layout_tests.rs index 276414a2..ad233c6a 100644 --- a/crates/oa-desktop/src/behavior/topology/layout_tests.rs +++ b/crates/oa-desktop/src/behavior/topology/layout_tests.rs @@ -1,381 +1,887 @@ 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); - } -} - -fn explicit_workspace( - pending: bool, - details: bool, - header_height: f32, -) -> iced::Element<'static, Message, iced::Theme, ()> { - let canvas = container( - scrollable(iced::widget::Space::new().width(1800.).height(2400.)) - .id("process-canvas") - .direction(scrollable::Direction::Both { - vertical: scrollable::Scrollbar::default(), - horizontal: scrollable::Scrollbar::default(), - }) - .width(Fill) - .height(Fill), - ) - .padding(8) - .width(Fill) - .height(Fill); - let graph = explicit_pane( - iced::widget::Space::new().height(header_height).into(), - iced::widget::Space::new().height(1800.).into(), - canvas.into(), - details, - pending, - ); - let form = if pending { - editor_panel( - container(iced::widget::Space::new().height(32)) - .id("test-process-actions") - .into(), - iced::widget::Space::new().height(2000).into(), - ) - } else { - iced::widget::Space::new().height(0).into() - }; - panes(graph, form, pending) -} - -#[test] -fn process_fit_layout_measures_remaining_canvas_with_details_forms_and_window_resize() { - // Minimum/default panes both with side panels and focused; tall content - // deliberately cannot influence the enclosing scrollable allocations. - for size in [ - Size::new(500., 570.), - Size::new(1050., 570.), - Size::new(840., 770.), - Size::new(1394., 802.), - ] { - for pending in [false, true] { - for details in [false, true] { - for header_height in [68., 96.] { - let mut view = explicit_workspace(pending, details, header_height); - let mut tree = widget::Tree::new(view.as_widget()); - let (node, actual) = geometry(&mut view, &mut tree, size); - assert_eq!(node.size(), size); - let canvas = actual +#[tokio::test] +async fn production_process_canvas_scaled_path_creation_preserves_source_and_editor_bounds() { + use crate::behavior::canvas_workspace::{self, tests::Mounted}; + use iced::Vector; + use iced::advanced::widget; + let initial = super::super::direct_tests::fixture(Layer::Oa); + let mut app = App::with_project(initial.p); + let _ = app.update(Message::Select(initial.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + let mut mounted = Mounted::new(&mut app).await; + for layer in [Layer::Oa, Layer::Sa] { + for size in [Size::new(1100., 700.), Size::new(1440., 900.)] { + let f = super::super::direct_tests::fixture(layer); + app = App::with_project(f.p); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = size; + mounted.settle(&mut app).await; + for scale in [0.5, 1., 2.] { + let context = app.topology_context().unwrap(); + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom( + context, + Some(scale), + ))); + let geometry = mounted.geometry(&app); + let bounds = geometry.bounds("process-viewport"); + assert!(bounds.height > 350.); + assert!( + !geometry .scrolls - .iter() - .find(|s| s.0 == Some(widget::Id::new("process-canvas"))) - .unwrap() - .1; - let pane_bottom = if pending { - size.height / 2. - } else { - size.height - }; - assert!(canvas.x >= 0. && canvas.y >= 0.); - assert!( - canvas.x + canvas.width <= size.width - && canvas.y + canvas.height <= pane_bottom, - "Canvas must be visible, not merely allocated outside a parent clip: {size:?} {canvas:?}" + .contains_key(&widget::Id::new("process-canvas")) + ); + let graph = Graph::new(app.project(), f.owner, f.owner); + let edge = graph.edges.iter().find(|edge| edge.id == f.link).unwrap(); + let segment = edge + .points + .windows(2) + .max_by(|a, b| a[0].distance(a[1]).total_cmp(&b[0].distance(b[1]))) + .unwrap(); + let world = Point::new( + (segment[0].x + segment[1].x) / 2., + (segment[0].y + segment[1].y) / 2., + ); + let desired = Vector::new(85., 130.); + let next_pan = desired - Vector::new(world.x, world.y) * scale; + let delta = next_pan - app.behavior.topology.pan; + let start = bounds.position() + Vector::new(130., 70.); + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + mounted.pan(&mut app, start, start + delta).await; + let diff = app.behavior.topology.pan - next_pan; + assert!(diff.x.hypot(diff.y) < 0.01); + let tool = mounted.geometry(&app).bounds("behavior-tool:OR").center(); + mounted.click(&mut app, tool).await; + let messages = mounted.click(&mut app, bounds.position() + desired).await; + assert!( + messages.iter().any( + |m| matches!(m, Message::TopologyCanvas(CanvasEvent::Begin { + tool: Tool::Control, control: ControlKind::Choice, insertion, .. + }) if insertion.link == Some(f.link) && insertion.before.is_none()) + ), + "{layer:?} {scale}: {messages:?}" + ); + let invocation = app.behavior.topology.draft.as_ref().unwrap().invocation; + let _ = app.update(event(Event::Name(" raw choice 雨 ".into()))); + let raw = app.behavior.recovery_snapshot().unwrap(); + mounted.settle(&mut app).await; + let shown = mounted.geometry(&app); + assert_eq!(shown.bounds("process-viewport"), bounds); + let actions = shown.bounds("process-actions"); + let form = shown.scrolls[&widget::Id::new("process-form")].0; + assert!(actions.y >= bounds.y && actions.y + actions.height <= form.y); + assert!(form.y + form.height <= bounds.y + bounds.height && form.height > 250.); + let pan = app.behavior.topology.pan; + mounted + .batch( + &mut app, + &[canvas_workspace::tests::key( + iced::keyboard::key::Named::ArrowRight, + )], + mouse::Cursor::Available(bounds.position() + desired), + ) + .await; + assert_eq!(app.behavior.topology.pan, pan); + mounted.scroll(&app, "process-form", 500.); + assert_eq!(mounted.geometry(&app).bounds("process-actions"), actions); + for shelf in [Shelf::Tools, Shelf::Selection, Shelf::Selection] { + let _ = app.update(event(Event::Shelf(shelf))); + assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); + assert_eq!( + app.behavior.topology.draft.as_ref().unwrap().invocation, + invocation ); - assert!(canvas.height >= if pending { 75. } else { 300. }); - let mut state = State::default(); - state.viewport(canvas.size()); - state.zoom(None); - let graph = Size::new(900., 1400.); - let scale = state.scale(graph, 1.); - assert!( - graph.width * scale <= canvas.width - && graph.height * scale <= canvas.height - ); - assert!(!state.fit_limited(graph, 1.)); - if pending { - let form = actual - .scrolls - .iter() - .find(|s| s.0 == Some(widget::Id::new("process-form"))) - .unwrap() - .1; - let actions = actual.actions.unwrap(); - assert!(form.height >= 200. && form.y + form.height <= size.height); - assert!(actions.y >= pane_bottom && actions.y + actions.height <= form.y); - } + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); } + let _ = app.update(event(Event::Cancel)); + assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); + app.behavior.topology.shelf = None; } } } } -#[test] -fn process_fit_layout_disclosures_and_pending_editor_keep_real_manual_canvas_scroll() { - let mut view = explicit_workspace(false, false, 68.); - let mut tree = widget::Tree::new(view.as_widget()); - let (node, _) = geometry(&mut view, &mut tree, Size::new(500., 570.)); - let mut scroll = widget::operation::scrollable::scroll_to::<()>( - widget::Id::new("process-canvas"), - widget::operation::scrollable::AbsoluteOffset { - x: Some(90.), - y: Some(180.), - }, - ); - view.as_widget_mut() - .operate(&mut tree, Layout::new(&node), &(), &mut scroll); - let (_, before) = geometry(&mut view, &mut tree, Size::new(500., 570.)); - let initial: Vec<_> = before - .scrolls - .iter() - .filter(|s| s.0 == Some(widget::Id::new("process-canvas"))) - .collect(); - assert_eq!(initial.len(), 1, "The exact canvas ID must be unique"); +#[tokio::test] +async fn production_process_canvas_focus_wheel_fit_and_stale_camera_are_presentation_only() { + use crate::behavior::canvas_workspace::{ + Event as Camera, + tests::{Mounted, key}, + }; + use iced::Vector; + let f = super::super::direct_tests::fixture(Layer::Sa); + let mut app = App::with_project(f.p); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let bounds = mounted.geometry(&app).bounds("process-viewport"); + let point = bounds.position() + Vector::new(90., 120.); + let accepted = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + mounted + .pan(&mut app, point, point + Vector::new(-20., 25.)) + .await; + let pan = app.behavior.topology.pan; + mounted + .batch( + &mut app, + &[key(iced::keyboard::key::Named::ArrowRight)], + mouse::Cursor::Available(point), + ) + .await; + assert_eq!(app.behavior.topology.pan, pan + Vector::new(-60., 0.)); + let local = point - bounds.position(); + let pan = app.behavior.topology.pan; + let old_scale = app.behavior.topology.zoom.unwrap_or(1.); + let before_world = (local - pan) / old_scale; + mounted + .batch( + &mut app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: 1. }, + }), + ], + mouse::Cursor::Available(point), + ) + .await; + let after_world = (local - app.behavior.topology.pan) / app.behavior.topology.zoom.unwrap(); + let diff = after_world - before_world; + assert!(diff.x.hypot(diff.y) < 0.001); + let context = app.topology_context().unwrap(); + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))); + let fitted = (app.behavior.topology.pan, app.behavior.topology.zoom); + let _ = app.update(event(Event::Shelf(Shelf::Selection))); + mounted.settle(&mut app).await; + app.window_size = Size::new(1440., 900.); + mounted.settle(&mut app).await; assert_eq!( - initial[0].2, - Vector::new(90., 180.), - "Establish the actual canvas scroll before any tree diff" + (app.behavior.topology.pan, app.behavior.topology.zoom), + fitted, + "Fit is a one-time camera action" ); - for (pending, details, height, size) in [ - (false, true, 96., Size::new(500., 570.)), - (true, true, 68., Size::new(1050., 570.)), - (true, false, 68., Size::new(1394., 802.)), - (false, false, 96., Size::new(500., 570.)), - ] { - view = explicit_workspace(pending, details, height); - tree.diff(view.as_widget()); - let (node, actual) = geometry(&mut view, &mut tree, size); - let canvas = actual - .scrolls - .iter() - .find(|s| s.0 == Some(widget::Id::new("process-canvas"))) - .unwrap(); - assert_eq!( - canvas.2, - Vector::new(90., 180.), - "Shelf/header/form/focus resize must retain the same scroll state" - ); - if pending { - let actions = actual.actions; - let mut form_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 form_scroll); - let (_, scrolled) = geometry(&mut view, &mut tree, size); - assert_eq!(scrolled.actions, actions); - assert!( - scrolled - .scrolls - .iter() - .any(|s| s.0 == Some(widget::Id::new("process-form")) && s.2.y == 600.) - ); - assert!( - scrolled - .scrolls - .iter() - .any(|s| s.0 == Some(widget::Id::new("process-canvas")) - && s.2 == Vector::new(90., 180.)) - ); + for mismatch in 0..3 { + let mut stale = app.topology_context().unwrap(); + match mismatch { + 0 => stale.document = Id::new_v4(), + 1 => stale.owner = Id::new_v4(), + _ => stale.revision += 1, } + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera( + stale, + app.camera_epoch, + Camera::Pan(Vector::new(400., 400.)), + ))); + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + fitted + ); } + app.hosted.visible = true; + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera( + app.topology_context().unwrap(), + app.camera_epoch, + Camera::Pan(Vector::new(30., 40.)), + ))); + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + fitted + ); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); } -#[test] -fn process_fit_whole_app_focus_and_disclosures_retain_canvas_tree_with_pending_raw_form() { - use iced::advanced::widget::tree; - use std::rc::Rc; - fn path_to(tree: &widget::Tree, tag: tree::Tag) -> Option> { - if tree.tag == tag { - return Some(vec![]); - } - tree.children.iter().enumerate().find_map(|(i, child)| { - let mut path = path_to(child, tag)?; - path.insert(0, i); - Some(path) - }) - } - fn at<'a>(mut tree: &'a mut widget::Tree, path: &[usize]) -> &'a mut widget::Tree { - for &i in path { - tree = &mut tree.children[i] - } - tree - } - for layer in [Layer::Oa, Layer::Sa] { - let f = super::super::direct_tests::fixture(layer); - let mut app = App::with_project(f.p); - let _ = app.update(Message::Select(f.nodes[1])); - let _ = app.update(Message::Page(crate::app::Page::Behavior)); - app.window_size = Size::new(1100., 700.); - let graph: Element<'_, Message> = - canvas::Canvas::new(Graph::new(app.project(), f.owner, f.nodes[1])).into(); - let tag = widget::Tree::new(graph.as_widget()).tag; - drop(graph); - let mut tree = widget::Tree::new(crate::views::view(&app).as_widget()); - let mut path = path_to(&tree, tag).unwrap(); - path.pop(); - let marker = Rc::new(()); - at(&mut tree, &path).state = tree::State::new(Rc::clone(&marker)); - let _ = app.update(event(Event::Start(f.owner, Tool::Contextual, None))); - let _ = app.update(event(Event::Name(" preserved raw 雨 ".into()))); - let raw = app.behavior.recovery_snapshot().unwrap(); - let accepted = app.document.snapshot(); - for (shelf, expected) in [ - (Shelf::Tools, Some(Shelf::Tools)), - (Shelf::Selection, Some(Shelf::Selection)), - (Shelf::Selection, None), - ] { - let focused = app.behavior.topology.focused; - let _ = app.update(event(Event::Shelf(shelf))); - assert_eq!(app.behavior.topology.shelf, expected); - let context = app.topology_context().unwrap(); - let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context))); - assert_eq!(app.behavior.topology.focused, !focused); - tree.diff(crate::views::view(&app).as_widget()); - let mut current = path_to(&tree, tag).unwrap(); - current.pop(); - assert_eq!( - current, path, - "Main must not shift when explorer/inspector are hidden" +#[tokio::test] +async fn production_process_camera_external_controls_bounds_and_retained_deltas_cancel_old_drags() { + use crate::behavior::canvas_workspace::tests::*; + let f = super::super::direct_tests::fixture(Layer::Oa); + let mut app = App::with_project(f.p); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + for boundary in ["Fit", "OR", "Details", "Panels", "Bounds"] { + let toolbar = capture_toolbar(&mut mounted, &app, "Fit"); + let bounds = mounted.geometry(&app).bounds("process-viewport"); + let point = bounds.position() + iced::Vector::new(95., 90.); + let lifetime = app.camera_epoch; + let pan = app.behavior.topology.pan; + let first = point + iced::Vector::new(13., 9.); + let last = first + iced::Vector::new(17., -3.); + let emitted = mounted + .batch( + &mut app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)), + iced::Event::Mouse(mouse::Event::CursorMoved { position: first }), + iced::Event::Mouse(mouse::Event::CursorMoved { position: last }), + ], + mouse::Cursor::Available(last), + ) + .await; + assert_eq!(app.behavior.topology.pan, pan + (last - point)); + assert_eq!( + app.camera_epoch, lifetime, + "own ordered deltas retain their lifetime" + ); + let retained = camera_delta(&emitted); + if boundary == "Bounds" { + let size = Size::new(app.window_size.width + 90., app.window_size.height + 35.); + let task = app.update(Message::WindowResized(size)); + mounted.perform(&mut app, task).await; + } else { + let id = scope(&app).control_id(boundary); + let at = mounted.geometry(&app).containers[&id].center(); + let clicked = mounted.click(&mut app, at).await; + assert!( + focused(&mut mounted, &app, boundary), + "same-batch pointer activation focuses the resulting live control without F6" ); - assert_eq!(Rc::strong_count(&marker), 2); - assert!(Rc::ptr_eq( - at(&mut tree, &path).state.downcast_ref::>(), - &marker - )); - assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); - assert!(std::sync::Arc::ptr_eq(&app.document.snapshot(), &accepted)); + if boundary == "Fit" { + mounted + .batch( + &mut app, + &[key(iced::keyboard::key::Named::Tab)], + mouse::Cursor::Unavailable, + ) + .await; + assert!( + focused(&mut mounted, &app, "ZoomOut"), + "Tab after the real press/release batch continues from Fit" + ); + assert!(clicked.iter().any(|m| matches!( + intent_action(m), + Message::TopologyCanvas(CanvasEvent::Zoom(_, None)) + ))); + assert_eq!( + app.behavior.topology.pan, + iced::Vector::ZERO, + "Fit emitted after this same batch's old pan must still execute" + ); + } } + mounted.settle(&mut app).await; + assert_ne!( + app.camera_epoch, lifetime, + "{boundary} ends the old input lifetime" + ); + let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); + let bounds = mounted.geometry(&app).bounds("process-viewport"); + let released = release_middle( + &mut mounted, + &mut app, + bounds.position() + iced::Vector::new(130., 145.), + ) + .await; + assert!(!released.iter().any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(_, _, crate::behavior::canvas_workspace::Event::Pan(delta))) if *delta != iced::Vector::ZERO)), + "{boundary} must cancel the retained widget drag"); + let task = app.update(retained); + mounted.perform(&mut app, task).await; + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + camera, + "{boundary} must reject its pre-boundary App delta" + ); + let current_lifetime = app.camera_epoch; + let task = app.update(toolbar); + mounted.perform(&mut app, task).await; + assert_eq!( + app.camera_epoch, current_lifetime, + "{boundary} rejects the retained toolbar lifetime" + ); + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + camera, + "{boundary} retained Fit cannot alter the camera" + ); } + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_process_keyboard_enters_activates_draws_focus_and_returns_to_inspector() { + use crate::behavior::canvas_workspace::tests::*; + use iced::keyboard::key::Named; + let f = super::super::direct_tests::fixture(Layer::Oa); + let mut app = App::with_project(f.p); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + assert!(!focused(&mut mounted, &app, "viewport")); + let operations = mounted.operations; + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!( + mounted.operations > operations, + "entry executes the returned App widget Task" + ); + assert!(focused(&mut mounted, &app, "viewport")); + let mut backwards = key(Named::Tab); + if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { modifiers, .. }) = + &mut backwards + { + *modifiers = iced::keyboard::Modifiers::SHIFT; + } + mounted + .batch(&mut app, &[backwards], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "ActualSize")); + mounted + .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "viewport")); + assert_eq!( + mounted + .geometry(&app) + .focused + .iter() + .filter(|(_, _, focused)| *focused) + .count(), + 1 + ); + tab_to(&mut mounted, &mut app, "OR").await; + let chosen = mounted + .batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable) + .await; + assert!(chosen.iter().any(|m| matches!( + intent_action(m), + Message::TopologyCanvas(CanvasEvent::Choose(..)) + ))); + assert!( + focused(&mut mounted, &app, "OR"), + "tool activation retains its keyboard target" + ); + // The selected palette tool must still have a visibly distinct focus ring. + let tool_on = mounted.pixels(&app); + let geometry = mounted.geometry(&app); + let mut tool_bounds = geometry.containers[&scope(&app).control_id("OR")]; + let offset = geometry.scrolls[&scope(&app).control_id("palette-scroll")].1; + tool_bounds.x -= offset.x; + tool_bounds.y -= offset.y; + mounted + .batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + let tool_off = mounted.pixels(&app); + assert_drawn_focus(&tool_off, &tool_on, app.window_size, tool_bounds); + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + // Focus alone changes the visible border of the real production Fit control. + let before = mounted.pixels(&app); + tab_to(&mut mounted, &mut app, "Fit").await; + let id = scope(&app).control_id("Fit"); + let bounds = mounted.geometry(&app).containers[&id]; + let after = mounted.pixels(&app); + assert_drawn_focus(&before, &after, app.window_size, bounds); + let fitted = mounted + .batch(&mut app, &[key(Named::Space)], mouse::Cursor::Unavailable) + .await; + assert!(fitted.iter().any(|m| matches!( + intent_action(m), + Message::TopologyCanvas(CanvasEvent::Zoom(_, None)) + ))); + assert_eq!(app.behavior.topology.pan, iced::Vector::ZERO); + assert!(focused(&mut mounted, &app, "Fit")); + tab_to(&mut mounted, &mut app, "Canvas").await; + mounted + .batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "viewport")); + mounted + .batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + assert!(!focused(&mut mounted, &app, "viewport")); + mounted + .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) + .await; + assert!( + mounted + .geometry(&app) + .focused + .iter() + .any(|(_, _, focused)| *focused), + "ordinary Tab resumes the production Inspector's own entry policy" + ); + assert!(!focused(&mut mounted, &app, "viewport")); + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "viewport")); + assert_eq!( + mounted + .geometry(&app) + .focused + .iter() + .filter(|(_, _, focused)| *focused) + .count(), + 1, + "explicit canvas entry releases Inspector keyboard ownership" + ); + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!(!focused(&mut mounted, &app, "viewport")); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + // Typing after exit belongs to Inspector. Replaying the prior clean tool + // action must retain those raw fields, never implicitly Apply them. + mounted + .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) + .await; + mounted + .batch(&mut app, &[character("x")], mouse::Cursor::Unavailable) + .await; + assert!(app.draft_dirty()); + let dirty_raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + assert_ne!(dirty_raw, raw); + let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); + for message in chosen { + let task = app.update(message); + mounted.perform(&mut app, task).await; + } + let emitted = mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!( + !emitted + .iter() + .any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..)))) + ); + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + camera + ); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + dirty_raw + ); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_process_caller_return_rejects_its_previous_camera_lifetime() { + use crate::behavior::canvas_workspace::tests::*; + let f = super::super::direct_tests::fixture(Layer::Oa); + let mut project = f.p; + let target = project + .add_element(Kind::Process, Layer::Oa, "Reusable check", None) + .unwrap(); + let reference = occurrence( + &project, + f.owner, + "Local use", + OccurrenceBody::SequenceReference { + sequence: Some(target), + }, + ); + let use_id = reference.id; + project = execute_batch(&project, &[Command::CreateElement(reference)]).unwrap(); + let mut app = App::with_project(project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let source = app.document.bundle().clone(); + let snapshot = app.document.snapshot(); + let bounds = mounted.geometry(&app).bounds("process-viewport"); + let point = bounds.position() + iced::Vector::new(80., 100.); + let emitted = mounted + .pan(&mut app, point, point + iced::Vector::new(35., -25.)) + .await; + let retained = camera_delta(&emitted); + let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); + let toolbar = capture_toolbar(&mut mounted, &app, "Fit"); + let task = app.update(Message::TopologyCanvas(CanvasEvent::Open( + app.topology_context().unwrap(), + use_id, + ))); + mounted.perform(&mut app, task).await; + mounted.settle(&mut app).await; + assert_eq!(app.topology_context().unwrap().owner, target); + let target_bounds = mounted.geometry(&app).bounds("process-viewport"); + let point = target_bounds.position() + iced::Vector::new(70., 90.); + mounted + .pan(&mut app, point, point + iced::Vector::new(-50., 20.)) + .await; + let task = app.update(Message::TopologyCanvas(CanvasEvent::Back( + app.topology_context().unwrap(), + ))); + mounted.perform(&mut app, task).await; + mounted.settle(&mut app).await; + assert_eq!(app.topology_context().unwrap().owner, f.owner); + assert_eq!(app.selected, use_id); + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + camera + ); + let task = app.update(retained); + mounted.perform(&mut app, task).await; + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + camera + ); + let lifetime = app.camera_epoch; + let task = app.update(toolbar); + mounted.perform(&mut app, task).await; + assert_eq!( + app.camera_epoch, lifetime, + "A to B and Back cannot revive toolbar intent" + ); + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + camera + ); + mounted + .pan(&mut app, point, point + iced::Vector::new(12., -8.)) + .await; + assert_eq!( + app.behavior.topology.pan, + camera.0 + iced::Vector::new(12., -8.) + ); + assert_eq!(app.document.bundle(), &source); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_canvas_keyboard_and_delayed_tasks_respect_real_chooser_and_review_owners() { + use crate::behavior::canvas_workspace::tests::*; + use iced::keyboard::key::Named; + let f = super::super::direct_tests::fixture(Layer::Oa); + let mut project = f.p; + let target = project + .add_element(Kind::FunctionalChain, Layer::Sa, "Counterpart", None) + .unwrap(); + let trace = Id::new_v4(); + project.traces.insert( + trace, + Trace { + id: trace, + alias: "canvas_focus_trace".into(), + name: "Review camera ownership".into(), + description: String::new(), + oa: f.owner, + sa: target, + kind: TraceKind::Manual, + baseline: None, + }, + ); + let mut app = App::with_project(project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1440., 900.); + let mut mounted = Mounted::new(&mut app).await; + let source = app.document.bundle().clone(); + let snapshot = app.document.snapshot(); + let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + let mut retained_apply = retained_focus_apply(&mut mounted, &mut app).await; + let entry = mounted.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable); + assert_eq!(entry.len(), 1); + let delayed = app.update(entry[0].clone()); + let draft = app.draft.as_ref().unwrap(); + let parent_id = + crate::forms::reference_chooser::trigger::field_id(draft.reference_generation(), "/parent"); + let parent_bounds = mounted + .geometry(&app) + .focused + .iter() + .find(|(id, _, _)| id.as_ref() == Some(&parent_id)) + .expect("actual Inspector reference trigger is mounted") + .1; + let request = draft.reference_open(app.project(), "/parent", parent_bounds); + let point = + mounted.geometry(&app).bounds("process-viewport").position() + iced::Vector::new(80., 100.); + press_middle(&mut mounted, &mut app, point).await; + let moved = point + iced::Vector::new(12., 8.); + let emitted = mounted + .batch( + &mut app, + &[iced::Event::Mouse(mouse::Event::CursorMoved { + position: moved, + })], + mouse::Cursor::Available(moved), + ) + .await; + let retained = camera_delta(&emitted); + let root_inputs = mounted.geometry(&app).text_inputs; + let task = app.update(Message::FieldReferenceOpen(request)); + mounted.perform(&mut app, task).await; + assert!( + app.reference_input_active(), + "the real App chooser must open" + ); + let popup_focus = mounted + .geometry(&app) + .focused + .into_iter() + .filter(|(_, _, value)| *value) + .collect::>(); + assert!( + popup_focus + .iter() + .any(|(id, bounds, focused)| id.is_none() && *focused && !root_inputs.contains(bounds)), + "the newly mounted nested chooser search, absent from root inputs, must be focused" + ); + mounted.perform(&mut app, delayed).await; + mounted.operate(&app, &mut *retained_apply); + assert_eq!( + mounted + .geometry(&app) + .focused + .into_iter() + .filter(|(_, _, value)| *value) + .collect::>(), + popup_focus, + "both delayed Collect and deliberately retained Apply respect the actual nested overlay owner" + ); + assert!(!focused(&mut mounted, &app, "viewport")); + let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); + for key_name in [Named::F6, Named::Tab, Named::ArrowRight] { + let emitted = mounted + .batch(&mut app, &[key(key_name)], mouse::Cursor::Unavailable) + .await; + assert!( + !emitted + .iter() + .any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..)))) + ); + } + // Tab may dismiss the chooser to its owning field; Escape closes it if still open. + if app.reference_input_active() { + mounted + .batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + } + assert!(!app.reference_input_active()); + let returned_focus = mounted + .geometry(&app) + .focused + .into_iter() + .filter(|(_, _, value)| *value) + .collect::>(); + mounted.operate(&app, &mut *retained_apply); + assert_eq!( + mounted + .geometry(&app) + .focused + .into_iter() + .filter(|(_, _, value)| *value) + .collect::>(), + returned_focus, + "the same owner after chooser Back has a different lifetime; retained Apply must stay inert" + ); + release_middle(&mut mounted, &mut app, point + iced::Vector::new(50., 35.)).await; + let task = app.update(retained); + mounted.perform(&mut app, task).await; + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + camera, + "a disabled-and-reenabled canvas cannot resume its pre-chooser pan" + ); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + + let mut retained_apply = retained_focus_apply(&mut mounted, &mut app).await; + let entry = mounted.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable); + let delayed = app.update( + entry + .into_iter() + .find(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..)))) + .unwrap(), + ); + let task = app.update(Message::StartSemanticReview(trace)); + mounted.perform(&mut app, task).await; + let facet = FacetSelector::Header(HeaderFacet::Parent); + for row in [ + crate::semantic_review::RowEvent::Select(facet.clone()), + crate::semantic_review::RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), + ] { + let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else { + panic!("actual Review dialog opens"); + }; + let message = state.address_event(row); + let task = app.update(Message::Workflow(crate::workflow::Event::Semantic(message))); + mounted.perform(&mut app, task).await; + } + let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else { + panic!("Review remains open"); + }; + let review = state.recovery_snapshot().unwrap(); + let review_id = crate::forms::reference_chooser::trigger::field_id( + state + .reference_form(app.project()) + .unwrap() + .reference_generation(), + "/parent", + ); + mounted + .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) + .await; + assert!( + mounted + .geometry(&app) + .focused + .iter() + .any(|(id, _, focused)| id.as_ref() == Some(&review_id) && *focused) + ); + let scrolls = mounted.geometry(&app).scrolls; + mounted.perform(&mut app, delayed).await; + mounted.operate(&app, &mut *retained_apply); + assert_eq!( + mounted.geometry(&app).scrolls, + scrolls, + "a retained Apply must not scroll the new owner" + ); + for key_name in [Named::F6, Named::ArrowRight] { + let emitted = mounted + .batch(&mut app, &[key(key_name)], mouse::Cursor::Unavailable) + .await; + assert!( + !emitted + .iter() + .any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..)))) + ); + } + assert!( + mounted + .geometry(&app) + .focused + .iter() + .any(|(id, _, focused)| id.as_ref() == Some(&review_id) && *focused) + ); + let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else { + panic!("Review retains ownership"); + }; + assert_eq!(state.recovery_snapshot().unwrap(), review); + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + camera + ); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + assert_eq!(app.document.bundle(), &source); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_process_form_replacement_resets_scroll_without_a_closed_render() { + use crate::behavior::canvas_workspace::tests::Mounted; + use iced::advanced::widget; + let f = super::super::direct_tests::fixture(Layer::Oa); + let mut app = App::with_project(f.p); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 520.); + let mut mounted = Mounted::new(&mut app).await; + let source = app.document.bundle().clone(); + let snapshot = app.document.snapshot(); + let bounds = mounted.geometry(&app).bounds("process-viewport"); + let point = bounds.position() + iced::Vector::new(65., 80.); + mounted + .pan(&mut app, point, point + iced::Vector::new(-15., 24.)) + .await; + let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); + let task = app.update(event(Event::Start(f.owner, Tool::Branch, None))); + mounted.perform(&mut app, task).await; + mounted.settle(&mut app).await; + let session = app.behavior.topology.draft.as_ref().unwrap().invocation; + mounted.scroll(&app, "process-form", 700.); + let offset = mounted.geometry(&app).scrolls[&widget::Id::new("process-form")].1; + assert!( + offset.y > 0., + "real production Branch fields must overflow this viewport" + ); + let task = app.update(event(Event::Name("Preserve this same session".into()))); + mounted.perform(&mut app, task).await; + assert_eq!( + mounted.geometry(&app).scrolls[&widget::Id::new("process-form")].1, + offset + ); + assert_eq!( + app.behavior.topology.draft.as_ref().unwrap().invocation, + session + ); + let task = app.update(event(Event::Cancel)); + mounted.perform(&mut app, task).await; + let task = app.update(event(Event::Start(f.owner, Tool::Branch, None))); + mounted.perform(&mut app, task).await; + assert_ne!( + app.behavior.topology.draft.as_ref().unwrap().invocation, + session + ); + // No closed-state App::view was rendered between Cancel and Start. + let geometry = mounted.geometry(&app); + assert_eq!( + geometry.scrolls[&widget::Id::new("process-form")].1, + iced::Vector::ZERO + ); + assert_eq!(geometry.bounds("process-viewport"), bounds); + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + camera + ); + assert_eq!(app.document.bundle(), &source); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_process_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes() { + use crate::behavior::canvas_workspace::tests::*; + let f = super::super::direct_tests::fixture(Layer::Oa); + let mut app = App::with_project(f.p); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1440., 900.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + retained_toolbar_boundaries(&mut mounted, &mut app, "OR").await; + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_process_keyboard_reveals_and_activates_initially_offscreen_last_tool() { + use crate::behavior::canvas_workspace::tests::*; + let f = super::super::direct_tests::fixture(Layer::Oa); + let mut app = App::with_project(f.p); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 420.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + overflow_last_tool(&mut mounted, &mut app, "->").await; + assert_eq!(app.behavior.topology.tool, CanvasTool::Connect); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); } diff --git a/crates/oa-desktop/src/behavior/topology/view.rs b/crates/oa-desktop/src/behavior/topology/view.rs index 1279881d..4302d849 100644 --- a/crates/oa-desktop/src/behavior/topology/view.rs +++ b/crates/oa-desktop/src/behavior/topology/view.rs @@ -66,107 +66,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele let pending = state.draft.is_some(); let ordered = !legacy_order(p, owner).is_empty(); let selected = is_contribution(p, owner, app.selected).then_some(app.selected); - let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool)); - let mut controls = Column::new().spacing(6); - if !pending { - controls = controls - .push( - row![ - button("Select / reorder") - .style(if state.tool == CanvasTool::Select { - button::primary - } else { - style::nav - }) - .on_press(choose(CanvasTool::Select)), - button("Work") - .style( - if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) { - button::primary - } else { - style::nav - } - ) - .on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))), - button("Exchange") - .style( - if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) { - button::primary - } else { - style::nav - } - ) - .on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))), - button("Reuse") - .style( - if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) { - button::primary - } else { - style::nav - } - ) - .on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice))) - ] - .spacing(6), - ) - .push( - row![ - pick_list( - vec![ - ControlKind::Choice, - ControlKind::Merge, - ControlKind::Fork, - ControlKind::Join, - ControlKind::Loop - ] - .into_iter() - .map(ControlChoice) - .collect::>(), - if let CanvasTool::Add(Tool::Control, kind) = state.tool { - Some(ControlChoice(kind)) - } else { - None - }, - move |c| Message::TopologyCanvas(CanvasEvent::Choose( - context, - CanvasTool::Add(Tool::Control, c.0) - )) - ) - .placeholder("Draw control…") - .text_size(12) - .padding(8), - button("Sequence link") - .style(if state.tool == CanvasTool::Connect { - button::primary - } else { - style::nav - }) - .on_press(choose(CanvasTool::Connect)), - button("Two paths…").on_press(event(Event::Start( - owner, - Tool::Branch, - selected - ))) - ] - .spacing(6), - ); - } - controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if ordered{"Ordered exchange path · click a path to insert or empty canvas to continue; the edit preserves its order"}else{match state.tool{ - CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column", - CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels", - CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}})); - if !pending && ordered { - controls = controls.push(button("Append exchange…").on_press(Message::TopologyCanvas( - CanvasEvent::AppendExchange(context), - ))); - let commands = preserved_order(p, owner, None); - if !commands.is_empty() { - controls = controls.push( - button("Edit path links and guards") - .on_press(Message::Command(unique_batch(p, commands))), - ); - } - } + let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(); // A fixed sibling keeps Iced's canvas scroll state through selection/form changes. let mut navigation = column![].spacing(6); if let Some(Relation { @@ -211,11 +111,6 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele ); } } - if !pending { - controls = controls.push(button("Qualified link / assembly…").on_press(event( - Event::Start(owner, Tool::Contextual, Some(app.selected)), - ))); - } if let Some(id) = selected { let mut actions = row![button("Contribution properties").on_press(Message::EditProperties(id))] @@ -256,27 +151,79 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))), ); } - let diagram = { - let mut graph = Graph::expanded_qualified( - p, - owner, - app.selected, - &state.expanded, - &state.contextual_expanded, + navigation = navigation + .push( + button("Two paths...").on_press_maybe(clean.then_some(event(Event::Start( + owner, + Tool::Branch, + selected, + )))), + ) + .push( + button("Qualified link / assembly...").on_press_maybe(clean.then_some(event( + Event::Start(owner, Tool::Contextual, Some(app.selected)), + ))), ); - graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.)); - graph.interaction = Some(( - context, - state.tool, - !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(), - )); - // Explicit graphs no longer have a scrolling page ancestor. A retained - // offset from an ordered view must not distort their pointer coordinates. - graph.scroll_offset = state.canvas_offset(); - let scale = graph.scale; - let mut header = column![ - row![ - out.width(Fill), + if ordered { + navigation = navigation.push(button("Append exchange...").on_press_maybe(clean.then_some( + Message::TopologyCanvas(CanvasEvent::AppendExchange(context)), + ))); + let commands = preserved_order(p, owner, None); + if !commands.is_empty() { + navigation = navigation.push( + button("Edit path links and guards") + .on_press_maybe(clean.then_some(Message::Command(unique_batch(p, commands)))), + ); + } + } + let mut graph = Graph::expanded_qualified( + p, + owner, + app.selected, + &state.expanded, + &state.contextual_expanded, + ); + graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.)); + graph.camera_pan = state.pan; + graph.pending_insertion = state + .draft + .as_ref() + .filter(|d| d.owner == owner && d.fixed) + .map(|d| d.insertion); + graph.interaction = Some((context, state.tool, clean)); + let scale = graph.scale; + let scope = super::super::canvas_workspace::Scope { + document: context.document, + owner, + revision: context.revision, + epoch: app.camera_epoch, + }; + let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app); + let camera = move |e| Message::TopologyCanvas(CanvasEvent::Camera(context, scope.epoch, e)); + let canvas = container( + iced::widget::sensor( + super::super::canvas_workspace::Viewport::new( + canvas::Canvas::new(graph).width(Fill).height(Fill), + scope, + scale, + keyboard, + camera, + ) + .pan_background(state.tool == CanvasTool::Select), + ) + .key((context.document, owner)) + .on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))) + .on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))), + ) + .id("process-viewport") + .width(Fill) + .height(Fill) + .style(style::card) + .into(); + let header = column![ + row![ + out.width(Fill), + super::super::canvas_workspace::Control::new( button( text(if state.focused { "Restore panels" @@ -285,121 +232,104 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele }) .size(12) ) - .on_press(Message::TopologyCanvas(CanvasEvent::Focus(context))) .padding(6) - ] - .spacing(6) - .align_y(iced::Center), - row![ - button(text("Tools").size(12)) - .padding(6) - .style(if state.shelf == Some(Shelf::Tools) { - button::primary - } else { - style::nav - }) - .on_press(event(Event::Shelf(Shelf::Tools))), - button(text("Selection details").size(12)) - .padding(6) - .style(if state.shelf == Some(Shelf::Selection) { - button::primary - } else { - style::nav - }) - .on_press(event(Event::Shelf(Shelf::Selection))), - button(text("Fit overview").size(12)) - .padding(6) - .on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))), - button(text("−").size(12)) - .padding(6) - .on_press(Message::TopologyCanvas(CanvasEvent::Zoom( - context, - Some(scale / 1.25) - ))), - label(format!("{:.0}%", scale * 100.)), - button(text("+").size(12)) - .padding(6) - .on_press(Message::TopologyCanvas(CanvasEvent::Zoom( - context, - Some(scale * 1.25) - ))), - button(text("100%").size(12)) - .padding(6) - .on_press(Message::TopologyCanvas(CanvasEvent::Zoom( - context, - Some(1.) - ))) - ] - .spacing(6) - .wrap(), + .on_press(super::super::canvas_workspace::intent( + scope, + "Panels", + Message::TopologyCanvas(CanvasEvent::Focus(context)) + )), + scope, + "Panels", + keyboard, + Some(super::super::canvas_workspace::intent( + scope, + "Panels", + Message::TopologyCanvas(CanvasEvent::Focus(context)) + )), + camera + ) ] - .spacing(6); - if state.fit_limited(graph.size, (app.window_size.width - 600.).max(200.)) { - header = header - .push(text("Minimum zoom reached · scroll to see the remaining graph.").size(11)); - } - if graph.nodes.is_empty() { - controls = controls.push(label( - "Choose Work, Exchange, Reuse or a control, then click the canvas to start.", - )); - } - let size = Size::new(graph.size.width * scale, graph.size.height * scale); - let canvas = scrollable( - canvas::Canvas::new(graph) - .width(size.width) - .height(size.height), - ) - .id("process-canvas") - .direction(scrollable::Direction::Both { - vertical: scrollable::Scrollbar::default(), - horizontal: scrollable::Scrollbar::default(), - }) - .width(Fill) - .height(Fill); - let canvas = container( - iced::widget::sensor(super::super::scenario_scroll::observe_topology( + .spacing(6), + super::super::canvas_workspace::controls( + scope, + keyboard, + scale, + event(Event::Shelf(Shelf::Selection)), + clean.then_some(Message::TopologyCanvas(CanvasEvent::Camera( context, - scenario::ScrollArea::Canvas, - canvas, - )) - .on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))) - .on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))), + scope.epoch, + super::super::canvas_workspace::Event::Focus + ))), + move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)), + camera ) - .padding(8) - .width(Fill) - .height(Fill) - .style(style::card); - if !pending && !p.children(owner).any(|e| is_contribution(p, owner, e.id)) { - let title = if p.layer_of(owner) == Some(Layer::Oa) { - "Add first activity contribution" - } else { - "Add first function contribution" - }; - header = header.push(button(title).on_press_maybe( - (!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty()).then( - || { - Message::TopologyCanvas(CanvasEvent::Begin { - context, - tool: Tool::Work, - control: ControlKind::Choice, - insertion: Insertion::default(), - }) - }, - ), - )); - } - let details: Element<'_, Message> = match state.shelf { - Some(Shelf::Tools) => controls.into(), - _ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(), - }; - explicit_pane( - header.into(), - details, - canvas.into(), - state.shelf.is_some(), - pending, - ) - }; + ] + .spacing(4); + let entries = [ + ("S", "Select contributions or paths", CanvasTool::Select), + ( + "W", + "Work: click a path or empty space", + CanvasTool::Add(Tool::Work, ControlKind::Choice), + ), + ( + "E", + "Exchange: click a path or empty space", + CanvasTool::Add(Tool::Exchange, ControlKind::Choice), + ), + ( + "ref", + "Reuse process / chain", + CanvasTool::Add(Tool::Reuse, ControlKind::Choice), + ), + ( + "OR", + "Choice", + CanvasTool::Add(Tool::Control, ControlKind::Choice), + ), + ( + "M", + "Merge", + CanvasTool::Add(Tool::Control, ControlKind::Merge), + ), + ( + "AND", + "Fork", + CanvasTool::Add(Tool::Control, ControlKind::Fork), + ), + ( + "J", + "Join", + CanvasTool::Add(Tool::Control, ControlKind::Join), + ), + ( + "IT", + "Loop", + CanvasTool::Add(Tool::Control, ControlKind::Loop), + ), + ( + "->", + "Sequence link: click or drag between contributions", + CanvasTool::Connect, + ), + ]; + let palette = super::super::canvas_workspace::palette( + scope, + keyboard, + camera, + entries.into_iter().map(|(glyph, label, choice)| { + ( + glyph, + label, + choice == state.tool, + clean.then_some(Message::TopologyCanvas(CanvasEvent::Choose( + context, choice, + ))), + ) + }), + ); + let details = scrollable(navigation.push(text("Layout is derived. The reorder handle changes display order within its column; it does not author a position.").size(11))) + .id("process-details").width(Fill).height(Fill).into(); 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) { if let Some(child) = d.source.view(p) { @@ -617,77 +547,37 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele button("Cancel").on_press(event(Event::Cancel)) ] .spacing(7); - form_panel = editor_panel(actions.into(), form.into()); + form_panel = editor_panel(d.invocation, actions.into(), form.into()); } } - panes(diagram, form_panel, pending) -} - -/// A permanent three-slot tree: variable controls never remount the canvas. -/// The shelf scrolls independently; the canvas receives actual remaining height. -fn explicit_pane<'a, R: iced::advanced::text::Renderer + 'a>( - header: iced::Element<'a, Message, iced::Theme, R>, - details: iced::Element<'a, Message, iced::Theme, R>, - canvas: iced::Element<'a, Message, iced::Theme, R>, - show_details: bool, - pending: bool, -) -> iced::Element<'a, Message, iced::Theme, R> { - // Column::push drops Fixed(0) children. from_vec retains the closed shelf - // slot so its disclosure cannot move/remount the following canvas tree. - Column::from_vec(vec![ - container(header).width(Fill).into(), - scrollable(details) - .id("process-details") - .width(Fill) - .height(if show_details { - if pending { 64. } else { 112. } - } else { - 0. - }) - .into(), - container(canvas).width(Fill).height(Fill).into(), - ]) - .spacing(6) - .padding(8) - .width(Fill) - .height(Fill) - .into() -} - -/// 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() + let editor = if pending { + Some(form_panel) + } else { + state.shelf.map(|_| details) + }; + super::super::canvas_workspace::host(header.into(), palette, canvas, editor) } fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>( + session: Id, 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), + container( + column![ + container(actions).id("process-actions"), + super::super::form_session::reset_on_change( + session, + scrollable(fields).id("process-form").height(Fill) + ) + ] + .spacing(8), + ) + .width(Fill) + .height(Fill) + .padding(12) + .style(style::card), ) .width(Fill) .height(Fill)