diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 40f56cb9..5513d68f 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, @@ -1032,7 +1038,10 @@ impl App { crate::model_menu::popup(self), ) .into(); - 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), + ) } pub fn subscription(&self) -> Subscription { Subscription::batch([ @@ -1088,6 +1097,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; } @@ -1383,6 +1491,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(), @@ -1446,6 +1555,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(); } @@ -1459,9 +1611,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() @@ -1583,13 +1737,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 3ccd1f45..a90bf26b 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 7890ea6f..54e18056 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -14,10 +14,11 @@ use iced::{ }; use oa_core::*; use std::collections::BTreeMap; +pub(crate) mod canvas_workspace; #[cfg(test)] mod contextual_source_tests; -pub(crate) mod canvas_workspace; pub(crate) mod creation; +mod form_session; pub(crate) mod scenario; // Projection acceptance precedes the coordinated scenario renderer integration. #[cfg(test)] @@ -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,7 +323,7 @@ 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, content: scrollable(iced::widget::Space::new().width(200.).height(height)) @@ -485,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()); @@ -1952,9 +1860,12 @@ impl canvas::Program for Scene { } _ => cursor, }; - 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 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 { @@ -1981,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 { @@ -1991,8 +1902,15 @@ impl canvas::Program for Scene { }); return Some(Action::request_redraw().and_capture()); } - 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)) { + 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, parent: self.rows[&occurrence].0, @@ -2070,8 +1988,17 @@ 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, + moved: false, + .. + } => { + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Select( + context, occurrence, + ))) + .and_capture(), + ); } SequencePress::Reorder { occurrence, @@ -2222,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.camera_pan.x) / self.scale, (p.y - self.camera_pan.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 { @@ -2269,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 index 024c9ebe..32b81926 100644 --- a/crates/oa-desktop/src/behavior/canvas_workspace.rs +++ b/crates/oa-desktop/src/behavior/canvas_workspace.rs @@ -1,28 +1,133 @@ //! Shared presentation and input contract; contains no model commands. -use iced::{Element, Length::Fill, Point, Theme, Vector}; +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 widget_id(self) -> iced::advanced::widget::Id { - iced::advanced::widget::Id::new(format!("behavior-canvas:{}:{}", self.document, self.owner)) + 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 { @@ -31,17 +136,29 @@ pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: E if next.x.is_finite() && next.y.is_finite() && next != *pan { *pan = next; true - } else { false } + } 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() => + 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; } + if old == next { + return false; + } let anchor = Vector::new(at.x, at.y); - *pan = anchor - (anchor - *pan) * (next / old); + 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 @@ -51,21 +168,46 @@ pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: E } /// Constant-width labelled palette. Arming a tool never reallocates the canvas. -pub fn palette<'a, Message: Clone + 'a>( +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( - button(text(glyph).size(12)).width(34).height(30).padding(3) - .style(if selected { button::primary } else { crate::style::nav }) - .on_press_maybe(message), - container(text(label).size(12)).padding(7).style(crate::style::card), - tooltip::Position::Right, + 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)).width(40).height(Fill).padding(3) - .style(crate::style::panel).into() + 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. @@ -75,16 +217,27 @@ pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>( canvas: Element<'a, Message, Theme, R>, editor: Option>, ) -> Element<'a, Message, Theme, R> { - let base = row![canvas, palette].width(Fill).height(Fill); - let mut body = stack![base]; + let mut body = stack![canvas]; if let Some(editor) = editor { body = body.push( - container(opaque(container(editor).width(320).height(Fill).style(crate::style::card))) - .align_right(Fill).height(Fill).padding(8), + container(opaque( + container(editor) + .width(300) + .height(Fill) + .style(crate::style::card), + )) + .align_right(Fill) + .height(Fill) + .padding(8), ); } - column![header, body].spacing(6).padding(8).width(Fill).height(Fill).into() + column![header, row![body, palette].height(Fill)] + .spacing(6) + .padding(8) + .width(Fill) + .height(Fill) + .into() } #[cfg(test)] -mod tests; +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..83013b9d --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs @@ -0,0 +1,753 @@ +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 new(app: &mut App) -> 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"); + let mut mounted = Self { + renderer, + cache: Cache::new(), + operations: 0, + }; + 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 index 4277cd72..58d46463 100644 --- a/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs +++ b/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs @@ -1,7 +1,9 @@ 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::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, - widget::{Operation, Tree, operation, tree}}; use iced::{keyboard, mouse}; #[derive(Default)] @@ -11,65 +13,191 @@ struct Input { 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; } + 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 { +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, publish: Box::new(publish) } + 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 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 != Some(self.scope) { *state = Input { scope: Some(self.scope), ..Default::default() }; } + 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 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) + 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.focusable(Some(&self.scope.widget_id()), layout.bounds(), tree.state.downcast_mut::()); - self.content.as_widget_mut().operate(&mut tree.children[0], layout, renderer, operation); + 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) { + 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 let Event::Mouse(mouse::Event::CursorMoved { position }) = event { state.pointer = Some(*position); } + 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(_)) => - state.pointer.or_else(|| cursor.position()), + 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(); + 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; + state.pan = None; + state.space = false; + state.focused = false; + } + Event::Mouse(mouse::Event::CursorLeft) => { + state.pan = None; + state.pointer = None; } - Event::Mouse(mouse::Event::CursorLeft) => { state.pan = None; state.pointer = None; } Event::Mouse(mouse::Event::ButtonPressed(button)) => { - state.focused = inside; - if inside && (*button == mouse::Button::Middle || (*button == mouse::Button::Left && state.space)) { + 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; } } @@ -81,28 +209,64 @@ impl Widget for Viewport<'_, } } Event::Mouse(mouse::Event::ButtonReleased(button)) => { - if let Some((held, previous)) = state.pan && held == *button { + if let Some((held, previous)) = state.pan + && held == *button + { state.pan = None; - camera = pointer.filter(|_| inside).map(|p| CameraEvent::Pan(p - previous)); + camera = pointer + .filter(|_| inside) + .map(|p| CameraEvent::Pan(p - previous)); capture = true; } } - Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside => { + Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside && state.focused => { state.pan = None; - let y = match delta { mouse::ScrollDelta::Lines { y, .. } => *y, - mouse::ScrollDelta::Pixels { y, .. } => *y / 40. }; + 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 }); + 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, modifiers, .. }) if state.focused && self.keyboard => { + 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; capture = true; } - Key::Named(Named::Tab) => { camera = Some(CameraEvent::Traverse(modifiers.shift())); capture = true; } - Key::Named(Named::Escape) => { state.pan = None; state.space = false; } + 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.)), @@ -111,39 +275,152 @@ impl Widget for Viewport<'_, Named::ArrowDown => Some(Vector::new(0., -60.)), _ => None, }; - camera = delta.map(CameraEvent::Pan); capture = camera.is_some(); + 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 *key == keyboard::Key::Named(keyboard::key::Named::Space) => + { + state.space = false; + } _ => {} } - if let Some(event) = camera { shell.publish((self.publish)(event)); } - if capture { shell.capture_event(); shell.request_redraw(); return; } - 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) { - 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); + 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 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 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 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) + 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) } +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/form_session.rs b/crates/oa-desktop/src/behavior/form_session.rs new file mode 100644 index 00000000..cd324f0d --- /dev/null +++ b/crates/oa-desktop/src/behavior/form_session.rs @@ -0,0 +1,148 @@ +//! Keep behavior field state within one editing session and reset it on replacement. +use iced::advanced::widget::{Operation, Tree, tree}; +use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer}; +use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, mouse}; +use oa_core::Id; + +struct Identity(Id); + +struct Session<'a, Message, R: renderer::Renderer> { + identity: Id, + content: Element<'a, Message, Theme, R>, +} + +pub(super) fn reset_on_change<'a, Message: 'a, R: renderer::Renderer + 'a>( + identity: Id, + content: impl Into>, +) -> Element<'a, Message, Theme, R> { + Element::new(Session { + identity, + content: content.into(), + }) +} + +impl Widget for Session<'_, Message, R> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(Identity(self.identity)) + } + + fn children(&self) -> Vec { + vec![Tree::new(self.content.as_widget())] + } + + fn diff(&self, tree: &mut Tree) { + let retained = tree.state.downcast_mut::(); + if retained.0 != self.identity { + retained.0 = self.identity; + // Iced's keyed column preserves same-length child replacements. + // An editing-session replacement must instead get fresh field state. + tree.children = self.children(); + } else { + 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, + ) { + 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, + ) { + 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/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 218ac309..3cd8b790 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -101,7 +101,7 @@ pub enum CanvasEvent { Viewport(Context, Size), FormShown(Context, Id), Zoom(Context, Option), - Camera(Context, super::canvas_workspace::Event), + Camera(Context, u64, super::canvas_workspace::Event), Focus(Context), Shelf(Context, Shelf), OpenUse(Context, Id), @@ -142,7 +142,7 @@ impl CanvasEvent { | Self::Viewport(context, _) | Self::FormShown(context, _) | Self::Zoom(context, _) - | Self::Camera(context, _) + | Self::Camera(context, ..) | Self::Focus(context) | Self::Shelf(context, _) | Self::OpenUse(context, _) @@ -238,13 +238,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 +289,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()) } @@ -339,6 +344,24 @@ impl State { 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 +429,7 @@ impl State { owner: frame.owner, token: Id::new_v4(), offsets: frame.offsets, - pending: [true; 2], + pending: [false, true], }); } pub(super) fn scroll_restore( @@ -496,6 +519,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 52316fb0..8f74186b 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -1,7 +1,7 @@ //! Scenario presentation. Layout/camera controls never construct model commands. use super::super::scenario_scroll; use super::*; -use iced::widget::{Space, column, keyed_column, sensor}; +use iced::widget::{Space, column, sensor}; pub(crate) const NAME_INPUT: &str = "scenario-form-name"; @@ -47,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> { @@ -79,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(), @@ -645,7 +565,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>( @@ -659,44 +584,10 @@ 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, @@ -705,45 +596,10 @@ fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>( 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>, @@ -752,16 +608,13 @@ fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>( container( column![ container(actions).id("scenario-actions").width(Fill), - keyed_column([( + super::super::form_session::reset_on_change( session, scrollable(fields) .id("scenario-form") .width(Fill) - .height(Fill) - .into(), - )]) - .width(Fill) - .height(Fill), + .height(Fill), + ), ] .spacing(6), ) diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs index 5321851b..77e83f2b 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs @@ -1,216 +1,375 @@ use super::*; 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!(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.); + 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); } + 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] @@ -321,457 +480,6 @@ async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_moda ))); assert!(iced_runtime::task::into_stream(task).is_none()); } -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}; @@ -923,63 +631,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(); @@ -1148,3 +799,338 @@ 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()); +} diff --git a/crates/oa-desktop/src/behavior/topology.rs b/crates/oa-desktop/src/behavior/topology.rs index b52e3404..1fcbce81 100644 --- a/crates/oa-desktop/src/behavior/topology.rs +++ b/crates/oa-desktop/src/behavior/topology.rs @@ -81,7 +81,7 @@ 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, @@ -126,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()) } @@ -349,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) @@ -972,6 +986,7 @@ struct Graph<'a> { interaction: Option<(Context, CanvasTool, bool)>, scroll_offset: iced::Vector, camera_pan: iced::Vector, + pending_insertion: Option, nodes: Vec, edges: Vec, ordered_paths: Vec, @@ -1305,6 +1320,7 @@ impl<'a> Graph<'a> { interaction: None, scroll_offset: iced::Vector::ZERO, camera_pan: iced::Vector::ZERO, + pending_insertion: None, nodes, edges, ordered_paths, @@ -1620,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.camera_pan.x) / self.scale, (p.y - self.camera_pan.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 cca69956..f2cbf4f7 100644 --- a/crates/oa-desktop/src/behavior/topology/direct.rs +++ b/crates/oa-desktop/src/behavior/topology/direct.rs @@ -60,7 +60,7 @@ pub enum CanvasEvent { Open(Context, Id), Back(Context), Zoom(Context, Option), - Camera(Context, super::super::canvas_workspace::Event), + Camera(Context, u64, super::super::canvas_workspace::Event), Focus(Context), Viewport(Context, Size), } @@ -88,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); @@ -170,9 +183,16 @@ impl State { } 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 @@ -194,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 @@ -581,9 +650,12 @@ impl Graph<'_> { } _ => cursor, }; - 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 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 => { @@ -736,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; } @@ -744,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.camera_pan.x) / self.scale, (p.y - bounds.y - self.camera_pan.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 { @@ -859,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.); @@ -876,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) diff --git a/harness/release-behavior-canvas-r123-source-20260911.md b/harness/release-behavior-canvas-r123-source-20260911.md new file mode 100644 index 00000000..a57a0960 --- /dev/null +++ b/harness/release-behavior-canvas-r123-source-20260911.md @@ -0,0 +1,80 @@ +# Shared behavior canvas phase 1 — R1/R2/R3 source correction freeze + +Codex-956atf `/root/behavior_canvas_resume`, 2026-09-11. Isolated `.local/behavior-canvas-worktree-20260911`, branch `codex/behavior-canvas-20260911`, base `e511c4153fb5bb484652434340e0e4c7000c93d0`. Correction lease: [issue 2 comment 377](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-377). This answers root's full independent correction review (`root harness/release-behavior-canvas-corrections-review-20260911.md`) and [comment 366](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-366). + +**Source preparation only.** All 19 owned Rust paths pass scoped standalone rustfmt formatting/parse check and `git diff --check`; only ordinary CRLF normalization notices were emitted. No Cargo/rustc, build, renderer/test/native execution, installation, Git mutation or Linux/WSL/VPN work occurred. Root owns independent review, integration and coherent Windows acceptance. Linux remains paused by the user. Prior root passing gates establish other bytes, not this candidate. Previous reports remain unchanged historical records; their superseded correctness claims must be read with the independent R1/R2/R3 review. + +## R1/R2: stamped toolbar admission + +Production palette, Details, Panels, Canvas, Fit and zoom actions now carry `Message::BehaviorControl(Intent)`: the current document/owner/revision/camera lifetime, stable control name and boxed existing action. App validates the live clean input owner and exact lifetime before inspecting a narrow presentation-action whitelist. Only the semantic epoch interval accumulated during that still-valid camera lifetime can be normalized to the current context. The existing exact-context process/scenario adapters then handle the action. The earlier process-only unstamped epoch exception is removed. Domain authoring gestures retain their exact full-context guards; the envelope cannot dispatch arbitrary model/source commands. + +Consequently a held camera drag's own ordered Pan can precede its same-batch Fit/tool/panel action in BOTH adapters. External Fit/tool/panel/bounds/caller/chooser/dirty/busy transitions terminate the lifetime and invalidate retained toolbar packets even when semantic context has returned to the same epoch. Reapplying an unchanged Fit or tool also ends the gesture. The existing held-pan Fit `pan == ZERO` assertions are preserved. + +Control no longer emits an earlier press-time ControlFocus Task. Accepted pointer or keyboard activation requests focus after the action, stamped with its resulting lifetime, matching Iced's final-view action execution. Canvas activation requests the viewport directly. F6 entry/exit, Tab/Shift+Tab traversal, Enter/Space activation and Escape exit remain the defined owner-respecting policy. Stable control identity preserves a visible keyboard target through a tool change. + +## R3: actual runtime UI and guarded focus passes + +The mounted helper now retains `iced_runtime::user_interface::Cache` and uses the installed `UserInterface` API for input, layout, operations, nested overlays and drawing. It drains every App message from an input batch while collecting immediately ready Task actions, builds the resulting final App view, then executes actions. Output messages become the next update cycle; pending Task streams survive until completion. Widget Chain passes run synchronously against that same UI, including root and nested overlays. Unexpected native actions panic. Production WGPU initialization still uses `expect`; there are no missing-backend skips. No private gesture/focus state is seeded. + +A presentation owner marker wraps the actual App root. Both Collect and its Apply pass independently check the current clean owner and exact camera lifetime before changing focus or palette scroll. This is independent of root's later scenario parent-focus marker. The test deliberately retaining Apply across ownership changes establishes the stronger retained-operation invariant; installed native Iced executes Chain passes synchronously, so no hypothetical native interleaving is claimed. + +The picker regression uses public Save(true) and cancellation completion transitions. On Windows it holds/drops the unopened external picker Task, verifies busy admission, and injects SavePath(None); every widget Task is executed. It does not test a native picker dialog. The existing Linux in-app picker path is represented by CancelFile, but was not run and Linux acceptance remains user-deferred. + +## Prepared regressions, all uncompiled and unexecuted + +There are now **17 production App-root mounted tests plus two shared math/input tests**. All previous direct-authoring, staged source-child, form identity, geometry, exact source/snapshot and history assertions remain. + +- Existing process/scenario held-middle tests retain Fit==ZERO, now require same-batch pointer activation to focus the resulting control without F6 and the following Tab to focus ZoomOut. Retained camera and toolbar packets are asserted separately across Fit, tool, Details, Panels and resize. +- Existing caller-return tests retain camera restoration/old-delta checks and additionally reject actual pre-call Fit packets after A→B→Back. +- `production_process_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes` and its scenario counterpart capture actual Fit/tool/Details/Panels/Canvas packets. Real chooser open/Escape, property Field/RevertProperties, and picker cancellation cycles must change lifetime without changing semantic epoch. Replays must preserve presentation, focus, source and history; dirty/busy owners also reject packets while active. +- `production_process_keyboard_reveals_and_activates_initially_offscreen_last_tool` and its scenario counterpart explicitly require the last tool to start fully below an overflowing palette, then require keyboard reveal of the whole tool, Enter activation and changed visible focus-border pixels. Connect and Fragment activation are asserted through resulting domain presentation state. +- The existing chooser/Review test now observes the newly focused nested search input, absent from root input geometry. Both delayed Collect and retained Apply must preserve it; a retained Apply stays inert after returning to the same clean canvas with a new lifetime. The actual Review field and scroll state must survive both delayed passes. The test states that retained Chain execution is deliberate. + +Root filters: `canvas_workspace::tests`, `production_process_`, `production_scenario_`, `production_empty_scenario`, `production_canvas_keyboard`, then preserved direct process/scenario/source/raw/history and root parent-focus/chooser gates. These are prepared filters, not results. Pixel assertions establish only the tested visible focus borders when executed; transformed scene clipping, refusal text, native input/accessibility and all popup geometry still require their existing acceptance gates. + +## Exact scope and later union + +This correction changed these ten existing Rust paths relative to comment360: + +- `crates/oa-desktop/src/app.rs` +- `crates/oa-desktop/src/app/process_canvas.rs` +- `crates/oa-desktop/src/behavior/canvas_workspace.rs` +- `crates/oa-desktop/src/behavior/canvas_workspace/control.rs` +- `crates/oa-desktop/src/behavior/canvas_workspace/focus.rs` +- `crates/oa-desktop/src/behavior/canvas_workspace/tests.rs` +- `crates/oa-desktop/src/behavior/scenario/workspace.rs` +- `crates/oa-desktop/src/behavior/scenario/workspace/tests.rs` +- `crates/oa-desktop/src/behavior/topology/view.rs` +- `crates/oa-desktop/src/behavior/topology/layout_tests.rs` + +The candidate's total union remains the 19 Rust paths below, plus this new versioned handoff. `app/reference_authoring.rs` still contains only the previously authorized read-only reference_input_active accessor relative to its isolated baseline; no new chooser changes were made. `behavior/form_session.rs` remains root's frozen verbatim helper with SHA256 `F5027FB3A5A5599A182E7D1A10DBA56DD66EB1F23A4BA4C4A1744C9A6A5B6E61`, used by scenario session and process invocation. No new source/core/schema/REST or persisted geometry contract was introduced. Camera/control/focus changes are presentation only; existing constrained semantic commands remain unchanged. + +Root must merge its I1/I2 Name session-versus-mount identity, current FormShown payload/sensor, Back/Use/Escape rearm, parent root marker and same-axis focus adjustment, five parent-focus regressions and scenario replacement-scroll regression. Preserve root's current dynamic Id::from(String), chooser Send/'static/compiler corrections and chooser fixes. Also preserve root checkpoint 3c9e6ee's boxed workflow Event::Semantic(Box), Event::semantic constructor and reference_authoring .as_ref() pattern handling. None of those root changes were imported here; do not overwrite root App/scenario/workspace wholesale. + +B4 remains ytr83r-owned. Apply pan plus scale to both its clipping rectangles and clipped child text during union; preserve its renderer/lifecycle oracles. Root owns common Wiki/ledgers, commits, releases and all execution. Full phase1 acceptance and issue2 closure are not claimed. Persistent placement/lane arrangement, exact source/reopen coverage, wider process body and endpoint manipulation, fragment wrapping/operand transfer and other phase2 domain work remain outside this bounded correction. + +## Frozen source fingerprints + +Ordered manifest SHA256: `9EBEC63D5D8B2595FB3867E63E7C6A26C4B04DFA1C7644B840E6FA001A2F0692`. Canonical manifest bytes are each table path, one ASCII space, its uppercase SHA256 and one LF, in this order, UTF-8. These are raw isolated working-file hashes; root integration will produce a separate union identity. + +| Path | SHA256 | +| --- | --- | +| `crates/oa-desktop/src/app.rs` | `F059158240FBD41491C65D7BDDE318B0089A6DFED2688A741172535154078236` | +| `crates/oa-desktop/src/app/process_canvas.rs` | `8B80661A021E2F9F8E83BD9B0858D707F067C2E42CB180A3CDFA59A1467B3DF9` | +| `crates/oa-desktop/src/app/reference_authoring.rs` | `CFD96F4A38754C5FDA1B176B342162B7BFA7BFE0DCAE2A8309953A40395D92BF` | +| `crates/oa-desktop/src/behavior.rs` | `A35D03A1A63BB8FD6D2E5D394CC41AB93A6F306F05BDD89353C6B79503F92E64` | +| `crates/oa-desktop/src/behavior/canvas_workspace.rs` | `096D667C4C8EE3732D52668A9562C665F2D9A192ABDA4E5487FCAD7621C8DBFD` | +| `crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs` | `AF1C7C1E802D1207EE6C7BD66B703811A250D7B56511512AF6C0945C776E68A7` | +| `crates/oa-desktop/src/behavior/canvas_workspace/control.rs` | `851AF1E9B155D6D0D079D9164C5616FADC33E961391A6604A43EABF5158957B3` | +| `crates/oa-desktop/src/behavior/canvas_workspace/focus.rs` | `CC58CB3838F546150D22FE4DD83A3FFD52504C279FEBAEB8D0B4A6A3B8A73950` | +| `crates/oa-desktop/src/behavior/canvas_workspace/tests.rs` | `0272F78DAA97FAB75E971F1548A3E224C2F2D0A4D9D603DB90DBEB9F6D673C1E` | +| `crates/oa-desktop/src/behavior/form_session.rs` | `F5027FB3A5A5599A182E7D1A10DBA56DD66EB1F23A4BA4C4A1744C9A6A5B6E61` | +| `crates/oa-desktop/src/behavior/scenario.rs` | `59F859B246C3186AC5B156B46AEE3A0DDC38F79C1CF332C4FB03092214A43A93` | +| `crates/oa-desktop/src/behavior/scenario/workspace.rs` | `FED77DDF304FD831CC163F6B8079BB6C17AC755BF141A5D30D5E64C04CBD9677` | +| `crates/oa-desktop/src/behavior/scenario/workspace/tests.rs` | `851772CE7C1DA0479DA7D6FB16EF17038653B43DB3B8EDF5E88605AAE4830C89` | +| `crates/oa-desktop/src/behavior/topology.rs` | `5723E51A7DA4EB224D51C1659EBBFA4DC8B11557BC8BF70F6E3E1649BAD24449` | +| `crates/oa-desktop/src/behavior/topology/view.rs` | `B2D502C11426BFFB8763515F2A56AA4C9F8E203BB36D65EB34F81C123FC406A6` | +| `crates/oa-desktop/src/behavior/topology/direct.rs` | `6102219487A0717C66190153C435B5B05226D156C59C7EAAB649249ADEF73644` | +| `crates/oa-desktop/src/behavior/topology/direct_tests.rs` | `D3CD44477BDEB31256BA52AA70383CEFC9805A8B172271D357884C95DDEF11D8` | +| `crates/oa-desktop/src/behavior/topology/layout_tests.rs` | `39D9C28201EC1B044EAE848B09AB276FF7FCF9F69FE6B5E39A6863F56A0111CE` | +| `crates/oa-desktop/src/behavior/topology/contextual.rs` | `5D0AC5B3E258FC70987A00EDE1E603681D99746596F2FCC46AD2B3667D65203A` |