diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 87ea4a63..40320fe2 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -5,6 +5,7 @@ mod checkpoints; #[cfg(test)] mod diagram_selection_tests; mod hosted; +mod reference_authoring; #[cfg(test)] mod review_navigation_tests; #[cfg(test)] @@ -57,7 +58,7 @@ mod direct_scenario_tests { } } #[test] - fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() { + fn direct_scenario_camera_details_and_real_resize_invalidate_only_pointer_context() { let (mut app, f) = setup(); let original = app.document.bundle().clone(); let event = begin(&app, &f); @@ -73,18 +74,26 @@ mod direct_scenario_tests { let _ = app.update(Message::ScenarioCanvas(event)); assert!(!app.behavior.has_pending()); let context = app.scenario_context().unwrap(); - for (area, offset) in [ - (scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)), - (scenario::ScrollArea::Page, iced::Vector::new(0., 90.)), - ] { + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera( + context, + app.camera_epoch, + behavior::canvas_workspace::Event::Pan(iced::Vector::new(30., 150.)), + ))); + assert_ne!(app.scenario_context(), Some(context)); + let context = app.scenario_context().unwrap(); + for offset in [iced::Vector::new(0., 90.), iced::Vector::new(0., 150.)] { let before = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( - context, area, offset, + context, + scenario::ScrollArea::Page, + offset, ))); assert_ne!(app.scenario_context(), Some(before)); let current = app.scenario_context().unwrap(); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( - context, area, offset, + context, + scenario::ScrollArea::Page, + offset, ))); assert_eq!( app.scenario_context(), @@ -113,7 +122,7 @@ mod direct_scenario_tests { } let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( stale, - scenario::ScrollArea::Canvas, + scenario::ScrollArea::Page, iced::Vector::new(0., 900.), ))); assert_eq!(app.scenario_context(), Some(current)); @@ -318,6 +327,7 @@ pub struct FilePrompt { #[derive(Debug, Clone)] pub enum Message { + BehaviorControl(behavior::canvas_workspace::Intent), Architecture(architecture_authoring::Event), Home(crate::home::Event), CheckpointTick, @@ -371,6 +381,8 @@ pub enum Message { FieldValuePath(String, crate::data_values::FieldPathKey), FieldValueBrowse(String, Option, usize), FieldReferenceBrowse(String, String, usize), + FieldReferenceOpen(crate::forms::reference_chooser::adapter::Open), + FieldReferenceEvent(crate::forms::reference_chooser::Event), FieldVariant(String, Value), FieldArray(String, Option, i32), Apply, @@ -445,10 +457,14 @@ pub struct App { pub hosted: crate::hosted::State, pub connection: Option, pub model_menu: Option, + reference_authoring: reference_authoring::State, pub source_tree: crate::source_tree::State, 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, @@ -547,10 +563,13 @@ impl App { checkpoint_close: None, connection: None, model_menu: None, + reference_authoring: Default::default(), source_tree: Default::default(), 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, @@ -1027,7 +1046,13 @@ impl App { crate::model_menu::popup(self), ) .into(); - crate::hosted::view::overlay(self, base) + crate::behavior::scenario::focus::root( + behavior::canvas_workspace::focus_root( + crate::hosted::view::overlay(self, self.reference_overlay(base)), + behavior::canvas_workspace::Scope::input_owner(self), + ), + self.scenario_focus_owner(), + ) } pub fn subscription(&self) -> Subscription { Subscription::batch([ @@ -1083,6 +1108,108 @@ 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; + } if matches!(&message, Message::Connection(_)) && self .architecture @@ -1305,6 +1432,7 @@ impl App { self.last_workspace_page = self.page; } let mut task = self.handle(message); + self.reconcile_reference_chooser(); self.route_state_diagram_workspace(); if self.page != Page::Home { self.last_workspace_page = self.page; @@ -1372,8 +1500,23 @@ impl App { epoch: self.canvas_epoch, }) } + pub(crate) fn scenario_focus_owner(&self) -> Option { + if self.busy + || self.hosted.visible + || self.workflow.is_some() + || self.architecture.is_some() + || self.model_menu.is_some() + || self.file_prompt.is_some() + || self.delete_pending + || self.pending.is_some() + { + return None; + } + self.behavior.scenario.focus_stamp(self.scenario_context()?) + } 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(), @@ -1389,6 +1532,18 @@ impl App { Message::ScenarioCanvas(event) => { use crate::behavior::scenario::{self, CanvasEvent}; let context = event.context(); + if let CanvasEvent::FormShown(_, mount) = event { + if self.scenario_context().is_some_and(|current| { + current.document == context.document + && current.scenario == context.scenario + && current.revision == context.revision + }) && self.scenario_focus_owner().is_some() + && self.behavior.scenario.take_form_focus(mount) + { + return scenario::focus::task(self.scenario_focus_owner().unwrap()); + } + return Task::none(); + } if let CanvasEvent::Viewport(_, size) = event { if self.scenario_context().is_some_and(|current| { current.document == context.document @@ -1413,9 +1568,9 @@ impl App { return Task::none(); } if let CanvasEvent::Scrolled(_, area, offset) = event { - // Canvas and outline notifications may come from the - // same frame. Accept both across the first epoch bump, - // while still rejecting another owner/document/revision. + // Details may report more than once in a frame. Accept the + // current owner's passive clamps across an epoch bump while + // still rejecting another document, owner or revision. if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario @@ -1426,6 +1581,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(); } @@ -1439,9 +1637,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() @@ -1525,37 +1725,6 @@ impl App { return Task::none(); } match mutation { - CanvasEvent::First(_, tool) => { - let empty = match tool { - scenario::Tool::Lifeline => !self - .project() - .children(context.scenario) - .any(|e| e.kind() == Kind::Lifeline), - scenario::Tool::Message | scenario::Tool::Action => { - self.project() - .children(context.scenario) - .any(|e| e.kind() == Kind::Lifeline) - && !self - .project() - .children(context.scenario) - .any(|e| e.kind() == Kind::Occurrence) - } - _ => false, - }; - if !empty { - self.status="This scenario already has content. Use its current authoring tools.".into(); - return Task::none(); - } - let mut state = std::mem::take(&mut self.behavior.scenario); - let result = state.update( - self.project(), - scenario::Event::Start(context.scenario, tool), - ); - self.behavior.scenario = state; - if let Err(error) = result { - self.status = error; - } - } CanvasEvent::BeginOccurrence { tool, slot, @@ -1563,13 +1732,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(()) => { @@ -1862,6 +2040,9 @@ impl App { .insert(path, crate::forms::ValueBrowser { scope, page }); } } + Message::FieldReferenceOpen(_) | Message::FieldReferenceEvent(_) => { + return Task::none(); + } Message::FieldReferenceBrowse(path, query, page) => { if let Some(draft) = &mut self.draft { let page = if draft @@ -2102,7 +2283,7 @@ impl App { if let crate::workflow::Workflow::Semantic(state) = &mut workflow { match event { crate::workflow::Event::Semantic(event) => { - state.update(self.project(), event) + state.update(self.project(), *event) } crate::workflow::Event::Apply => { let focus = state.target(self.project()); diff --git a/crates/oa-desktop/src/app/process_canvas.rs b/crates/oa-desktop/src/app/process_canvas.rs index 211a15bf..089275bd 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); @@ -524,32 +569,30 @@ mod tests { assert!(!app.behavior.has_pending()); } #[test] - fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() { + fn direct_process_app_save_details_resize_and_passive_geometry_keep_drafts_and_camera() { let (mut app, f) = setup(); let context = app.topology_context().unwrap(); + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera( + context, + app.camera_epoch, + behavior::canvas_workspace::Event::Pan(iced::Vector::new(25., 100.)), + ))); + let context = app.topology_context().unwrap(); let original = app.document.snapshot(); app.busy = true; - for (area, offset) in [ - ( - behavior::scenario::ScrollArea::Canvas, - iced::Vector::new(25., 100.), - ), - ( - behavior::scenario::ScrollArea::Page, - iced::Vector::new(0., 40.), - ), - ] { - let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled( - context, area, offset, - ))); - } + let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled( + context, + behavior::scenario::ScrollArea::Page, + iced::Vector::new(0., 40.), + ))); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport( context, iced::Size::new(500., 300.), ))); + let camera = app.behavior.topology.camera_boundary(); assert_eq!( - app.behavior.topology.scroll_offset(), - iced::Vector::new(25., 140.) + app.behavior.topology.details_offset(), + iced::Vector::new(0., 40.) ); assert_ne!(app.topology_context(), Some(context)); assert!(app.busy); @@ -557,12 +600,12 @@ mod tests { stale.document = Id::new_v4(); let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled( stale, - behavior::scenario::ScrollArea::Canvas, + behavior::scenario::ScrollArea::Page, iced::Vector::new(0., 900.), ))); assert_eq!( - app.behavior.topology.scroll_offset(), - iced::Vector::new(25., 140.) + app.behavior.topology.details_offset(), + iced::Vector::new(0., 40.) ); let root = PathBuf::from("process-scroll-save-fixture"); let receipt = FileSnapshot { @@ -582,6 +625,7 @@ mod tests { assert!(!app.busy); assert!(!app.document.can_undo()); assert_eq!(app.project(), &f.p); + assert!(app.behavior.topology.camera_boundary() == camera); let context = app.topology_context().unwrap(); let size = app.window_size; let _ = app.update(Message::WindowResized(size)); diff --git a/crates/oa-desktop/src/app/reference_authoring.rs b/crates/oa-desktop/src/app/reference_authoring.rs new file mode 100644 index 00000000..c8820787 --- /dev/null +++ b/crates/oa-desktop/src/app/reference_authoring.rs @@ -0,0 +1,304 @@ +//! One transient chooser for the currently mounted Inspector or review field. +//! Browsing never calls a draft setter or semantic planner. +use super::{App, Message, Page, Pending}; +use crate::{ + forms::{ + Draft, + reference_chooser::{self as chooser, Action, Close, Outcome, adapter, trigger}, + }, + navigation::InspectorTab, + semantic_review::{self, RowAddress, RowEvent}, + workflow, +}; +use iced::{Element, Rectangle, Task}; +use oa_core::{FacetSelector, Id, Layer}; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Host { + Inspector, + Review { + address: Option, + facet: FacetSelector, + }, +} +impl Host { + fn message(&self, event: chooser::Event) -> Message { + match self { + Self::Inspector => Message::FieldReferenceEvent(event), + Self::Review { address, facet } => { + Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row { + address: *address, + facet: Some(facet.clone()), + event: RowEvent::ReferenceEvent(event), + })) + } + } + } +} +struct Opened { + host: Host, + request: adapter::Open, + document_session: Id, + page: Page, + layer: Layer, + chooser: chooser::State, +} +#[derive(Default)] +pub(super) struct State { + opened: Option, +} + +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 + if self.workflow.is_none() && self.inspector_tab == InspectorTab::Properties => + { + self.draft.as_ref() + } + Host::Review { address, facet } => match self.workflow.as_ref()? { + workflow::Workflow::Semantic(state) => { + state.reference_draft(self.project(), *address, facet, path) + } + _ => None, + }, + _ => None, + } + } + fn reference_available(&self) -> bool { + !self.busy + && !self.hosted.visible + && self.architecture.is_none() + && self.model_menu.is_none() + && self.file_prompt.is_none() + && !self.delete_pending + && self.pending.is_none() + && !self.source_dirty() + && !self.behavior.has_pending() + && !matches!(self.page, Page::Home | Page::Guide | Page::Text) + } + fn reference_valid(&self, opened: &Opened) -> bool { + self.reference_available() + && self.canvas_document == opened.document_session + && self.page == opened.page + && self.layer == opened.layer + && self.document.revision() == opened.chooser.stamp().context.revision + && self.project().id == opened.request.document + && self + .reference_draft(&opened.host, &opened.request.path) + .is_some_and(|draft| draft.reference_matches(&opened.request)) + } + pub(super) fn reconcile_reference_chooser(&mut self) { + if self + .reference_authoring + .opened + .as_ref() + .is_some_and(|opened| !self.reference_valid(opened)) + { + self.reference_authoring + .opened + .as_mut() + .unwrap() + .chooser + .invalidate(); + self.reference_authoring.opened = None; + } + } + fn open_reference(&mut self, host: Host, request: adapter::Open) -> Task { + if !self.reference_available() { + return Task::none(); + } + let Some(draft) = self.reference_draft(&host, &request.path) else { + return Task::none(); + }; + let Ok(chooser) = + draft.reference_catalog(self.project(), &request, self.document.revision()) + else { + return Task::none(); + }; + self.reference_authoring.opened = Some(Opened { + host, + request, + document_session: self.canvas_document, + page: self.page, + layer: self.layer, + chooser, + }); + Task::none() + } + fn reference_event(&mut self, host: Host, event: chooser::Event) -> Task { + let Some(mut opened) = self.reference_authoring.opened.take() else { + return Task::none(); + }; + if !self.reference_valid(&opened) { + return Task::none(); + } + if opened.host != host { + self.reference_authoring.opened = Some(opened); + return Task::none(); + } + match opened.chooser.update(event) { + Outcome::Ignored | Outcome::Browsed => { + self.reference_authoring.opened = Some(opened); + Task::none() + } + Outcome::Closed(reason) => self.reference_focus( + &opened, + match reason { + Close::Escape => Some(0), + Close::Tab { backwards } => Some(if backwards { -1 } else { 1 }), + Close::Outside | Close::WindowChanged => None, + }, + ), + Outcome::Write { context, value } => { + let intent = adapter::Set { + context, + generation: opened.request.generation, + value, + }; + let result = match &opened.host { + Host::Inspector => self + .draft + .as_mut() + .ok_or_else(|| "The property editor closed.".to_owned()) + .and_then(|draft| draft.reference_set(self.document.project(), &intent)), + Host::Review { address, facet } => match self.workflow.as_mut() { + Some(workflow::Workflow::Semantic(state)) => { + state.set_reference(self.document.project(), *address, facet, intent) + } + _ => Err("The review editor closed.".into()), + }, + }; + if let Err(error) = result { + self.status = error; + } + // A scoped write rotates field generation. The task targets the + // newly rendered field, while a later structural edit makes it inert. + self.reference_focus(&opened, Some(0)) + } + } + } + fn reference_focus(&self, opened: &Opened, step: Option) -> Task { + let Some(step) = step else { + return Task::none(); + }; + let Some(draft) = self.reference_draft(&opened.host, &opened.request.path) else { + return Task::none(); + }; + if draft.reference_session() != opened.request.session { + return Task::none(); + } + trigger::focus( + draft.reference_session(), + draft.reference_generation(), + &opened.request.path, + step, + ) + } + /// Called before generic modal dispatch: the review owns a modal, and this + /// chooser owns only its active field. All browser events stop here. + pub(super) fn reference_message(&mut self, message: &Message) -> Option> { + self.reconcile_reference_chooser(); + match message { + Message::FieldReferenceOpen(request) => { + Some(self.open_reference(Host::Inspector, request.clone())) + } + Message::FieldReferenceEvent(event) => { + Some(self.reference_event(Host::Inspector, event.clone())) + } + Message::Workflow(workflow::Event::Semantic(review_event)) => { + let semantic_review::Event::Row { + address, + facet: Some(facet), + event, + } = review_event.as_ref() + else { + return None; + }; + let host = Host::Review { + address: *address, + facet: facet.clone(), + }; + match event { + RowEvent::ReferenceOpen(request) => { + Some(self.open_reference(host, request.clone())) + } + RowEvent::ReferenceEvent(event) => { + Some(self.reference_event(host, event.clone())) + } + _ => None, + } + } + Message::Escape | Message::Request(Pending::Close(_)) + if self.reference_authoring.opened.is_some() => + { + let opened = self.reference_authoring.opened.as_ref().unwrap(); + let host = opened.host.clone(); + let event = opened.chooser.event(Action::Close(Close::Escape)); + Some(self.reference_event(host, event)) + } + // Global shortcuts are also emitted for keys captured by the search + // input. They must not apply drafts, navigate, or undo the model. + Message::Save(_) + | Message::ShortcutUndo(_) + | Message::Request(Pending::Open | Pending::New) + | Message::SourceNavigation(_) + | Message::Page(Page::Guide) + if self.reference_authoring.opened.is_some() => + { + Some(Task::none()) + } + _ => None, + } + } + pub(super) fn reference_overlay< + 'a, + R: iced::advanced::text::Renderer + 'a, + >( + &'a self, + content: Element<'a, Message, iced::Theme, R>, + ) -> Element<'a, Message, iced::Theme, R> { + let opened = self + .reference_authoring + .opened + .as_ref() + .filter(|opened| self.reference_valid(opened)); + let host = opened + .map(|opened| opened.host.clone()) + .unwrap_or(Host::Inspector); + let focus_scope = if self.reference_available() { + match self.workflow.as_ref() { + None if self.inspector_tab == InspectorTab::Properties => { + self.draft.as_ref().map(|draft| trigger::FormScope { + session: draft.reference_session(), + modal: false, + }) + } + Some(workflow::Workflow::Semantic(state)) => state + .reference_form(self.project()) + .map(|draft| trigger::FormScope { + session: draft.reference_session(), + modal: true, + }), + _ => None, + } + } else { + None + }; + chooser::widget::Popup::new( + content, + opened.map(|opened| &opened.chooser), + opened.map_or(Rectangle::default(), |opened| opened.request.anchor), + move |event| host.message(event), + ) + .focus_scope(focus_scope) + .into() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/app/reference_authoring/tests.rs b/crates/oa-desktop/src/app/reference_authoring/tests.rs new file mode 100644 index 00000000..ce275df7 --- /dev/null +++ b/crates/oa-desktop/src/app/reference_authoring/tests.rs @@ -0,0 +1,586 @@ +use super::*; +use crate::forms::Draft; +use oa_core::*; +use serde_json::{Value, json}; + +const OWNER: Id = Id::from_u128(100); +const BASE: u128 = 10_000; +const PATH: &str = "/body/Oa/Shared/DataType/definition/Record/supertype"; + +fn element(p: &mut Project, id: Id, kind: Kind, layer: Layer, name: &str, parent: Option) { + let generated = p.add_element(kind, layer, name, parent).unwrap(); + let mut element = p.elements.remove(&generated).unwrap(); + element.id = id; + element.alias = format!("object_{}", id.as_u128()); + if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() { + *definition = DataTypeDefinition::Record { supertype: None }; + } + p.elements.insert(id, element); +} +fn catalog() -> Project { + let mut p = Project::empty(); + p.id = Id::from_u128(1); + p.name = "Reference catalog".into(); + element( + &mut p, + OWNER, + Kind::DataType, + Layer::Oa, + "Edited record", + None, + ); + for n in (0..1_000).rev() { + let id = Id::from_u128(BASE + n); + element(&mut p, id, Kind::DataType, Layer::Oa, "Controller", None); + p.elements.get_mut(&id).unwrap().alias = format!("controller_{n:04}"); + } + element( + &mut p, + Id::from_u128(200), + Kind::DataType, + Layer::Sa, + "Controller", + None, + ); + element( + &mut p, + Id::from_u128(201), + Kind::Activity, + Layer::Oa, + "Controller", + None, + ); + p +} +fn inspector(p: Project, selected: Id) -> App { + let mut app = App::with_project(p); + let _ = app.update(Message::Select(selected)); + app +} +fn request(app: &App, host: &Host, path: &str) -> adapter::Open { + app.reference_draft(host, path).unwrap().reference_open( + app.project(), + path, + Rectangle { + x: 790., + y: 640., + width: 278., + height: 32., + }, + ) +} +fn open(app: &mut App, host: &Host, path: &str) { + let request = request(app, host, path); + let message = match host { + Host::Inspector => Message::FieldReferenceOpen(request), + Host::Review { address, facet } => { + Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row { + address: *address, + facet: Some(facet.clone()), + event: RowEvent::ReferenceOpen(request), + })) + } + }; + let _ = app.update(message); + assert!( + app.reference_authoring.opened.is_some(), + "field must open: {}", + app.status + ); +} +fn event(app: &App, action: Action) -> Message { + let opened = app.reference_authoring.opened.as_ref().unwrap(); + opened.host.message(opened.chooser.event(action)) +} +fn search(app: &mut App, query: &str) { + let serial = app + .reference_authoring + .opened + .as_ref() + .unwrap() + .chooser + .query_serial() + + 1; + let event = event( + app, + Action::Query { + text: query.into(), + serial, + }, + ); + let _ = app.update(event); +} +fn choose(app: &mut App, id: Id) { + let event = event(app, Action::Choose(id)); + let _ = app.update(event); +} +fn raw(app: &App) -> Value { + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap() +} +fn review(app: &App) -> &semantic_review::State { + let Some(workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else { + panic!("Review is open") + }; + state +} +fn send_review(app: &mut App, event: RowEvent) { + let addressed = review(app).address_event(event); + let _ = app.update(Message::Workflow(workflow::Event::semantic(addressed))); +} +fn review_host(app: &App) -> Host { + let semantic_review::Event::Row { + address, + facet: Some(facet), + .. + } = review(app).address_event(RowEvent::Preview) + else { + panic!("Active facet") + }; + Host::Review { address, facet } +} +fn trace(p: &mut Project, id: Id, oa: Id, sa: Id) { + p.traces.insert( + id, + Trace { + id, + alias: format!("trace_{}", id.as_u128()), + name: "Rationale".into(), + description: String::new(), + oa, + sa, + kind: TraceKind::Manual, + baseline: None, + }, + ); +} + +#[test] +fn inspector_reference_browse_is_read_only_then_one_scoped_value_applies_with_exact_undo_redo() { + let p = catalog(); + let mut app = inspector(p.clone(), OWNER); + let before = app.document.bundle().clone(); + let revision = app.document.revision(); + // Malformed escaped text is unrelated to the selected reference. Browsing + // and the scoped write must keep its exact spelling and inactive payload. + app.draft.as_mut().unwrap().set_exact(true).unwrap(); + let _ = app.update(Message::Field("/name".into(), "\"unfinished".into())); + let raw_before = raw(&app); + open(&mut app, &Host::Inspector, PATH); + assert_eq!( + app.reference_authoring + .opened + .as_ref() + .unwrap() + .chooser + .count(), + 1_000 + ); + search(&mut app, "controller @controller_0720 OA"); + let picker = &app.reference_authoring.opened.as_ref().unwrap().chooser; + assert_eq!(picker.count(), 1); + assert_eq!(picker.highlighted().unwrap().id, Id::from_u128(BASE + 720)); + assert_eq!(raw(&app), raw_before); + assert_eq!(app.document.bundle(), &before); + assert_eq!(app.document.revision(), revision); + assert!(!app.document.can_undo()); + // Captured search shortcuts cannot accidentally save/apply or undo a model. + let _ = app.update(Message::Save(false)); + let _ = app.update(Message::ShortcutUndo(false)); + assert_eq!(raw(&app), raw_before); + assert_eq!(app.document.bundle(), &before); + choose(&mut app, Id::from_u128(BASE + 720)); + let mut expected_raw = raw_before; + *expected_raw["value"].pointer_mut(PATH).unwrap() = json!(Id::from_u128(BASE + 720)); + expected_raw["inputs"][PATH] = json!(Id::from_u128(BASE + 720).to_string()); + assert_eq!(raw(&app), expected_raw); + assert_eq!(app.document.bundle(), &before); + assert!(!app.document.can_undo()); + let _ = app.update(Message::Apply); + assert_eq!( + app.document.bundle(), + &before, + "Unrelated invalid text remains an Apply error" + ); + let _ = app.update(Message::Field("/name".into(), "\"Edited record\"".into())); + let _ = app.update(Message::Apply); + let mut expected = p; + if let Some(SharedBody::DataType { definition, .. }) = + expected.elements.get_mut(&OWNER).unwrap().shared_mut() + { + *definition = DataTypeDefinition::Record { + supertype: Some(Id::from_u128(BASE + 720)), + }; + } + assert_eq!(app.project(), &expected); + let accepted = app.document.bundle().clone(); + assert_ne!(accepted, before); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before); + let _ = app.update(Message::Redo); + assert_eq!(app.document.bundle(), &accepted); + assert_eq!(app.project(), &expected); +} + +#[test] +fn inspector_reference_retains_invalid_missing_and_incompatible_current_until_explicit_clear() { + for (raw_id, expected) in [ + ("-".into(), "invalid"), + (Id::from_u128(999).to_string(), "missing"), + (Id::from_u128(200).to_string(), "incompatible"), + ] { + let mut app = inspector(catalog(), OWNER); + app.draft + .as_mut() + .unwrap() + .shape_scoped(PATH, json!(raw_id)) + .unwrap(); + let original = raw(&app); + let accepted = app.document.bundle().clone(); + open(&mut app, &Host::Inspector, PATH); + let current = &app + .reference_authoring + .opened + .as_ref() + .unwrap() + .chooser + .current; + assert!(matches!( + (expected, current), + ("invalid", chooser::Current::InvalidRaw(_)) + | ("missing", chooser::Current::Missing(_)) + | ("incompatible", chooser::Current::Incompatible(_)) + )); + let _ = app.update(Message::Escape); + assert_eq!(raw(&app), original); + open(&mut app, &Host::Inspector, PATH); + let clear = event(&app, Action::Clear); + let _ = app.update(clear); + assert!( + app.draft + .as_ref() + .unwrap() + .value + .pointer(PATH) + .unwrap() + .is_null() + ); + assert_eq!(app.document.bundle(), &accepted); + assert!(!app.document.can_undo()); + } +} + +#[test] +fn chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_history_text_out() { + for change in 0..7 { + let mut app = inspector(catalog(), OWNER); + open(&mut app, &Host::Inspector, PATH); + let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 1))); + match change { + 0 => { + app.draft + .as_mut() + .unwrap() + .shape_scoped(PATH, json!(Id::from_u128(BASE + 2))) + .unwrap(); + } + 1 => { + let saved = app.draft.as_ref().unwrap().recovery_snapshot(); + app.draft = Some(Draft::from_recovery(app.project(), saved).unwrap()); + } + 2 => { + app.canvas_document = Id::new_v4(); + } + 3 => { + app.hosted.visible = true; + } + 4 => { + app.draft + .as_mut() + .unwrap() + .set_context(Some(json!({"different":"scope"}))); + } + 5 => { + let _ = app.update(Message::Command(Command::Rename( + Id::from_u128(BASE + 3), + "Changed model".into(), + ))); + } + _ => { + app.source_workspace.edit( + "unfinished source".into(), + iced::widget::text_editor::Cursor { + position: iced::widget::text_editor::Position { line: 0, column: 0 }, + selection: None, + }, + ); + } + } + let expected = raw(&app); + let source = app.document.bundle().clone(); + let _ = app.update(delayed); + assert!(app.reference_authoring.opened.is_none()); + assert_eq!(raw(&app), expected); + assert_eq!(app.document.bundle(), &source); + } + let mut p = catalog(); + trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200)); + let app = inspector(p, Id::from_u128(300)); + let draft = app.draft.as_ref().unwrap(); + let request = draft.reference_open( + app.project(), + "/baseline/semantic/facets/0/references/0/target", + Rectangle::default(), + ); + assert!(draft.reference_catalog(app.project(), &request, 0).is_err()); +} + +#[test] +fn reference_array_row_reorder_and_constructor_switch_invalidate_exact_old_field() { + let mut p = Project::empty(); + for n in 0..3 { + element( + &mut p, + Id::from_u128(BASE + n), + Kind::ExchangeItem, + Layer::Oa, + "Signal item", + None, + ); + } + element(&mut p, OWNER, Kind::Event, Layer::Oa, "Signal", None); + *p.elements.get_mut(&OWNER).unwrap().shared_mut().unwrap() = SharedBody::Event { + body: EventBody::Signal { + items: vec![Id::from_u128(BASE), Id::from_u128(BASE + 1)], + }, + }; + let mut app = inspector(p, OWNER); + let list = "/body/Oa/Shared/Event/body/Signal/items"; + let path = format!("{list}/0"); + open(&mut app, &Host::Inspector, &path); + let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2))); + let _ = app.update(Message::FieldArray(list.into(), Some(0), 1)); + let expected = raw(&app); + assert_eq!( + app.draft.as_ref().unwrap().value.pointer(list), + Some(&json!([Id::from_u128(BASE + 1), Id::from_u128(BASE)])) + ); + let _ = app.update(delayed); + assert_eq!(raw(&app), expected); + open(&mut app, &Host::Inspector, &path); + let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2))); + let _ = app.update(Message::FieldVariant( + "/body/Oa/Shared/Event/body".into(), + json!({"Change":{"expression":"unfinished"}}), + )); + let expected = raw(&app); + let _ = app.update(delayed); + assert_eq!(raw(&app), expected); + assert!(!app.document.can_undo()); +} + +#[test] +fn review_reference_facets_groups_and_attachment_hosts_browse_without_replanning_and_reject_old_facet() + { + for mode in 0..3 { + let mut p = Project::empty(); + p.id = Id::from_u128(1); + for (id, layer, parent) in [ + (100, Layer::Oa, None), + (101, Layer::Sa, None), + (102, Layer::Sa, None), + (103, Layer::Oa, Some(Id::from_u128(100))), + (104, Layer::Sa, Some(Id::from_u128(101))), + ] { + element( + &mut p, + Id::from_u128(id), + if parent.is_some() { + Kind::DataField + } else { + Kind::DataType + }, + layer, + "Record", + parent, + ); + } + trace( + &mut p, + Id::from_u128(300), + Id::from_u128(100), + Id::from_u128(101), + ); + let facet = match mode { + 0 => FacetSelector::Data(DataFacet::RecordSupertype), + 1 => FacetSelector::Data(DataFacet::Constructor), + _ => FacetSelector::Child(KeyPair { + oa: Some(Id::from_u128(103)), + sa: Some(Id::from_u128(104)), + }), + }; + let path = if mode == 2 { + "/parent" + } else { + "/body/Sa/Shared/DataType/definition/Record/supertype" + }; + let mut app = App::with_project(p.clone()); + let _ = app.update(Message::StartSemanticReview(Id::from_u128(100))); + if mode == 2 { + // A paired attachment is a real catalog facet only after explicit + // correspondence; selecting an unstaged synthetic pair is rejected. + for (id, oa) in [(103, true), (104, false)] { + let choice = crate::app::Choice { + id: Id::from_u128(id), + label: format!("Record · @object_{id}"), + }; + send_review( + &mut app, + if oa { + RowEvent::ReferenceOa(choice) + } else { + RowEvent::ReferenceSa(choice) + }, + ); + } + send_review(&mut app, RowEvent::AddReference); + } + send_review(&mut app, RowEvent::Select(facet.clone())); + send_review( + &mut app, + RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)), + ); + let host = review_host(&app); + let record = if mode == 2 { + Id::from_u128(104) + } else { + Id::from_u128(101) + }; + assert_eq!( + app.reference_draft(&host, path) + .unwrap_or_else(|| { + panic!( + "Reference fixture mode={mode}, facet={facet:?}, selected={:?}, decisions={:?}, error={:?}", + review(&app).selected, + review(&app).decisions, + review(&app).error + ) + }) + .original, + record + ); + let before = review(&app).recovery_snapshot().unwrap(); + let plan = serde_json::to_value(&review(&app).plan).unwrap(); + let proposed = review(&app).proposed.clone(); + let source = app.document.bundle().clone(); + open(&mut app, &host, path); + search(&mut app, "@object_102 SA"); + assert_eq!( + app.reference_authoring + .opened + .as_ref() + .unwrap() + .chooser + .count(), + 1 + ); + assert_eq!(review(&app).recovery_snapshot().unwrap(), before); + assert_eq!(serde_json::to_value(&review(&app).plan).unwrap(), plan); + assert_eq!(review(&app).proposed, proposed); + assert_eq!(app.document.bundle(), &source); + let old = event(&app, Action::Choose(Id::from_u128(102))); + send_review( + &mut app, + RowEvent::Select(FacetSelector::Header(HeaderFacet::Name)), + ); + let switched = review(&app).recovery_snapshot().unwrap(); + let _ = app.update(old); + assert_eq!(review(&app).recovery_snapshot().unwrap(), switched); + send_review(&mut app, RowEvent::Select(facet)); + open(&mut app, &host, path); + choose(&mut app, Id::from_u128(102)); + let draft = app.reference_draft(&host, path).unwrap(); + let mut expected_record = serde_json::to_value(&p.elements[&record]).unwrap(); + *expected_record.pointer_mut(path).unwrap() = json!(Id::from_u128(102)); + assert_eq!(draft.materialize().unwrap(), expected_record); + assert_eq!(app.document.bundle(), &source); + assert!( + !app.document.can_undo(), + "A choice only stages the active review candidate" + ); + } +} + +mod mounted; + +#[test] +fn review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_focus() { + let mut p = Project::empty(); + for (id, layer) in [ + (100, Layer::Oa), + (101, Layer::Sa), + (102, Layer::Sa), + (110, Layer::Oa), + (111, Layer::Sa), + ] { + element( + &mut p, + Id::from_u128(id), + Kind::DataType, + layer, + "Record", + None, + ); + } + trace( + &mut p, + Id::from_u128(300), + Id::from_u128(100), + Id::from_u128(101), + ); + trace( + &mut p, + Id::from_u128(310), + Id::from_u128(110), + Id::from_u128(111), + ); + let mut app = App::with_project(p); + let _ = app.update(Message::StartSemanticReview(Id::from_u128(300))); + let facet = FacetSelector::Data(DataFacet::RecordSupertype); + send_review(&mut app, RowEvent::Select(facet.clone())); + send_review( + &mut app, + RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), + ); + let host = review_host(&app); + let path = "/body/Sa/Shared/DataType/definition/Record/supertype"; + let old_open = request(&app, &host, path); + open(&mut app, &host, path); + let old_choice = event(&app, Action::Choose(Id::from_u128(102))); + for event in [ + semantic_review::Event::ChooseTrace(crate::app::Choice { + id: Id::from_u128(310), + label: "Second exact trace".into(), + }), + semantic_review::Event::OpenTrace, + ] { + let _ = app.update(Message::Workflow(workflow::Event::semantic(event))); + } + assert_eq!(review(&app).target(app.project()), Some(Id::from_u128(111))); + let expected = review(&app).recovery_snapshot().unwrap(); + let source = app.document.bundle().clone(); + let _ = app.update(old_choice); + let Host::Review { address, facet } = host else { + unreachable!() + }; + let _ = app.update(Message::Workflow(workflow::Event::semantic( + semantic_review::Event::Row { + address, + facet: Some(facet), + event: RowEvent::ReferenceOpen(old_open), + }, + ))); + assert!(app.reference_authoring.opened.is_none()); + assert_eq!(review(&app).recovery_snapshot().unwrap(), expected); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); +} diff --git a/crates/oa-desktop/src/app/reference_authoring/tests/mounted.rs b/crates/oa-desktop/src/app/reference_authoring/tests/mounted.rs new file mode 100644 index 00000000..0c536c35 --- /dev/null +++ b/crates/oa-desktop/src/app/reference_authoring/tests/mounted.rs @@ -0,0 +1,910 @@ +//! Real field + app-root overlay interaction. The null text renderer lets the +//! same retained widget tree exercise focus and geometry without a GPU/window. +use super::*; +use iced::futures::StreamExt; +use iced::{ + Element, Event as Input, Point, Size, Theme, Vector, + advanced::{ + Layout, Shell, layout, + widget::{Id as WidgetId, Operation, Tree, operation}, + }, + keyboard, mouse, + widget::{Space, column, container, opaque, scrollable, stack, text_input}, +}; +use oa_core::transitions::TransitionDirection; + +#[derive(Default)] +struct Clipboard; +impl iced::advanced::Clipboard for Clipboard { + fn read(&self, _: iced::advanced::clipboard::Kind) -> Option { + None + } + fn write(&mut self, _: iced::advanced::clipboard::Kind, _: String) {} +} +#[derive(Default)] +struct Fields { + values: Vec<(WidgetId, Rectangle, bool)>, + scrolls: Vec<(Rectangle, Vector)>, +} +impl Operation for Fields { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&WidgetId>, + bounds: Rectangle, + state: &mut dyn operation::Focusable, + ) { + if let Some(id) = id { + self.values.push((id.clone(), bounds, state.is_focused())); + } + } + fn scrollable( + &mut self, + _: Option<&WidgetId>, + bounds: Rectangle, + _: Rectangle, + translation: Vector, + _: &mut dyn operation::Scrollable, + ) { + self.scrolls.push((bounds, translation)); + } +} +impl Fields { + fn field(&self, id: &WidgetId) -> (Rectangle, bool) { + self.values + .iter() + .find(|(actual, _, _)| actual == id) + .map(|(_, bounds, focused)| (*bounds, *focused)) + .expect("Mounted field identity") + } +} +fn view( + app: &App, + scrolled: bool, + nested: bool, + show_review: bool, +) -> Element<'_, Message, Theme, ()> { + let draft = app.draft.as_ref().unwrap(); + let form = column![ + text_input("Name", &draft.inputs["/name"].raw) + .id("before-reference") + .on_input(|raw| Message::Field("/name".into(), raw)), + adapter::field(draft, app.project(), PATH), + text_input("Description", &draft.inputs["/description"].raw) + .id("after-reference") + .on_input(|raw| Message::Field("/description".into(), raw)), + ] + .spacing(9); + let form = trigger::scope(form, draft.reference_session()); + let base: Element<'_, Message, Theme, ()> = if scrolled { + container( + scrollable(column![Space::new().height(500), form]) + .id("reference-scroll") + .height(180), + ) + .width(300) + .into() + } else { + container(form).width(300).height(iced::Fill).into() + }; + let base: Element<'_, Message, Theme, ()> = if nested { + scrollable(column![Space::new().height(400), base]) + .id("outer-reference-scroll") + .height(180) + .width(300) + .into() + } else { + base + }; + let base = if show_review { + let review: Element<'_, Message, Theme, ()> = match app.workflow.as_ref() { + Some(workflow::Workflow::Semantic(state)) => { + match state.reference_form(app.project()) { + Some(draft) => { + let mut content = column![]; + if let Some(raw) = &state.operation_omission { + content = content.push( + text_input("Required engineering rationale", raw) + .id("review-rationale") + .on_input(|raw| { + Message::Workflow(workflow::Event::semantic( + state.address_event(RowEvent::OperationRationale(raw)), + )) + }), + ); + } + content = content.push(trigger::scope( + adapter::field(draft, app.project(), "/parent").map(|message| { + match message { + Message::FieldReferenceOpen(request) => { + Message::Workflow(workflow::Event::semantic( + state.address_event(RowEvent::ReferenceOpen(request)), + )) + } + other => other, + } + }), + draft.reference_session(), + )); + opaque(container(trigger::modal(content)).width(300).height(180)) + } + None => Space::new().into(), + } + } + _ => Space::new().into(), + }; + stack![base, review].into() + } else { + base + }; + app.reference_overlay(base) +} +struct Mount { + tree: Option, + size: Size, + scrolled: bool, + nested: bool, + show_review: bool, +} +impl Default for Mount { + fn default() -> Self { + Self { + tree: None, + size: Size::new(360., 320.), + scrolled: false, + nested: false, + show_review: false, + } + } +} +impl Mount { + fn frame( + &mut self, + app: &App, + events: &[(Input, Option)], + ) -> (Vec, Fields, Option) { + let mut view = view(app, self.scrolled, self.nested, self.show_review); + let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget())); + view.as_widget().diff(tree); + let node = + view.as_widget_mut() + .layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size)); + let viewport = Rectangle::with_size(self.size); + let overlay_node = view + .as_widget_mut() + .overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO) + .map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size)); + let mut messages = Vec::new(); + for (input, point) in events { + let mut shell = Shell::new(&mut messages); + let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available); + if let Some(overlay_node) = &overlay_node { + let mut overlay = view + .as_widget_mut() + .overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO) + .unwrap(); + overlay.as_overlay_mut().update( + input, + Layout::new(overlay_node), + cursor, + &(), + &mut Clipboard, + &mut shell, + ); + assert!(shell.is_event_captured()); + } else { + view.as_widget_mut().update( + tree, + input, + Layout::new(&node), + cursor, + &(), + &mut Clipboard, + &mut shell, + &viewport, + ); + } + } + let mut fields = Fields::default(); + view.as_widget_mut() + .operate(tree, Layout::new(&node), &(), &mut fields); + ( + messages, + fields, + overlay_node.map(|node| node.children()[0].bounds()), + ) + } + fn click(&mut self, app: &App, point: Point) -> Vec { + self.frame( + app, + &[ + ( + Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Some(point), + ), + ( + Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + Some(point), + ), + ], + ) + .0 + } + async fn perform(&mut self, app: &App, task: Task) { + let Some(mut stream) = iced_runtime::task::into_stream(task) else { + return; + }; + while let Some(action) = stream.next().await { + let iced_runtime::Action::Widget(mut operation) = action else { + panic!("Chooser only requests scoped widget focus") + }; + let mut view = view(app, self.scrolled, self.nested, self.show_review); + let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget())); + view.as_widget().diff(tree); + let node = + view.as_widget_mut() + .layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size)); + loop { + view.as_widget_mut() + .operate(tree, Layout::new(&node), &(), operation.as_mut()); + match operation.finish() { + operation::Outcome::Chain(next) => operation = next, + _ => break, + } + } + } + } + async fn dispatch(&mut self, app: &mut App, messages: Vec) { + for message in messages { + let task = app.update(message); + self.perform(app, task).await; + } + } +} +fn key(key: keyboard::key::Named, shift: bool) -> Input { + Input::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(key), + modified_key: keyboard::Key::Named(key), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space), + location: keyboard::Location::Standard, + modifiers: if shift { + keyboard::Modifiers::SHIFT + } else { + keyboard::Modifiers::empty() + }, + text: None, + repeat: false, + }) +} +fn typing(raw: &str) -> Vec<(Input, Option)> { + raw.chars() + .map(|character| { + let raw = character.to_string(); + ( + Input::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(raw.clone().into()), + modified_key: keyboard::Key::Character(raw.clone().into()), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: Some(raw.into()), + repeat: false, + }), + None, + ) + }) + .collect() +} + +#[tokio::test] +async fn mounted_reference_trigger_tabs_to_root_popup_search_escape_and_exact_adjacent_fields() { + let mut app = inspector(catalog(), OWNER); + let accepted = app.document.bundle().clone(); + let original = raw(&app); + let mut mount = Mount::default(); + let fields = mount.frame(&app, &[]).1; + let messages = mount.click( + &app, + fields.field(&WidgetId::new("before-reference")).0.center(), + ); + mount.dispatch(&mut app, messages).await; + let (_, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]); + let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + assert!( + fields.field(&id).1, + "Tab reaches the real reference trigger" + ); + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Enter, false), None)]) + .0; + assert_eq!(messages.len(), 1, "One keyboard activation opens one field"); + mount.dispatch(&mut app, messages).await; + let popup = mount + .frame(&app, &[]) + .2 + .expect("App root overlay is mounted"); + assert!( + popup.x >= 0. + && popup.y >= 0. + && popup.x + popup.width <= 360. + && popup.y + popup.height <= 320. + ); + let mut inputs = typing("@controller_0720"); + inputs.push((key(keyboard::key::Named::Escape, false), None)); + let messages = mount.frame(&app, &inputs).0; + mount.dispatch(&mut app, messages).await; + let (_, fields, popup) = mount.frame(&app, &[]); + assert!(popup.is_none()); + assert!( + fields.field(&id).1, + "Escape returns to its exact reference field" + ); + assert_eq!(raw(&app), original); + assert_eq!(app.document.bundle(), &accepted); + for (backwards, expected) in [(false, "after-reference"), (true, "before-reference")] { + let messages = mount.click(&app, fields.field(&id).0.center()); + mount.dispatch(&mut app, messages).await; + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)]) + .0; + mount.dispatch(&mut app, messages).await; + let (_, fields, popup) = mount.frame(&app, &[]); + assert!(popup.is_none()); + assert!( + fields.field(&WidgetId::new(expected)).1, + "Chooser Tab stays within the originating form" + ); + } + assert_eq!(app.document.bundle(), &accepted); +} + +#[tokio::test] +async fn mounted_reference_choice_stages_literal_identity_and_delayed_focus_cannot_hit_replacement_field() + { + let mut app = inspector(catalog(), OWNER); + let accepted = app.document.bundle().clone(); + let mut mount = Mount::default(); + let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + let fields = mount.frame(&app, &[]).1; + let messages = mount.click(&app, fields.field(&id).0.center()); + mount.dispatch(&mut app, messages).await; + let messages = mount.frame(&app, &typing("@controller_0720")).0; + mount.dispatch(&mut app, messages).await; + assert_eq!( + app.reference_authoring + .opened + .as_ref() + .unwrap() + .chooser + .count(), + 1 + ); + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Enter, false), None)]) + .0; + assert_eq!(messages.len(), 1); + let delayed_focus = app.update(messages.into_iter().next().unwrap()); + assert_eq!( + app.draft.as_ref().unwrap().value.pointer(PATH), + Some(&json!(Id::from_u128(BASE + 720))) + ); + assert_eq!(app.document.bundle(), &accepted); + // A structural edit occurs after Choose but before its asynchronous focus. + app.draft + .as_mut() + .unwrap() + .shape_scoped(PATH, Value::Null) + .unwrap(); + let fields = mount.frame(&app, &[]).1; + let messages = mount.click( + &app, + fields.field(&WidgetId::new("after-reference")).0.center(), + ); + mount.dispatch(&mut app, messages).await; + mount.perform(&app, delayed_focus).await; + let fields = mount.frame(&app, &[]).1; + assert!( + fields.field(&WidgetId::new("after-reference")).1, + "A missing old field target makes no focus changes" + ); + assert!( + !fields + .field(&trigger::field_id( + app.draft.as_ref().unwrap().reference_generation(), + PATH + )) + .1 + ); + assert_eq!(app.document.bundle(), &accepted); +} + +#[tokio::test] +async fn mounted_scrolled_reference_anchor_uses_window_geometry_for_pointer_and_keyboard() { + let mut app = inspector(catalog(), OWNER); + let mut mount = Mount { + scrolled: true, + ..Default::default() + }; + mount.frame(&app, &[]); + mount + .perform( + &app, + iced::widget::operation::snap_to("reference-scroll", scrollable::RelativeOffset::END), + ) + .await; + let fields = mount.frame(&app, &[]).1; + let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + let content = fields.field(&id).0; + let (viewport, offset) = fields.scrolls[0]; + assert!(offset.y > 400., "Fixture is genuinely scrolled"); + let expected = Rectangle { + x: content.x - offset.x, + y: content.y - offset.y, + ..content + } + .intersection(&viewport) + .unwrap(); + let messages = mount.click(&app, expected.center()); + let [Message::FieldReferenceOpen(request)] = messages.as_slice() else { + panic!("Scrolled field should emit one actual Open") + }; + assert_eq!(request.anchor, expected); + mount.dispatch(&mut app, messages).await; + let close = app.update(Message::Escape); + mount.perform(&app, close).await; + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Enter, false), None)]) + .0; + let [Message::FieldReferenceOpen(request)] = messages.as_slice() else { + panic!("Focused scrolled field opens without a pointer") + }; + assert_eq!(request.anchor, expected); +} + +#[tokio::test] +async fn mounted_fresh_tab_reveals_offscreen_reference_and_return_reveals_it_again() { + for nested in [false, true] { + let mut app = inspector(catalog(), OWNER); + let original = raw(&app); + let source = app.document.bundle().clone(); + let mut mount = Mount { + scrolled: true, + nested, + ..Default::default() + }; + let fields = mount.frame(&app, &[]).1; + assert!( + fields + .scrolls + .iter() + .all(|(_, offset)| *offset == Vector::ZERO) + ); + assert!(fields.field(&WidgetId::new("before-reference")).0.y > 180.); + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]) + .0; + assert!( + messages.is_empty(), + "Keyboard reveal must not author any input" + ); + let fields = mount.frame(&app, &[]).1; + assert!(fields.field(&WidgetId::new("before-reference")).1); + assert!( + fields.scrolls.iter().all(|(_, offset)| offset.y > 0.), + "Every enclosing viewport reveals fresh focus" + ); + let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum(); + assert!( + fields.field(&id).0.y - offset >= 180., + "The next reference starts below the fold" + ); + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]) + .0; + assert!(messages.is_empty()); + let fields = mount.frame(&app, &[]).1; + let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum(); + let (bounds, focused) = fields.field(&id); + assert!(focused); + assert!(bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01); + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Enter, false), None)]) + .0; + assert_eq!( + messages.len(), + 1, + "A newly revealed field opens through Enter" + ); + mount.dispatch(&mut app, messages).await; + let return_focus = app.update(Message::Escape); + mount + .perform( + &app, + iced::widget::operation::snap_to( + "reference-scroll", + scrollable::RelativeOffset::START, + ), + ) + .await; + if nested { + mount + .perform( + &app, + iced::widget::operation::snap_to( + "outer-reference-scroll", + scrollable::RelativeOffset::START, + ), + ) + .await; + } + assert!( + mount + .frame(&app, &[]) + .1 + .scrolls + .iter() + .all(|(_, offset)| *offset == Vector::ZERO) + ); + mount.perform(&app, return_focus).await; + let fields = mount.frame(&app, &[]).1; + let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum(); + let (bounds, focused) = fields.field(&id); + assert!( + focused && bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01 + ); + assert_eq!(raw(&app), original); + assert_eq!(app.document.bundle(), &source); + } +} + +#[tokio::test] +async fn mounted_form_tab_enters_in_both_directions_and_cycles_boundaries_without_losing_focus() { + for backwards in [false, true] { + let app = inspector(catalog(), OWNER); + let original = raw(&app); + let mut mount = Mount::default(); + let reference = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + let forward = [ + WidgetId::new("before-reference"), + reference.clone(), + WidgetId::new("after-reference"), + ]; + let reverse = [ + WidgetId::new("after-reference"), + reference, + WidgetId::new("before-reference"), + ]; + let expected = if backwards { &reverse } else { &forward }; + for n in 0..7 { + let (messages, fields, popup) = + mount.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)]); + assert!(messages.is_empty() && popup.is_none()); + assert!(fields.field(&expected[n % 3]).1); + assert_eq!( + fields + .values + .iter() + .filter(|(_, _, focused)| *focused) + .count(), + 1 + ); + } + assert_eq!(raw(&app), original); + assert!(!app.document.can_undo()); + } +} + +#[tokio::test] +async fn mounted_delayed_return_cannot_focus_under_review_and_fresh_modal_tab_owns_its_form() { + let mut p = catalog(); + trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200)); + let mut app = inspector(p, OWNER); + let source = app.document.bundle().clone(); + let original = raw(&app); + let mut mount = Mount { + show_review: true, + ..Default::default() + }; + let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + let session = app.draft.as_ref().unwrap().reference_session(); + let fields = mount.frame(&app, &[]).1; + let messages = mount.click(&app, fields.field(&id).0.center()); + mount.dispatch(&mut app, messages).await; + let return_focus = app.update(Message::Escape); + let fields = mount.frame(&app, &[]).1; + let messages = mount.click( + &app, + fields.field(&WidgetId::new("after-reference")).0.center(), + ); + mount.dispatch(&mut app, messages).await; + let _ = app.update(Message::StartSemanticReview(Id::from_u128(300))); + let facet = FacetSelector::Header(HeaderFacet::Parent); + send_review(&mut app, RowEvent::Select(facet.clone())); + send_review( + &mut app, + RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), + ); + assert_eq!( + app.draft.as_ref().unwrap().reference_session(), + session, + "Old Inspector really remains mounted" + ); + assert_eq!( + trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH), + id + ); + let review_before = review(&app).recovery_snapshot().unwrap(); + let review_field = trigger::field_id( + review(&app) + .reference_form(app.project()) + .unwrap() + .reference_generation(), + "/parent", + ); + mount.perform(&app, return_focus).await; + let fields = mount.frame(&app, &[]).1; + assert!( + !fields.field(&id).1, + "A live old field underneath a modal cannot receive the stale focus task" + ); + assert!(fields.field(&WidgetId::new("after-reference")).1); + assert!(!fields.field(&review_field).1); + let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]); + assert!(messages.is_empty()); + assert!( + fields.field(&review_field).1, + "Review entry ignores retained focus under its modal" + ); + assert!( + !fields.field(&WidgetId::new("after-reference")).1, + "Modal focus acquisition deactivates background inputs" + ); + let messages = mount.frame(&app, &typing("x")).0; + assert!( + messages.is_empty(), + "A character at the focused Review trigger cannot type into the Inspector below it" + ); + let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, true), None)]); + assert!(messages.is_empty()); + assert!(fields.field(&review_field).1); + assert!(!fields.field(&id).1); + assert_eq!(review(&app).recovery_snapshot().unwrap(), review_before); + assert_eq!(raw(&app), original); + assert_eq!(app.document.bundle(), &source); +} + +#[tokio::test] +async fn mounted_review_pointer_open_and_outside_dismissal_keep_background_input_inactive() { + let mut p = catalog(); + trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200)); + let mut app = inspector(p, OWNER); + let original = raw(&app); + let source = app.document.bundle().clone(); + let mut mount = Mount { + show_review: true, + ..Default::default() + }; + let fields = mount.frame(&app, &[]).1; + let messages = mount.click( + &app, + fields.field(&WidgetId::new("after-reference")).0.center(), + ); + mount.dispatch(&mut app, messages).await; + let _ = app.update(Message::StartSemanticReview(Id::from_u128(300))); + let facet = FacetSelector::Header(HeaderFacet::Parent); + send_review(&mut app, RowEvent::Select(facet.clone())); + send_review( + &mut app, + RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), + ); + let saved = review(&app).recovery_snapshot().unwrap(); + let id = trigger::field_id( + review(&app) + .reference_form(app.project()) + .unwrap() + .reference_generation(), + "/parent", + ); + let fields = mount.frame(&app, &[]).1; + assert!( + fields.field(&WidgetId::new("after-reference")).1, + "Fixture retains Inspector focus before the modal receives input" + ); + let messages = mount.click(&app, fields.field(&id).0.center()); + mount.dispatch(&mut app, messages).await; + assert!(matches!( + &app.reference_authoring.opened.as_ref().unwrap().host, + Host::Review { .. } + )); + let fields = mount.frame(&app, &[]).1; + assert!(!fields.field(&WidgetId::new("after-reference")).1); + let messages = mount.click(&app, Point::new(359., 319.)); + assert!(matches!( + messages.as_slice(), + [Message::Workflow(workflow::Event::Semantic(event))] if matches!(event.as_ref(), + semantic_review::Event::Row { + event: RowEvent::ReferenceEvent(chooser::Event { + action: Action::Close(Close::Outside), + .. + }), + .. + } + ) + )); + for message in messages { + let task = app.update(message); + assert!( + iced_runtime::task::into_stream(task).is_none(), + "Outside dismissal must not request focus or apply" + ); + } + let (messages, fields, popup) = mount.frame(&app, &typing("x")); + assert!(messages.is_empty() && popup.is_none()); + assert!(!fields.field(&WidgetId::new("after-reference")).1); + assert_eq!(review(&app).recovery_snapshot().unwrap(), saved); + assert_eq!(raw(&app), original); + assert_eq!(app.document.bundle(), &source); +} + +#[tokio::test] +async fn mounted_reverse_review_required_rationale_remains_editable_beside_candidate_reference_form() + { + let mut p = persistence::decode(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../harness/fixtures/interface-operation-probe/output/boundary-only.json" + ))) + .unwrap(); + let sa = p + .elements + .values() + .find(|element| element.alias == "operation_probe_43") + .unwrap() + .id; + let allocation = p + .relations + .values() + .find(|relation| relation.alias == "operation_probe_30") + .unwrap() + .id; + // The boundary fixture deliberately has no selected operation. Author one + // through the validated command path before testing reverse omission. + let mut message = p.elements[&sa].clone(); + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { operation, .. }, + .. + }) = message.shared_mut() + else { + panic!("The operation fixture must contain an Exchange call") + }; + *operation = Some(OperationUse { + allocation: Some(allocation), + }); + p = execute_batch(&p, &[Command::EditElement(sa, message)]).unwrap(); + assert!(matches!( + oa_core::operations::resolve(&p, sa).unwrap(), + oa_core::operations::OperationResolution::Selected(value) + if value.allocation == allocation + )); + let inspector_id = Id::new_v4(); + element( + &mut p, + inspector_id, + Kind::DataType, + Layer::Oa, + "Inspector record", + None, + ); + let scenario = p + .add_element(Kind::Scenario, Layer::Oa, "Operational counterpart", None) + .unwrap(); + let oa = p + .add_element( + Kind::Occurrence, + Layer::Oa, + "Operational message", + Some(scenario), + ) + .unwrap(); + *p.elements.get_mut(&oa).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }, + precondition: String::new(), + postcondition: String::new(), + }; + p.elements + .get_mut(&scenario) + .unwrap() + .steps_mut() + .unwrap() + .push(oa); + let trace_id = Id::new_v4(); + trace(&mut p, trace_id, oa, sa); + let mut app = inspector(p, inspector_id); + let source = app.document.bundle().clone(); + let inspector_raw = raw(&app); + let _ = app.update(Message::StartSemanticReview(trace_id)); + let _ = app.update(Message::Workflow(workflow::Event::semantic( + semantic_review::Event::Direction(TransitionDirection::SaToOa), + ))); + let facet = FacetSelector::Header(HeaderFacet::Parent); + send_review(&mut app, RowEvent::Select(facet.clone())); + send_review( + &mut app, + RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), + ); + send_review(&mut app, RowEvent::OperationOmission(true)); + assert_eq!(review(&app).direction, TransitionDirection::SaToOa); + assert_eq!(review(&app).operation_omission.as_deref(), Some("")); + let candidate = serde_json::to_value( + review(&app) + .reference_form(app.project()) + .unwrap_or_else(|| { + panic!( + "Reverse review candidate: selected={:?}, decisions={:?}, error={:?}", + review(&app).selected, + review(&app).decisions, + review(&app).error + ) + }) + .recovery_snapshot(), + ) + .unwrap(); + let mut mount = Mount { + show_review: true, + ..Default::default() + }; + let fields = mount.frame(&app, &[]).1; + let messages = mount.click( + &app, + fields.field(&WidgetId::new("review-rationale")).0.center(), + ); + mount.dispatch(&mut app, messages).await; + let messages = mount.frame(&app, &typing("Engineering reason")).0; + assert_eq!( + messages.len(), + "Engineering reason".chars().count(), + "Every key reaches the legitimate sibling inside Review" + ); + assert!(messages.iter().all(|message| matches!( + message, + Message::Workflow(workflow::Event::Semantic(event)) if matches!(event.as_ref(), semantic_review::Event::Row { + event: RowEvent::OperationRationale(_), + .. + }) + ))); + mount.dispatch(&mut app, messages).await; + assert_eq!( + review(&app).operation_omission.as_deref(), + Some("Engineering reason") + ); + assert!( + mount + .frame(&app, &[]) + .1 + .field(&WidgetId::new("review-rationale")) + .1 + ); + assert_eq!( + serde_json::to_value( + review(&app) + .reference_form(app.project()) + .unwrap() + .recovery_snapshot() + ) + .unwrap(), + candidate + ); + assert_eq!(raw(&app), inspector_raw); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); +} diff --git a/crates/oa-desktop/src/app/review_navigation_tests.rs b/crates/oa-desktop/src/app/review_navigation_tests.rs index 741d1b36..6c985bfb 100644 --- a/crates/oa-desktop/src/app/review_navigation_tests.rs +++ b/crates/oa-desktop/src/app/review_navigation_tests.rs @@ -16,7 +16,7 @@ fn state(app: &App) -> &State { } fn row_event(app: &mut App, event: RowEvent) { let event = state(app).address_event(event); - send(app, Message::Workflow(Event::Semantic(event))); + send(app, Message::Workflow(Event::semantic(event))); } fn fixture(reverse: bool, destination_view: bool) -> (App, Id, Id, Id, Option) { let mut p = Project::empty(); diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 80aaa3f9..d199096d 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -14,9 +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 creation; +mod form_session; pub(crate) mod scenario; mod scenario_lifecycle; pub(crate) mod source_draft; @@ -37,6 +39,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> { @@ -44,28 +47,10 @@ mod scenario_scroll { content: content.into(), context, area, - process: false, + process, restore, }) } - 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, - }) - } struct Observer<'a, R = Renderer> { content: Element<'a, Message, Theme, R>, context: scenario::Context, @@ -336,7 +321,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)) @@ -406,97 +391,96 @@ mod scenario_scroll { std::time::Instant::now(), )) }; - for area in [ScrollArea::Canvas, ScrollArea::Page] { - for returned_height in [600., 250.] { - let (project, [a, _, _], [use_id, _, _]) = 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 source = app.document.bundle().clone(); - let mut view = observer(600., app.scenario_context().unwrap()); - view.area = area; - let mut tree = widget::Tree::new(&view as &dyn Widget); - for message in update(&mut view, &mut tree, redraw()).0 { - let _ = app.update(message); - } - for message in update( - &mut view, - &mut tree, - iced::Event::Mouse(mouse::Event::WheelScrolled { - delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. }, - }), - ) - .0 - { - let _ = app.update(message); - } - assert_eq!(actual_offset(&mut view, &mut tree), Vector::new(0., 180.)); - navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, use_id)); - view = observer(600., app.scenario_context().unwrap()); - view.area = area; - view.diff(&mut tree); - for message in update(&mut view, &mut tree, redraw()).0 { - let _ = app.update(message); - } - assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO); - navigation_event(&mut app, CanvasEvent::Back); - assert_eq!(app.selected, use_id); - let context = app.scenario_context().unwrap(); - let request = app.behavior.scenario.scroll_restore(context, area).unwrap(); - view = observer(returned_height, context); - view.area = area; - view.restore = Some(request); - view.diff(&mut tree); - let expected = Vector::new(0., (returned_height - 200.).min(180.)); - let messages = update(&mut view, &mut tree, redraw()).0; - assert!( - matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Restored(_, _, _, actual))] if *actual == expected) - ); - assert_eq!(actual_offset(&mut view, &mut tree), expected); - for message in messages { - let _ = app.update(message); - } - assert!( - app.behavior - .scenario - .scroll_restore(app.scenario_context().unwrap(), area) - .is_none() - ); - // A delayed receipt leaves the same request in the view; later input must not snap back. - for message in update( - &mut view, - &mut tree, - iced::Event::Mouse(mouse::Event::WheelScrolled { - delta: mouse::ScrollDelta::Pixels { x: 0., y: 20. }, - }), - ) - .0 - { - let _ = app.update(message); - } - let moved = expected - Vector::new(0., 20.); - assert_eq!(actual_offset(&mut view, &mut tree), moved); - view.context = app.scenario_context().unwrap(); - view.diff(&mut tree); - assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); - assert_eq!(actual_offset(&mut view, &mut tree), moved); - navigation_event(&mut app, |ctx| { - CanvasEvent::Restored(ctx, request.token, area, Vector::new(0., 999.)) - }); - let late_context = app.scenario_context().unwrap(); - let _ = app.update(Message::Page(crate::app::Page::Diagram)); - let epoch = app.canvas_epoch; - let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Restored( - late_context, - request.token, - area, - Vector::ZERO, - ))); - assert_eq!(app.canvas_epoch, epoch); - assert_eq!(app.project(), &project); - assert_eq!(app.document.bundle(), &source); - assert!(!app.document.can_undo()); + let area = ScrollArea::Page; + for returned_height in [600., 250.] { + let (project, [a, _, _], [use_id, _, _]) = 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 source = app.document.bundle().clone(); + let mut view = observer(600., app.scenario_context().unwrap()); + view.area = area; + let mut tree = widget::Tree::new(&view as &dyn Widget); + for message in update(&mut view, &mut tree, redraw()).0 { + let _ = app.update(message); } + for message in update( + &mut view, + &mut tree, + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. }, + }), + ) + .0 + { + let _ = app.update(message); + } + assert_eq!(actual_offset(&mut view, &mut tree), Vector::new(0., 180.)); + navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, use_id)); + view = observer(600., app.scenario_context().unwrap()); + view.area = area; + view.diff(&mut tree); + for message in update(&mut view, &mut tree, redraw()).0 { + let _ = app.update(message); + } + assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO); + navigation_event(&mut app, CanvasEvent::Back); + assert_eq!(app.selected, use_id); + let context = app.scenario_context().unwrap(); + let request = app.behavior.scenario.scroll_restore(context, area).unwrap(); + view = observer(returned_height, context); + view.area = area; + view.restore = Some(request); + view.diff(&mut tree); + let expected = Vector::new(0., (returned_height - 200.).min(180.)); + let messages = update(&mut view, &mut tree, redraw()).0; + assert!( + matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Restored(_, _, _, actual))] if *actual == expected) + ); + assert_eq!(actual_offset(&mut view, &mut tree), expected); + for message in messages { + let _ = app.update(message); + } + assert!( + app.behavior + .scenario + .scroll_restore(app.scenario_context().unwrap(), area) + .is_none() + ); + // A delayed receipt leaves the same request in the view; later input must not snap back. + for message in update( + &mut view, + &mut tree, + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: 20. }, + }), + ) + .0 + { + let _ = app.update(message); + } + let moved = expected - Vector::new(0., 20.); + assert_eq!(actual_offset(&mut view, &mut tree), moved); + view.context = app.scenario_context().unwrap(); + view.diff(&mut tree); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + assert_eq!(actual_offset(&mut view, &mut tree), moved); + navigation_event(&mut app, |ctx| { + CanvasEvent::Restored(ctx, request.token, area, Vector::new(0., 999.)) + }); + let late_context = app.scenario_context().unwrap(); + let _ = app.update(Message::Page(crate::app::Page::Diagram)); + let epoch = app.canvas_epoch; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Restored( + late_context, + request.token, + area, + Vector::ZERO, + ))); + assert_eq!(app.canvas_epoch, epoch); + assert_eq!(app.project(), &project); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); } } #[test] @@ -578,9 +562,9 @@ mod scenario_scroll { other.scenario = Id::new_v4(); let mut state = topology::State::default(); state.enter(context.document, context.scenario); - state.scrolled(scenario::ScrollArea::Canvas, Vector::new(0., 180.)); + state.scrolled(scenario::ScrollArea::Page, Vector::new(0., 180.)); state.enter(other.document, other.scenario); - assert_eq!(state.scroll_offset(), Vector::ZERO); + assert_eq!(state.details_offset(), Vector::ZERO); view = observer(600., other); view.process = true; view.diff(&mut tree); @@ -597,92 +581,91 @@ mod scenario_scroll { { use widget::operation::scrollable::{AbsoluteOffset, scroll_to}; for process in [false, true] { - for area in [scenario::ScrollArea::Canvas, scenario::ScrollArea::Page] { - for change in 0..4 { - let context = scenario::direct_tests::context(Id::new_v4()); - let mut view = observer(600., context); - view.process = process; - view.area = area; - let mut tree = widget::Tree::new(&view as &dyn Widget); - let node = view.layout( - &mut tree, - &(), - &layout::Limits::new(Size::ZERO, Size::new(800., 700.)), - ); - // Model a programmatic reveal before the observer has reported. - // Mounted identity must not depend on its last scroll notification. - let mut operation = scroll_to::<()>( - widget::Id::new("observer-test"), - AbsoluteOffset { - x: None, - y: Some(180.), - }, - ); - view.operate(&mut tree, Layout::new(&node), &(), &mut operation); - let mut next = context; - match change { - 0 => next.scenario = Id::new_v4(), - 1 => next.document = Id::new_v4(), - 2 => { - next.revision += 1; - next.epoch += 5; - } - _ => {} + let area = scenario::ScrollArea::Page; + for change in 0..4 { + let context = scenario::direct_tests::context(Id::new_v4()); + let mut view = observer(600., context); + view.process = process; + view.area = area; + let mut tree = widget::Tree::new(&view as &dyn Widget); + let node = view.layout( + &mut tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(800., 700.)), + ); + // Model a programmatic reveal before the observer has reported. + // Mounted identity must not depend on its last scroll notification. + let mut operation = scroll_to::<()>( + widget::Id::new("observer-test"), + AbsoluteOffset { + x: None, + y: Some(180.), + }, + ); + view.operate(&mut tree, Layout::new(&node), &(), &mut operation); + let mut next = context; + match change { + 0 => next.scenario = Id::new_v4(), + 1 => next.document = Id::new_v4(), + 2 => { + next.revision += 1; + next.epoch += 5; } - view = observer(if change == 3 { 800. } else { 600. }, next); - view.process = process; - view.area = area; - view.diff(&mut tree); - let redraw = || { - iced::Event::Window(iced::window::Event::RedrawRequested( - std::time::Instant::now(), - )) - }; - let (messages, _) = update(&mut view, &mut tree, redraw()); - let actual = messages - .into_iter() - .filter_map(|message| match message { - Message::TopologyCanvas(topology::CanvasEvent::Scrolled( - c, - reported_area, - offset, - )) => { - assert_eq!( - (c.document, c.owner, c.revision, c.epoch), - (next.document, next.scenario, next.revision, next.epoch) - ); - assert_eq!( - std::mem::discriminant(&reported_area), - std::mem::discriminant(&area) - ); - Some(offset) - } - Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled( - c, - reported_area, - offset, - )) => { - assert_eq!(c, next); - assert_eq!( - std::mem::discriminant(&reported_area), - std::mem::discriminant(&area) - ); - Some(offset) - } - _ => None, - }) - .collect::>(); - assert_eq!( - actual, - vec![if change < 2 { - Vector::ZERO - } else { - Vector::new(0., 180.) - }], - "process={process} area={area:?} change={change}" - ); - assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + _ => {} } + view = observer(if change == 3 { 800. } else { 600. }, next); + view.process = process; + view.area = area; + view.diff(&mut tree); + let redraw = || { + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )) + }; + let (messages, _) = update(&mut view, &mut tree, redraw()); + let actual = messages + .into_iter() + .filter_map(|message| match message { + Message::TopologyCanvas(topology::CanvasEvent::Scrolled( + c, + reported_area, + offset, + )) => { + assert_eq!( + (c.document, c.owner, c.revision, c.epoch), + (next.document, next.scenario, next.revision, next.epoch) + ); + assert_eq!( + std::mem::discriminant(&reported_area), + std::mem::discriminant(&area) + ); + Some(offset) + } + Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled( + c, + reported_area, + offset, + )) => { + assert_eq!(c, next); + assert_eq!( + std::mem::discriminant(&reported_area), + std::mem::discriminant(&area) + ); + Some(offset) + } + _ => None, + }) + .collect::>(); + assert_eq!( + actual, + vec![if change < 2 { + Vector::ZERO + } else { + Vector::new(0., 180.) + }], + "process={process} area={area:?} change={change}" + ); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); } } } @@ -1141,12 +1124,26 @@ struct BoxItem { detail: String, marker: Option, } -fn box_text_clip(rect: Rectangle, scale: f32, viewport: Size) -> Option { - if !scale.is_finite() || scale <= 0. || rect.width <= 20. || rect.height <= 16. { +fn box_text_clip( + pan: iced::Vector, + rect: Rectangle, + scale: f32, + viewport: Size, +) -> Option { + if !pan.x.is_finite() + || !pan.y.is_finite() + || !scale.is_finite() + || scale <= 0. + || rect.width <= 20. + || rect.height <= 16. + { return None; } Rectangle::new( - Point::new((rect.x + 10.) * scale, (rect.y + 8.) * scale), + Point::new( + pan.x + (rect.x + 10.) * scale, + pan.y + (rect.y + 8.) * scale, + ), Size::new((rect.width - 20.) * scale, (rect.height - 16.) * scale), ) .intersection(&Rectangle::new(Point::ORIGIN, viewport)) @@ -1165,7 +1162,7 @@ struct Scene { size: Size, selected: Id, scale: f32, - scroll_offset: iced::Vector, + camera_pan: iced::Vector, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, pending_placement: Option<(scenario::Slot, Option)>, lanes: BTreeMap, @@ -1220,7 +1217,7 @@ impl Scene { fn sequence(p: &Project, owner: Id, selected: Id) -> Self { let mut scene = Self { scale: 1., - scroll_offset: iced::Vector::ZERO, + camera_pan: iced::Vector::ZERO, boxes: vec![], lines: vec![], size: Size::new(700., 200.), @@ -1931,8 +1928,7 @@ impl canvas::Program for Scene { if !editable { state.cancel(); } - // Scrollable marks a pointer outside its visible viewport unavailable, - // even when it lies inside the full (scrolled) canvas rectangle. + // The viewport masks input outside its visible canvas bounds. let visible = cursor.position().is_some(); if !visible && matches!( @@ -1955,7 +1951,7 @@ impl canvas::Program for Scene { SequencePress::Occurrence { .. } => {} } } - mouse::Cursor::Available(*position + self.scroll_offset) + mouse::Cursor::Available(*position) } canvas::Event::Mouse(mouse::Event::WheelScrolled { .. }) | canvas::Event::Window(iced::window::Event::Resized(_)) @@ -1967,11 +1963,9 @@ impl canvas::Program for Scene { } canvas::Event::Mouse( mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_), - ) => state - .pointer - .map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)), + ) => state.pointer.map_or(cursor, mouse::Cursor::Available), canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => { - state.pointer = cursor.position().map(|p| p - self.scroll_offset); + state.pointer = cursor.position(); cursor } canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { @@ -1983,9 +1977,12 @@ impl canvas::Program for Scene { } _ => cursor, }; - let world = cursor - .position_in(bounds) - .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); + let world = cursor.position_in(bounds).map(|p| { + Point::new( + (p.x - self.camera_pan.x) / self.scale, + (p.y - self.camera_pan.y) / self.scale, + ) + }); match event { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { if !visible { @@ -2005,14 +2002,14 @@ impl canvas::Program for Scene { state.press = Some(SequencePress::Message { source, slot, - start: cursor.position()? - self.scroll_offset, + start: cursor.position()?, first, moved: false, }); 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 { @@ -2022,11 +2019,19 @@ impl canvas::Program for Scene { }); return Some(Action::request_redraw().and_capture()); } - if self.reorder_handle().is_some_and(|r| r.contains(point)) { + if let Some(occurrence) = self + .hit(point) + .filter(|id| self.rows.contains_key(id)) + .or_else(|| { + self.reorder_handle() + .filter(|r| r.contains(point)) + .map(|_| self.selected) + }) + { state.press = Some(SequencePress::Reorder { - occurrence: self.selected, - parent: self.rows[&self.selected].0, - start: cursor.position()? - self.scroll_offset, + occurrence, + parent: self.rows[&occurrence].0, + start: cursor.position()?, moved: false, }); return Some(Action::request_redraw().and_capture()); @@ -2100,6 +2105,18 @@ impl canvas::Program for Scene { ); } } + SequencePress::Reorder { + occurrence, + moved: false, + .. + } => { + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Select( + context, occurrence, + ))) + .and_capture(), + ); + } SequencePress::Reorder { occurrence, parent, @@ -2137,6 +2154,7 @@ impl canvas::Program for Scene { ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); + f.translate(self.camera_pan); f.scale(self.scale); self.lifecycle.draw_lanes(&mut f, self.selected); for b in &self.boxes { @@ -2217,10 +2235,11 @@ impl canvas::Program for Scene { } } for b in &self.boxes { - if let Some(clip) = box_text_clip(b.rect, self.scale, f.size()) { + if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) { // Clipped child frames start with an identity transform. Match - // the card's scale explicitly, as for lifecycle Create labels. + // the card's pan and scale, as for lifecycle Create labels. f.with_clip(clip, |label| { + label.translate(self.camera_pan); label.scale(self.scale); label.fill_text(Text { content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize), @@ -2255,12 +2274,15 @@ impl canvas::Program for Scene { }); } self.lifecycle - .draw_markers(&mut f, self.selected, self.scale); + .draw_markers(&mut f, self.selected, self.scale, self.camera_pan); if let Some((context, tool, editable)) = self.interaction { let active = editable && state.context == Some(context) && state.tool == tool; - let point = cursor - .position_in(bounds) - .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); + let point = cursor.position_in(bounds).map(|p| { + Point::new( + (p.x - self.camera_pan.x) / self.scale, + (p.y - self.camera_pan.y) / self.scale, + ) + }); let source = active.then_some(state.source).flatten(); let reorder = active.then_some(state.press).flatten().and_then(|press| { if let SequencePress::Reorder { parent, .. } = press { @@ -2305,6 +2327,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( @@ -2510,25 +2544,26 @@ mod direct_canvas_tests { } } #[test] - fn direct_scrolled_canvas_maps_raw_pointer_events_to_the_visible_slot() { + fn direct_panned_canvas_maps_ordered_pointer_events_to_the_visible_slot() { let (f, mut scene) = scene(); - scene.scroll_offset = iced::Vector::new(35., 140.); + scene.camera_pan = iced::Vector::new(-35., -140.); let bounds = Rectangle::new(Point::new(20., 230.), scene.size); let slot = scene.slots.last().unwrap().slot; let y = scene.slots.last().unwrap().y; - let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) - scene.scroll_offset; - let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) - scene.scroll_offset; + let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) + scene.camera_pan; + let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) + scene.camera_pan; + assert!(bounds.contains(a) && bounds.contains(b)); let mut state = SequenceInput::default(); - // Iced Scrollable passes raw event coordinates and a translated cursor. + // Batched pointer events use their own positions with the final cursor. for event in [ mouse::Event::CursorMoved { position: a }, mouse::Event::ButtonPressed(mouse::Button::Left), mouse::Event::CursorMoved { position: b }, ] { - send(&scene, &mut state, event, b + scene.scroll_offset, bounds); + send(&scene, &mut state, event, b, bounds); } assert!( - matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b + scene.scroll_offset, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot) + matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot) ); } #[test] diff --git a/crates/oa-desktop/src/behavior/canvas_workspace.rs b/crates/oa-desktop/src/behavior/canvas_workspace.rs new file mode 100644 index 00000000..a30d5070 --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace.rs @@ -0,0 +1,243 @@ +//! Shared presentation and input contract; contains no model commands. +use crate::app::{App, Message}; +use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip}; +use iced::{Element, Length::Fill, Point, Theme, Vector}; +use oa_core::Id; +mod control; +mod focus; +mod viewport; +pub use control::Control; +pub use viewport::Viewport; + +pub fn keyboard_enabled(app: &crate::app::App) -> bool { + !app.busy + && !app.hosted.visible + && app.workflow.is_none() + && app.file_prompt.is_none() + && !app.delete_pending + && app.pending.is_none() + && app.model_menu.is_none() + && !app.reference_input_active() +} + +pub fn controls<'a>( + scope: Scope, + keyboard: bool, + scale: f32, + details: Message, + focus: Option, + zoom: impl Fn(Option) -> Message, + publish: impl Fn(Event) -> Message + Copy + 'a, +) -> Element<'a, Message> { + let control = |name: &'static str, label: &'static str, message: Option| { + let message = message.map(|message| intent(scope, name, message)); + Control::new( + button(text(label).size(12)) + .padding(6) + .on_press_maybe(message.clone()), + scope, + name, + keyboard, + message, + publish, + ) + }; + row![ + control("Details", "Details", Some(details)), + tooltip(control("Canvas", "Canvas", focus), + container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card), + tooltip::Position::Bottom), + control("Fit", "Fit", Some(zoom(None))), + control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))), + text(format!("{:.0}%", scale * 100.)).size(12), + control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))), + control("ActualSize", "100%", Some(zoom(Some(1.)))), + ].spacing(4).align_y(iced::Center).into() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Scope { + pub document: Id, + pub owner: Id, + pub revision: u64, + pub epoch: u64, +} +impl Scope { + pub(crate) 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(crate) fn input_owner(app: &App) -> Option { + if keyboard_enabled(app) + && !app.behavior.has_pending() + && !app.draft_dirty() + && !app.source_dirty() + { + Self::current(app) + } else { + None + } + } + + pub fn control_id(self, name: &str) -> iced::advanced::widget::Id { + iced::advanced::widget::Id::from(format!( + "behavior-control:{}:{}:{}:{name}", + self.document, self.owner, self.revision + )) + } +} +/// Only a live presentation intent may carry a toolbar action across its own pan. +#[derive(Debug, Clone)] +pub struct Intent { + pub scope: Scope, + pub name: &'static str, + pub message: Box, +} +pub fn intent(scope: Scope, name: &'static str, message: Message) -> Message { + Message::BehaviorControl(Intent { + scope, + name, + message: Box::new(message), + }) +} + +#[derive(Debug, Clone, Copy)] +pub enum Event { + CancelGesture, + Pan(Vector), + Zoom { factor: f32, at: Point, base: f32 }, + Focus, + Traverse(bool), + ControlFocus(&'static str), + ToggleFocus, + Leave, +} + +pub use focus::{focus_task, root as focus_root}; +/// Fold relative input in event order. The base scale is used only on leaving Fit. +pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: Event) -> bool { + match event { + Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => { + let next = *pan + delta; + if next.x.is_finite() && next.y.is_finite() && next != *pan { + *pan = next; + true + } else { + false + } + } + Event::Zoom { factor, at, base } + if factor.is_finite() + && factor > 0. + && base.is_finite() + && base > 0. + && at.x.is_finite() + && at.y.is_finite() => + { + let old = if *fit { base } else { zoom.unwrap_or(base) }; + let next = (old * factor).clamp(0.05, 3.); + if old == next { + return false; + } + let anchor = Vector::new(at.x, at.y); + let next_pan = anchor - (anchor - *pan) * (next / old); + if !next_pan.x.is_finite() || !next_pan.y.is_finite() { + return false; + } + *pan = next_pan; + *zoom = Some(next); + *fit = false; + true + } + _ => false, + } +} + +/// Constant-width labelled palette. Arming a tool never reallocates the canvas. +pub fn palette<'a>( + scope: Scope, + keyboard: bool, + publish: impl Fn(Event) -> Message + Copy + 'a, + entries: impl IntoIterator)>, +) -> Element<'a, Message> { + let mut tools = column![].spacing(3); + for (glyph, label, selected, message) in entries { + let message = message.map(|message| intent(scope, glyph, message)); + tools = tools.push(tooltip( + container(Control::new( + button(text(glyph).size(12)) + .width(34) + .height(30) + .padding(3) + .style(if selected { + button::primary + } else { + crate::style::nav + }) + .on_press_maybe(message.clone()), + scope, + glyph, + keyboard, + message, + publish, + )) + .id(format!("behavior-tool:{glyph}")), + container(text(label).size(12)) + .padding(7) + .style(crate::style::card), + tooltip::Position::Left, + )); + } + container(scrollable(tools).id(scope.control_id("palette-scroll"))) + .width(40) + .height(Fill) + .padding(3) + .style(crate::style::panel) + .into() +} + +/// Header and palette are permanent. Details/editors overlay the same viewport. +pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>( + header: Element<'a, Message, Theme, R>, + palette: Element<'a, Message, Theme, R>, + canvas: Element<'a, Message, Theme, R>, + editor: Option>, +) -> Element<'a, Message, Theme, R> { + let mut body = stack![canvas]; + if let Some(editor) = editor { + body = body.push( + container(opaque( + container(editor) + .width(300) + .height(Fill) + .style(crate::style::card), + )) + .align_right(Fill) + .height(Fill) + .padding(8), + ); + } + column![header, row![body, palette].height(Fill)] + .spacing(6) + .padding(8) + .width(Fill) + .height(Fill) + .into() +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/oa-desktop/src/behavior/canvas_workspace/control.rs b/crates/oa-desktop/src/behavior/canvas_workspace/control.rs new file mode 100644 index 00000000..814ba192 --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace/control.rs @@ -0,0 +1,256 @@ +//! 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)) + ) && !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..ba9f77cc --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs @@ -0,0 +1,757 @@ +use super::*; +use crate::app::{App, Message}; +use iced::advanced::{Layout, Shell, layout, widget}; +use iced::futures::{ + StreamExt, + stream::{BoxStream, SelectAll}, +}; +use iced::{Rectangle, Size, mouse}; +use iced_runtime::{ + Action, + user_interface::{Cache, UserInterface}, +}; +use std::collections::HashMap; + +type Pending = SelectAll>>; + +// iced_winit drains messages while collecting immediately ready actions. A +// pending Task stream survives until its widget operation sends its result. +fn enqueue(task: iced::Task, ready: &mut Vec>, pending: &mut Pending) { + if let Some(mut stream) = iced_runtime::task::into_stream(task) { + let waker = iced::futures::task::noop_waker(); + let mut context = std::task::Context::from_waker(&waker); + loop { + match stream.poll_next_unpin(&mut context) { + std::task::Poll::Ready(Some(action)) => ready.push(action), + std::task::Poll::Ready(None) => break, + std::task::Poll::Pending => { + pending.push(stream); + break; + } + } + } + } +} + +#[derive(Default)] +pub(crate) struct Geometry { + pub containers: HashMap, + pub focused: Vec<(Option, Rectangle, bool)>, + pub text_inputs: Vec, + pub scrolls: HashMap, +} +impl widget::Operation for Geometry { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + state: &mut dyn widget::operation::Focusable, + ) { + self.focused.push((id.cloned(), bounds, state.is_focused())); + } + fn text_input( + &mut self, + _: Option<&widget::Id>, + bounds: Rectangle, + _: &mut dyn widget::operation::TextInput, + ) { + self.text_inputs.push(bounds); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if let Some(id) = id { + self.containers.insert(id.clone(), bounds); + } + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + _: Rectangle, + offset: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + if let Some(id) = id { + self.scrolls.insert(id.clone(), (bounds, offset)); + } + } +} +impl Geometry { + pub fn bounds(&self, id: &str) -> Rectangle { + self.containers[&widget::Id::from(id.to_owned())] + } +} + +/// Mount the actual App root, production widgets and renderer. Events are +/// delivered in order with Iced's final-batch cursor, then fed through App::update. +/// This deliberately does not seed any private canvas gesture state. +pub(crate) struct Mounted { + pub renderer: iced::Renderer, + cache: Cache, + pub operations: usize, +} +impl Mounted { + pub async fn unshown() -> Self { + use iced::advanced::renderer::Headless; + let renderer = ::new(iced::Font::DEFAULT, 16.into(), None) + .await + .expect("production mounted tests require the supported WGPU headless renderer"); + Self { + renderer, + cache: Cache::new(), + operations: 0, + } + } + pub async fn new(app: &mut App) -> Self { + let mut mounted = Self::unshown().await; + mounted.settle(app).await; + mounted + } + pub fn geometry(&mut self, app: &App) -> Geometry { + let mut geometry = Geometry::default(); + self.operate(app, &mut geometry); + geometry + } + /// One operation pass, including every nested overlay, via installed Iced. + pub fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) { + let mut ui = UserInterface::build( + app.view(), + app.window_size, + std::mem::take(&mut self.cache), + &mut self.renderer, + ); + ui.operate(&self.renderer, operation); + self.cache = ui.into_cache(); + } + pub async fn settle(&mut self, app: &mut App) { + for _ in 0..3 { + self.batch( + app, + &[iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + ))], + mouse::Cursor::Unavailable, + ) + .await; + } + } + pub async fn batch( + &mut self, + app: &mut App, + events: &[iced::Event], + cursor: mouse::Cursor, + ) -> Vec { + let messages = self.input(app, events, cursor); + let mut ready = vec![]; + let mut pending = Pending::new(); + for message in messages.iter().cloned() { + enqueue(app.update(message), &mut ready, &mut pending); + } + self.drain(app, ready, pending).await; + messages + } + pub fn input( + &mut self, + app: &App, + events: &[iced::Event], + cursor: mouse::Cursor, + ) -> Vec { + let mut messages = vec![]; + let mut ui = UserInterface::build( + app.view(), + app.window_size, + std::mem::take(&mut self.cache), + &mut self.renderer, + ); + let _ = ui.update( + events, + cursor, + &mut self.renderer, + &mut iced::advanced::clipboard::Null, + &mut messages, + ); + self.cache = ui.into_cache(); + messages + } + pub async fn perform(&mut self, app: &mut App, task: iced::Task) { + let mut ready = vec![]; + let mut pending = Pending::new(); + enqueue(task, &mut ready, &mut pending); + self.drain(app, ready, pending).await; + } + async fn drain( + &mut self, + app: &mut App, + mut ready: Vec>, + mut pending: Pending, + ) { + loop { + let mut outputs = vec![]; + if !ready.is_empty() { + // Build only after all App messages in this cycle have run. + let mut ui = UserInterface::build( + app.view(), + app.window_size, + std::mem::take(&mut self.cache), + &mut self.renderer, + ); + for action in ready.drain(..) { + match action { + Action::Output(message) => outputs.push(message), + Action::Widget(mut operation) => { + self.operations += 1; + loop { + ui.operate(&self.renderer, &mut *operation); + match operation.finish() { + widget::operation::Outcome::Chain(next) => operation = next, + widget::operation::Outcome::None + | widget::operation::Outcome::Some(()) => break, + } + } + } + action => { + panic!("Unexpected native action in mounted App harness: {action:?}") + } + } + } + self.cache = ui.into_cache(); + } + for message in outputs { + enqueue(app.update(message), &mut ready, &mut pending); + } + if !ready.is_empty() { + continue; + } + let Some(action) = pending.next().await else { + break; + }; + ready.push(action); + let waker = iced::futures::task::noop_waker(); + let mut context = std::task::Context::from_waker(&waker); + while let std::task::Poll::Ready(Some(action)) = pending.poll_next_unpin(&mut context) { + ready.push(action); + } + } + } + pub fn pixels(&mut self, app: &App) -> Vec { + use iced::advanced::renderer::Headless; + let mut ui = UserInterface::build( + app.view(), + app.window_size, + std::mem::take(&mut self.cache), + &mut self.renderer, + ); + // update initializes the nested overlay layout used by draw. + let mut messages = vec![]; + let _ = ui.update( + &[], + mouse::Cursor::Unavailable, + &mut self.renderer, + &mut iced::advanced::clipboard::Null, + &mut messages, + ); + assert!( + messages.is_empty(), + "An empty draw preparation must not drop App messages" + ); + ui.draw( + &mut self.renderer, + &crate::style::theme(), + &iced::advanced::renderer::Style { + text_color: crate::style::INK, + }, + mouse::Cursor::Unavailable, + ); + self.cache = ui.into_cache(); + Headless::screenshot( + &mut self.renderer, + Size::new(app.window_size.width as u32, app.window_size.height as u32), + 1., + crate::style::BG, + ) + } + pub async fn click(&mut self, app: &mut App, point: Point) -> Vec { + self.batch( + app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ], + mouse::Cursor::Available(point), + ) + .await + } + pub async fn pan(&mut self, app: &mut App, start: Point, end: Point) -> Vec { + self.batch( + app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: start }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)), + iced::Event::Mouse(mouse::Event::CursorMoved { position: end }), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)), + ], + mouse::Cursor::Available(end), + ) + .await + } + pub fn scroll(&mut self, app: &App, id: &str, offset: f32) { + let mut operation = widget::operation::scrollable::scroll_to::<()>( + widget::Id::from(id.to_owned()), + widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(offset), + }, + ); + self.operate(app, &mut operation); + } +} + +pub(crate) fn key(named: iced::keyboard::key::Named) -> iced::Event { + use iced::keyboard::{self, Key}; + iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(named), + modified_key: Key::Named(named), + physical_key: keyboard::key::Physical::Unidentified( + keyboard::key::NativeCode::Unidentified, + ), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }) +} + +#[test] +fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs() { + for scale in [0.05, 0.5, 1., 2.] { + let mut pan = Vector::new(-130., 75.); + let mut zoom = Some(scale); + let mut fit = false; + let at = Point::new(171., 93.); + let world = (at - Point::ORIGIN - pan) / scale; + change( + &mut pan, + &mut zoom, + &mut fit, + Event::Zoom { + factor: 1.25, + at, + base: scale, + }, + ); + let after = (at - Point::ORIGIN - pan) / zoom.unwrap(); + assert!((after.x - world.x).abs() < 0.001 && (after.y - world.y).abs() < 0.001); + let before = (pan, zoom, fit); + for input in [ + Event::Pan(Vector::new(f32::INFINITY, 0.)), + Event::Zoom { + factor: f32::NAN, + at, + base: scale, + }, + Event::Zoom { + factor: 0., + at, + base: scale, + }, + ] { + assert!(!change(&mut pan, &mut zoom, &mut fit, input)); + assert_eq!((pan, zoom, fit), before); + } + } +} + +#[test] +fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() { + use iced::widget::Space; + let scope = Scope { + document: Id::new_v4(), + owner: Id::new_v4(), + revision: 2, + epoch: 0, + }; + let mut view: Element<'_, Event, Theme, ()> = Viewport::new( + Space::new().width(Fill).height(Fill), + scope, + 1., + true, + |e| e, + ) + .into(); + let mut tree = widget::Tree::new(view.as_widget()); + let node = view + .as_widget_mut() + .layout( + &mut tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(400., 300.)), + ) + .move_to(Point::new(30., 80.)); + let start = Point::new(90., 160.); + let middle = Point::new(125., 175.); + let end = Point::new(145., 185.); + let mut messages = vec![]; + for event in [ + mouse::Event::CursorMoved { position: start }, + mouse::Event::ButtonPressed(mouse::Button::Middle), + mouse::Event::CursorMoved { position: middle }, + mouse::Event::CursorMoved { position: end }, + mouse::Event::ButtonReleased(mouse::Button::Middle), + ] { + view.as_widget_mut().update( + &mut tree, + &iced::Event::Mouse(event), + Layout::new(&node), + mouse::Cursor::Available(end), + &(), + &mut iced::advanced::clipboard::Null, + &mut Shell::new(&mut messages), + &Rectangle::with_size(Size::new(700., 600.)), + ); + } + assert!(matches!(messages.first(), Some(Event::CancelGesture))); + let total = messages + .iter() + .fold(Vector::ZERO, |total, event| match event { + Event::Pan(delta) => total + *delta, + _ => total, + }); + assert_eq!(total, end - start); +} + +pub(crate) fn scope(app: &App) -> Scope { + if let Some(context) = app.scenario_context() { + Scope { + document: context.document, + owner: context.scenario, + revision: context.revision, + epoch: app.camera_epoch, + } + } else { + let context = app.topology_context().expect("mounted behavior owner"); + Scope { + document: context.document, + owner: context.owner, + revision: context.revision, + epoch: app.camera_epoch, + } + } +} +pub(crate) fn focused(mounted: &mut Mounted, app: &App, name: &str) -> bool { + let id = scope(app).control_id(name); + mounted + .geometry(app) + .focused + .iter() + .any(|(member, _, focused)| member.as_ref() == Some(&id) && *focused) +} +pub(crate) async fn tab_to(mounted: &mut Mounted, app: &mut App, name: &str) { + for _ in 0..32 { + if focused(mounted, app, name) { + return; + } + mounted + .batch( + app, + &[key(iced::keyboard::key::Named::Tab)], + mouse::Cursor::Unavailable, + ) + .await; + } + panic!("Keyboard traversal did not reach {name}"); +} +pub(crate) fn camera_delta(messages: &[Message]) -> Message { + messages.iter().find(|m| matches!(m, + Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(_, _, Event::Pan(delta))) + | Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(_, _, Event::Pan(delta))) if *delta != Vector::ZERO + )).expect("actual nonzero camera delta from a mounted gesture").clone() +} +pub(crate) async fn press_middle(mounted: &mut Mounted, app: &mut App, point: Point) { + mounted + .batch( + app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)), + ], + mouse::Cursor::Available(point), + ) + .await; +} +pub(crate) async fn release_middle( + mounted: &mut Mounted, + app: &mut App, + point: Point, +) -> Vec { + mounted + .batch( + app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)), + ], + mouse::Cursor::Available(point), + ) + .await +} +pub(crate) fn assert_drawn_focus(before: &[u8], after: &[u8], size: Size, bounds: Rectangle) { + let width = size.width as usize; + assert_eq!(before.len(), width * size.height as usize * 4); + assert_eq!(after.len(), before.len()); + let mut changed = 0; + for y in bounds.y.ceil() as usize..(bounds.y + bounds.height).floor() as usize { + for x in bounds.x.ceil() as usize..(bounds.x + bounds.width).floor() as usize { + if x as f32 - bounds.x > 2. + && bounds.x + bounds.width - x as f32 > 2. + && y as f32 - bounds.y > 2. + && bounds.y + bounds.height - y as f32 > 2. + { + continue; + } + let start = (y * width + x) * 4; + if before[start..start + 3] != after[start..start + 3] { + changed += 1; + } + } + } + assert!( + changed >= bounds.width.min(bounds.height) as usize, + "Focus must change visible border pixels, changed={changed}, bounds={bounds:?}" + ); +} + +pub(crate) fn character(value: &str) -> iced::Event { + let mut event = key(iced::keyboard::key::Named::Space); + if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key, + modified_key, + text, + .. + }) = &mut event + { + *key = iced::keyboard::Key::Character(value.into()); + *modified_key = key.clone(); + *text = Some(value.into()); + } + event +} + +/// Inspect the action without stripping the lifetime from retained packets. +pub(crate) fn intent_action(message: &Message) -> &Message { + match message { + Message::BehaviorControl(intent) => &intent.message, + message => message, + } +} + +/// Deliberately retain a returned Chain to test the stronger ownership invariant. +/// Native Iced runs Chain passes synchronously; this is not a native interleaving. +pub(crate) async fn retained_focus_apply( + mounted: &mut Mounted, + app: &mut App, +) -> Box { + let entry = mounted.input( + app, + &[key(iced::keyboard::key::Named::F6)], + mouse::Cursor::Unavailable, + ); + assert_eq!( + entry.len(), + 1, + "actual initial F6 must emit one canvas focus request" + ); + let mut stream = iced_runtime::task::into_stream(app.update(entry[0].clone())) + .expect("F6 returns a widget Task"); + let Some(Action::Widget(mut operation)) = stream.next().await else { + panic!("F6 first action must be Collect"); + }; + mounted.operate(app, &mut *operation); + match operation.finish() { + widget::operation::Outcome::Chain(apply) => apply, + _ => panic!("Live Collect must produce its guarded Apply pass"), + } +} + +pub(crate) async fn overflow_last_tool(mounted: &mut Mounted, app: &mut App, last: &'static str) { + use iced::keyboard::key::Named; + let geometry = mounted.geometry(app); + let last_bounds = geometry.containers[&scope(app).control_id(last)]; + let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")]; + assert_eq!(offset.y, 0.); + assert!( + last_bounds.y >= rail.y + rail.height, + "last tool starts fully below the overflowing palette: tool={last_bounds:?}, rail={rail:?}" + ); + mounted + .batch(app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + tab_to(mounted, app, last).await; + let geometry = mounted.geometry(app); + let mut visible = geometry.containers[&scope(app).control_id(last)]; + let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")]; + assert!(offset.y > 0., "keyboard traversal must scroll the palette"); + visible.x -= offset.x; + visible.y -= offset.y; + assert!( + visible.y >= rail.y && visible.y + visible.height <= rail.y + rail.height + 0.5, + "the entire last tool is revealed: tool={visible:?}, rail={rail:?}" + ); + let messages = mounted + .batch(app, &[key(Named::Enter)], mouse::Cursor::Unavailable) + .await; + assert!( + messages.iter().any( + |message| matches!(message, Message::BehaviorControl(intent) if intent.name == last) + ), + "the revealed last tool is keyboard activated" + ); + assert!(focused(mounted, app, last)); + let on = mounted.pixels(app); + mounted + .batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + let off = mounted.pixels(app); + assert_drawn_focus(&off, &on, app.window_size, visible); +} + +pub(crate) async fn retained_toolbar_boundaries( + mounted: &mut Mounted, + app: &mut App, + tool: &'static str, +) { + use iced::keyboard::key::Named; + for boundary in ["Chooser", "Dirty", "Picker"] { + let mut packets = vec![]; + for name in ["Fit", tool, "Details", "Panels", "Canvas"] { + packets.push(capture_toolbar(mounted, app, name)); + } + let lifetime = app.camera_epoch; + let epoch = app.canvas_epoch; + let process = app.behavior.topology.camera_boundary(); + let scenario = app.behavior.scenario.camera_boundary(); + match boundary { + "Chooser" => { + let draft = app.draft.as_ref().unwrap(); + let id = crate::forms::reference_chooser::trigger::field_id( + draft.reference_generation(), + "/parent", + ); + let bounds = mounted + .geometry(app) + .focused + .iter() + .find(|(field, _, _)| field.as_ref() == Some(&id)) + .expect("real Inspector parent reference trigger") + .1; + let request = draft.reference_open(app.project(), "/parent", bounds); + let task = app.update(Message::FieldReferenceOpen(request)); + mounted.perform(app, task).await; + assert!(app.reference_input_active()); + mounted + .batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + assert!(!app.reference_input_active()); + } + "Dirty" => { + let task = app.update(Message::Field( + "/name".into(), + "Unsaved camera boundary".into(), + )); + mounted.perform(app, task).await; + assert!(app.draft_dirty()); + // Every retained packet is also rejected while the owner is dirty. + for packet in packets.iter().cloned() { + let task = app.update(packet); + mounted.perform(app, task).await; + } + assert!(app.draft_dirty()); + let task = app.update(Message::RevertProperties); + mounted.perform(app, task).await; + assert!(!app.draft_dirty()); + } + "Picker" => { + // Public transitions, with cancellation injected at the external + // picker boundary. Never poll the native dialog future here. + let picker = app.update(Message::Save(true)); + if cfg!(target_os = "linux") { + assert!(app.file_prompt.is_some()); + mounted.perform(app, picker).await; + let task = app.update(Message::CancelFile); + mounted.perform(app, task).await; + } else { + assert!(app.busy, "Save As enters the real native-picker busy state"); + drop(picker); + for packet in packets.iter().cloned() { + let task = app.update(packet); + mounted.perform(app, task).await; + } + let task = app.update(Message::SavePath(None)); + mounted.perform(app, task).await; + assert!(!app.busy); + } + } + _ => unreachable!(), + } + assert_ne!( + app.camera_epoch, lifetime, + "{boundary} changes the presentation lifetime" + ); + assert_eq!( + app.canvas_epoch, epoch, + "{boundary} cycle leaves semantic context unchanged" + ); + let focus_before = mounted + .geometry(app) + .focused + .into_iter() + .filter(|(_, _, value)| *value) + .collect::>(); + let current_lifetime = app.camera_epoch; + for packet in packets { + let task = app.update(packet); + mounted.perform(app, task).await; + } + assert_eq!( + app.camera_epoch, current_lifetime, + "{boundary} retained intents cannot start a new lifetime" + ); + assert!( + app.behavior.topology.camera_boundary() == process, + "{boundary} retained toolbar packet changed process presentation" + ); + assert!( + app.behavior.scenario.camera_boundary() == scenario, + "{boundary} retained toolbar packet changed scenario presentation" + ); + assert_eq!( + mounted + .geometry(app) + .focused + .into_iter() + .filter(|(_, _, value)| *value) + .collect::>(), + focus_before, + "{boundary} retained toolbar packet stole field focus" + ); + } +} + +pub(crate) fn capture_toolbar(mounted: &mut Mounted, app: &App, name: &'static str) -> Message { + let point = mounted.geometry(app).containers[&scope(app).control_id(name)].center(); + mounted + .input( + app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ], + mouse::Cursor::Available(point), + ) + .into_iter() + .find(|message| matches!(message, Message::BehaviorControl(intent) if intent.name == name)) + .expect("actual production toolbar packet") +} diff --git a/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs b/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs new file mode 100644 index 00000000..58d46463 --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs @@ -0,0 +1,426 @@ +use super::{Event as CameraEvent, Scope}; +use iced::advanced::{ + Clipboard, Layout, Shell, Widget, layout, overlay, renderer, + widget::{Operation, Tree, operation, tree}, +}; +use iced::{Border, Element, Event, Length, Point, Rectangle, Size, Theme, Vector}; +use iced::{keyboard, mouse}; + +#[derive(Default)] +struct Input { + scope: Option, + focused: bool, + pointer: Option, + pan: Option<(mouse::Button, Point)>, + space: bool, + bounds: Option, +} +impl operation::Focusable for Input { + fn is_focused(&self) -> bool { + self.focused + } + fn focus(&mut self) { + self.focused = true; + } + fn unfocus(&mut self) { + self.focused = false; + self.pan = None; + self.space = false; + } +} +pub struct Viewport<'a, Message, R = iced::Renderer> +where + R: renderer::Renderer, +{ + content: Element<'a, Message, Theme, R>, + scope: Scope, + scale: f32, + keyboard: bool, + blank_pan: bool, + publish: Box Message + 'a>, +} +impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> { + pub fn new( + content: impl Into>, + scope: Scope, + scale: f32, + keyboard: bool, + publish: impl Fn(CameraEvent) -> Message + 'a, + ) -> Self { + Self { + content: content.into(), + scope, + scale, + keyboard, + blank_pan: false, + publish: Box::new(publish), + } + } + pub fn pan_background(mut self, enabled: bool) -> Self { + self.blank_pan = enabled; + self + } +} +impl Widget for Viewport<'_, Message, R> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + fn state(&self) -> tree::State { + tree::State::new(Input { + scope: Some(self.scope), + ..Default::default() + }) + } + fn children(&self) -> Vec { + vec![Tree::new(self.content.as_widget())] + } + fn diff(&self, tree: &mut Tree) { + let state = tree.state.downcast_mut::(); + if state.scope.is_none_or(|s| { + (s.document, s.owner, s.revision) + != (self.scope.document, self.scope.owner, self.scope.revision) + }) { + *state = Input::default(); + } + if state + .scope + .is_some_and(|scope| scope.epoch != self.scope.epoch) + || !self.keyboard + { + state.pan = None; + state.space = false; + } + if !self.keyboard { + state.focused = false; + } + state.scope = Some(self.scope); + tree.diff_children(std::slice::from_ref(&self.content)); + } + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Fill) + } + fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, + operation: &mut dyn Operation, + ) { + if self.keyboard { + let id = self.scope.control_id("viewport"); + operation.custom( + Some(&id), + layout.bounds(), + &mut super::focus::Member(self.scope), + ); + operation.focusable( + Some(&id), + layout.bounds(), + tree.state.downcast_mut::(), + ); + } + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if shell.is_event_captured() { + return; + } + let state = tree.state.downcast_mut::(); + let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds()); + state.bounds = Some(layout.bounds()); + if changed_bounds || !self.keyboard { + state.pan = None; + state.space = false; + } + if !self.keyboard { + state.focused = false; + } + if let Event::Mouse(mouse::Event::CursorMoved { position }) = event { + state.pointer = Some(*position); + } + let pointer = match event { + Event::Mouse( + mouse::Event::ButtonPressed(_) + | mouse::Event::ButtonReleased(_) + | mouse::Event::WheelScrolled { .. }, + ) => state.pointer.or_else(|| cursor.position()), + Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position), + _ => cursor.position().or(state.pointer), + }; + let visible = layout.bounds().intersection(viewport); + let inside = pointer.is_some_and(|p| visible.is_some_and(|v| v.contains(p))) + && cursor.position().is_some(); + if !self.keyboard { + state.space = false; + } + let mut cancel = changed_bounds; + let mut camera = None; + let mut capture = false; + match event { + Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) => { + state.pan = None; + state.space = false; + state.focused = false; + } + Event::Mouse(mouse::Event::CursorLeft) => { + state.pan = None; + state.pointer = None; + } + Event::Mouse(mouse::Event::ButtonPressed(button)) => { + if state.pan.is_some_and(|(held, _)| held != *button) || !inside { + state.pan = None; + state.space = false; + cancel = true; + } + state.focused = inside && self.keyboard; + if inside + && self.keyboard + && (*button == mouse::Button::Middle + || (*button == mouse::Button::Left && state.space)) + { + state.pan = pointer.map(|p| (*button, p)); + cancel = true; + camera = Some(CameraEvent::CancelGesture); + capture = true; + } + } + Event::Mouse(mouse::Event::CursorMoved { position }) => { + if let Some((button, previous)) = state.pan { + state.pan = Some((button, *position)); + camera = Some(CameraEvent::Pan(*position - previous)); + capture = true; + } + } + Event::Mouse(mouse::Event::ButtonReleased(button)) => { + if let Some((held, previous)) = state.pan + && held == *button + { + state.pan = None; + camera = pointer + .filter(|_| inside) + .map(|p| CameraEvent::Pan(p - previous)); + capture = true; + } + } + Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside && state.focused => { + state.pan = None; + cancel = true; + let y = match delta { + mouse::ScrollDelta::Lines { y, .. } => *y, + mouse::ScrollDelta::Pixels { y, .. } => *y / 40., + }; + if y != 0. { + let at = pointer.unwrap() - Vector::new(layout.bounds().x, layout.bounds().y); + camera = Some(CameraEvent::Zoom { + factor: 1.1_f32.powf(y.clamp(-8., 8.)), + at, + base: self.scale, + }); + capture = true; + } + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::F6), + modifiers, + repeat: false, + .. + }) if self.keyboard && modifiers.is_empty() => { + camera = Some(CameraEvent::ToggleFocus); + capture = true; + cancel = true; + } + Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) + if state.focused && self.keyboard => + { + use keyboard::{Key, key::Named}; + match key.as_ref() { + Key::Named(Named::Space) => { + state.space = true; + cancel = true; + camera = Some(CameraEvent::CancelGesture); + capture = true; + } + Key::Named(Named::Tab) => { + camera = Some(CameraEvent::Traverse(modifiers.shift())); + capture = true; + } + Key::Named(Named::Escape) => { + state.pan = None; + state.space = false; + camera = Some(CameraEvent::Leave); + capture = true; + } + Key::Named(key) if modifiers.is_empty() => { + let delta = match key { + Named::ArrowLeft => Some(Vector::new(60., 0.)), + Named::ArrowRight => Some(Vector::new(-60., 0.)), + Named::ArrowUp => Some(Vector::new(0., 60.)), + Named::ArrowDown => Some(Vector::new(0., -60.)), + _ => None, + }; + camera = delta.map(CameraEvent::Pan); + capture = camera.is_some(); + cancel = capture; + } + _ => {} + } + } + Event::Keyboard(keyboard::Event::KeyReleased { key, .. }) + if *key == keyboard::Key::Named(keyboard::key::Named::Space) => + { + state.space = false; + } + _ => {} + } + if cancel { + self.content.as_widget_mut().update( + &mut tree.children[0], + &Event::Window(iced::window::Event::Unfocused), + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + if let Some(event) = camera { + shell.publish((self.publish)(event)); + } + if capture { + shell.capture_event(); + shell.request_redraw(); + return; + } + if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) { + return; + } + let child_cursor = if inside { + pointer.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available) + } else { + mouse::Cursor::Unavailable + }; + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + child_cursor, + renderer, + clipboard, + shell, + viewport, + ); + // A semantic hit or armed creation tool gets first refusal. In Select, + // an otherwise unused blank press starts the same camera gesture. + if self.blank_pan + && self.keyboard + && inside + && !shell.is_event_captured() + && matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + ) + { + state.pan = pointer.map(|p| (mouse::Button::Left, p)); + shell.publish((self.publish)(CameraEvent::CancelGesture)); + shell.capture_event(); + shell.request_redraw(); + } + } + fn draw( + &self, + tree: &Tree, + renderer: &mut R, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + renderer.with_layer(layout.bounds(), |renderer| { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + }); + if tree.state.downcast_ref::().focused && self.keyboard { + renderer.fill_quad( + renderer::Quad { + bounds: layout.bounds(), + border: Border { + color: crate::style::TEAL, + width: 1., + ..Default::default() + }, + ..Default::default() + }, + iced::Color::TRANSPARENT, + ); + } + } + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + if tree.state.downcast_ref::().pan.is_some() { + mouse::Interaction::Grabbing + } else { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + } + fn overlay<'a>( + &'a mut self, + tree: &'a mut Tree, + layout: Layout<'a>, + renderer: &R, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} +impl<'a, Message: 'a, R: renderer::Renderer + 'a> From> + for Element<'a, Message, Theme, R> +{ + fn from(viewport: Viewport<'a, Message, R>) -> Self { + Element::new(viewport) + } +} diff --git a/crates/oa-desktop/src/behavior/contextual_source_tests.rs b/crates/oa-desktop/src/behavior/contextual_source_tests.rs index 8f4a51bb..7e53ae0c 100644 --- a/crates/oa-desktop/src/behavior/contextual_source_tests.rs +++ b/crates/oa-desktop/src/behavior/contextual_source_tests.rs @@ -886,7 +886,7 @@ fn contextual_source_incomplete_recovered_kind_reports_error_without_panicking() } #[test] -fn contextual_source_old_empty_action_cannot_open_a_previous_owner_or_layer() { +fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_layer() { use crate::app::{App, Message, Page}; let (mut p, a) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); let b = p @@ -898,30 +898,32 @@ fn contextual_source_old_empty_action_cannot_open_a_previous_owner_or_layer() { app.behavior.active_owner = Some(a); app.selected = a; let context = app.scenario_context().unwrap(); + let source = app.document.bundle().clone(); + let begin = |context| scenario::CanvasEvent::BeginOccurrence { + context, + tool: scenario::Tool::Lifeline, + slot: scenario::Slot { + parent: a, + before: None, + }, + lifeline: None, + }; app.behavior.active_owner = Some(b); app.selected = b; - let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First( - context, - scenario::Tool::Lifeline, - ))); + let _ = app.update(Message::ScenarioCanvas(begin(context))); assert!(!app.behavior.has_pending()); assert_eq!(app.project(), &p); app.behavior.active_owner = Some(a); app.selected = a; app.layer = Layer::Sa; - let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First( - context, - scenario::Tool::Lifeline, - ))); + let _ = app.update(Message::ScenarioCanvas(begin(context))); assert!(!app.behavior.has_pending()); app.layer = Layer::Oa; let current = app.scenario_context().unwrap(); - let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First( - current, - scenario::Tool::Lifeline, - ))); + let _ = app.update(Message::ScenarioCanvas(begin(current))); assert_eq!(app.behavior.pending_owner(), Some(a)); assert_eq!(app.project(), &p); + assert_eq!(app.document.bundle(), &source); assert!(!app.document.can_undo()); } 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 63f08848..ca2a4b15 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -4,7 +4,9 @@ use super::topology::{occurrence, unique_batch}; use super::*; use iced::widget::{Column, column, text_input}; use std::{cell::RefCell, collections::BTreeSet}; +pub(crate) mod focus; mod workspace; +pub(crate) use workspace::NAME_INPUT; pub use workspace::{Shelf, view}; #[cfg(test)] @@ -94,11 +96,12 @@ impl std::fmt::Display for CanvasTool { } #[derive(Debug, Clone)] pub enum CanvasEvent { - First(Context, Tool), Scrolled(Context, ScrollArea, iced::Vector), Restored(Context, Id, ScrollArea, iced::Vector), Viewport(Context, Size), + FormShown(Context, Id), Zoom(Context, Option), + Camera(Context, u64, super::canvas_workspace::Event), Focus(Context), Shelf(Context, Shelf), OpenUse(Context, Id), @@ -126,18 +129,18 @@ pub enum CanvasEvent { } #[derive(Debug, Clone, Copy)] pub enum ScrollArea { - Canvas, - // Retained navigation channel, now the independent Selection/outline shelf. + // The independent Details/outline scroller; viewport motion uses Camera. Page, } impl CanvasEvent { pub fn context(&self) -> Context { match self { - Self::First(context, _) - | Self::Scrolled(context, _, _) + Self::Scrolled(context, _, _) | Self::Restored(context, ..) | Self::Viewport(context, _) + | Self::FormShown(context, _) | Self::Zoom(context, _) + | Self::Camera(context, ..) | Self::Focus(context) | Self::Shelf(context, _) | Self::OpenUse(context, _) @@ -200,11 +203,14 @@ const VALUE_PAGE_SIZE: usize = 24; #[derive(Default)] pub struct State { draft: Option, + form_session: Id, + form_mount: Id, + form_focus_pending: bool, canvas_tool: Option<(Id, CanvasTool)>, call_choices: RefCell)>>, - scroll_offsets: [iced::Vector; 2], + details_offset: iced::Vector, return_to: Vec, - restore: Option, + restore: Option, presentation: workspace::Presentation, } const CALLER_LIMIT: usize = 32; @@ -214,15 +220,14 @@ pub(crate) struct Caller { pub occurrence: Id, target: Id, tool: CanvasTool, - offsets: [iced::Vector; 2], + details_offset: iced::Vector, presentation: workspace::Presentation, } -struct ViewportRestore { +struct DetailsRestore { document: Id, owner: Id, token: Id, - offsets: [iced::Vector; 2], - pending: [bool; 2], + offset: iced::Vector, } #[derive(Clone, Copy)] pub(super) struct ScrollRestore { @@ -282,6 +287,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()) } @@ -301,7 +318,11 @@ impl State { self.draft.as_ref().is_some_and(|d| d.source.is_open()) } pub fn source_back(&mut self) -> bool { - self.draft.as_mut().is_some_and(|d| d.source.back()) + let returned = self.draft.as_mut().is_some_and(|d| d.source.back()); + if returned { + self.remount_form(); + } + returned } pub(crate) fn enter(&mut self, document: Id, owner: Id) { @@ -311,16 +332,38 @@ impl State { focused: self.presentation.focused, ..Default::default() }; - self.scroll_offsets = [iced::Vector::ZERO; 2]; + self.details_offset = iced::Vector::ZERO; self.restore = None; } } pub(crate) fn viewport(&mut self, size: Size) -> bool { self.presentation.viewport(size) } + pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool { + let p = &mut self.presentation; + super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event) + } pub(crate) fn zoom(&mut self, scale: Option) -> bool { self.presentation.zoom(scale) } + pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option) -> bool { + let size = Scene::sequence(p, owner, owner).size; + let old = self.presentation.scale(size); + if let Some(next) = zoom { + let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.)); + self.camera(super::canvas_workspace::Event::Zoom { + factor: next / old, + at: Point::new(viewport.width / 2., viewport.height / 2.), + base: old, + }) + } else { + self.zoom(None); + let fitted = self.presentation.scale(size); + self.zoom(Some(fitted)); + self.presentation.pan = iced::Vector::ZERO; + true + } + } pub(crate) fn focused(&self) -> bool { self.presentation.focused } @@ -349,11 +392,11 @@ impl State { occurrence, target, tool: self.canvas_tool(context.scenario), - offsets: self.scroll_offsets, + details_offset: self.details_offset, presentation: self.presentation.clone(), }); self.canvas_tool = Some((target, CanvasTool::Select)); - self.scroll_offsets = [iced::Vector::ZERO; 2]; + self.details_offset = iced::Vector::ZERO; self.restore = None; Ok(target) } @@ -380,34 +423,30 @@ impl State { return; } self.canvas_tool = Some((frame.owner, frame.tool)); - self.scroll_offsets = frame.offsets; + self.details_offset = frame.details_offset; self.presentation = frame.presentation; self.presentation.identity = Some((context.document, context.scenario)); - self.restore = Some(ViewportRestore { + self.restore = Some(DetailsRestore { document: context.document, owner: frame.owner, token: Id::new_v4(), - offsets: frame.offsets, - pending: [true; 2], + offset: frame.details_offset, }); } pub(super) fn scroll_restore( &self, context: Context, - area: ScrollArea, + ScrollArea::Page: ScrollArea, ) -> Option { - let index = match area { - ScrollArea::Canvas => 0, - ScrollArea::Page => 1, - }; - let restore = self.restore.as_ref().filter(|r| { - r.document == context.document && r.owner == context.scenario && r.pending[index] - })?; + let restore = self + .restore + .as_ref() + .filter(|r| r.document == context.document && r.owner == context.scenario)?; Some(ScrollRestore { document: restore.document, owner: restore.owner, token: restore.token, - offset: restore.offsets[index], + offset: restore.offset, }) } pub(crate) fn restored( @@ -417,22 +456,12 @@ impl State { area: ScrollArea, offset: iced::Vector, ) -> bool { - let index = match area { - ScrollArea::Canvas => 0, - ScrollArea::Page => 1, - }; - let Some(restore) = self.restore.as_mut().filter(|r| { - r.document == context.document - && r.owner == context.scenario - && r.token == token - && r.pending[index] - }) else { + if !self.restore.as_ref().is_some_and(|r| { + r.document == context.document && r.owner == context.scenario && r.token == token + }) { return false; - }; - restore.pending[index] = false; - if !restore.pending.contains(&true) { - self.restore = None; } + self.restore = None; self.scrolled(area, offset) } pub(super) fn recovery_snapshot(&self) -> Result, String> { @@ -445,28 +474,57 @@ impl State { pub(super) fn from_recovery(value: serde_json::Value) -> Result { let draft = serde_json::from_value(value).map_err(|e| e.to_string())?; + let session = Id::new_v4(); Ok(Self { draft: Some(draft), + form_session: session, + form_mount: session, + form_focus_pending: true, ..Default::default() }) } - pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool { + pub(crate) fn form_session(&self) -> Option { + self.draft.as_ref().map(|_| self.form_session) + } + + pub(crate) fn form_mount(&self) -> Option { + self.draft + .as_ref() + .filter(|d| !d.source.is_open()) + .map(|_| self.form_mount) + } + + pub(crate) fn focus_stamp(&self, context: Context) -> Option { + (self.draft.as_ref()?.owner == context.scenario).then_some(focus::Stamp::new( + context, + self.form_session, + self.form_mount()?, + )) + } + + fn remount_form(&mut self) { + self.form_mount = Id::new_v4(); + self.form_focus_pending = true; + } + + pub(crate) fn take_form_focus(&mut self, mount: Id) -> bool { + if self.form_mount() == Some(mount) && self.form_focus_pending { + self.form_focus_pending = false; + true + } else { + false + } + } + + pub(crate) fn scrolled(&mut self, ScrollArea::Page: ScrollArea, offset: iced::Vector) -> bool { if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. { return false; } - let index = match area { - ScrollArea::Canvas => 0, - ScrollArea::Page => 1, - }; - let changed = self.scroll_offsets[index] != offset; - self.scroll_offsets[index] = offset; + let changed = self.details_offset != offset; + self.details_offset = offset; changed } - fn scroll_offset(&self) -> iced::Vector { - // The former page scroll now belongs to a sibling outline shelf. - self.scroll_offsets[0] - } fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec { let key = CallChoiceContext { document: context.document, @@ -616,6 +674,7 @@ impl State { .source .dismiss(&event)? { + self.remount_form(); return Ok(None); } let d = self @@ -656,7 +715,11 @@ impl State { next.target = Some(id); next.command(p)?; } + let returned = d.source.is_open() && !next.source.is_open(); self.draft = Some(next); + if returned { + self.remount_form(); + } return Ok(None); } if self.source_open() && !matches!(&event, Event::Cancel) { @@ -671,6 +734,9 @@ impl State { if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) { return Err("Choose a scenario.".into()); } + self.form_session = Id::new_v4(); + self.form_mount = self.form_session; + self.form_focus_pending = true; self.draft = Some(Draft { source: Default::default(), invocation: Id::new_v4(), @@ -1986,20 +2052,31 @@ pub(crate) mod direct_tests { navigation_event(&mut app, |ctx| { CanvasEvent::Choose(ctx, CanvasTool::Add(Tool::Reuse)) }); + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None)); + let camera_epoch = app.camera_epoch; navigation_event(&mut app, |ctx| { - CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(25., 170.)) + CanvasEvent::Camera( + ctx, + camera_epoch, + super::super::canvas_workspace::Event::Pan(iced::Vector::new(25., 170.)), + ) }); navigation_event(&mut app, |ctx| { CanvasEvent::Scrolled(ctx, ScrollArea::Page, iced::Vector::new(0., 80.)) }); navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, caller)); assert_eq!(app.selected, b); - assert_eq!(app.behavior.scenario.scroll_offset(), iced::Vector::ZERO); + assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO); navigation_event(&mut app, |ctx| { CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer)) }); + let camera_epoch = app.camera_epoch; navigation_event(&mut app, |ctx| { - CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(0., 115.)) + CanvasEvent::Camera( + ctx, + camera_epoch, + super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 115.)), + ) }); navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, nested)); assert_eq!(app.selected, c); @@ -2010,7 +2087,7 @@ pub(crate) mod direct_tests { CanvasTool::Message(MessageStyle::Timer) ); assert_eq!( - app.behavior.scenario.scroll_offset(), + app.behavior.scenario.presentation.pan, iced::Vector::new(0., 115.) ); navigation_event(&mut app, CanvasEvent::Back); @@ -2021,11 +2098,11 @@ pub(crate) mod direct_tests { CanvasTool::Add(Tool::Reuse) ); assert_eq!( - app.behavior.scenario.scroll_offset(), + app.behavior.scenario.presentation.pan, iced::Vector::new(25., 170.) ); assert_eq!( - app.behavior.scenario.scroll_offsets[1], + app.behavior.scenario.details_offset, iced::Vector::new(0., 80.), "The caller's independent outline scroll is restored separately" ); @@ -2203,22 +2280,24 @@ pub(crate) mod direct_tests { let before = app.document.bundle().clone(); let mut scene = Scene::sequence(app.project(), f.owner, f.call); scene.interaction = Some((context, CanvasTool::Add(tool), true)); - scene.scroll_offset = iced::Vector::new(23., 80.); - let bounds = Rectangle::new(Point::new(20., 40.), scene.size); + scene.camera_pan = iced::Vector::new(23., 80.); + let bounds = Rectangle::new( + Point::new(20., 40.), + Size::new(scene.size.width + 46., scene.size.height + 160.), + ); let y = scene.slots.iter().find(|s| s.slot == slot).unwrap().y; let x = if on_lifeline { scene.lanes[&f.sender] } else { scene.size.width - 35. }; - let point = Point::new(bounds.x + x, bounds.y + y); + let point = Point::new(bounds.x + x, bounds.y + y) + scene.camera_pan; + assert!(bounds.contains(point)); let mut input = SequenceInput::default(); canvas_mouse( &scene, &mut input, - mouse::Event::CursorMoved { - position: point - scene.scroll_offset, - }, + mouse::Event::CursorMoved { position: point }, point, bounds, ); @@ -2394,10 +2473,10 @@ pub(crate) mod direct_tests { assert!(!app.behavior.has_pending()); } let old_context = app.scenario_context().unwrap(); - let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera( old_context, - ScrollArea::Canvas, - iced::Vector::new(0., 100.), + app.camera_epoch, + super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 100.)), ))); let _ = app.update(Message::ScenarioCanvas(event(old_context))); assert!(!app.behavior.has_pending()); @@ -2588,26 +2667,29 @@ pub(crate) mod direct_tests { ); } #[test] - fn direct_busy_save_keeps_observed_scroll_current_for_the_next_pointer_gesture() { + fn direct_busy_save_keeps_details_current_and_camera_for_the_next_pointer_gesture() { let f = 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)); let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera( + context, + app.camera_epoch, + super::super::canvas_workspace::Event::Pan(iced::Vector::new(-25., -100.)), + ))); + let context = app.scenario_context().unwrap(); let original = app.document.snapshot(); app.busy = true; - for (area, offset) in [ - (ScrollArea::Canvas, iced::Vector::new(25., 100.)), - (ScrollArea::Page, iced::Vector::new(0., 40.)), - ] { - let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( - context, area, offset, - ))); - } + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( + context, + ScrollArea::Page, + iced::Vector::new(0., 40.), + ))); assert!(app.busy); assert_eq!( - app.behavior.scenario.scroll_offset(), - iced::Vector::new(25., 100.) + app.behavior.scenario.details_offset, + iced::Vector::new(0., 40.) ); assert_ne!( app.scenario_context(), @@ -2618,14 +2700,14 @@ pub(crate) mod direct_tests { stale.document = Id::new_v4(); let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( stale, - ScrollArea::Canvas, + ScrollArea::Page, iced::Vector::new(0., 900.), ))); assert_eq!( - app.behavior.scenario.scroll_offset(), - iced::Vector::new(25., 100.) + app.behavior.scenario.details_offset, + iced::Vector::new(0., 40.) ); - let root = std::path::PathBuf::from("scenario-scroll-save-fixture"); + let root = std::path::PathBuf::from("scenario-details-save-fixture"); let receipt = crate::app::FileSnapshot { document: context.document, revision: context.revision, @@ -2646,12 +2728,17 @@ pub(crate) mod direct_tests { let context = app.scenario_context().unwrap(); let mut scene = Scene::sequence(app.project(), f.owner, f.call); scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true)); - scene.scroll_offset = app.behavior.scenario.scroll_offset(); + assert_eq!( + app.behavior.scenario.presentation.pan, + iced::Vector::new(-25., -100.) + ); + scene.camera_pan = app.behavior.scenario.presentation.pan; let slot = scene.slots.last().unwrap().slot; let bounds = Rectangle::new(Point::new(20., 250.), scene.size); let y = bounds.y + scene.slots.last().unwrap().y; - let a = Point::new(bounds.x + scene.lanes[&f.sender], y) - scene.scroll_offset; - let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) - scene.scroll_offset; + let a = Point::new(bounds.x + scene.lanes[&f.sender], y) + scene.camera_pan; + let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) + scene.camera_pan; + assert!(bounds.contains(a) && bounds.contains(b)); let mut input = super::super::SequenceInput::default(); let mut published = vec![]; for event in [ @@ -2665,7 +2752,7 @@ pub(crate) mod direct_tests { &mut input, &canvas::Event::Mouse(event), bounds, - mouse::Cursor::Available(b + scene.scroll_offset), + mouse::Cursor::Available(b), ) .and_then(|action| action.into_inner().0) { diff --git a/crates/oa-desktop/src/behavior/scenario/focus.rs b/crates/oa-desktop/src/behavior/scenario/focus.rs new file mode 100644 index 00000000..9f6b0b73 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/focus.rs @@ -0,0 +1,334 @@ +//! A delayed focus request must still own the live parent tree when executed. +//! Collect first: a missing target must not unfocus unrelated fields. +use super::Context; +use iced::advanced::widget::{Id, Operation, Tree, operation}; +use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer}; +use iced::{Element, Event, Length, Rectangle, Size, Task, Theme, Vector, mouse}; +use oa_core::Id as ModelId; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Stamp { + document: ModelId, + owner: ModelId, + revision: u64, + session: ModelId, + mount: ModelId, +} +impl Stamp { + pub(crate) fn new(context: Context, session: ModelId, mount: ModelId) -> Self { + Self { + document: context.document, + owner: context.scenario, + revision: context.revision, + session, + mount, + } + } +} +pub(crate) fn name_id(stamp: Stamp) -> Id { + Id::from(format!( + "{}:{}:{}:{}:{}:{}", + super::NAME_INPUT, + stamp.document, + stamp.owner, + stamp.revision, + stamp.session, + stamp.mount + )) +} +struct Owner(Option); +struct Root<'a, Message, R: renderer::Renderer> { + content: Element<'a, Message, Theme, R>, + owner: Option, +} +pub(crate) fn root<'a, Message: 'a, R: renderer::Renderer + 'a>( + content: impl Into>, + owner: Option, +) -> Element<'a, Message, Theme, R> { + Element::new(Root { + content: content.into(), + owner, + }) +} +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, + op: &mut dyn Operation, + ) { + // This is computed from the current App view, never retained widget state. + op.custom(None, layout.bounds(), &mut Owner(self.owner)); + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, op); + } + 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, + ) + } +} + +#[derive(Clone)] +struct Scroll { + index: usize, + bounds: Rectangle, + capacity: Vector, + offset: Vector, +} +struct Collect { + stamp: Stamp, + valid: bool, + translation: Vector, + scrolls: Vec, + pending: Option, + index: usize, + target: Option<(Rectangle, Vec)>, +} +pub(crate) fn task(stamp: Stamp) -> Task { + iced::advanced::widget::operate(Collect { + stamp, + valid: false, + translation: Vector::ZERO, + scrolls: vec![], + pending: None, + index: 0, + target: None, + }) + .discard() +} +impl Operation for Collect { + fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) { + if let Some(owner) = state.downcast_ref::() { + self.valid = owner.0 == Some(self.stamp); + } + } + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + let previous = (self.translation, self.scrolls.len()); + if let Some(scroll) = self.pending.take() { + self.translation += scroll.offset; + self.scrolls.push(scroll); + } + visit(self); + self.translation = previous.0; + self.scrolls.truncate(previous.1); + } + fn scrollable( + &mut self, + _: Option<&Id>, + bounds: Rectangle, + content: Rectangle, + offset: Vector, + _: &mut dyn operation::Scrollable, + ) { + self.pending = Some(Scroll { + index: self.index, + bounds: translated(bounds, self.translation), + capacity: Vector::new( + (content.width - bounds.width).max(0.), + (content.height - bounds.height).max(0.), + ), + offset, + }); + self.index += 1; + } + fn focusable(&mut self, id: Option<&Id>, bounds: Rectangle, _: &mut dyn operation::Focusable) { + if id == Some(&name_id(self.stamp)) { + self.target = Some((translated(bounds, self.translation), self.scrolls.clone())); + } + } + fn finish(&self) -> operation::Outcome<()> { + if !self.valid { + return operation::Outcome::None; + } + let Some((mut bounds, ancestors)) = self.target.clone() else { + return operation::Outcome::None; + }; + let mut shifts = BTreeMap::new(); + for ancestor in ancestors.iter().rev() { + let dx = reveal( + bounds.x, + bounds.width, + ancestor.bounds.x, + ancestor.bounds.width, + ); + let dy = reveal( + bounds.y, + bounds.height, + ancestor.bounds.y, + ancestor.bounds.height, + ); + // Drawing offsets are rounded; an already-visible axis must not + // acquire a scroll delta from a fractional capacity alone. + let delta = Vector::new( + if dx == 0. { + 0. + } else { + (ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x + }, + if dy == 0. { + 0. + } else { + (ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y + }, + ); + if delta != Vector::ZERO { + shifts.insert(ancestor.index, delta); + } + bounds = translated(bounds, delta); + } + operation::Outcome::Chain(Box::new(Apply { + stamp: self.stamp, + valid: false, + shifts, + index: 0, + })) + } +} +fn translated(bounds: Rectangle, delta: Vector) -> Rectangle { + Rectangle { + x: bounds.x - delta.x, + y: bounds.y - delta.y, + ..bounds + } +} +fn reveal(start: f32, length: f32, viewport: f32, size: f32) -> f32 { + if start < viewport || length > size { + start - viewport + } else { + (start + length - viewport - size).max(0.) + } +} +struct Apply { + stamp: Stamp, + valid: bool, + shifts: BTreeMap, + index: usize, +} +impl Operation for Apply { + fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) { + if let Some(owner) = state.downcast_ref::() { + self.valid = owner.0 == Some(self.stamp); + } + } + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + visit(self); + } + fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) { + if self.valid { + if id == Some(&name_id(self.stamp)) { + state.focus(); + } else { + state.unfocus(); + } + } + } + fn scrollable( + &mut self, + _: Option<&Id>, + bounds: Rectangle, + content: Rectangle, + _: Vector, + state: &mut dyn operation::Scrollable, + ) { + if self.valid + && let Some(delta) = self.shifts.get(&self.index) + { + state.scroll_by( + operation::scrollable::AbsoluteOffset { + x: delta.x, + y: delta.y, + }, + bounds, + content, + ); + } + self.index += 1; + } +} diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index 3265bc22..d4d08d7b 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -1,11 +1,12 @@ //! Scenario presentation. Layout/camera controls never construct model commands. -use super::super::scenario_scroll; +use super::super::{form_session, scenario_scroll}; use super::*; use iced::widget::{Space, column, sensor}; +pub(crate) const NAME_INPUT: &str = "scenario-form-name"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Shelf { - Tools, Selection, } @@ -15,6 +16,7 @@ pub(super) struct Presentation { pub(super) viewport: Option, pub(super) focused: bool, pub(super) shelf: Option, + pub(super) pan: iced::Vector, pub(super) zoom: Option, pub(super) fit: bool, } @@ -44,23 +46,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> { @@ -76,249 +74,188 @@ 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 size = Size::new(scene.size.width * scale, scene.size.height * scale); - let canvas = canvas_pane( - context, - state.scroll_restore(context, ScrollArea::Canvas), - 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(), + Space::new().height(0).into(), + Space::new().height(0).into(), ); - let mut form_panel = editor_panel(Space::new().height(0).into(), Space::new().height(0).into()); if let Some(d) = app .behavior .scenario @@ -342,6 +279,9 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a let mut form = column![ text(d.tool.label()).size(16), text_input("Name (defaults to selected source)", &d.name) + .id(focus::name_id( + state.focus_stamp(context).expect("visible parent form") + )) .on_input(|s| event(Event::Name(s))) .padding(9) .size(12) @@ -619,10 +559,20 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a action("Cancel", event(Event::Cancel)) ] .spacing(7); - form_panel = editor_panel(footer.into(), form.into()); + let session = state.form_session().expect("visible scenario draft"); + let mount = state.form_mount().expect("visible parent form"); + form_panel = sensor(editor_panel(session, footer.into(), form.into())) + .key(mount) + .on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, mount))) + .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>( @@ -636,101 +586,38 @@ fn sequence_action<'a>( .on_press_maybe(enabled.then_some(message)) } -// Permanent header/shelf/body and canvas/form slots. Zero-sized disclosures -// must remain in the tree: Column::push otherwise drops them and remounts siblings. -fn workspace_layout<'a, R: iced::advanced::text::Renderer + 'a>( - header: iced::Element<'a, Message, Theme, R>, - details: iced::Element<'a, Message, Theme, R>, - canvas: iced::Element<'a, Message, Theme, R>, - form: iced::Element<'a, Message, Theme, R>, - pending: bool, -) -> iced::Element<'a, Message, Theme, R> { - let panes = Column::from_vec(vec![ - container(canvas).width(Fill).height(Fill).into(), - container(form) - .width(Fill) - .height(if pending { - Fill - } else { - iced::Length::Fixed(0.) - }) - .clip(true) - .into(), - ]) - .spacing(if pending { 6 } else { 0 }) - .width(Fill) - .height(Fill); - Column::from_vec(vec![header, details, panes.into()]) - .spacing(6) - .padding(8) - .width(Fill) - .height(Fill) - .into() -} - fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>( context: Context, restore: Option, details: iced::Element<'a, Message, Theme, R>, - open: bool, - pending: bool, ) -> iced::Element<'a, Message, Theme, R> { scenario_scroll::observe( context, ScrollArea::Page, + false, restore, scrollable(details) .id("scenario-details") .width(Fill) - .height(if open { - if pending { 72. } else { 100. } - } else { - 0. - }), + .height(Fill), ) } -fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>( - context: Context, - restore: Option, - content: iced::Element<'a, Message, Theme, R>, -) -> iced::Element<'a, Message, Theme, R> { - container( - sensor(scenario_scroll::observe( - context, - ScrollArea::Canvas, - restore, - 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>, fields: iced::Element<'a, Message, Theme, R>, ) -> iced::Element<'a, Message, Theme, R> { container( column![ container(actions).id("scenario-actions").width(Fill), - scrollable(fields) - .id("scenario-form") - .width(Fill) - .height(Fill), + form_session::reset_on_change( + session, + scrollable(fields) + .id("scenario-form") + .width(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 27b2441e..311c93cb 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs @@ -1,20 +1,670 @@ use super::*; +use crate::behavior::canvas_workspace::tests::{Geometry, Mounted}; use iced::Vector; -use iced::advanced::{Layout, Shell, layout, widget}; +use iced::advanced::{Layout, layout, widget}; -#[derive(Default)] -struct Geometry { - scrolls: BTreeMap, - actions: Option, +#[tokio::test] +async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_through_editing() { + use crate::behavior::canvas_workspace::{self, tests::Mounted}; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project.clone()); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + for size in [Size::new(1100., 700.), Size::new(1440., 900.)] { + app.window_size = size; + mounted.settle(&mut app).await; + for scale in [0.5, 1., 2.] { + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Zoom( + app.scenario_context().unwrap(), + Some(scale), + ))); + let geometry = mounted.geometry(&app); + let bounds = geometry.bounds("scenario-viewport"); + assert!( + !geometry + .scrolls + .contains_key(&widget::Id::new("scenario-canvas")) + ); + assert!( + bounds.height > 350., + "actual Fill viewport at {size:?}: {bounds:?}" + ); + let scene = Scene::sequence(app.project(), f.owner, f.owner); + let slot = scene + .slots + .iter() + .find(|s| s.slot.parent == f.owner && s.slot.before == Some(f.reply)) + .unwrap(); + let expected = slot.slot; + let world = Point::new(scene.lanes[&f.sender], slot.y); + let desired = Vector::new(70., 115.); + let next_pan = desired - Vector::new(world.x, world.y) * scale; + let delta = next_pan - app.behavior.scenario.presentation.pan; + let start = bounds.position() + Vector::new(120., 60.); + let snapshot = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + mounted.pan(&mut app, start, start + delta).await; + let diff = app.behavior.scenario.presentation.pan - next_pan; + assert!(diff.x.hypot(diff.y) < 0.01); + let tool = mounted.geometry(&app).bounds("behavior-tool:A").center(); + mounted.click(&mut app, tool).await; + let messages = mounted.click(&mut app, bounds.position() + desired).await; + assert!( + messages.iter().any( + |m| matches!(m, Message::ScenarioCanvas(CanvasEvent::BeginOccurrence { + tool: Tool::Action, slot, lifeline: Some(line), .. + }) if *slot == expected && *line == f.sender) + ), + "scale {scale}: {messages:?}" + ); + let session = app.behavior.scenario.form_session().unwrap(); + let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation; + let _ = app.update(event(Event::Name(" raw action 雨 ".into()))); + let raw = app.behavior.recovery_snapshot().unwrap(); + mounted.settle(&mut app).await; + let shown = mounted.geometry(&app); + assert_eq!( + app.behavior.scenario.pending_placement(f.owner), + Some((expected, Some(f.sender))) + ); + let retained_pan = app.behavior.scenario.presentation.pan - next_pan; + assert!( + retained_pan.x.hypot(retained_pan.y) < 0.01, + "opening the editor must not move the clicked, already-visible insertion marker" + ); + assert_eq!(shown.bounds("scenario-viewport"), bounds); + let actions = shown.bounds("scenario-actions"); + let form = shown.scrolls[&widget::Id::new("scenario-form")].0; + assert!(actions.y >= bounds.y && actions.y + actions.height <= form.y); + assert!(form.y + form.height <= bounds.y + bounds.height); + assert!(form.height > 250.); + let pan = app.behavior.scenario.presentation.pan; + // Background canvas keys cannot move the camera while the draft owns editing. + mounted + .batch( + &mut app, + &[canvas_workspace::tests::key( + iced::keyboard::key::Named::ArrowDown, + )], + mouse::Cursor::Available(bounds.position() + desired), + ) + .await; + assert_eq!(app.behavior.scenario.presentation.pan, pan); + mounted.scroll(&app, "scenario-form", 500.); + let offset = mounted.geometry(&app).scrolls[&widget::Id::new("scenario-form")].1; + assert_eq!(mounted.geometry(&app).bounds("scenario-actions"), actions); + for shelf in [Shelf::Selection, Shelf::Selection] { + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Shelf( + app.scenario_context().unwrap(), + shelf, + ))); + assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds); + assert_eq!(app.behavior.scenario.form_session(), Some(session)); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().invocation, + invocation + ); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert_eq!( + mounted.geometry(&app).scrolls[&widget::Id::new("scenario-form")].1, + offset + ); + } + // Width-only responsive changes preserve the actual editor offset, + // identity and camera; the right editor keeps its fixed field width. + let other_width = if size.width == 1100. { 1440. } else { 1100. }; + for resized in [Size::new(other_width, size.height), size] { + let task = app.update(Message::WindowResized(resized)); + mounted.perform(&mut app, task).await; + mounted.settle(&mut app).await; + let resized_geometry = mounted.geometry(&app); + assert_eq!( + resized_geometry.scrolls[&widget::Id::new("scenario-form")].1, + offset + ); + assert_eq!(resized_geometry.bounds("scenario-actions").y, actions.y); + assert_eq!(app.behavior.scenario.form_session(), Some(session)); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().invocation, + invocation + ); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert_eq!(app.behavior.scenario.presentation.pan, pan); + } + let _ = app.update(event(Event::Cancel)); + assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); + // Close the optional details overlay before the next pointer workflow. + app.behavior.scenario.presentation.shelf = None; + } + } } -impl widget::Operation for Geometry { + +#[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)); + 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), + ) + .await; + assert!( + !app.behavior.has_pending(), + "a zero-distance camera press cancels semantic creation" + ); + 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, + ); + 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!( + ( + 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()); +} + +#[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), + }, + ); + let second = other.id; + f.project = execute_batch(&f.project, &[Command::CreateElement(other)]).unwrap(); + let mut app = App::with_project(f.project.clone()); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let scene = Scene::sequence(app.project(), f.owner, f.owner); + let glyph = scene + .boxes + .iter() + .find(|b| b.id == first && !b.title.is_empty()) + .unwrap() + .rect; + let end = scene + .slots + .iter() + .find(|s| s.slot.parent == branch && s.slot.before.is_none()) + .unwrap(); + let forbidden = scene + .slots + .iter() + .find(|s| s.slot.parent == f.owner) + .unwrap(); + let world_start = glyph.position() + Vector::new(25., 12.); + let scale = 0.5; + let pan = Vector::new(70. - world_start.x * scale, 260. - world_start.y * scale); + let screen = |point: Point| bounds.position() + pan + Vector::new(point.x, point.y) * scale; + let start = screen(world_start); + let valid = screen(Point::new(world_start.x, end.y)); + let invalid = screen(Point::new(world_start.x, forbidden.y)); + assert!( + bounds.contains(start) && bounds.contains(valid) && bounds.contains(invalid), + "all drag destinations must be visible" + ); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Zoom( + app.scenario_context().unwrap(), + Some(scale), + ))); + let delta = pan - app.behavior.scenario.presentation.pan; + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera( + context, + app.camera_epoch, + crate::behavior::canvas_workspace::Event::Pan(delta), + ))); + let snapshot = app.document.snapshot(); + let before = app.document.bundle().clone(); + let drag = |end| { + [ + iced::Event::Mouse(mouse::Event::CursorMoved { position: start }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + iced::Event::Mouse(mouse::Event::CursorMoved { position: end }), + iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ] + }; + let messages = mounted + .batch(&mut app, &drag(invalid), mouse::Cursor::Available(invalid)) + .await; + assert!( + !messages + .iter() + .any(|m| matches!(m, Message::ScenarioCanvas(CanvasEvent::Reorder { .. }))) + ); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + let messages = mounted + .batch(&mut app, &drag(valid), mouse::Cursor::Available(valid)) + .await; + assert!(messages.iter().any( + |m| matches!(m, Message::ScenarioCanvas(CanvasEvent::Reorder { occurrence, slot, .. }) + if *occurrence == first && *slot == (Slot { parent: branch, before: None })) + )); + assert_eq!(app.selected, first); + let expected = execute_batch( + &f.project, + &[Command::SetSteps(branch, vec![second, first])], + ) + .unwrap(); + assert_eq!(app.project(), &expected); + let after = app.document.bundle().clone(); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &f.project); + assert_eq!(app.document.bundle(), &before); + assert!(!app.document.can_undo()); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected); + assert_eq!(app.document.bundle(), &after); + assert!(!app.document.can_redo()); +} + +#[tokio::test] +async fn production_empty_scenario_lifeline_tool_keeps_staged_source_inside_stable_canvas() { + use crate::behavior::canvas_workspace::tests::Mounted; + let mut project = Project::empty(); + let owners = [Layer::Oa, Layer::Sa].map(|layer| { + project + .add_element(Kind::Scenario, layer, "Empty scenario", None) + .unwrap() + }); + let mut app = App::with_project(project.clone()); + let _ = app.update(Message::Select(owners[0])); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + for owner in owners { + let _ = app.update(Message::Select(owner)); + mounted.settle(&mut app).await; + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let tool = mounted.geometry(&app).bounds("behavior-tool:L").center(); + mounted.click(&mut app, tool).await; + mounted + .click(&mut app, bounds.position() + Vector::new(70., 100.)) + .await; + let draft = app + .behavior + .scenario + .draft + .as_ref() + .expect("canvas lifeline opens its single typed draft"); + assert_eq!(draft.owner, owner); + assert_eq!(draft.tool, Tool::Lifeline); + let invocation = draft.invocation; + let _ = app.update(event(Event::Name(" consumer raw 雨 ".into()))); + let snapshot = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + let _ = app.update(event(Event::Source( + crate::behavior::source_draft::Event::Open(invocation), + ))); + assert!(app.behavior.scenario.source_open()); + mounted.settle(&mut app).await; + assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds); + let _ = app.update(Message::Escape); + assert!(!app.behavior.scenario.source_open()); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().invocation, + invocation + ); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().name, + " consumer raw 雨 " + ); + mounted.settle(&mut app).await; + assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + let _ = app.update(event(Event::Cancel)); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[tokio::test] +async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_modals() { + let 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 _ = app.update(event(Event::Start(f.owner, Tool::Fragment))); + let _ = app.update(event(Event::Name(" retained 雨 ".into()))); + let context = app.scenario_context().unwrap(); + let session = app.behavior.scenario.form_session().unwrap(); + let mount = app.behavior.scenario.form_mount().unwrap(); + app.window_size = Size::new(1100., 520.); + let raw = app.behavior.recovery_snapshot().unwrap(); + let accepted = app.document.snapshot(); + for mismatch in 0..4 { + let mut stale = context; + let mut token = mount; + match mismatch { + 0 => stale.document = Id::new_v4(), + 1 => stale.scenario = Id::new_v4(), + 2 => stale.revision += 1, + _ => token = Id::new_v4(), + } + let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + stale, token, + ))); + assert!(iced_runtime::task::into_stream(task).is_none()); + } + app.hosted.visible = true; + let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + context, mount, + ))); + assert!(iced_runtime::task::into_stream(task).is_none()); + app.hosted.visible = false; + app.busy = true; + let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + context, mount, + ))); + assert!(iced_runtime::task::into_stream(task).is_none()); + app.busy = false; + + // The viewport report from the same mount may advance its geometry epoch. + // That does not change the identity of the field awaiting its first focus. + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport( + context, + Size::new(480., 180.), + ))); + assert_ne!(app.scenario_context(), Some(context)); + let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + context, mount, + ))); + let mut mounted = Mounted::unshown().await; + focus_other(&app, &mut mounted); + let mut away = widget::operation::scrollable::scroll_to::<()>( + widget::Id::new("scenario-form"), + widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(1000.), + }, + ); + mounted.operate(&app, &mut away); + let mut before_reveal = Geometry::default(); + mounted.operate(&app, &mut before_reveal); + assert!( + before_reveal.scrolls[&widget::Id::new("scenario-form")].1.y > 64., + "the production parent must actually overflow" + ); + mounted.perform(&mut app, task).await; + assert_parent_focused(&app, &mut mounted); + let mut revealed = ParentBounds::new(app.scenario_focus_owner().unwrap()); + mounted.operate(&app, &mut revealed); + let name = revealed.name.expect("mounted parent Name"); + let viewport = revealed.viewport.expect("actual parent scroll viewport"); + assert!( + name.y >= viewport.y - 0.5 && name.y + name.height <= viewport.y + viewport.height + 0.5, + "Name {name:?} must be visible inside {viewport:?}" + ); + let repeated = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + context, mount, + ))); + assert!(iced_runtime::task::into_stream(repeated).is_none()); + assert_eq!(app.behavior.scenario.form_session(), Some(session)); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo()); + let _ = app.update(event(Event::Cancel)); + let _ = app.update(event(Event::Start(f.owner, Tool::Fragment))); + let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + app.scenario_context().unwrap(), + mount, + ))); + assert!(iced_runtime::task::into_stream(task).is_none()); +} +// These gates mount the production App, including its modal and scenario +// branches. A missing headless backend is a test failure, never a silent skip. +const FOCUS_VIEWPORT: Size = Size::new(1100., 700.); +async fn focus_action(task: iced::Task) -> Box { + use iced::futures::StreamExt; + let mut stream = iced_runtime::task::into_stream(task).expect("parent focus task"); + let Some(iced_runtime::Action::Widget(operation)) = stream.next().await else { + panic!("focus must emit only a widget operation"); + }; + // The operation owns the task channel until it is dropped; polling its + // completion while retaining this delayed action would wait forever. + operation +} +#[derive(Default, Debug, PartialEq)] +struct Focused(Vec<(Option, bool)>); +impl widget::Operation for Focused { 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 focusable( + &mut self, + id: Option<&widget::Id>, + _: Rectangle, + state: &mut dyn widget::operation::Focusable, + ) { + self.0.push((id.cloned(), state.is_focused())); + } +} +fn parent_fields(app: &App, mounted: &mut Mounted) -> Focused { + let mut result = Focused::default(); + mounted.operate(app, &mut result); + result +} +struct FocusIndex { + target: usize, + index: usize, +} +impl widget::Operation for FocusIndex { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + _: Option<&widget::Id>, + _: Rectangle, + state: &mut dyn widget::operation::Focusable, + ) { + if self.index == self.target { + state.focus(); + } else { + state.unfocus(); } + self.index += 1; + } +} +fn focus_other(app: &App, mounted: &mut Mounted) { + let parent = app.scenario_focus_owner().map(focus::name_id); + let fields = parent_fields(app, mounted); + let target = fields + .0 + .iter() + .rposition(|(id, _)| parent.is_none() || *id != parent) + .expect("actual tree contains another focusable input"); + mounted.operate(app, &mut FocusIndex { target, index: 0 }); + assert!(parent_fields(app, mounted).0[target].1); +} +fn focus_source(app: &App, mounted: &mut Mounted) { + let id = widget::Id::new(crate::behavior::source_draft::NAME_INPUT); + assert!( + parent_fields(app, mounted) + .0 + .iter() + .any(|(field, _)| field.as_ref() == Some(&id)) + ); + let mut operation = widget::operation::focusable::focus::<()>(id.clone()); + mounted.operate(app, &mut operation); + assert!(parent_fields(app, mounted).0.contains(&(Some(id), true))); +} +#[derive(Default)] +struct ReviewField { + inside: bool, + entering: Option, + index: usize, + target: Option, +} +impl widget::Operation for ReviewField { + fn container(&mut self, id: Option<&widget::Id>, _: Rectangle) { + self.entering = + Some(self.inside || id == Some(&widget::Id::new("reference-owning-review-modal"))); + } + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + let prior = self.inside; + if let Some(inside) = self.entering.take() { + self.inside = inside; + } + visit(self); + self.inside = prior; + } + fn focusable( + &mut self, + _: Option<&widget::Id>, + _: Rectangle, + _: &mut dyn widget::operation::Focusable, + ) { + if self.inside { + self.target = Some(self.index); + } + self.index += 1; + } +} +fn focus_review(app: &App, mounted: &mut Mounted) { + let mut field = ReviewField::default(); + mounted.operate(app, &mut field); + let target = field + .target + .expect("production Review owns a mounted candidate input"); + mounted.operate(app, &mut FocusIndex { target, index: 0 }); + assert!(parent_fields(app, mounted).0[target].1); +} +fn assert_parent_focused(app: &App, mounted: &mut Mounted) { + let expected = focus::name_id(app.scenario_focus_owner().unwrap()); + let fields = parent_fields(app, mounted); + assert_eq!( + fields + .0 + .iter() + .filter(|(_, focused)| *focused) + .collect::>(), + vec![&(Some(expected), true)] + ); +} +struct ParentBounds { + target: widget::Id, + translation: Vector, + pending: Option, + viewport: Option, + name: Option, +} +impl ParentBounds { + fn new(stamp: focus::Stamp) -> Self { + Self { + target: focus::name_id(stamp), + translation: Vector::ZERO, + pending: None, + viewport: None, + name: None, + } + } + fn actual(&self, bounds: Rectangle) -> Rectangle { + Rectangle { + x: bounds.x - self.translation.x, + y: bounds.y - self.translation.y, + ..bounds + } + } +} +impl widget::Operation for ParentBounds { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + let previous = self.translation; + if let Some(offset) = self.pending.take() { + self.translation += offset; + } + visit(self); + self.translation = previous; } fn scrollable( &mut self, @@ -24,489 +674,322 @@ impl widget::Operation for Geometry { 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() - ); - } + if id == Some(&widget::Id::new("scenario-form")) { + self.viewport = Some(self.actual(bounds)); + } + self.pending = Some(translation); + } + fn focusable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + _: &mut dyn widget::operation::Focusable, + ) { + if id == Some(&self.target) { + self.name = Some(self.actual(bounds)); } } } -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), - Space::new().width(1800.).height(2400.).into(), - ), - editor_panel( - Space::new().height(32.).into(), - Space::new().height(2000.).into(), - ), - pending, - ) +async fn shown_task(app: &mut App, mounted: &mut Mounted) -> iced::Task { + let messages = mounted.input( + app, + &[iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + ))], + mouse::Cursor::Unavailable, + ); + let mut shown = None; + let mut ready = vec![]; + // Drain the whole App message batch before any operation sees the final UI. + // Retain only the parent sensor's Task for the explicit delayed-focus gates. + for message in messages { + let parent = matches!( + &message, + Message::ScenarioCanvas(CanvasEvent::FormShown(..)) + ); + let task = app.update(message); + if parent { + assert!(shown.replace(task).is_none()); + } else { + ready.push(task); + } + } + mounted.perform(app, iced::Task::batch(ready)).await; + shown.expect("production parent sensor reports its newly mounted identity") } -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), +fn focus_fixture() -> (App, Id, Id) { + let mut p = Project::empty(); + let owner = p + .add_element(Kind::Scenario, Layer::Oa, "Operational flow", None) + .unwrap(); + p.add_element(Kind::Scenario, Layer::Oa, "Another scenario", None) + .unwrap(); + if let Some(SharedBody::Scenario { viewpoint, .. }) = + p.elements.get_mut(&owner).unwrap().shared_mut() + { + *viewpoint = ScenarioViewpoint::OperationalActivity; + } + // A real Review candidate input is needed for the delayed-modal falsifier. + let oa = p + .add_element(Kind::DataType, Layer::Oa, "Operational number", None) + .unwrap(); + let sa = p + .add_element(Kind::DataType, Layer::Sa, "System number", None) + .unwrap(); + for (id, unit) in [(oa, "m"), (sa, "cm")] { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Integer { + minimum: None, + maximum: None, + }, + default: None, + unit: unit.into(), + }; + } + let trace = Id::new_v4(); + p.traces.insert( + trace, + Trace { + id: trace, + alias: format!("review_{}", trace.simple()), + name: "Numbers review".into(), + description: String::new(), + oa, + sa, + kind: TraceKind::Manual, + baseline: None, }, ); - 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)); + app.window_size = FOCUS_VIEWPORT; + let _ = app.update(Message::Select(owner)); 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] { + let _ = app.update(event(Event::Start(owner, Tool::Lifeline))); + let _ = app.update(event(Event::Name(" retained parent 雨 ".into()))); + (app, owner, oa) +} +fn open_source(app: &mut App) -> Id { + use crate::behavior::source_draft as source; + let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation; + let _ = app.update(event(Event::Source(source::Event::Open(invocation)))); + assert!(app.behavior.scenario.source_open(), "{}", app.status); + let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); + serde_json::from_value(raw["source"]["editing"]["token"].clone()).unwrap() +} +fn open_review(app: &mut App, source: Id) { + use crate::semantic_review::{self, RowEvent}; + let mut review = semantic_review::State::new(app.project(), source).unwrap(); + let facet = FacetSelector::Data(DataFacet::Unit); + for event in [ + RowEvent::Select(facet.clone()), + RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), + ] { + let addressed = review.address_event(event); + review.update(app.project(), addressed); + } + app.workflow = Some(crate::workflow::Workflow::Semantic(Box::new(review))); +} + +#[tokio::test] +async fn scenario_form_focus_returns_from_back_use_and_escape_after_initial_focus_is_consumed() { + use crate::behavior::source_draft as source; + for route in 0..3 { + let (mut app, _, _) = focus_fixture(); + let mut mounted = Mounted::unshown().await; + let initial = shown_task(&mut app, &mut mounted).await; + mounted.perform(&mut app, initial).await; + assert_parent_focused(&app, &mut mounted); + let first_mount = app.behavior.scenario.form_mount().unwrap(); + let session = app.behavior.scenario.form_session(); + let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); + let accepted = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + let token = open_source(&mut app); + focus_source(&app, &mut mounted); // mount the actual child + match route { + 0 => { + let _ = app.update(event(Event::Source(source::Event::Edit( + token, + source::Edit::Back, + )))); + } + 1 => { + let _ = app.update(event(Event::Source(source::Event::Edit( + token, + source::Edit::Name("New activity 雨".into()), + )))); + let _ = app.update(event(Event::Source(source::Event::Edit( + token, + source::Edit::Use, + )))); + } + _ => { + let _ = app.update(Message::Escape); + } + } + assert!(!app.behavior.scenario.source_open(), "{}", app.status); + assert_eq!(app.behavior.scenario.form_session(), session); + assert_ne!(app.behavior.scenario.form_mount(), Some(first_mount)); + let returned = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); + for key in [ + "name", + "invocation", + "owner", + "parent", + "before", + "source_origin", + ] { + assert_eq!(returned[key], raw[key], "route {route}, raw field {key}"); + } + let task = shown_task(&mut app, &mut mounted).await; + mounted.perform(&mut app, task).await; + assert_parent_focused(&app, &mut mounted); + assert_eq!( + app.behavior.scenario.recovery_snapshot().unwrap().unwrap(), + returned + ); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo()); + assert!(!app.document.can_redo()); + let repeated = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + app.scenario_context().unwrap(), + app.behavior.scenario.form_mount().unwrap(), + ))); + assert!(iced_runtime::task::into_stream(repeated).is_none()); + } +} + +#[tokio::test] +async fn scenario_form_focus_saved_action_is_inert_on_replacement_child_review_and_changed_context() +{ + for change in 0..8 { + let (mut app, owner, review_source) = focus_fixture(); + let mut mounted = Mounted::unshown().await; + let task = shown_task(&mut app, &mut mounted).await; + // Obtain S1's actual Action before changing the live state; stale + // FormShown rejection alone would not exercise this race. + let mut operation = focus_action(task).await; + match change { + 0 => { + let _ = app.update(event(Event::Cancel)); + let _ = app.update(event(Event::Start(owner, Tool::Lifeline))); + } + 1 => { + open_source(&mut app); + } + 2 => open_review(&mut app, review_source), + 3 => app.canvas_document = Id::new_v4(), + 4 => { + app.document + .execute(Command::Rename(owner, "Renamed scenario".into())) + .unwrap(); + } + 5 => { + let other = app + .project() + .elements + .values() + .find(|e| e.kind() == Kind::Scenario && e.id != owner) + .unwrap() + .id; + let draft = app.behavior.scenario.draft.as_mut().unwrap(); + draft.owner = other; + draft.parent = other; + } + 6 => { + open_source(&mut app); + focus_source(&app, &mut mounted); + let _ = app.update(Message::Escape); // same S1 session, different mount + } + _ => app.hosted.visible = true, + } + if change == 1 { + focus_source(&app, &mut mounted); + } else if change == 2 { + focus_review(&app, &mut mounted); + } else { + focus_other(&app, &mut mounted); + } + let before = parent_fields(&app, &mut mounted); + let source = app.document.bundle().clone(); + let raw = app.behavior.recovery_snapshot().unwrap(); + let undo = app.document.can_undo(); + let redo = app.document.can_redo(); + mounted.operate(&app, operation.as_mut()); assert!( - app.behavior - .scenario - .scroll_restore(app.scenario_context().unwrap(), area) - .is_none() + matches!(operation.finish(), widget::operation::Outcome::None), + "change {change}" + ); + assert_eq!(parent_fields(&app, &mut mounted), before, "change {change}"); + assert_eq!(app.document.bundle(), &source); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert_eq!( + (app.document.can_undo(), app.document.can_redo()), + (undo, redo) ); } - 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" - ); +#[tokio::test] +async fn scenario_form_focus_chained_apply_rechecks_live_review_owner() { + let (mut app, _, review_source) = focus_fixture(); + let mut mounted = Mounted::unshown().await; + let task = shown_task(&mut app, &mut mounted).await; + let mut collect = focus_action(task).await; + mounted.operate(&app, collect.as_mut()); + let widget::operation::Outcome::Chain(mut apply) = collect.finish() else { + panic!("current parent produces the guarded apply traversal"); + }; + open_review(&mut app, review_source); + focus_review(&app, &mut mounted); + let before = parent_fields(&app, &mut mounted); + let raw = app.behavior.recovery_snapshot().unwrap(); + let source = app.document.bundle().clone(); + mounted.operate(&app, apply.as_mut()); + assert!(matches!(apply.finish(), widget::operation::Outcome::None)); + assert_eq!(parent_fields(&app, &mut mounted), before); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); } -#[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., +#[tokio::test] +async fn scenario_form_focus_missing_exact_target_leaves_other_inputs_untouched() { + let (app, _, _) = focus_fixture(); + let stamp = app.scenario_focus_owner().unwrap(); + let mut view: iced::Element<'_, (), Theme, ()> = focus::root( + text_input("Unrelated input", "still focused") + .id("unrelated") + .on_input(|_| ()), + Some(stamp), ); 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, + let node = view.as_widget_mut().layout( &mut tree, - &node, - "scenario-details", - Vector::new(0., 80.), + &(), + &layout::Limits::new(Size::ZERO, Size::new(320., 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.as_widget_mut().operate( + &mut tree, + Layout::new(&node), + &(), + &mut FocusIndex { + target: 0, + index: 0, + }, + ); + let mut operation = focus_action(focus::task(stamp)).await; + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &(), operation.as_mut()); + assert!(matches!( + operation.finish(), + widget::operation::Outcome::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); - 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()); + let mut fields = Focused::default(); + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &(), &mut fields); + assert_eq!(fields.0, vec![(Some(widget::Id::new("unrelated")), true)]); } #[test] @@ -547,7 +1030,7 @@ fn scenario_workspace_viewport_events_are_passive_guarded_and_do_not_apply_raw_f assert_eq!(app.canvas_epoch, epoch); } app.busy = false; - navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Tools)); + navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection)); navigation_event(&mut app, CanvasEvent::Focus); navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None)); assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); @@ -661,84 +1144,31 @@ 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() { +fn scenario_workspace_scaled_and_panned_scene_keeps_clicked_slots_and_message_endpoints() { let f = super::super::direct_tests::fixture(); for scale in [0.25, 0.5, 1., 2.] { for drag in [false, true] { let mut scene = Scene::sequence(&f.project, f.owner, f.call); let context = super::super::direct_tests::context(f.owner); scene.scale = scale; - scene.scroll_offset = Vector::new(35., 40.); + scene.camera_pan = Vector::new(35., 40.); scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true)); let slot = scene.slots.last().unwrap(); let expected = slot.slot; let bounds = Rectangle::new( Point::new(20., 80.), - Size::new(scene.size.width * scale, scene.size.height * scale), + Size::new( + scene.size.width * scale + 70., + scene.size.height * scale + 80., + ), ); let screen = |world: Point| { Point::new(bounds.x + world.x * scale, bounds.y + world.y * scale) - - scene.scroll_offset + + scene.camera_pan }; let a = screen(Point::new(scene.lanes[&f.sender], slot.y)); let b = screen(Point::new(scene.lanes[&f.receiver], slot.y)); + assert!(bounds.contains(a) && bounds.contains(b)); let mut input = super::super::super::SequenceInput::default(); let mut messages = vec![]; let mut events = vec![ @@ -759,7 +1189,7 @@ fn scenario_workspace_scaled_and_scrolled_scene_keeps_clicked_slots_and_message_ &mut input, &canvas::Event::Mouse(event), bounds, - mouse::Cursor::Available(cursor + scene.scroll_offset), + mouse::Cursor::Available(cursor), ) && let Some(message) = action.into_inner().0 { messages.push(message); @@ -781,14 +1211,17 @@ fn scenario_workspace_scaled_nested_occurrences_and_reorder_keep_exact_sequence_ let mut scene = Scene::sequence(&f.project, f.owner, f.call); let context = super::super::direct_tests::context(f.owner); scene.scale = scale; - scene.scroll_offset = Vector::new(35., 40.); + scene.camera_pan = Vector::new(35., 40.); let bounds = Rectangle::new( Point::new(20., 80.), - Size::new(scene.size.width * scale, scene.size.height * scale), + Size::new( + scene.size.width * scale + 70., + scene.size.height * scale + 80., + ), ); let screen = |world: Point| { Point::new(bounds.x + world.x * scale, bounds.y + world.y * scale) - - Vector::new(35., 40.) + + Vector::new(35., 40.) }; let send = |scene: &Scene, input: &mut super::super::super::SequenceInput, event, cursor| { @@ -797,7 +1230,7 @@ fn scenario_workspace_scaled_nested_occurrences_and_reorder_keep_exact_sequence_ input, &canvas::Event::Mouse(event), bounds, - mouse::Cursor::Available(cursor + scene.scroll_offset), + mouse::Cursor::Available(cursor), ) .and_then(|action| action.into_inner().0) }; @@ -819,6 +1252,7 @@ fn scenario_workspace_scaled_nested_occurrences_and_reorder_keep_exact_sequence_ .find(|slot| slot.slot == expected) .unwrap(); let point = screen(Point::new(scene.lanes[&f.sender], slot.y)); + assert!(bounds.contains(point)); let mut input = super::super::super::SequenceInput::default(); send( &scene, @@ -885,3 +1319,642 @@ fn scenario_workspace_scaled_nested_occurrences_and_reorder_keep_exact_sequence_ ); } } + +#[tokio::test] +async fn production_scenario_camera_external_controls_bounds_and_retained_deltas_cancel_old_drags() +{ + use crate::behavior::canvas_workspace::tests::*; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + for boundary in ["Fit", "A", "Details", "Panels", "Bounds"] { + let toolbar = capture_toolbar(&mut mounted, &app, "Fit"); + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let point = bounds.position() + iced::Vector::new(95., 90.); + let lifetime = app.camera_epoch; + let pan = app.behavior.scenario.presentation.pan; + let first = point + iced::Vector::new(13., 9.); + let last = first + iced::Vector::new(17., -3.); + let emitted = mounted + .batch( + &mut app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: point }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)), + iced::Event::Mouse(mouse::Event::CursorMoved { position: first }), + iced::Event::Mouse(mouse::Event::CursorMoved { position: last }), + ], + mouse::Cursor::Available(last), + ) + .await; + assert_eq!(app.behavior.scenario.presentation.pan, pan + (last - point)); + assert_eq!( + app.camera_epoch, lifetime, + "own ordered deltas retain their lifetime" + ); + let retained = camera_delta(&emitted); + if boundary == "Bounds" { + let size = Size::new(app.window_size.width + 90., app.window_size.height + 35.); + let task = app.update(Message::WindowResized(size)); + mounted.perform(&mut app, task).await; + } else { + let id = scope(&app).control_id(boundary); + let at = mounted.geometry(&app).containers[&id].center(); + let clicked = mounted.click(&mut app, at).await; + assert!( + focused(&mut mounted, &app, boundary), + "same-batch pointer activation focuses the resulting live control without F6" + ); + if boundary == "Fit" { + mounted + .batch( + &mut app, + &[key(iced::keyboard::key::Named::Tab)], + mouse::Cursor::Unavailable, + ) + .await; + assert!( + focused(&mut mounted, &app, "ZoomOut"), + "Tab after the real press/release batch continues from Fit" + ); + assert!(clicked.iter().any(|m| matches!( + intent_action(m), + Message::ScenarioCanvas(CanvasEvent::Zoom(_, None)) + ))); + assert_eq!( + app.behavior.scenario.presentation.pan, + iced::Vector::ZERO, + "Fit emitted after this same batch's old pan must still execute" + ); + } + } + mounted.settle(&mut app).await; + assert_ne!( + app.camera_epoch, lifetime, + "{boundary} ends the old input lifetime" + ); + let camera = ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom, + ); + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let released = release_middle( + &mut mounted, + &mut app, + bounds.position() + iced::Vector::new(130., 145.), + ) + .await; + assert!(!released.iter().any(|m| matches!(m, Message::ScenarioCanvas(CanvasEvent::Camera(_, _, crate::behavior::canvas_workspace::Event::Pan(delta))) if *delta != iced::Vector::ZERO)), + "{boundary} must cancel the retained widget drag"); + let task = app.update(retained); + mounted.perform(&mut app, task).await; + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera, + "{boundary} must reject its pre-boundary App delta" + ); + let current_lifetime = app.camera_epoch; + let task = app.update(toolbar); + mounted.perform(&mut app, task).await; + assert_eq!( + app.camera_epoch, current_lifetime, + "{boundary} rejects the retained toolbar lifetime" + ); + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera, + "{boundary} retained Fit cannot alter the camera" + ); + } + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_scenario_keyboard_enters_activates_draws_focus_and_returns_to_inspector() { + use crate::behavior::canvas_workspace::tests::*; + use iced::keyboard::key::Named; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + assert!(!focused(&mut mounted, &app, "viewport")); + let operations = mounted.operations; + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!( + mounted.operations > operations, + "entry executes the returned App widget Task" + ); + assert!(focused(&mut mounted, &app, "viewport")); + let mut backwards = key(Named::Tab); + if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { modifiers, .. }) = + &mut backwards + { + *modifiers = iced::keyboard::Modifiers::SHIFT; + } + mounted + .batch(&mut app, &[backwards], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "ActualSize")); + mounted + .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "viewport")); + assert_eq!( + mounted + .geometry(&app) + .focused + .iter() + .filter(|(_, _, focused)| *focused) + .count(), + 1 + ); + tab_to(&mut mounted, &mut app, "A").await; + let chosen = mounted + .batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable) + .await; + assert!(chosen.iter().any(|m| matches!( + intent_action(m), + Message::ScenarioCanvas(CanvasEvent::Choose(..)) + ))); + assert!( + focused(&mut mounted, &app, "A"), + "tool activation retains its keyboard target" + ); + // The selected palette tool must still have a visibly distinct focus ring. + let tool_on = mounted.pixels(&app); + let geometry = mounted.geometry(&app); + let mut tool_bounds = geometry.containers[&scope(&app).control_id("A")]; + let offset = geometry.scrolls[&scope(&app).control_id("palette-scroll")].1; + tool_bounds.x -= offset.x; + tool_bounds.y -= offset.y; + mounted + .batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + let tool_off = mounted.pixels(&app); + assert_drawn_focus(&tool_off, &tool_on, app.window_size, tool_bounds); + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + // Focus alone changes the visible border of the real production Fit control. + let before = mounted.pixels(&app); + tab_to(&mut mounted, &mut app, "Fit").await; + let id = scope(&app).control_id("Fit"); + let bounds = mounted.geometry(&app).containers[&id]; + let after = mounted.pixels(&app); + assert_drawn_focus(&before, &after, app.window_size, bounds); + let fitted = mounted + .batch(&mut app, &[key(Named::Space)], mouse::Cursor::Unavailable) + .await; + assert!(fitted.iter().any(|m| matches!( + intent_action(m), + Message::ScenarioCanvas(CanvasEvent::Zoom(_, None)) + ))); + assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO); + assert!(focused(&mut mounted, &app, "Fit")); + tab_to(&mut mounted, &mut app, "Canvas").await; + mounted + .batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "viewport")); + mounted + .batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + assert!(!focused(&mut mounted, &app, "viewport")); + mounted + .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) + .await; + assert!( + mounted + .geometry(&app) + .focused + .iter() + .any(|(_, _, focused)| *focused), + "ordinary Tab resumes the production Inspector's own entry policy" + ); + assert!(!focused(&mut mounted, &app, "viewport")); + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!(focused(&mut mounted, &app, "viewport")); + assert_eq!( + mounted + .geometry(&app) + .focused + .iter() + .filter(|(_, _, focused)| *focused) + .count(), + 1, + "explicit canvas entry releases Inspector keyboard ownership" + ); + mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!(!focused(&mut mounted, &app, "viewport")); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + // Typing after exit belongs to Inspector. Replaying the prior clean tool + // action must retain those raw fields, never implicitly Apply them. + mounted + .batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable) + .await; + mounted + .batch(&mut app, &[character("x")], mouse::Cursor::Unavailable) + .await; + assert!(app.draft_dirty()); + let dirty_raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + assert_ne!(dirty_raw, raw); + let camera = ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom, + ); + for message in chosen { + let task = app.update(message); + mounted.perform(&mut app, task).await; + } + let emitted = mounted + .batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable) + .await; + assert!( + !emitted + .iter() + .any(|m| matches!(m, Message::ScenarioCanvas(CanvasEvent::Camera(..)))) + ); + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera + ); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + dirty_raw + ); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_scenario_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes() { + use crate::behavior::canvas_workspace::tests::*; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1440., 900.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + retained_toolbar_boundaries(&mut mounted, &mut app, "A").await; + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn production_scenario_keyboard_reveals_and_activates_initially_offscreen_last_tool() { + use crate::behavior::canvas_workspace::tests::*; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 420.); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let source = app.document.bundle().clone(); + overflow_last_tool(&mut mounted, &mut app, "alt").await; + assert_eq!( + app.behavior.scenario.canvas_tool(f.owner), + CanvasTool::Add(Tool::Fragment) + ); + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +// Successor to the former 2000px synthetic form / scrollable-canvas fixture. +// The real editor clamps the 700px request; that exact measured offset must +// survive same-session edits and reset to zero on replacement without a closed render. +#[tokio::test] +async fn new_scenario_form_resets_only_its_scroll_and_recovery_gets_a_fresh_session() { + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 420.); + let mut mounted = Mounted::new(&mut app).await; + let canvas = mounted.geometry(&app).bounds("scenario-viewport"); + let start = canvas.position() + Vector::new(90., 80.); + mounted + .pan(&mut app, start, start + Vector::new(-137., -850.)) + .await; + let camera = ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom, + ); + assert_eq!(camera.0, Vector::new(-137., -850.)); + let task = app.update(event(Event::Start(f.owner, Tool::Action))); + mounted.perform(&mut app, task).await; + mounted.settle(&mut app).await; + let first = app.behavior.scenario.form_session().unwrap(); + let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation; + mounted.scroll(&app, "scenario-form", 700.); + let offset = mounted.geometry(&app).scrolls[&widget::Id::new("scenario-form")].1; + assert!( + offset.y > 64., + "real Action editor must actually overflow: {offset:?}" + ); + let accepted = app.document.snapshot(); + let source = app.document.bundle().clone(); + let task = app.update(event(Event::Name(" retained raw 雨 ".into()))); + mounted.perform(&mut app, task).await; + let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); + assert_eq!( + mounted.geometry(&app).scrolls[&widget::Id::new("scenario-form")].1, + offset + ); + assert_eq!(app.behavior.scenario.form_session(), Some(first)); + assert_eq!( + app.behavior.scenario.draft.as_ref().unwrap().invocation, + invocation + ); + // No render or widget operation between Cancel and Start. + let cancel = app.update(event(Event::Cancel)); + let start = app.update(event(Event::Start(f.owner, Tool::Observation))); + mounted + .perform(&mut app, iced::Task::batch([cancel, start])) + .await; + assert_ne!(app.behavior.scenario.form_session(), Some(first)); + let replaced = mounted.geometry(&app); + assert_eq!( + replaced.scrolls[&widget::Id::new("scenario-form")].1, + Vector::ZERO + ); + assert_eq!(replaced.bounds("scenario-viewport"), canvas); + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera + ); + let recovered = State::from_recovery(raw.clone()).unwrap(); + assert_ne!(recovered.form_session(), Some(first)); + assert_eq!( + recovered.draft.as_ref().unwrap().name, + " retained raw 雨 " + ); + assert_eq!(recovered.draft.as_ref().unwrap().invocation, invocation); + let recovered_raw = recovered.recovery_snapshot().unwrap().unwrap(); + for field in [ + "name", + "owner", + "parent", + "before", + "target", + "source_origin", + "invocation", + ] { + assert_eq!(recovered_raw[field], raw[field], "recovered {field}"); + } + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inputs() { + let graph = Size::new(900., 1400.); + let mut presentation = Presentation::default(); + assert_eq!(presentation.scale(graph), 1.); + presentation.zoom(None); + assert_eq!( + presentation.scale(graph), + 1., + "no invented window-height fallback" + ); + assert!(presentation.viewport(Size::new(484., 204.))); + assert!(!presentation.viewport(Size::new(484., 204.))); + let scale = presentation.scale(graph); + assert!(graph.width * scale <= 468. && graph.height * scale <= 188.); + assert!(scale > MIN_SCALE); + for bad in [f32::NAN, f32::INFINITY, 0., -1.] { + assert!(!presentation.zoom(Some(bad))); + assert!(!presentation.viewport(Size::new(bad, 100.))); + assert!(!presentation.viewport(Size::new(100., bad))); + assert_eq!(presentation.scale(graph), scale); + } + assert_eq!(presentation.scale(Size::new(900., 10000.)), MIN_SCALE); + assert!(presentation.zoom(Some(1.25))); + presentation.viewport(Size::new(200., 100.)); + assert_eq!( + presentation.scale(graph), + 1.25, + "manual zoom survives form/viewport resize" + ); +} + +#[tokio::test] +async fn scenario_workspace_same_size_owner_and_document_changes_remeasure_fit_viewport() { + use super::super::direct_tests::{navigation_event, navigation_fixture}; + let (project, [a, b, _], [reference, _, _]) = navigation_fixture(); + let mut app = App::with_project(project.clone()); + let _ = app.update(Message::Select(a)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1440., 900.); + let mut mounted = Mounted::new(&mut app).await; + let first = mounted.geometry(&app).bounds("scenario-viewport").size(); + assert_eq!(app.behavior.scenario.presentation.viewport, Some(first)); + navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference)); + assert_eq!(app.scenario_context().unwrap().scenario, b); + assert!(app.behavior.scenario.presentation.viewport.is_none()); + mounted.settle(&mut app).await; + assert_eq!(app.behavior.scenario.presentation.viewport, Some(first)); + let graph = Scene::sequence(app.project(), b, b).size; + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None)); + let fitted = app.behavior.scenario.presentation.scale(graph); + assert!( + graph.width * fitted <= first.width - 16. && graph.height * fitted <= first.height - 16. + ); + assert_eq!(app.behavior.scenario.presentation.pan, Vector::ZERO); + let prior_document = app.scenario_context().unwrap().document; + app = App::with_project(project); + let _ = app.update(Message::Select(b)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1440., 900.); + let context = app.scenario_context().unwrap(); + assert_ne!(context.document, prior_document); + assert_eq!( + context.scenario, b, + "semantic UUID is retained across documents" + ); + assert!(app.behavior.scenario.presentation.viewport.is_none()); + let accepted = app.document.snapshot(); + let source = app.document.bundle().clone(); + mounted.settle(&mut app).await; + assert_eq!(app.behavior.scenario.presentation.viewport, Some(first)); + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None)); + assert_eq!(app.behavior.scenario.presentation.scale(graph), fitted); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +// Real mounted counterpart of both former synthetic caller-scroll fixtures. +#[tokio::test] +async fn scenario_workspace_open_and_back_restore_owner_and_mounted_camera_after_preselection() { + use super::super::direct_tests::{navigation_event, navigation_fixture}; + let (mut p, [a, b, _], [reference, _, _]) = navigation_fixture(); + // Give the real Details overlay enough legitimate rows to test its offset. + for index in 0..30 { + let element = super::super::super::topology::occurrence( + &p, + a, + &format!("Additional use {index}"), + OccurrenceBody::ScenarioUse { + scenario: Some(b), + bindings: vec![], + }, + ); + p = execute_batch(&p, &[Command::CreateElement(element)]).unwrap(); + } + let mut app = App::with_project(p); + let _ = app.update(Message::Select(a)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + navigation_event(&mut app, |ctx| { + CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer)) + }); + navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection)); + navigation_event(&mut app, CanvasEvent::Focus); + let mut mounted = Mounted::new(&mut app).await; + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75))); + let canvas = mounted.geometry(&app).bounds("scenario-viewport"); + let point = canvas.position() + Vector::new(90., 120.); + mounted + .pan(&mut app, point, point + Vector::new(90., 180.)) + .await; + let camera = ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom, + ); + mounted.scroll(&app, "scenario-details", 80.); + mounted.settle(&mut app).await; + let details = mounted.geometry(&app).scrolls[&widget::Id::new("scenario-details")].1; + assert_eq!(details, Vector::new(0., 80.)); + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera, + "Details is not a camera ancestor" + ); + let _ = app.update(Message::Command(Command::Rename( + b, + "Renamed definition".into(), + ))); + assert_eq!(app.selected, b); + assert_eq!(app.scenario_context().unwrap().scenario, a); + let before_open = app.document.snapshot(); + let source_open = app.document.bundle().clone(); + navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference)); + assert_eq!(app.selected, b); + assert_eq!(app.scenario_context().unwrap().scenario, b); + assert!(std::sync::Arc::ptr_eq( + &before_open, + &app.document.snapshot() + )); + assert_eq!(app.document.bundle(), &source_open); + mounted.settle(&mut app).await; + assert_eq!(app.behavior.scenario.presentation.pan, Vector::ZERO); + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(2.))); + navigation_event(&mut app, CanvasEvent::Focus); + let _ = app.update(Message::Command(Command::Rename( + reference, + "Renamed caller use".into(), + ))); + assert_eq!(app.selected, reference); + assert_eq!(app.scenario_context().unwrap().scenario, b); + let before_back = app.document.snapshot(); + let source_back = app.document.bundle().clone(); + let revision = app.document.revision(); + let bindings = app.project().elements[&reference].clone(); + navigation_event(&mut app, CanvasEvent::Back); + assert_eq!(app.selected, reference); + assert_eq!(app.scenario_context().unwrap().scenario, a); + assert_eq!( + app.behavior.scenario.canvas_tool(a), + CanvasTool::Message(MessageStyle::Timer) + ); + assert!(app.behavior.scenario.focused()); + assert_eq!( + app.behavior.scenario.presentation.shelf, + Some(Shelf::Selection) + ); + assert_eq!( + ( + app.behavior.scenario.presentation.pan, + app.behavior.scenario.presentation.zoom + ), + camera + ); + assert_eq!(app.behavior.scenario.presentation.zoom, Some(0.75)); + mounted.settle(&mut app).await; + assert_eq!( + mounted.geometry(&app).scrolls[&widget::Id::new("scenario-details")].1, + details + ); + assert_eq!( + app.behavior.scenario.details_offset, details, + "the actual Details observer acknowledges the one-shot return offset" + ); + assert!( + app.behavior + .scenario + .scroll_restore(app.scenario_context().unwrap(), ScrollArea::Page) + .is_none() + ); + assert!(app.behavior.scenario.restore.is_none()); + assert!(std::sync::Arc::ptr_eq( + &before_back, + &app.document.snapshot() + )); + assert_eq!(app.document.bundle(), &source_back); + assert_eq!(app.project().elements[&reference], bindings); + assert_eq!(app.document.revision(), revision); + assert!(app.document.can_undo()); + assert!(!app.document.can_redo()); + mounted + .pan(&mut app, point, point + Vector::new(0., 40.)) + .await; + mounted.settle(&mut app).await; + assert_eq!( + app.behavior.scenario.presentation.pan, + camera.0 + Vector::new(0., 40.), + "a consumed restore receipt cannot snap a later gesture back" + ); +} diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs index 9fa0d1bd..a066d967 100644 --- a/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs +++ b/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs @@ -277,7 +277,13 @@ impl Rendering { } } - pub(in crate::behavior) fn draw_markers(&self, frame: &mut Frame, selected: Id, scale: f32) { + pub(in crate::behavior) fn draw_markers( + &self, + frame: &mut Frame, + selected: Id, + scale: f32, + pan: iced::Vector, + ) { for marker in &self.markers { let color = if selected == marker.event.occurrence || selected == marker.lifeline { style::TEAL @@ -302,10 +308,11 @@ impl Rendering { }, ), ); - if let Some(clip) = birth_label_clip(marker.capsule, scale, frame.size()) { + if let Some(clip) = birth_label_clip(pan, marker.capsule, scale, frame.size()) { // WGPU clipped frames start with an identity transform; - // neither the parent's scale nor its clip is inherited. + // neither the parent's pan/scale nor its clip is inherited. frame.with_clip(clip, |label| { + label.translate(pan); label.scale(scale); label.fill_text(Text { content: short(&marker.name, 18), @@ -330,12 +337,20 @@ impl Rendering { } } -fn birth_label_clip(capsule: Rectangle, scale: f32, viewport: Size) -> Option { - if !scale.is_finite() || scale <= 0. { +fn birth_label_clip( + pan: iced::Vector, + capsule: Rectangle, + scale: f32, + viewport: Size, +) -> Option { + if !pan.x.is_finite() || !pan.y.is_finite() || !scale.is_finite() || scale <= 0. { return None; } Rectangle::new( - Point::new((capsule.x + 8.) * scale, (capsule.y + 4.) * scale), + Point::new( + pan.x + (capsule.x + 8.) * scale, + pan.y + (capsule.y + 4.) * scale, + ), Size::new((capsule.width - 16.) * scale, (capsule.height - 8.) * scale), ) .intersection(&Rectangle::new(Point::ORIGIN, viewport)) diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs index 166e09a3..02673f06 100644 --- a/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs +++ b/crates/oa-desktop/src/behavior/scenario_lifecycle/render_tests.rs @@ -194,9 +194,13 @@ fn real_scene_long_header_title_and_detail_keep_names_and_have_bounded_clips() { // Scroll offset is handled by the existing outer canvas/scrollable; // these are the local drawing frame's coordinates, not screen input. for scale in [0.05, 0.5, 0.512, 0.64, 0.8, 1., 1.953125, 2.] { - let clip = - crate::behavior::box_text_clip(header.rect, scale, Size::new(2000., 2000.)) - .unwrap(); + let clip = crate::behavior::box_text_clip( + iced::Vector::ZERO, + header.rect, + scale, + Size::new(2000., 2000.), + ) + .unwrap(); let world = Rectangle::new( Point::new(clip.x / scale, clip.y / scale), Size::new(clip.width / scale, clip.height / scale), @@ -221,11 +225,12 @@ fn box_text_clip_intersects_parent_viewport_and_rejects_empty_interiors() { use crate::behavior::box_text_clip; let card = Rectangle::new(Point::new(100., 200.), Size::new(204., 70.)); assert_eq!( - box_text_clip(card, 2., Size::new(300., 500.)), + box_text_clip(iced::Vector::ZERO, card, 2., Size::new(300., 500.)), Some(Rectangle::new(Point::new(220., 416.), Size::new(80., 84.))) ); assert_eq!( box_text_clip( + iced::Vector::ZERO, Rectangle::new(Point::new(-20., -10.), card.size()), 2., Size::new(300., 500.), @@ -233,14 +238,22 @@ fn box_text_clip_intersects_parent_viewport_and_rejects_empty_interiors() { Some(Rectangle::new(Point::ORIGIN, Size::new(300., 104.))) ); for viewport in [Size::new(200., 500.), Size::new(300., 400.), Size::ZERO] { - assert_eq!(box_text_clip(card, 2., viewport), None); + assert_eq!(box_text_clip(iced::Vector::ZERO, card, 2., viewport), None); } for scale in [0., -1., f32::NAN, f32::INFINITY] { - assert_eq!(box_text_clip(card, scale, Size::new(1000., 1000.)), None); + assert_eq!( + box_text_clip(iced::Vector::ZERO, card, scale, Size::new(1000., 1000.)), + None + ); } for size in [Size::new(20., 70.), Size::new(204., 16.), Size::ZERO] { assert_eq!( - box_text_clip(Rectangle::new(Point::ORIGIN, size), 1., card.size()), + box_text_clip( + iced::Vector::ZERO, + Rectangle::new(Point::ORIGIN, size), + 1., + card.size() + ), None ); } @@ -304,7 +317,8 @@ fn birth_label_clip_keeps_padded_world_bounds_at_each_scene_scale() { Rectangle::new(Point::new(216., 408.), Size::new(248., 40.)), ), ] { - let clip = birth_label_clip(capsule, scale, Size::new(1000., 1000.)).unwrap(); + let clip = + birth_label_clip(iced::Vector::ZERO, capsule, scale, Size::new(1000., 1000.)).unwrap(); for (actual, expected) in [ (clip.x, expected.x), (clip.y, expected.y), @@ -325,20 +339,28 @@ fn birth_label_clip_keeps_padded_world_bounds_at_each_scene_scale() { fn birth_label_clip_preserves_parent_viewport_bounds() { let capsule = Rectangle::new(Point::new(100., 200.), Size::new(140., 28.)); assert_eq!( - birth_label_clip(capsule, 2., Size::new(300., 420.)), + birth_label_clip(iced::Vector::ZERO, capsule, 2., Size::new(300., 420.)), Some(Rectangle::new(Point::new(216., 408.), Size::new(84., 12.))) ); let crossing_origin = Rectangle::new(Point::new(-20., -10.), Size::new(140., 28.)); assert_eq!( - birth_label_clip(crossing_origin, 2., Size::new(300., 420.)), + birth_label_clip( + iced::Vector::ZERO, + crossing_origin, + 2., + Size::new(300., 420.) + ), Some(Rectangle::new(Point::ORIGIN, Size::new(224., 28.))) ); for viewport in [Size::new(200., 420.), Size::new(300., 400.), Size::ZERO] { - assert_eq!(birth_label_clip(capsule, 2., viewport), None); + assert_eq!( + birth_label_clip(iced::Vector::ZERO, capsule, 2., viewport), + None + ); } for scale in [0., -1., f32::NAN, f32::INFINITY] { assert_eq!( - birth_label_clip(capsule, scale, Size::new(1000., 1000.)), + birth_label_clip(iced::Vector::ZERO, capsule, scale, Size::new(1000., 1000.)), None ); } @@ -794,3 +816,158 @@ fn real_scene_lifecycle_edit_undo_redo_restores_exact_source_and_changed_geometr assert!(!document.can_redo()); } } + +#[test] +fn panned_header_and_create_clips_use_screen_translation_and_actual_viewport() { + use crate::behavior::box_text_clip; + use iced::Vector; + let header = Rectangle::new(Point::new(100., 200.), Size::new(204., 70.)); + let capsule = Rectangle::new(header.position(), Size::new(140., 28.)); + let rect = |x, y, w, h| Rectangle::new(Point::new(x, y), Size::new(w, h)); + for (pan, scale, viewport, header_expected, birth_expected) in [ + ( + Vector::new(37., -41.), + 0.5, + Size::new(300., 400.), + rect(92., 63., 92., 27.), + rect(91., 61., 62., 10.), + ), + ( + Vector::new(37., -41.), + 1., + Size::new(300., 400.), + rect(147., 167., 153., 54.), + rect(145., 163., 124., 20.), + ), + ( + Vector::new(37., -41.), + 2., + Size::new(300., 400.), + rect(257., 375., 43., 25.), + rect(253., 367., 47., 33.), + ), + ( + Vector::new(-200., -400.), + 2., + Size::new(300., 100.), + rect(20., 16., 280., 84.), + rect(16., 8., 248., 40.), + ), + ( + Vector::new(-237., -411.), + 2., + Size::new(300., 100.), + rect(0., 5., 300., 95.), + rect(0., 0., 227., 37.), + ), + ] { + assert_eq!( + box_text_clip(pan, header, scale, viewport), + Some(header_expected) + ); + assert_eq!( + birth_label_clip(pan, capsule, scale, viewport), + Some(birth_expected) + ); + } + for pan in [ + Vector::new(1000., 1000.), + Vector::new(-1000., -1000.), + Vector::new(f32::NAN, 0.), + Vector::new(0., f32::INFINITY), + ] { + assert_eq!(box_text_clip(pan, header, 2., Size::new(300., 100.)), None); + assert_eq!( + birth_label_clip(pan, capsule, 2., Size::new(300., 100.)), + None + ); + } +} + +#[test] +fn real_scene_panned_zoomed_program_keeps_header_birth_and_death_hit_identity() { + use crate::{ + app::Message, + behavior::{ + SequenceInput, + scenario::{CanvasEvent, CanvasTool}, + }, + }; + use iced::{Vector, mouse, widget::canvas}; + for layer in [Layer::Oa, Layer::Sa] { + let mut f = Fixture::new(layer); + f.project.elements.get_mut(&f.receiver).unwrap().name = "界".repeat(24); + let create = f.message(f.owner, MessageKind::Create); + let delete = f.message(f.owner, MessageKind::Delete); + let document = f.document(); + let before = document.snapshot(); + for scale in [0.5, 1., 2.] { + for pan in [Vector::new(35., 40.), Vector::new(-35., -5.)] { + let mut scene = f.scene(); + scene.scale = scale; + scene.camera_pan = pan; + let context = crate::behavior::scenario::direct_tests::context(f.owner); + scene.interaction = Some((context, CanvasTool::Select, true)); + let header = scene.boxes.iter().find(|b| b.id == f.sender).unwrap(); + let targets = [ + (f.sender, header.rect.center()), + ( + create, + scene + .lifecycle + .markers + .iter() + .find(|m| m.event.occurrence == create) + .unwrap() + .center, + ), + ( + delete, + scene + .lifecycle + .markers + .iter() + .find(|m| m.event.occurrence == delete) + .unwrap() + .center, + ), + ]; + // Absolute widget origin models the outer layout/scroll translation; + // camera pan remains local screen units and must be subtracted once. + let bounds = Rectangle::new(Point::new(73., 119.), Size::new(2000., 1200.)); + for (expected, world) in targets { + let screen = + bounds.position() + pan + Vector::new(world.x * scale, world.y * scale); + assert!(bounds.contains(screen)); + let mut state = SequenceInput::default(); + let mut published = Vec::new(); + for event in [ + mouse::Event::CursorMoved { position: screen }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::ButtonReleased(mouse::Button::Left), + ] { + if let Some(message) = canvas::Program::update( + &scene, + &mut state, + &canvas::Event::Mouse(event), + bounds, + mouse::Cursor::Available(screen), + ) + .and_then(|a| a.into_inner().0) + { + published.push(message); + } + } + assert!( + matches!(published.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Select(actual_context, actual))] if *actual_context == context && *actual == expected), + "Pan/zoom must retain the typed hit identity" + ); + } + assert_eq!(scene.lifecycle, f.scene().lifecycle); + } + } + assert_eq!(document.project(), &before.decoded.project); + assert_eq!(document.bundle(), &before.bundle); + assert!(!document.can_undo() && !document.dirty()); + } +} diff --git a/crates/oa-desktop/src/behavior/topology.rs b/crates/oa-desktop/src/behavior/topology.rs index d23c40f2..5d973bd0 100644 --- a/crates/oa-desktop/src/behavior/topology.rs +++ b/crates/oa-desktop/src/behavior/topology.rs @@ -59,7 +59,6 @@ pub enum Event { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Shelf { - Tools, Selection, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -80,10 +79,11 @@ pub struct State { identity: Option<(Id, Id)>, tool: CanvasTool, expanded: BTreeSet, - scroll_offsets: [iced::Vector; 2], - return_to: Vec<(Id, Id, Option)>, + details_offset: iced::Vector, + return_to: Vec, draft: Option, viewport: Option, + pan: iced::Vector, zoom: Option, fit: bool, pub(crate) focused: bool, @@ -125,6 +125,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()) } @@ -338,8 +350,8 @@ impl State { } fn fit_area(&self, fallback_width: f32) -> Size { - // The sensor measures the inner scroller, independent of graph content. - // Leave a small gutter for scrollbars; before first layout use a compact + // The sensor measures the viewport, independent of graph content. + // Leave a small content gutter; before first layout use a compact // provisional viewport and recompute when the sensor reports actual size. let viewport = self.viewport.unwrap_or(Size::new(fallback_width, 180.)); Size::new( @@ -348,10 +360,7 @@ impl State { ) } - 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) @@ -969,7 +978,8 @@ mod contextual_routing; struct Graph<'a> { project: &'a Project, interaction: Option<(Context, CanvasTool, bool)>, - scroll_offset: iced::Vector, + camera_pan: iced::Vector, + pending_insertion: Option, nodes: Vec, edges: Vec, ordered_paths: Vec, @@ -1301,7 +1311,8 @@ impl<'a> Graph<'a> { Self { project: p, interaction: None, - scroll_offset: iced::Vector::ZERO, + camera_pan: iced::Vector::ZERO, + pending_insertion: None, nodes, edges, ordered_paths, @@ -1401,6 +1412,7 @@ impl canvas::Program for Graph<'_> { ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); + f.translate(self.camera_pan); f.scale(self.scale); for pass in PAINT_PASSES { match pass { @@ -1616,7 +1628,10 @@ impl canvas::Program for Graph<'_> { cursor: mouse::Cursor, ) -> mouse::Interaction { if cursor.position_in(bounds).is_some_and(|p| { - let p = Point::new(p.x / self.scale, p.y / self.scale); + let p = Point::new( + (p.x - self.camera_pan.x) / self.scale, + (p.y - self.camera_pan.y) / self.scale, + ); self.nodes.iter().any(|n| n.rect.contains(p)) || self.ordered_paths.iter().any(|path| { path.points.windows(2).any(|pair| { @@ -2149,7 +2164,6 @@ mod tests { let accepted = app.document.snapshot(); assert!(app.behavior.prepare_pending(app.project()).is_err()); for (shelf, expected) in [ - (Shelf::Tools, Some(Shelf::Tools)), (Shelf::Selection, Some(Shelf::Selection)), (Shelf::Selection, None), ] { @@ -2166,10 +2180,12 @@ mod tests { } #[test] - fn process_fit_canvas_selection_ignores_retained_ordered_page_scroll() { + fn process_camera_selection_ignores_independent_details_scroll() { let (p, owner, _, _, _, _) = fixture(); let mut state = State::default(); - state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(11., 19.)); + state.camera(crate::behavior::canvas_workspace::Event::Pan( + iced::Vector::new(11., 19.), + )); state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 370.)); let context = Context { document: p.id, @@ -2180,16 +2196,17 @@ mod tests { for scale in [0.45, 1.] { let mut graph = Graph::new(&p, owner, owner); graph.scale = scale; - graph.scroll_offset = state.canvas_offset(); + graph.camera_pan = state.pan; graph.interaction = Some((context, CanvasTool::Select, true)); let node = &graph.nodes[0]; let id = node.id; let point = node.rect.center(); let bounds = Rectangle::new(Point::new(40., 100.), Size::new(800., 300.)); let screen = Point::new( - bounds.x + point.x * scale - 11., - bounds.y + point.y * scale - 19., + bounds.x + point.x * scale + 11., + bounds.y + point.y * scale + 19., ); + assert!(bounds.contains(screen)); let message = graph .input( &mut Input::default(), diff --git a/crates/oa-desktop/src/behavior/topology/contextual.rs b/crates/oa-desktop/src/behavior/topology/contextual.rs index d7396f31..7c178649 100644 --- a/crates/oa-desktop/src/behavior/topology/contextual.rs +++ b/crates/oa-desktop/src/behavior/topology/contextual.rs @@ -608,7 +608,11 @@ mod tests { "Authored endpoint is retained for explicit repair" ); assert!(state.prepare_pending(&deleted).is_err()); - state.remember(owner, reference); + state.remember( + owner, + reference, + super::super::direct::referenced(&p, owner, reference).unwrap(), + ); assert_eq!(state.back(&deleted), Some((owner, reference))); assert!(state.selected_context.is_none()); state.draft = None; diff --git a/crates/oa-desktop/src/behavior/topology/direct.rs b/crates/oa-desktop/src/behavior/topology/direct.rs index 88c6e3f7..1c76ecdf 100644 --- a/crates/oa-desktop/src/behavior/topology/direct.rs +++ b/crates/oa-desktop/src/behavior/topology/direct.rs @@ -60,6 +60,7 @@ pub enum CanvasEvent { Open(Context, Id), Back(Context), Zoom(Context, Option), + Camera(Context, u64, super::super::canvas_workspace::Event), Focus(Context), Viewport(Context, Size), } @@ -77,6 +78,7 @@ impl CanvasEvent { | Self::Back(c) | Self::AppendExchange(c) | Self::Zoom(c, ..) + | Self::Camera(c, ..) | Self::Focus(c) | Self::Viewport(c, ..) => *c, Self::Begin { context, .. } @@ -86,6 +88,19 @@ impl CanvasEvent { } } } +pub(super) struct ReturnFrame { + identity: Option<(Id, Id)>, + owner: Id, + occurrence: Id, + target: Id, + address: Option, + pan: iced::Vector, + zoom: Option, + fit: bool, + viewport: Option, + expanded: BTreeSet, + contextual_expanded: BTreeSet, +} impl State { pub(crate) fn select_context(&mut self, address: oa_core::topology_context::ContextAddress) { self.selected_context = Some(address); @@ -138,35 +153,49 @@ impl State { if self.identity != Some((document, owner)) { self.identity = Some((document, owner)); self.tool = CanvasTool::Select; + self.pan = iced::Vector::ZERO; self.zoom = Some(1.); self.fit = false; self.viewport = None; self.expanded.clear(); - self.scroll_offsets = [iced::Vector::ZERO; 2]; + self.details_offset = iced::Vector::ZERO; } } #[cfg(test)] - pub(crate) fn scroll_offset(&self) -> iced::Vector { - self.scroll_offsets[0] + self.scroll_offsets[1] + pub(crate) fn details_offset(&self) -> iced::Vector { + self.details_offset } - pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool { - let index = match area { - scenario::ScrollArea::Canvas => 0, - scenario::ScrollArea::Page => 1, - }; - let changed = self.scroll_offsets[index] != offset; - self.scroll_offsets[index] = offset; + pub(crate) fn scrolled( + &mut self, + scenario::ScrollArea::Page: scenario::ScrollArea, + offset: iced::Vector, + ) -> bool { + if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. { + return false; + } + let changed = self.details_offset != offset; + self.details_offset = offset; changed } pub(crate) fn choose(&mut self, tool: CanvasTool) { // Choosing how to edit must retain the user's current view. self.tool = tool; } + pub(crate) fn camera(&mut self, event: super::super::canvas_workspace::Event) -> bool { + super::super::canvas_workspace::change(&mut self.pan, &mut self.zoom, &mut self.fit, event) + } pub(crate) fn zoom(&mut self, value: Option) { self.fit = value.is_none(); - self.zoom = value.map(|v| v.clamp(0.5, 2.)); + self.zoom = value.map(|v| v.clamp(0.05, 3.)); } pub(crate) fn viewport(&mut self, size: Size) -> bool { + if !size.width.is_finite() + || !size.height.is_finite() + || size.width <= 0. + || size.height <= 0. + { + return false; + } let changed = self.viewport != Some(size); self.viewport = Some(size); changed @@ -188,24 +217,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 @@ -547,7 +625,7 @@ impl Graph<'_> { { *moved |= position.distance(*start) >= 3.; } - mouse::Cursor::Available(*position + self.scroll_offset) + mouse::Cursor::Available(*position) } canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft) | canvas::Event::Window( @@ -566,18 +644,19 @@ impl Graph<'_> { } canvas::Event::Mouse( mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_), - ) => state - .pointer - .map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)), + ) => state.pointer.map_or(cursor, mouse::Cursor::Available), canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => { - state.pointer = cursor.position().map(|p| p - self.scroll_offset); + state.pointer = cursor.position(); cursor } _ => cursor, }; - let world = cursor - .position_in(bounds) - .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); + let world = cursor.position_in(bounds).map(|p| { + Point::new( + (p.x - self.camera_pan.x) / self.scale, + (p.y - self.camera_pan.y) / self.scale, + ) + }); let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture()); match event { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => { @@ -591,7 +670,7 @@ impl Graph<'_> { if !editable { return None; } - let start = cursor.position()? - self.scroll_offset; + let start = cursor.position()?; match tool { CanvasTool::Add(..) => { state.press = Some(Press::Add { @@ -730,15 +809,35 @@ 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; } let point = state .pointer - .map(|p| p + self.scroll_offset) .or_else(|| cursor.position()) .filter(|p| bounds.contains(*p)) - .map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale)); + .map(|p| { + Point::new( + (p.x - bounds.x - self.camera_pan.x) / self.scale, + (p.y - bounds.y - self.camera_pan.y) / self.scale, + ) + }); let live = state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds); if live && let Some(source) = state.source { @@ -853,12 +952,7 @@ impl Graph<'_> { } impl State { - pub(crate) fn selection_offset( - &self, - p: &Project, - owner: Id, - id: Id, - ) -> Option { + pub(crate) fn reveal_selection(&mut self, p: &Project, owner: Id, id: Id) -> bool { let graph = Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded); let scale = self.scale(graph.size, 500.); @@ -870,10 +964,25 @@ impl State { self.qualified_selection(p, owner) .map_or(n.rect, |a| n.endpoint_rect(&a.endpoint)) }) - .or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?; - Some(scrollable::AbsoluteOffset { - x: (rect.x * scale - 12.).max(0.), - y: (rect.y * scale - 12.).max(0.), - }) + .or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label)); + let Some(rect) = rect else { + return false; + }; + let viewport = self.viewport.unwrap_or(Size::new(500., 300.)); + let x = rect.x * scale + self.pan.x; + let y = rect.y * scale + self.pan.y; + let delta = iced::Vector::new( + if x < 12. || x + rect.width * scale > viewport.width - 12. { + 12. - x + } else { + 0. + }, + if y < 12. || y + rect.height * scale > viewport.height - 12. { + 12. - y + } else { + 0. + }, + ); + self.camera(super::super::canvas_workspace::Event::Pan(delta)) } } diff --git a/crates/oa-desktop/src/behavior/topology/direct_tests.rs b/crates/oa-desktop/src/behavior/topology/direct_tests.rs index 3b7918db..7a2c2d65 100644 --- a/crates/oa-desktop/src/behavior/topology/direct_tests.rs +++ b/crates/oa-desktop/src/behavior/topology/direct_tests.rs @@ -540,7 +540,7 @@ fn send( input, &canvas::Event::Mouse(event), bounds, - mouse::Cursor::Available(point + graph.scroll_offset), + mouse::Cursor::Available(point), ) .and_then(|a| a.into_inner().0) } @@ -554,16 +554,20 @@ fn position(graph: &Graph<'_>, id: Id) -> Point { .center() } #[test] -fn direct_process_production_click_click_drag_insert_and_scroll_coordinates() { +fn direct_process_production_click_click_drag_insert_and_camera_coordinates() { let f = fixture(Layer::Oa); for drag in [false, true] { let mut graph = graph(&f, CanvasTool::Connect); - graph.scroll_offset = iced::Vector::new(35., 90.); - let bounds = Rectangle::new(Point::new(10., 20.), graph.size); + graph.camera_pan = iced::Vector::new(35., 90.); + let bounds = Rectangle::new( + Point::new(10., 20.), + Size::new(graph.size.width + 70., graph.size.height + 180.), + ); let screen = - |id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) - graph.scroll_offset; + |id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) + graph.camera_pan; let a = screen(f.nodes[0]); let b = screen(f.nodes[2]); + assert!(bounds.contains(a) && bounds.contains(b)); let mut input = Input::default(); send( &graph, @@ -732,7 +736,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(); @@ -1331,7 +1335,9 @@ fn direct_process_palette_preserves_fit_and_manual_camera() { let document = Id::new_v4(); state.enter(document, f.owner); state.viewport(Size::new(500., 300.)); - state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(35., 90.)); + state.camera(super::super::canvas_workspace::Event::Pan( + iced::Vector::new(35., 90.), + )); state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 120.)); for camera in [None, Some(0.75), Some(1.5)] { state.zoom(camera); @@ -1353,90 +1359,16 @@ fn direct_process_palette_preserves_fit_and_manual_camera() { "{layer:?} {camera:?} {tool:?}" ); assert_eq!(state.fit, camera.is_none()); - assert_eq!(state.scroll_offset(), iced::Vector::new(35., 210.)); + assert_eq!(state.pan, iced::Vector::new(35., 90.)); + assert_eq!(state.details_offset(), iced::Vector::new(0., 120.)); state.enter(document, f.owner); assert_eq!(state.scale(size, 500.), before); } } state.enter(Id::new_v4(), f.owner); assert_eq!(state.scale(Size::new(690., 250.), 500.), 1.); - assert_eq!(state.scroll_offset(), iced::Vector::ZERO); - } -} - -#[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)); - } - } + assert_eq!(state.pan, iced::Vector::ZERO); + assert_eq!(state.details_offset(), iced::Vector::ZERO); } } @@ -1447,15 +1379,18 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() { for scale in [0.15, 0.6898551, 1.5] { let mut graph = graph(&f, CanvasTool::Add(Tool::Control, ControlKind::Choice)); graph.scale = scale; - graph.scroll_offset = iced::Vector::new(11., 19.); + graph.camera_pan = iced::Vector::new(11., 19.); let bounds = Rectangle::new( Point::new(40., 70.), - Size::new(graph.size.width * scale, graph.size.height * scale), + Size::new( + graph.size.width * scale + 22., + graph.size.height * scale + 38., + ), ); let screen = |world: Point| { Point::new( - world.x * scale + bounds.x - 11., - world.y * scale + bounds.y - 19., + world.x * scale + bounds.x + 11., + world.y * scale + bounds.y + 19., ) }; let edge = &graph.edges[0]; @@ -1463,6 +1398,7 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() { (edge.points[0].x + edge.points[1].x) / 2., edge.points[0].y, )); + assert!(bounds.contains(point)); let mut input = Input::default(); send( &graph, @@ -1493,6 +1429,7 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() { let mut input = Input::default(); let a = screen(position(&graph, f.nodes[0])); let b = screen(position(&graph, f.nodes[2])); + assert!(bounds.contains(a) && bounds.contains(b)); send( &graph, &mut input, diff --git a/crates/oa-desktop/src/behavior/topology/layout_tests.rs b/crates/oa-desktop/src/behavior/topology/layout_tests.rs index 276414a2..1ac71711 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::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/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs index 3661b709..4363d9b0 100644 --- a/crates/oa-desktop/src/forms.rs +++ b/crates/oa-desktop/src/forms.rs @@ -18,6 +18,7 @@ mod context_fields; mod data_fields; mod history_fields; mod port_fields; +pub(crate) mod reference_chooser; mod view_fields; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -85,6 +86,8 @@ pub struct Draft { pub value_browsers: BTreeMap, pub reference_browsers: BTreeMap, history: history_fields::State, + reference_session: Id, + reference_generation: Id, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -209,11 +212,14 @@ impl Draft { value_browsers: BTreeMap::new(), reference_browsers: BTreeMap::new(), history: Default::default(), + reference_session: Id::new_v4(), + reference_generation: Id::new_v4(), }; draft.rebuild(); Some(draft) } fn rebuild(&mut self) { + self.reference_generation = Id::new_v4(); // Array/variant edits can change the meaning of a row path. self.value_browsers.clear(); self.inputs.clear(); @@ -274,6 +280,9 @@ impl Draft { .unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone())) } pub fn set_context(&mut self, context: Option) { + if self.context != context { + self.reference_generation = Id::new_v4(); + } self.context = context; } /// Materialize selected scalar subtrees without parsing retained, unselected drafts. @@ -359,6 +368,9 @@ impl Draft { } pub fn input(&mut self, path: &str, value: String) { if let Some(input) = self.inputs.get_mut(path) { + if input.raw != value { + self.reference_generation = Id::new_v4(); + } input.editor = text_editor::Content::with_text(&value); input.raw = value; } @@ -366,7 +378,11 @@ impl Draft { pub fn edit_text(&mut self, path: &str, action: text_editor::Action) { if let Some(input) = self.inputs.get_mut(path) { input.editor.perform(action); - input.raw = input.editor.text(); + let raw = input.editor.text(); + if input.raw != raw { + self.reference_generation = Id::new_v4(); + } + input.raw = raw; } } pub fn shape(&mut self, path: &str, value: Value) -> Result<(), String> { @@ -1795,7 +1811,7 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> { )); } } - content.into() + reference_chooser::trigger::scope(content, draft.reference_session) } /// Render one closed property subtree for a workflow-owned typed draft. @@ -1937,41 +1953,12 @@ fn property<'a>( return out.into(); } if reference(path) && (value.is_string() || value.is_null()) { - let raw = draft - .inputs - .get(path) - .map(|v| v.raw.as_str()) - .or_else(|| value.as_str()); - let current = raw.and_then(|s| s.parse::().ok()).unwrap_or(Id::nil()); - let choices = reference_choices(draft, project, path, current); - let selected = choices.iter().find(|c| c.id == current).cloned(); - let owned_path = path.to_string(); - let optional = optional_reference(path); - let mut content = column![ + return column![ label_view(), - pick_list(choices, selected, move |choice| Message::FieldShape( - owned_path.clone(), - if choice.id.is_nil() && optional { - Value::Null - } else { - json!(choice.id) - } - )) - .text_size(12) - .padding(8) - .width(Fill) + reference_chooser::adapter::field(draft, project, path) ] - .spacing(5); - if let Some(raw) = raw - && raw.parse::().is_err() - { - content = content.push( - text(format!("Invalid reference retained in the draft: {raw}")) - .size(11) - .color(style::MUTED), - ); - } - return content.into(); + .spacing(5) + .into(); } match value { Value::Object(map) => { diff --git a/crates/oa-desktop/src/forms/data_fields.rs b/crates/oa-desktop/src/forms/data_fields.rs index 61a9c62f..de251cab 100644 --- a/crates/oa-desktop/src/forms/data_fields.rs +++ b/crates/oa-desktop/src/forms/data_fields.rs @@ -670,7 +670,7 @@ mod tests { } fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::RowEvent) { let event = semantic_state(app).address_event(event); - let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event))); + let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(event))); } fn semantic_decide(app: &mut crate::app::App, facet: FieldFacet, decision: SemanticDecision) { use crate::semantic_review::RowEvent as Event; diff --git a/crates/oa-desktop/src/forms/reference_chooser.rs b/crates/oa-desktop/src/forms/reference_chooser.rs new file mode 100644 index 00000000..211d2242 --- /dev/null +++ b/crates/oa-desktop/src/forms/reference_chooser.rs @@ -0,0 +1,389 @@ +//! Isolated live-reference chooser experiment, declared only under cfg(test). +//! The host supplies an eligible catalog and revalidates every +//! Write intent against the current document before changing a staged scalar. +//! No core command, draft serialization, or App message belongs in this module. +use oa_core::Id; +use std::collections::BTreeSet; + +pub mod adapter; +#[cfg(test)] +mod tests; +pub mod trigger; +pub mod widget; + +pub const MAX_ROWS: usize = 6; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Context { + pub document: Id, + pub revision: u64, + pub draft: Id, + pub record: Id, + pub path: String, + pub optional: bool, +} +impl Context { + /// This is an additional historical-path guard, not live schema discovery. + /// Call only after the parent form has identified a live reference field. + pub fn live( + document: Id, + revision: u64, + draft: Id, + record: Id, + path: impl Into, + optional: bool, + ) -> Result { + let path = path.into(); + if !path.starts_with('/') || path.split('/').any(|part| part == "baseline") { + return Err("The chooser requires a live reference property."); + } + Ok(Self { + document, + revision, + draft, + record, + path, + optional, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// For a diagram placement this is the placement ID, not its element ID. + pub id: Id, + pub name: String, + pub alias: String, + pub kind: String, + pub scope: String, + pub containment: String, +} +impl Candidate { + pub fn context_label(&self) -> String { + format!("{} · {} · {}", self.scope, self.kind, self.containment) + } + pub fn detail(&self) -> String { + format!( + "{} · @{} · {} · {}", + self.name, + self.alias, + self.context_label(), + self.id + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Current { + Unassigned, + Valid(Candidate), + Incompatible(Candidate), + Missing(Id), + InvalidRaw(String), +} +impl Current { + pub fn label(&self) -> String { + match self { + Self::Unassigned => "Not assigned".into(), + Self::Valid(c) => format!("Current: {} · @{}", c.name, c.alias), + Self::Incompatible(c) => { + format!("Current choice needs review: {} · @{}", c.name, c.alias) + } + Self::Missing(id) => format!("Missing reference: {id}"), + Self::InvalidRaw(raw) => format!("Invalid reference retained: {raw}"), + } + } +} + +#[derive(Debug, Clone)] +struct Searchable { + candidate: Candidate, + all: String, + alias: String, + name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Stamp { + pub context: Context, + pub invocation: Id, + pub results: u64, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Close { + Escape, + Tab { backwards: bool }, + Outside, + WindowChanged, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// The mounted text input numbers emitted edits. Multiple edits in one + /// runtime batch may share the old result stamp; none may be dropped. + Query { + text: String, + serial: u64, + }, + Move { + delta: i32, + rows: usize, + }, + Page { + delta: i32, + rows: usize, + }, + Highlight(Id), + Choose(Id), + Clear, + Close(Close), +} +impl Action { + pub fn writes_staged_value(&self) -> bool { + matches!(self, Self::Choose(_) | Self::Clear) + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Event { + pub stamp: Stamp, + pub action: Action, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + Ignored, + Browsed, + Closed(Close), + /// The host must still check current eligibility and scoped draft guards. + Write { + context: Context, + value: Option, + }, +} + +#[derive(Debug, Clone)] +pub struct State { + context: Context, + invocation: Id, + results: u64, + serial: u64, + open: bool, + candidates: Vec, + filtered: Vec, + query: String, + start: usize, + highlight: Option, + pub current: Current, + pub scope_label: String, +} +impl State { + pub fn open( + context: Context, + candidates: Vec, + current: Current, + scope_label: impl Into, + ) -> Result { + // Context fields are public for host comparisons; check again in case + // a caller constructed one without the live-path constructor. + if !context.path.starts_with('/') || context.path.split('/').any(|p| p == "baseline") { + return Err("Historical values are not live reference candidates."); + } + let mut seen = BTreeSet::new(); + let mut indexed = Vec::with_capacity(candidates.len()); + for candidate in candidates { + if candidate.id.is_nil() || !seen.insert(candidate.id) { + return Err("Eligible references must have distinct, non-nil identities."); + } + indexed.push(Searchable { + all: candidate.detail().to_lowercase(), + alias: candidate.alias.to_lowercase(), + name: candidate.name.to_lowercase(), + candidate, + }); + } + indexed.sort_by(|a, b| { + (&a.name, &a.alias, a.candidate.id).cmp(&(&b.name, &b.alias, b.candidate.id)) + }); + let filtered: Vec<_> = (0..indexed.len()).collect(); + let highlight = (!filtered.is_empty()).then_some(0); + Ok(Self { + context, + invocation: Id::new_v4(), + results: 0, + serial: 0, + open: true, + candidates: indexed, + filtered, + query: String::new(), + start: 0, + highlight, + current, + scope_label: scope_label.into(), + }) + } + pub fn stamp(&self) -> Stamp { + Stamp { + context: self.context.clone(), + invocation: self.invocation, + results: self.results, + } + } + pub fn event(&self, action: Action) -> Event { + Event { + stamp: self.stamp(), + action, + } + } + pub fn is_open(&self) -> bool { + self.open + } + pub fn query(&self) -> &str { + &self.query + } + pub fn query_serial(&self) -> u64 { + self.serial + } + pub fn count(&self) -> usize { + self.filtered.len() + } + pub fn start(&self) -> usize { + self.start + } + pub fn optional(&self) -> bool { + self.context.optional + } + pub fn highlighted(&self) -> Option<&Candidate> { + self.highlight.and_then(|i| self.at(i)) + } + fn at(&self, index: usize) -> Option<&Candidate> { + self.filtered + .get(index) + .map(|i| &self.candidates[*i].candidate) + } + pub fn visible(&self, rows: usize) -> impl Iterator { + self.filtered + .iter() + .skip(self.start) + .take(rows.min(MAX_ROWS)) + .map(|index| &self.candidates[*index].candidate) + } + /// The host calls this before rendering on any path/document/facet/context + /// replacement. Existing events remain tied to the old invocation. + pub fn invalidate(&mut self) { + self.open = false; + } + + pub fn update(&mut self, event: Event) -> Outcome { + if !self.open + || event.stamp.context != self.context + || event.stamp.invocation != self.invocation + { + return Outcome::Ignored; + } + // Absolute row actions require exactly the presented result set. + // Relative navigation and dismissal are processed in input order within + // this session: typing must not make a following Escape stale. + if matches!(event.action, Action::Choose(_) | Action::Highlight(_)) + && event.stamp.results != self.results + { + return Outcome::Ignored; + } + match event.action { + Action::Query { text, serial } => { + if serial <= self.serial { + return Outcome::Ignored; + } + self.serial = serial; + self.query = text; + let query = self.query.to_lowercase(); + let terms: Vec<_> = query.split_whitespace().collect(); + self.filtered = self + .candidates + .iter() + .enumerate() + .filter_map(|(i, c)| { + terms + .iter() + .all(|term| match term.strip_prefix('@') { + Some(alias) => c.alias.contains(alias), + None => c.all.contains(term), + }) + .then_some(i) + }) + .collect(); + self.start = 0; + self.highlight = (!self.filtered.is_empty()).then_some(0); + } + Action::Page { delta, rows } => { + let rows = rows.clamp(1, MAX_ROWS); + if self.filtered.is_empty() { + return Outcome::Ignored; + } + let relative = self + .highlight + .unwrap_or(self.start) + .saturating_sub(self.start) + .min(rows - 1); + let last_page = (self.filtered.len() - 1) / rows * rows; + self.start = (self.start as i64 + i64::from(delta) * rows as i64) + .clamp(0, last_page as i64) as usize; + self.highlight = Some((self.start + relative).min(self.filtered.len() - 1)); + } + Action::Move { delta, rows } => { + let rows = rows.clamp(1, MAX_ROWS); + if self.filtered.is_empty() { + return Outcome::Ignored; + } + let current = self.highlight.unwrap_or(self.start) as i64; + let next = + (current + i64::from(delta)).clamp(0, self.filtered.len() as i64 - 1) as usize; + self.highlight = Some(next); + if next < self.start { + self.start = next; + } + if next >= self.start + rows { + self.start = next + 1 - rows; + } + } + Action::Highlight(id) => { + let Some(index) = self + .filtered + .iter() + .position(|i| self.candidates[*i].candidate.id == id) + else { + return Outcome::Ignored; + }; + if self.highlight == Some(index) { + return Outcome::Ignored; + } + self.highlight = Some(index); + } + Action::Choose(id) => { + if !self + .filtered + .iter() + .any(|i| self.candidates[*i].candidate.id == id) + { + return Outcome::Ignored; + } + self.open = false; + return Outcome::Write { + context: self.context.clone(), + value: Some(id), + }; + } + Action::Clear if self.context.optional => { + self.open = false; + return Outcome::Write { + context: self.context.clone(), + value: None, + }; + } + Action::Clear => return Outcome::Ignored, + Action::Close(reason) => { + self.open = false; + return Outcome::Closed(reason); + } + } + self.results = self.results.wrapping_add(1); + Outcome::Browsed + } +} diff --git a/crates/oa-desktop/src/forms/reference_chooser/adapter.rs b/crates/oa-desktop/src/forms/reference_chooser/adapter.rs new file mode 100644 index 00000000..f62e5ead --- /dev/null +++ b/crates/oa-desktop/src/forms/reference_chooser/adapter.rs @@ -0,0 +1,307 @@ +//! Live form adapter. Compatibility stays in the existing parent adapter; +//! browser metadata and scoped writes never reinterpret historical UUID text. +use super::super::{Draft, friendly, history_fields, optional_reference, reference, reference_ids}; +use super::{Candidate, Context, Current, State, trigger}; +use crate::{app::Message, style}; +use iced::{ + Element, Fill, Rectangle, + widget::{column, container, row, text, tooltip}, +}; +use oa_core::{Id, Project}; +use serde_json::{Value, json}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone)] +pub struct Open { + pub document: Id, + pub session: Id, + pub generation: Id, + pub record: Id, + pub path: String, + pub anchor: Rectangle, +} +#[derive(Debug, Clone)] +pub struct Set { + pub context: Context, + pub generation: Id, + pub value: Option, +} +impl Draft { + pub fn reference_session(&self) -> Id { + self.reference_session + } + pub fn reference_generation(&self) -> Id { + self.reference_generation + } + pub fn reference_open(&self, project: &Project, path: &str, anchor: Rectangle) -> Open { + Open { + document: project.id, + session: self.reference_session, + generation: self.reference_generation, + record: self.original, + path: path.into(), + anchor, + } + } + pub fn reference_matches(&self, request: &Open) -> bool { + self.reference_session == request.session + && self.reference_generation == request.generation + && self.original == request.record + && self.live_reference(&request.path) + } + fn live_reference(&self, path: &str) -> bool { + !history_fields::is_path(path) + && reference(path) + && self + .value + .pointer(path) + .is_some_and(|v| v.is_string() || v.is_null()) + } + pub fn reference_catalog( + &self, + project: &Project, + request: &Open, + revision: u64, + ) -> Result { + if request.document != project.id || !self.reference_matches(request) { + return Err("The reference field changed. Open its chooser again.".into()); + } + let allowed: BTreeSet<_> = reference_ids(self, project, &request.path) + .into_iter() + .filter(|id| !id.is_nil()) + .collect(); + let candidates = allowed + .iter() + .filter_map(|id| candidate(project, *id)) + .collect(); + let raw = self + .inputs + .get(&request.path) + .map(|v| v.raw.as_str()) + .or_else(|| self.value.pointer(&request.path).and_then(Value::as_str)); + let current = match raw { + None => Current::Unassigned, + Some(raw) => match raw.parse::() { + Ok(id) if id.is_nil() => Current::Unassigned, + Ok(id) => match candidate(project, id) { + Some(c) if allowed.contains(&id) => Current::Valid(c), + Some(c) => Current::Incompatible(c), + None => Current::Missing(id), + }, + Err(_) => Current::InvalidRaw(raw.into()), + }, + }; + let context = Context::live( + project.id, + revision, + self.reference_session, + self.original, + &request.path, + optional_reference(&request.path), + ) + .map_err(str::to_owned)?; + State::open( + context, + candidates, + current, + format!( + "{} · compatible references", + friendly(request.path.rsplit('/').next().unwrap_or("Reference")) + ), + ) + .map_err(str::to_owned) + } + pub fn reference_set(&mut self, project: &Project, intent: &Set) -> Result<(), String> { + let value = self.reference_value(project, intent)?; + self.shape_scoped(&intent.context.path, value) + } + pub fn reference_value(&self, project: &Project, intent: &Set) -> Result { + let context = &intent.context; + if context.document != project.id + || context.draft != self.reference_session + || context.record != self.original + || intent.generation != self.reference_generation + || !self.live_reference(&context.path) + || context.optional != optional_reference(&context.path) + { + return Err("The originating reference field is no longer current.".into()); + } + let value = match intent.value { + Some(id) + if !id.is_nil() && reference_ids(self, project, &context.path).contains(&id) => + { + json!(id) + } + None if context.optional => Value::Null, + _ => return Err("Choose a currently compatible reference.".into()), + }; + Ok(value) + } +} + +fn named(project: &Project, id: Id) -> String { + if let Some(e) = project.elements.get(&id) { + format!("{} · @{}", e.name, e.alias) + } else { + project.named(id).into() + } +} +fn ancestry(project: &Project, id: Id) -> String { + let mut parts = vec![]; + let mut seen = BTreeSet::new(); + let mut current = project.elements.get(&id).and_then(|e| e.parent); + while let Some(id) = current { + if !seen.insert(id) { + parts.push("[containment cycle]".into()); + break; + } + parts.push(named(project, id)); + current = project.elements.get(&id).and_then(|e| e.parent); + } + parts.reverse(); + if parts.is_empty() { + project.name.clone() + } else { + parts.join(" / ") + } +} +pub(crate) fn candidate(project: &Project, id: Id) -> Option { + let layer = |layer| format!("{layer:?}").to_uppercase(); + if id == project.id { + return Some(Candidate { + id, + name: project.name.clone(), + alias: project.alias.clone(), + kind: "Project".into(), + scope: "Model".into(), + containment: "Model root".into(), + }); + } + if let Some(e) = project.elements.get(&id) { + return Some(Candidate { + id, + name: e.name.clone(), + alias: e.alias.clone(), + kind: e.kind().to_string(), + scope: layer(e.layer()), + containment: ancestry(project, id), + }); + } + if let Some(r) = project.relations.get(&id) { + return Some(Candidate { + id, + name: r.name.clone(), + alias: r.alias.clone(), + kind: r.kind().to_string(), + scope: project.layer_of(id).map_or_else(|| "Model".into(), layer), + containment: format!( + "{} → {}", + named(project, r.source()), + named(project, r.target()) + ), + }); + } + for diagram in project.diagrams.values() { + if id == diagram.id { + return Some(Candidate { + id, + name: diagram.name.clone(), + alias: diagram.alias.clone(), + kind: format!("{:?} diagram", diagram.projection), + scope: layer(diagram.layer), + containment: project.name.clone(), + }); + } + if let Some(node) = diagram.nodes.get(&id) { + return Some(Candidate { + id, + name: project.named(node.element).into(), + alias: node.alias.clone(), + kind: "Placement".into(), + scope: layer(diagram.layer), + containment: format!( + "{} · @{} / {}", + diagram.name, + diagram.alias, + named(project, node.element) + ), + }); + } + } + if let Some(link) = project.artifact_links.get(&id) { + return Some(Candidate { + id, + name: link.name.clone(), + alias: link.alias.clone(), + kind: "Artifact link".into(), + scope: project + .layer_of(link.owner) + .map_or_else(|| "Model".into(), layer), + containment: named(project, link.owner), + }); + } + project.traces.get(&id).map(|trace| Candidate { + id, + name: trace.name.clone(), + alias: trace.alias.clone(), + kind: "Trace".into(), + scope: "OA ↔ SA".into(), + containment: format!( + "{} ↔ {}", + named(project, trace.oa), + named(project, trace.sa) + ), + }) +} + +pub fn field<'a, R: iced::advanced::text::Renderer + 'a>( + draft: &'a Draft, + project: &'a Project, + path: &str, +) -> Element<'a, Message, iced::Theme, R> { + let raw = draft + .inputs + .get(path) + .map(|v| v.raw.as_str()) + .or_else(|| draft.value.pointer(path).and_then(Value::as_str)); + let (label, detail) = match raw { + None => ( + if optional_reference(path) { + "Not assigned" + } else { + "Choose a reference…" + } + .into(), + String::new(), + ), + Some(raw) => match raw.parse::() { + Ok(id) if id.is_nil() => ("Choose a reference…".into(), String::new()), + Ok(id) => match candidate(project, id) { + Some(c) => (format!("{} · @{}", c.name, c.alias), c.detail()), + None => ("Missing reference".into(), raw.into()), + }, + Err(_) => ("Invalid reference retained".into(), raw.into()), + }, + }; + let label = crate::views::short(&label, 35); + let body = container(row![text(label).size(12).width(Fill), text("⌄").size(12)].spacing(6)) + .padding(8) + .width(Fill); + let path = path.to_owned(); + let id = trigger::field_id(draft.reference_generation, &path); + let field = trigger::Field::new(body, id, move |anchor| { + Message::FieldReferenceOpen(draft.reference_open(project, &path, anchor)) + }); + if detail.is_empty() { + field.into() + } else { + tooltip( + field, + container(column![text(detail).size(11)]) + .padding(8) + .style(style::card), + tooltip::Position::Left, + ) + .into() + } +} diff --git a/crates/oa-desktop/src/forms/reference_chooser/tests.rs b/crates/oa-desktop/src/forms/reference_chooser/tests.rs new file mode 100644 index 00000000..30c09174 --- /dev/null +++ b/crates/oa-desktop/src/forms/reference_chooser/tests.rs @@ -0,0 +1,803 @@ +use super::*; +use iced::{ + Element, Event as Input, Point, Rectangle, Size, Theme, Vector, + advanced::{Layout, Shell, layout, widget::Tree}, + keyboard, mouse, + widget::{Space, mouse_area}, +}; + +fn context(optional: bool) -> Context { + Context::live( + Id::from_u128(1), + 7, + Id::from_u128(2), + Id::from_u128(3), + "/body/SequenceUse/sequence", + optional, + ) + .unwrap() +} +fn candidate(index: u128) -> Candidate { + Candidate { + id: Id::from_u128(10_000 + index), + name: "Control".into(), + alias: format!("controller_{index:04}"), + kind: "Function".into(), + scope: "SA".into(), + containment: format!("System / Subsystem {} / Controller", index / 100), + } +} +fn thousand(current: Current, optional: bool) -> State { + // Reverse the fixture input to falsify accidental dependence on ingestion + // order. Expected IDs and aliases below are literal fixture identities. + State::open( + context(optional), + (0..1_000).rev().map(candidate).collect(), + current, + "SA · compatible functions", + ) + .unwrap() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Message { + Chooser(Event), + UnderPress, + UnderRelease, +} + +#[derive(Default)] +struct Clipboard(Option); +impl iced::advanced::Clipboard for Clipboard { + fn read(&self, _: iced::advanced::clipboard::Kind) -> Option { + self.0.clone() + } + fn write(&mut self, _: iced::advanced::clipboard::Kind, text: String) { + self.0 = Some(text); + } +} + +struct Mount { + tree: Option, + size: Size, + anchor: Rectangle, + clipboard: Clipboard, +} +impl Default for Mount { + fn default() -> Self { + Self { + tree: None, + size: Size::new(1100., 700.), + anchor: Rectangle { + x: 790., + y: 640., + width: 278., + height: 32., + }, + clipboard: Clipboard::default(), + } + } +} +impl Mount { + fn batch( + &mut self, + state: Option<&State>, + events: &[(Input, Option)], + ) -> (Vec, Option) { + let underneath: Element<'_, Message, Theme, ()> = + mouse_area(Space::new().width(iced::Fill).height(iced::Fill)) + .on_press(Message::UnderPress) + .on_release(Message::UnderRelease) + .into(); + let mut view: Element<'_, Message, Theme, ()> = + widget::Popup::new(underneath, state, self.anchor, Message::Chooser).into(); + let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget())); + view.as_widget().diff(tree); + let node = + view.as_widget_mut() + .layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size)); + let viewport = Rectangle::with_size(self.size); + let overlay_node = view + .as_widget_mut() + .overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO) + .map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size)); + let mut messages = Vec::new(); + for (event, point) in events { + let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available); + let mut shell = Shell::new(&mut messages); + if let Some(overlay_node) = &overlay_node { + // Reconstruct the overlay between layout and every input, as + // Iced does. Geometry must live in the measured layout tree. + let mut overlay = view + .as_widget_mut() + .overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO) + .unwrap(); + overlay.as_overlay_mut().update( + event, + Layout::new(overlay_node), + cursor, + &(), + &mut self.clipboard, + &mut shell, + ); + overlay.as_overlay().draw( + &mut (), + &crate::style::theme(), + &iced::advanced::renderer::Style { + text_color: iced::Color::BLACK, + }, + Layout::new(overlay_node), + cursor, + ); + if matches!( + event, + Input::Mouse(_) | Input::Keyboard(_) | Input::Touch(_) | Input::InputMethod(_) + ) { + assert!(shell.is_event_captured(), "An open chooser owns input"); + } + } else { + view.as_widget_mut().update( + tree, + event, + Layout::new(&node), + cursor, + &(), + &mut self.clipboard, + &mut shell, + &viewport, + ); + } + } + (messages, overlay_node) + } + fn send(&mut self, state: &mut State, event: Input) -> Vec { + let (messages, _) = self.batch(Some(state), &[(event, None)]); + messages + .into_iter() + .map(|message| match message { + Message::Chooser(event) => state.update(event), + _ => panic!("Popup leaked input to its parent"), + }) + .collect() + } + fn type_text(&mut self, state: &mut State, text: &str) -> Vec { + let (messages, _) = self.batch(Some(state), &typing(text)); + messages + .into_iter() + .map(|message| match message { + Message::Chooser(event) => state.update(event), + _ => panic!("Typing leaked input to its parent"), + }) + .collect() + } + fn geometry(&mut self, state: &State) -> widget::Geometry { + let (_, node) = self.batch(Some(state), &[]); + widget::Geometry::from_bounds(node.unwrap().children()[0].bounds()) + } + fn click(&mut self, state: &State, point: Point) -> Vec { + self.batch( + Some(state), + &[ + ( + Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Some(point), + ), + ( + Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + Some(point), + ), + ], + ) + .0 + } +} + +fn named(key: keyboard::key::Named) -> Input { + Input::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(key), + modified_key: keyboard::Key::Named(key), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }) +} +// Iced 0.14's physical-key path intentionally inserts one scalar from text. +// Whole-string typing is several key events; paste and IME commit are separate +// input paths and are exercised below rather than faked as one physical key. +fn typed(character: char) -> Input { + let text = character.to_string(); + Input::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(text.clone().into()), + modified_key: keyboard::Key::Character(text.clone().into()), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: Some(text.into()), + repeat: false, + }) +} +fn typing(text: &str) -> Vec<(Input, Option)> { + text.chars() + .map(|character| (typed(character), None)) + .collect() +} +fn query(state: &mut State, value: &str) { + let event = state.event(Action::Query { + text: value.into(), + serial: state.query_serial() + 1, + }); + assert_eq!(state.update(event), Outcome::Browsed); +} + +#[test] +fn catalog_search_disambiguates_repeated_names_containment_alias_and_exact_placement_identity() { + let mut state = thousand(Current::Unassigned, false); + assert_eq!( + state.visible(6).map(|c| c.id).collect::>(), + (10_000..10_006).map(Id::from_u128).collect::>() + ); + query(&mut state, "control subsystem 6 @controller_0617"); + assert_eq!(state.count(), 1); + assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_617)); + query(&mut state, "OA"); + assert_eq!(state.count(), 0); + query(&mut state, &Id::from_u128(10_617).to_string()); + assert_eq!(state.count(), 1); + assert_eq!(state.highlighted().unwrap().alias, "controller_0617"); + + let placement = Candidate { + id: Id::from_u128(20_001), + name: "Control".into(), + alias: "control_placement".into(), + kind: "Placement".into(), + scope: "SA".into(), + containment: "Architecture / Main diagram / Control".into(), + }; + let mut state = State::open( + context(false), + vec![placement], + Current::Unassigned, + "SA · placements", + ) + .unwrap(); + query(&mut state, "placement main"); + assert_eq!(state.highlighted().unwrap().id, Id::from_u128(20_001)); + let event = state.event(Action::Choose(Id::from_u128(20_000))); + assert_eq!( + state.update(event), + Outcome::Ignored, + "Semantic ID is not the placement identity" + ); + assert!( + State::open( + context(false), + vec![candidate(1), candidate(1)], + Current::Unassigned, + "SA" + ) + .is_err() + ); +} + +#[test] +fn mounted_overlay_clamps_at_three_field_positions_and_keeps_results_and_query_bounded() { + let state = thousand(Current::InvalidRaw("not a uuid".into()), true); + for size in [ + Size::new(1100., 700.), + Size::new(1600., 1000.), + Size::new(320., 320.), + ] { + for y in [5., size.height / 2., size.height - 34.] { + let mut mount = Mount { + size, + anchor: Rectangle { + x: size.width - 300., + y, + width: 278., + height: 32., + }, + ..Default::default() + }; + let (_, node) = mount.batch(Some(&state), &[]); + let node = node.unwrap(); + let bounds = node.children()[0].bounds(); + let geometry = widget::Geometry::from_bounds(bounds); + assert!(bounds.x >= 8. && bounds.y >= 8.); + assert!(bounds.x + bounds.width <= size.width - 8.); + assert!(bounds.y + bounds.height <= size.height - 8.); + assert!(bounds.width <= 420. && bounds.height <= 360.); + assert!(geometry.rows > 0 && geometry.rows <= 6); + assert_eq!(geometry.rows, if size.height == 320. { 4 } else { 6 }); + let search = node.children()[1].bounds(); + assert!( + bounds.contains(search.position()) + && bounds.contains(Point::new( + search.x + search.width, + search.y + search.height + )) + ); + assert!(geometry.row(geometry.rows - 1).y + 34. <= geometry.detail.y); + assert_eq!(state.visible(geometry.rows).count(), geometry.rows); + } + } +} + +#[test] +fn mounted_input_preserves_rapid_same_batch_typing_and_cursor_across_controlled_rebuilds() { + let mut state = thousand(Current::Unassigned, false); + let mut mount = Mount::default(); + let (messages, _) = mount.batch(Some(&state), &[(typed('a'), None), (typed('b'), None)]); + assert_eq!( + messages.len(), + 2, + "Actual focused input must publish each edit" + ); + let mut queries = Vec::new(); + for message in messages { + let Message::Chooser(event) = message else { + panic!("Unexpected parent event") + }; + let Action::Query { text, .. } = &event.action else { + panic!("Typing must not choose a reference") + }; + queries.push(text.clone()); + assert_eq!(state.update(event), Outcome::Browsed); + } + assert_eq!(queries, ["a", "ab"]); + assert_eq!(state.query(), "ab"); + assert!( + mount + .send(&mut state, named(keyboard::key::Named::Home)) + .is_empty() + ); + assert_eq!(mount.send(&mut state, typed('z')), [Outcome::Browsed]); + assert_eq!( + state.query(), + "zab", + "Rebuilding the controlled widget must not refocus at the end" + ); + assert_eq!(state.current, Current::Unassigned); +} + +#[test] +fn mounted_keyboard_pages_and_selects_only_the_highlighted_visible_candidate_once() { + let mut state = thousand(Current::Unassigned, false); + let mut mount = Mount::default(); + assert_eq!( + mount.send(&mut state, named(keyboard::key::Named::PageDown)), + [Outcome::Browsed] + ); + assert_eq!(state.start(), 6); + assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_006)); + assert_eq!( + mount.send(&mut state, named(keyboard::key::Named::ArrowDown)), + [Outcome::Browsed] + ); + assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_007)); + let (messages, _) = mount.batch( + Some(&state), + &[ + (named(keyboard::key::Named::Enter), None), + (named(keyboard::key::Named::Enter), None), + ], + ); + assert_eq!(messages.len(), 1); + let Message::Chooser(event) = messages.into_iter().next().unwrap() else { + panic!() + }; + assert!(event.action.writes_staged_value()); + assert_eq!( + state.update(event), + Outcome::Write { + context: context(false), + value: Some(Id::from_u128(10_007)) + } + ); + assert!(!state.is_open()); +} + +#[test] +fn mounted_filtered_row_click_uses_the_displayed_identity_and_scoped_write_intent() { + let mut state = thousand(Current::Missing(Id::from_u128(99)), false); + let mut mount = Mount::default(); + let query = "@controller_0731"; + assert_eq!( + mount.type_text(&mut state, query), + vec![Outcome::Browsed; query.chars().count()] + ); + assert_eq!(state.query(), query); + assert_eq!(state.count(), 1); + let point = mount.geometry(&state).row(0).center(); + let messages = mount.click(&state, point); + assert_eq!(messages.len(), 1); + let Message::Chooser(event) = messages[0].clone() else { + panic!() + }; + assert_eq!( + state.update(event), + Outcome::Write { + context: context(false), + value: Some(Id::from_u128(10_731)) + } + ); + assert_eq!(state.current, Current::Missing(Id::from_u128(99))); +} + +#[test] +fn mounted_query_change_rejects_old_release_and_old_result_events_without_locking_popup() { + let mut state = thousand(Current::Unassigned, false); + let mut mount = Mount::default(); + let point = mount.geometry(&state).row(4).center(); + let captured = mount.click(&state, point); + let Message::Chooser(old_choice) = captured[0].clone() else { + panic!() + }; + query(&mut state, "@controller_0700"); + assert_eq!(state.update(old_choice), Outcome::Ignored); + // The rejected old Choose had marked the widget closing; a changed result + // context must unlock it so an explicit current click can complete. + let point = mount.geometry(&state).row(0).center(); + let messages = mount.click(&state, point); + assert_eq!(messages.len(), 1); + let Message::Chooser(event) = messages[0].clone() else { + panic!() + }; + assert_eq!( + state.update(event), + Outcome::Write { + context: context(false), + value: Some(Id::from_u128(10_700)) + } + ); + + let mut state = thousand(Current::Unassigned, false); + let point = mount.geometry(&state).row(0).center(); + assert!( + mount + .batch( + Some(&state), + &[( + Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Some(point) + )] + ) + .0 + .is_empty() + ); + query(&mut state, "@controller_0319"); + assert!( + mount + .batch( + Some(&state), + &[( + Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + Some(point) + )] + ) + .0 + .is_empty() + ); + assert!(state.is_open()); +} + +#[test] +fn captured_choice_cannot_cross_document_revision_draft_record_or_array_path() { + let source = thousand(Current::Unassigned, false); + let mut mount = Mount::default(); + let point = mount.geometry(&source).row(0).center(); + let Message::Chooser(event) = mount.click(&source, point).remove(0) else { + panic!() + }; + for changed in 0..6 { + let mut destination = context(false); + match changed { + 0 => destination.document = Id::from_u128(999), + 1 => destination.revision += 1, + 2 => destination.draft = Id::from_u128(999), + 3 => destination.record = Id::from_u128(999), + 4 => destination.path = "/body/SomeList/members/1/scenario".into(), + _ => {} // A new invocation even with every semantic ID unchanged. + } + let mut state = State::open( + destination, + vec![candidate(0)], + Current::InvalidRaw("unfinished".into()), + "SA", + ) + .unwrap(); + assert_eq!(state.update(event.clone()), Outcome::Ignored); + assert_eq!(state.current, Current::InvalidRaw("unfinished".into())); + assert!(state.is_open()); + } + let mut invalidated = source.clone(); + invalidated.invalidate(); + assert_eq!(invalidated.update(event), Outcome::Ignored); +} + +#[test] +fn empty_search_escape_tab_and_optional_clear_do_not_silently_choose_nil() { + for current in [ + Current::Missing(Id::from_u128(999)), + Current::InvalidRaw("@incomplete/".into()), + Current::Incompatible(candidate(999)), + ] { + let mut state = thousand(current.clone(), false); + let mut mount = Mount::default(); + let query = "no match exists"; + assert_eq!( + mount.type_text(&mut state, query), + vec![Outcome::Browsed; query.chars().count()] + ); + assert_eq!(state.query(), query); + assert_eq!( + state.count(), + 0, + "Verify actual input reached the empty-result state before Enter" + ); + assert!( + mount + .send(&mut state, named(keyboard::key::Named::Enter)) + .is_empty() + ); + assert!(state.is_open()); + assert_eq!(state.current, current); + assert_eq!( + mount.send(&mut state, named(keyboard::key::Named::Escape)), + [Outcome::Closed(Close::Escape)] + ); + assert_eq!(state.current, current); + } + let mut state = thousand(Current::Unassigned, false); + let mut mount = Mount::default(); + assert_eq!( + mount.send(&mut state, named(keyboard::key::Named::Tab)), + [Outcome::Closed(Close::Tab { backwards: false })] + ); + let mut state = thousand(Current::InvalidRaw("?".into()), true); + let point = mount.geometry(&state).clear.center(); + let Message::Chooser(event) = mount.click(&state, point).remove(0) else { + panic!() + }; + assert_eq!( + state.update(event), + Outcome::Write { + context: context(true), + value: None + } + ); + let mut required = thousand(Current::Unassigned, false); + let clear = required.event(Action::Clear); + assert_eq!(required.update(clear), Outcome::Ignored); +} + +#[test] +fn dismissal_swallows_matching_release_across_closed_rebuild_but_next_click_reaches_parent() { + let mut state = thousand(Current::Unassigned, false); + let mut mount = Mount::default(); + let outside = Point::new(20., 20.); + let (messages, _) = mount.batch( + Some(&state), + &[( + Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Some(outside), + )], + ); + let [Message::Chooser(event)] = messages.as_slice() else { + panic!("Expected only popup dismissal") + }; + assert_eq!(state.update(event.clone()), Outcome::Closed(Close::Outside)); + let release = Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)); + assert!( + mount + .batch(None, &[(release.clone(), Some(outside))]) + .0 + .is_empty() + ); + assert_eq!( + mount + .batch( + None, + &[ + ( + Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Some(outside) + ), + (release, Some(outside)), + ] + ) + .0, + [Message::UnderPress, Message::UnderRelease] + ); +} + +#[test] +fn historical_paths_never_open_and_only_explicit_choice_or_clear_is_a_write() { + for path in [ + "/baseline", + "/baseline/oa/value", + "/baseline/sa/value/TypeRef/Named", + ] { + assert!( + Context::live( + Id::from_u128(1), + 0, + Id::from_u128(2), + Id::from_u128(3), + path, + true + ) + .is_err() + ); + let mut forged = context(true); + forged.path = path.into(); + assert!(State::open(forged, vec![candidate(0)], Current::Unassigned, "History").is_err()); + } + for action in [ + Action::Query { + text: "Control".into(), + serial: 1, + }, + Action::Highlight(Id::from_u128(10_000)), + Action::Move { delta: 1, rows: 6 }, + Action::Page { delta: 1, rows: 6 }, + Action::Close(Close::Escape), + ] { + assert!(!action.writes_staged_value()); + } + assert!(Action::Choose(Id::from_u128(10_000)).writes_staged_value()); + assert!(Action::Clear.writes_staged_value()); +} + +#[test] +fn out_of_order_query_serials_cannot_restore_obsolete_results() { + let mut state = thousand(Current::Unassigned, false); + let old = state.event(Action::Query { + text: "@controller_0001".into(), + serial: 1, + }); + let new = state.event(Action::Query { + text: "@controller_0999".into(), + serial: 2, + }); + assert_eq!(state.update(new), Outcome::Browsed); + assert_eq!(state.update(old), Outcome::Ignored); + assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_999)); +} + +#[test] +fn mounted_query_then_escape_in_one_batch_closes_and_relative_keys_keep_input_order() { + let mut state = thousand(Current::InvalidRaw("unfinished".into()), false); + let mut mount = Mount::default(); + let query = "@controller_0720"; + let mut inputs = typing(query); + inputs.push((named(keyboard::key::Named::Escape), None)); + let (messages, _) = mount.batch(Some(&state), &inputs); + let outcomes: Vec<_> = messages + .into_iter() + .map(|message| { + let Message::Chooser(event) = message else { + panic!("Input escaped the popup") + }; + state.update(event) + }) + .collect(); + let mut expected = vec![Outcome::Browsed; query.chars().count()]; + expected.push(Outcome::Closed(Close::Escape)); + assert_eq!(outcomes, expected); + assert_eq!(state.query(), "@controller_0720"); + assert_eq!(state.current, Current::InvalidRaw("unfinished".into())); + + let mut state = thousand(Current::Unassigned, false); + let old_absolute_row = state.event(Action::Choose(Id::from_u128(10_000))); + let (messages, _) = mount.batch( + Some(&state), + &[ + (named(keyboard::key::Named::PageDown), None), + (named(keyboard::key::Named::PageDown), None), + (named(keyboard::key::Named::ArrowUp), None), + ], + ); + assert_eq!(messages.len(), 3); + for message in messages { + let Message::Chooser(event) = message else { + panic!("Input escaped the popup") + }; + assert_eq!(state.update(event), Outcome::Browsed); + } + assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_011)); + assert_eq!(state.start(), 11); + assert_eq!(state.update(old_absolute_row), Outcome::Ignored); + assert!(state.is_open()); +} + +#[test] +fn mounted_clipboard_paste_and_ime_commit_preserve_whole_strings_without_selecting() { + let mut state = thousand(Current::Unassigned, false); + let mut mount = Mount::default(); + mount.clipboard.0 = Some("@controller_0472".into()); + let paste = Input::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character("v".into()), + modified_key: keyboard::Key::Character("v".into()), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyV), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::COMMAND, + text: None, + repeat: false, + }); + let (messages, _) = mount.batch( + Some(&state), + &[ + ( + Input::Keyboard(keyboard::Event::ModifiersChanged( + keyboard::Modifiers::COMMAND, + )), + None, + ), + (paste, None), + ( + Input::Keyboard(keyboard::Event::ModifiersChanged( + keyboard::Modifiers::empty(), + )), + None, + ), + ], + ); + let [Message::Chooser(event)] = messages.as_slice() else { + panic!("Paste should emit exactly one whole-string query") + }; + assert_eq!(state.update(event.clone()), Outcome::Browsed); + assert_eq!(state.query(), "@controller_0472"); + assert_eq!(state.count(), 1); + assert_eq!(state.highlighted().unwrap().id, Id::from_u128(10_472)); + assert_eq!(state.current, Current::Unassigned); + assert!(state.is_open()); + + let unicode_name = "Режим Δ負荷"; + let unicode = Candidate { + id: Id::from_u128(30_111), + name: unicode_name.into(), + alias: "mode_unicode".into(), + kind: "Function".into(), + scope: "SA".into(), + containment: "Система / 控制".into(), + }; + let mut state = State::open( + context(false), + vec![unicode], + Current::InvalidRaw("retained".into()), + "SA", + ) + .unwrap(); + use iced::advanced::input_method::Event as Ime; + let preedit = [ + (Input::InputMethod(Ime::Opened), None), + ( + Input::InputMethod(Ime::Preedit(unicode_name.into(), None)), + None, + ), + ]; + assert!( + mount.batch(Some(&state), &preedit).0.is_empty(), + "Composition is not a committed query" + ); + assert_eq!(state.query(), ""); + let (messages, _) = mount.batch( + Some(&state), + &[ + (Input::InputMethod(Ime::Preedit(String::new(), None)), None), + (Input::InputMethod(Ime::Commit(unicode_name.into())), None), + (Input::InputMethod(Ime::Closed), None), + ], + ); + let [Message::Chooser(event)] = messages.as_slice() else { + panic!("IME commit should emit one complete query") + }; + assert_eq!(state.update(event.clone()), Outcome::Browsed); + assert_eq!(state.query(), unicode_name); + assert_eq!(state.count(), 1); + assert_eq!(state.highlighted().unwrap().id, Id::from_u128(30_111)); + assert_eq!(state.current, Current::InvalidRaw("retained".into())); + assert!(state.is_open()); +} diff --git a/crates/oa-desktop/src/forms/reference_chooser/trigger.rs b/crates/oa-desktop/src/forms/reference_chooser/trigger.rs new file mode 100644 index 00000000..68e0b314 --- /dev/null +++ b/crates/oa-desktop/src/forms/reference_chooser/trigger.rs @@ -0,0 +1,447 @@ +//! Keyboard entry and focus operations scoped to one property form. Focus +//! targets include the draft generation so delayed operations cannot focus a +//! replacement array row or a recovered document with the same semantic IDs. +use crate::style; +use iced::{ + Border, Color, Element, Event, Length, Rectangle, Size, Task, Theme, Vector, + advanced::{ + Clipboard, Layout, Shell, Widget, layout, overlay, renderer, + widget::{Id, Operation, Tree, operation, tree}, + }, + keyboard, mouse, + widget::container, +}; +use oa_core::Id as ModelId; +mod focus; + +#[derive(Clone, Copy)] +pub struct FormScope { + pub session: ModelId, + pub modal: bool, +} +pub(super) struct FormOwner(pub Option); +const REVIEW_MODAL: &str = "reference-owning-review-modal"; + +/// Marks the entire Review dialog without changing its layout or event flow. +pub fn modal<'a, Message: 'a, R: renderer::Renderer + 'a>( + content: impl Into>, +) -> Element<'a, Message, Theme, R> { + Field { + content: content.into(), + id: Some(Id::new(REVIEW_MODAL)), + on_open: None, + } + .into() +} + +pub fn field_id(generation: ModelId, path: &str) -> Id { + Id::from(format!("reference:{generation}:{path}")) +} +pub fn scope_id(session: ModelId) -> Id { + Id::from(format!("reference-form:{session}")) +} + +#[derive(Default)] +struct Interaction { + focused: bool, + pressed: bool, + identity: Option, + anchor: Option, +} +impl operation::Focusable for Interaction { + fn is_focused(&self) -> bool { + self.focused + } + fn focus(&mut self) { + self.focused = true; + } + fn unfocus(&mut self) { + self.focused = false; + self.pressed = false; + } +} +pub struct Field<'a, Message, R = iced::Renderer> +where + R: renderer::Renderer, +{ + content: Element<'a, Message, Theme, R>, + id: Option, + on_open: Option Message + 'a>>, +} +impl<'a, Message, R: renderer::Renderer> Field<'a, Message, R> { + pub fn new( + content: impl Into>, + id: Id, + on_open: impl Fn(Rectangle) -> Message + 'a, + ) -> Self { + Self { + content: content.into(), + id: Some(id), + on_open: Some(Box::new(on_open)), + } + } +} +pub fn scope<'a, Message: 'a, R: renderer::Renderer + 'a>( + content: impl Into>, + session: ModelId, +) -> Element<'a, Message, Theme, R> { + container(Field { + content: content.into(), + id: None, + on_open: None, + }) + .id(scope_id(session)) + .width(Length::Fill) + .into() +} + +fn run( + content: &mut Element<'_, Message, Theme, R>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, + mut operation: Box, +) -> bool { + let mut changed = false; + loop { + content + .as_widget_mut() + .operate(tree, layout, renderer, &mut *operation); + match operation.finish() { + operation::Outcome::Chain(next) => { + operation = next; + changed = true; + } + _ => break, + } + } + changed +} + +pub(super) fn tab( + content: &mut Element<'_, Message, Theme, R>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, + scope: FormScope, + backwards: bool, +) -> bool { + run( + content, + tree, + layout, + renderer, + Box::new(focus::relative(scope.session, backwards, scope.modal)), + ) +} +pub(super) fn own_input( + content: &mut Element<'_, Message, Theme, R>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, +) { + content + .as_widget_mut() + .operate(tree, layout, renderer, &mut focus::exclusive()); +} +impl Widget for Field<'_, Message, R> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + fn state(&self) -> tree::State { + tree::State::new(Interaction { + identity: self.id.clone(), + ..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.identity != self.id { + *state = Interaction { + identity: self.id.clone(), + ..Default::default() + }; + } + 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, + ) { + if let Some(id) = &self.id + && self.on_open.is_some() + { + operation.focusable( + Some(id), + layout.bounds(), + tree.state.downcast_mut::(), + ); + operation.custom( + Some(id), + layout.bounds(), + tree.state.downcast_mut::(), + ); + } + if self.on_open.is_none() { + operation.container(self.id.as_ref(), layout.bounds()); + operation.traverse(&mut |operation| { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + operation, + ) + }); + } else { + 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 let Some(on_open) = &self.on_open { + let state = tree.state.downcast_mut::(); + let visible = layout.bounds().intersection(viewport); + let hovered = visible.is_some_and(|bounds| cursor.is_over(bounds)); + let activate = match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if hovered => { + state.focused = true; + state.pressed = true; + shell.capture_event(); + shell.request_redraw(); + false + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + let activate = state.pressed && hovered; + if state.pressed { + shell.capture_event(); + } + state.pressed = false; + activate + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: + keyboard::Key::Named(keyboard::key::Named::Enter | keyboard::key::Named::Space), + repeat: false, + .. + }) if state.focused => true, + Event::Mouse(mouse::Event::ButtonPressed(_)) => { + state.focused = false; + state.pressed = false; + false + } + _ => false, + }; + if activate && let Some(bounds) = state.anchor { + shell.publish(on_open(bounds)); + shell.capture_event(); + 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, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + if self.on_open.is_some() { + let focus = tree.state.downcast_ref::().focused; + renderer.fill_quad( + renderer::Quad { + bounds: layout.bounds(), + border: Border { + color: if focus || cursor.is_over(layout.bounds()) { + style::TEAL + } else { + style::LINE + }, + width: if focus { 2. } else { 1. }, + radius: 5.into(), + }, + ..Default::default() + }, + Color::WHITE, + ); + } + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + renderer_style, + layout, + cursor, + viewport, + ); + } + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + if self.on_open.is_some() && cursor.is_over(layout.bounds()) { + mouse::Interaction::Pointer + } else { + 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, + ) + } +} + +/// Scrollable child updates use content coordinates. The root visits the +/// retained tree before each input, so field anchors use current window +/// coordinates even when a wheel event and activation share one runtime batch. +pub(super) fn measure( + content: &mut Element<'_, Message, Theme, R>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, + viewport: Rectangle, +) { + content.as_widget_mut().operate( + tree, + layout, + renderer, + &mut Measure { + translation: Vector::ZERO, + viewport: Some(viewport), + pending: None, + }, + ); +} +struct Measure { + translation: Vector, + viewport: Option, + pending: Option<(Vector, Option)>, +} +impl Operation for Measure { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + let previous = (self.translation, self.viewport); + if let Some((translation, viewport)) = self.pending.take() { + self.translation = translation; + self.viewport = viewport; + } + visit(self); + (self.translation, self.viewport) = previous; + } + fn scrollable( + &mut self, + _: Option<&Id>, + bounds: Rectangle, + _: Rectangle, + translation: Vector, + _: &mut dyn operation::Scrollable, + ) { + let bounds = Rectangle { + x: bounds.x - self.translation.x, + y: bounds.y - self.translation.y, + ..bounds + }; + self.pending = Some(( + self.translation + translation, + self.viewport + .and_then(|viewport| viewport.intersection(&bounds)), + )); + } + fn custom(&mut self, _: Option<&Id>, bounds: Rectangle, state: &mut dyn std::any::Any) { + if let Some(state) = state.downcast_mut::() { + let bounds = Rectangle { + x: bounds.x - self.translation.x, + y: bounds.y - self.translation.y, + ..bounds + }; + state.anchor = self + .viewport + .and_then(|viewport| viewport.intersection(&bounds)); + } + } +} +impl<'a, Message: 'a, R: renderer::Renderer + 'a> From> + for Element<'a, Message, Theme, R> +{ + fn from(field: Field<'a, Message, R>) -> Self { + Element::new(field) + } +} + +pub fn focus_operation( + session: ModelId, + generation: ModelId, + path: &str, + step: i32, +) -> impl Operation + 'static { + focus::exact(session, generation, path, step) +} +pub fn focus( + session: ModelId, + generation: ModelId, + path: &str, + step: i32, +) -> Task { + iced::advanced::widget::operate(focus_operation(session, generation, path, step)).discard() +} diff --git a/crates/oa-desktop/src/forms/reference_chooser/trigger/focus.rs b/crates/oa-desktop/src/forms/reference_chooser/trigger/focus.rs new file mode 100644 index 00000000..c3260108 --- /dev/null +++ b/crates/oa-desktop/src/forms/reference_chooser/trigger/focus.rs @@ -0,0 +1,313 @@ +//! Focus movement and reveal share one root traversal. A form may be inside a +//! scrollable; restricting traversal to its container would hide that ancestor. +use super::{FormOwner, Id, ModelId, Operation, Rectangle, Vector, field_id, operation, scope_id}; +use std::collections::BTreeMap; + +enum Origin { + Current, + Exact(Id), +} +#[derive(Clone)] +struct Scroll { + index: usize, + bounds: Rectangle, + capacity: Vector, + offset: Vector, +} +struct Field { + id: Option, + bounds: Rectangle, + ancestors: Vec, +} +pub(super) struct Collect { + scope: Id, + origin: Origin, + step: i32, + inside: bool, + entering: Option, + translation: Vector, + scrolls: Vec, + pending_scroll: Option, + scroll_index: usize, + fields: Vec, + focused: Option, + outside_focused: bool, + modal: bool, + owner_valid: bool, +} +impl Collect { + fn new(scope: Id, origin: Origin, step: i32) -> Self { + let owner_valid = matches!(origin, Origin::Current); + Self { + scope, + origin, + step, + inside: false, + entering: None, + translation: Vector::ZERO, + scrolls: vec![], + pending_scroll: None, + scroll_index: 0, + fields: vec![], + focused: None, + outside_focused: false, + modal: false, + owner_valid, + } + } +} +pub(super) fn relative(session: ModelId, backwards: bool, modal: bool) -> Collect { + Collect { + modal, + ..Collect::new( + scope_id(session), + Origin::Current, + if backwards { -1 } else { 1 }, + ) + } +} +pub(super) fn exact(session: ModelId, generation: ModelId, path: &str, step: i32) -> Collect { + Collect::new( + scope_id(session), + Origin::Exact(field_id(generation, path)), + step, + ) +} +impl Operation for Collect { + fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) { + if let Some(owner) = state.downcast_ref::() { + self.owner_valid = owner + .0 + .is_some_and(|owner| scope_id(owner.session) == self.scope); + self.modal = owner.0.is_some_and(|owner| owner.modal); + } + } + fn container(&mut self, id: Option<&Id>, _: Rectangle) { + self.entering = Some(self.inside || id == Some(&self.scope)); + } + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + let previous = (self.inside, self.translation, self.scrolls.len()); + if let Some(inside) = self.entering.take() { + self.inside = inside; + } + if let Some(scroll) = self.pending_scroll.take() { + self.translation += scroll.offset; + self.scrolls.push(scroll); + } + visit(self); + self.inside = previous.0; + self.translation = previous.1; + self.scrolls.truncate(previous.2); + } + fn scrollable( + &mut self, + _: Option<&Id>, + bounds: Rectangle, + content: Rectangle, + offset: Vector, + _: &mut dyn operation::Scrollable, + ) { + self.pending_scroll = Some(Scroll { + index: self.scroll_index, + bounds: translated(bounds, self.translation), + capacity: Vector::new( + (content.width - bounds.width).max(0.), + (content.height - bounds.height).max(0.), + ), + offset, + }); + self.scroll_index += 1; + } + fn focusable( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + state: &mut dyn operation::Focusable, + ) { + if !self.inside { + self.outside_focused |= state.is_focused(); + return; + } + if state.is_focused() { + self.focused = Some(self.fields.len()); + } + self.fields.push(Field { + id: id.cloned(), + bounds: translated(bounds, self.translation), + ancestors: self.scrolls.clone(), + }); + } + fn finish(&self) -> operation::Outcome<()> { + if !self.owner_valid || self.fields.is_empty() { + return operation::Outcome::None; + } + let origin = match &self.origin { + Origin::Current if self.focused.is_none() && self.outside_focused && !self.modal => { + return operation::Outcome::None; + } + Origin::Current => self + .focused + .map(|index| index as i64) + .unwrap_or(if self.step < 0 { + self.fields.len() as i64 + } else { + -1 + }), + Origin::Exact(id) => { + let Some(index) = self + .fields + .iter() + .position(|field| field.id.as_ref() == Some(id)) + else { + return operation::Outcome::None; + }; + index as i64 + } + }; + // A form keeps a reachable focus at both boundaries. Initial Tab enters + // the first field; initial Shift+Tab enters the last field. + let target = (origin + i64::from(self.step)).rem_euclid(self.fields.len() as i64) as usize; + let field = &self.fields[target]; + let mut bounds = field.bounds; + let mut shifts = BTreeMap::new(); + for ancestor in field.ancestors.iter().rev() { + let dx = reveal_axis( + bounds.x, + bounds.width, + ancestor.bounds.x, + ancestor.bounds.width, + ); + let dy = reveal_axis( + bounds.y, + bounds.height, + ancestor.bounds.y, + ancestor.bounds.height, + ); + // Iced reports rounded drawing offsets, while capacity can be + // fractional. Clamping an already-visible axis can move it a pixel. + let delta = Vector::new( + if dx == 0. { + 0. + } else { + (ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x + }, + if dy == 0. { + 0. + } else { + (ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y + }, + ); + if delta != Vector::ZERO { + shifts.insert(ancestor.index, delta); + } + bounds = translated(bounds, delta); + } + operation::Outcome::Chain(Box::new(Apply { + scope: self.scope.clone(), + inside: false, + entering: None, + target, + field_index: 0, + scroll_index: 0, + shifts, + owner_valid: matches!(self.origin, Origin::Current), + clear_outside: self.modal, + preserve_inside: false, + })) + } +} +fn translated(bounds: Rectangle, delta: Vector) -> Rectangle { + Rectangle { + x: bounds.x - delta.x, + y: bounds.y - delta.y, + ..bounds + } +} +fn reveal_axis(start: f32, length: f32, viewport_start: f32, viewport_length: f32) -> f32 { + if start < viewport_start || length > viewport_length { + start - viewport_start + } else { + (start + length - viewport_start - viewport_length).max(0.) + } +} +pub(super) struct Apply { + scope: Id, + inside: bool, + entering: Option, + target: usize, + field_index: usize, + scroll_index: usize, + shifts: BTreeMap, + owner_valid: bool, + clear_outside: bool, + preserve_inside: bool, +} +pub(super) fn exclusive() -> Apply { + Apply { + scope: Id::new(super::REVIEW_MODAL), + inside: false, + entering: None, + target: 0, + field_index: 0, + scroll_index: 0, + shifts: BTreeMap::new(), + owner_valid: true, + clear_outside: true, + preserve_inside: true, + } +} +impl Operation for Apply { + fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) { + if let Some(owner) = state.downcast_ref::() { + self.owner_valid = owner + .0 + .is_some_and(|owner| scope_id(owner.session) == self.scope); + self.clear_outside = owner.0.is_some_and(|owner| owner.modal); + } + } + fn container(&mut self, id: Option<&Id>, _: Rectangle) { + self.entering = Some(self.inside || id == Some(&self.scope)); + } + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + let previous = self.inside; + if let Some(inside) = self.entering.take() { + self.inside = inside; + } + visit(self); + self.inside = previous; + } + fn scrollable( + &mut self, + _: Option<&Id>, + bounds: Rectangle, + content: Rectangle, + _: Vector, + state: &mut dyn operation::Scrollable, + ) { + if self.owner_valid + && let Some(delta) = self.shifts.get(&self.scroll_index) + { + state.scroll_by( + operation::scrollable::AbsoluteOffset { + x: delta.x, + y: delta.y, + }, + bounds, + content, + ); + } + self.scroll_index += 1; + } + fn focusable(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) { + if self.owner_valid && self.inside && !self.preserve_inside { + if self.field_index == self.target { + state.focus(); + } else { + state.unfocus(); + } + self.field_index += 1; + } else if self.owner_valid && !self.inside && self.clear_outside { + state.unfocus(); + } + } +} diff --git a/crates/oa-desktop/src/forms/reference_chooser/widget.rs b/crates/oa-desktop/src/forms/reference_chooser/widget.rs new file mode 100644 index 00000000..112935dc --- /dev/null +++ b/crates/oa-desktop/src/forms/reference_chooser/widget.rs @@ -0,0 +1,994 @@ +//! Root overlay adapter. Keep the wrapper mounted while closed so the release +//! from a dismissal gesture cannot reach the underlying content. Parent modal +//! ownership is supplied by the host (pass no chooser while a modal is active). +use super::{Action, Close, Event as ChoiceEvent, MAX_ROWS, Stamp, State as Chooser}; +use crate::style; +use iced::{ + Border, Color, Element, Event, Font, Length, Point, Rectangle, Size, Theme, Vector, + advanced::{ + Clipboard, Layout, Shell, Widget, layout, overlay, renderer, + text::{self, Paragraph as _}, + widget::{Operation, Tree, tree}, + }, + alignment, keyboard, mouse, touch, + widget::text_input, + window, +}; +use std::{cell::Cell, rc::Rc}; + +const ROW: f32 = 34.; +const MARGIN: f32 = 8.; + +#[derive(Debug, Clone, Copy)] +pub(super) struct Geometry { + pub popup: Rectangle, + pub search: Rectangle, + pub current: Rectangle, + pub detail: Rectangle, + pub close: Rectangle, + pub previous: Rectangle, + pub next: Rectangle, + pub clear: Rectangle, + pub rows: usize, +} +impl Geometry { + fn new(anchor: Rectangle, viewport: Size) -> Self { + let width = anchor + .width + .clamp(278., 420.) + .min((viewport.width - 2. * MARGIN).max(0.)); + let height = 360f32.min((viewport.height - 2. * MARGIN).max(0.)); + let x = anchor.x.clamp( + MARGIN.min(viewport.width / 2.), + (viewport.width - MARGIN - width).max(MARGIN.min(viewport.width / 2.)), + ); + let below = anchor.y + anchor.height + 4.; + let preferred = if below + height + MARGIN <= viewport.height { + below + } else { + anchor.y - height - 4. + }; + let y = preferred.clamp( + MARGIN.min(viewport.height / 2.), + (viewport.height - MARGIN - height).max(MARGIN.min(viewport.height / 2.)), + ); + Self::from_bounds(Rectangle { + x, + y, + width, + height, + }) + } + pub(super) fn from_bounds(popup: Rectangle) -> Self { + let inner = (popup.width - 16.).max(0.); + let rect = |left, top, width, height| Rectangle { + x: popup.x + left, + y: popup.y + top, + width, + height, + }; + let footer = (popup.height - 32.).max(0.); + let detail_height = if popup.height >= 180. { 32. } else { 0. }; + let detail_top = (footer - detail_height - 4.).max(84.); + let rows = (((detail_top - 88.).max(0.) / ROW) as usize).min(MAX_ROWS); + Self { + popup, + search: rect(8., 28., inner, 30.), + current: rect(8., 62., inner, 18.), + detail: rect(8., detail_top, inner, detail_height), + close: rect((popup.width - 58.).max(0.), 4., 50f32.min(popup.width), 22.), + previous: rect(8., footer, 28., 24.), + next: rect(40., footer, 28., 24.), + clear: rect((popup.width - 84.).max(0.), footer, 76., 24.), + rows, + } + } + pub fn row(&self, index: usize) -> Rectangle { + Rectangle { + x: self.popup.x + 8., + y: self.popup.y + 84. + index as f32 * ROW, + width: (self.popup.width - 16.).max(0.), + height: ROW, + } + } + fn hit(&self, chooser: &Chooser, point: Point) -> Option { + if self.close.contains(point) { + return Some(Action::Close(Close::Escape)); + } + if self.previous.contains(point) && chooser.start() > 0 { + return Some(Action::Page { + delta: -1, + rows: self.rows, + }); + } + if self.next.contains(point) && chooser.start() + self.rows < chooser.count() { + return Some(Action::Page { + delta: 1, + rows: self.rows, + }); + } + if self.clear.contains(point) && chooser.optional() { + return Some(Action::Clear); + } + chooser + .visible(self.rows) + .enumerate() + .find_map(|(i, c)| self.row(i).contains(point).then_some(Action::Choose(c.id))) + } +} + +#[derive(Default)] +struct Interaction { + stamp: Option, + serial: u64, + pressed: Option<(ChoiceEvent, Pointer)>, + closing: bool, + swallow_mouse: Option, + swallow_touch: Option, + held_mouse: Option, + held_touch: Option, + detail_scroll: f32, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Pointer { + Mouse, + Touch(touch::Finger), +} +impl Interaction { + fn swallow(&mut self, event: &Event) -> bool { + match event { + Event::Mouse(mouse::Event::ButtonReleased(button)) + if self.swallow_mouse == Some(*button) => + { + self.swallow_mouse = None; + true + } + Event::Touch( + touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. }, + ) if self.swallow_touch == Some(*id) => { + self.swallow_touch = None; + true + } + _ => false, + } + } +} + +pub struct Popup<'a, Message, R = iced::Renderer> +where + R: text::Renderer, +{ + content: Element<'a, Message, Theme, R>, + input: Element<'a, String, Theme, R>, + chooser: Option<&'a Chooser>, + anchor: Rectangle, + on_event: Rc Message + 'a>, + focus_scope: Option, +} +impl<'a, Message: Clone + 'a, R: text::Renderer + 'a> Popup<'a, Message, R> { + pub fn new( + content: impl Into>, + chooser: Option<&'a Chooser>, + anchor: Rectangle, + on_event: impl Fn(ChoiceEvent) -> Message + 'a, + ) -> Self { + let chooser = chooser.filter(|state| state.is_open()); + let input = text_input( + "Name, @alias, kind or containment", + chooser.map_or("", Chooser::query), + ) + .on_input(std::convert::identity) + .size(12) + .padding(6) + .width(Length::Fill) + .into(); + Self { + content: content.into(), + input, + chooser, + anchor, + on_event: Rc::new(on_event), + focus_scope: None, + } + } + pub fn focus_scope(mut self, scope: Option) -> Self { + self.focus_scope = scope; + self + } + fn reconcile(&self, tree: &mut Tree) { + let state = tree.state.downcast_mut::(); + let stamp = self.chooser.map(Chooser::stamp); + if state.stamp.as_ref().map(|s| s.invocation) != stamp.as_ref().map(|s| s.invocation) { + let input = tree.children[1] + .state + .downcast_mut::>(); + if stamp.is_some() { + input.focus(); + } else { + input.unfocus(); + } + state.closing = false; + state.serial = self.chooser.map_or(0, Chooser::query_serial); + } + if state.stamp != stamp { + state.pressed = None; + // A queued Choose can be rejected after a preceding query changed + // the result generation. Reconciliation must unlock this view. + state.closing = false; + state.detail_scroll = 0.; + } + state.stamp = stamp; + } +} +impl> Widget + for Popup<'_, Message, R> +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + fn state(&self) -> tree::State { + tree::State::new(Interaction::default()) + } + fn children(&self) -> Vec { + vec![ + Tree::new(self.content.as_widget()), + Tree::new(self.input.as_widget()), + ] + } + fn diff(&self, tree: &mut Tree) { + tree.children[0].diff(self.content.as_widget()); + tree.children[1].diff(self.input.as_widget()); + self.reconcile(tree); + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node { + self.reconcile(tree); + 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 mut owner = + super::trigger::FormOwner(self.focus_scope.filter(|_| self.chooser.is_none())); + operation.custom(None, layout.bounds(), &mut owner); + 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, + ) { + let state = tree.state.downcast_mut::(); + if state.swallow(event) || self.chooser.is_some() && is_input(event) { + shell.capture_event(); + return; + } + if is_input(event) && self.focus_scope.is_some_and(|scope| scope.modal) { + super::trigger::own_input(&mut self.content, &mut tree.children[0], layout, renderer); + } + if let Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Tab), + modifiers, + .. + }) = event + && let Some(scope) = self.focus_scope + && super::trigger::tab( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + scope, + modifiers.shift(), + ) + { + shell.capture_event(); + shell.request_redraw(); + return; + } + super::trigger::measure( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + *viewport, + ); + 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, + if self.chooser.is_some() { + mouse::Cursor::Unavailable + } else { + cursor + }, + viewport, + ); + } + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + if self.chooser.is_some() { + mouse::Interaction::None + } else { + 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> { + if let Some(chooser) = self.chooser { + Some(overlay::Element::new(Box::new(ChooserOverlay { + chooser, + anchor: self.anchor, + state: tree.state.downcast_mut(), + input: &mut self.input, + input_tree: &mut tree.children[1], + on_event: &*self.on_event, + }))) + } else { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } + } +} +impl<'a, Message: Clone + 'a, R: text::Renderer + 'a> From> + for Element<'a, Message, Theme, R> +{ + fn from(popup: Popup<'a, Message, R>) -> Self { + Element::new(popup) + } +} + +struct ChooserOverlay<'b, 'a, Message, R: text::Renderer> { + chooser: &'a Chooser, + anchor: Rectangle, + state: &'b mut Interaction, + input: &'b mut Element<'a, String, Theme, R>, + input_tree: &'b mut Tree, + on_event: &'b dyn Fn(ChoiceEvent) -> Message, +} +impl> ChooserOverlay<'_, '_, Message, R> { + fn geometry(&self, layout: Layout<'_>) -> Geometry { + Geometry::from_bounds( + layout + .children() + .next() + .expect("measured chooser bounds") + .bounds(), + ) + } + fn publish(&mut self, action: Action, shell: &mut Shell<'_, Message>) { + if self.state.closing { + return; + } + if action.writes_staged_value() || matches!(action, Action::Close(_)) { + self.state.closing = true; + self.state.swallow_mouse = self.state.held_mouse.or(self.state.swallow_mouse); + self.state.swallow_touch = self.state.held_touch.or(self.state.swallow_touch); + } + shell.publish((self.on_event)(self.chooser.event(action))); + } + fn press( + &mut self, + point: Point, + pointer: Pointer, + geometry: Geometry, + shell: &mut Shell<'_, Message>, + ) { + if !geometry.popup.contains(point) { + self.publish(Action::Close(Close::Outside), shell); + } else { + self.state.pressed = geometry + .hit(self.chooser, point) + .map(|action| (self.chooser.event(action), pointer)); + } + } + fn release( + &mut self, + point: Point, + pointer: Pointer, + geometry: Geometry, + shell: &mut Shell<'_, Message>, + ) { + if let Some((pressed, origin)) = self.state.pressed.take() + && origin == pointer + && pressed.stamp == self.chooser.stamp() + && geometry.hit(self.chooser, point).as_ref() == Some(&pressed.action) + { + self.publish(pressed.action, shell); + } + } +} +impl> overlay::Overlay + for ChooserOverlay<'_, '_, Message, R> +{ + fn layout(&mut self, renderer: &R, bounds: Size) -> layout::Node { + let geometry = Geometry::new(self.anchor, bounds); + let input = self + .input + .as_widget_mut() + .layout( + self.input_tree, + renderer, + &layout::Limits::new(Size::ZERO, geometry.search.size()), + ) + .move_to(geometry.search.position()); + layout::Node::with_children( + bounds, + vec![ + layout::Node::new(geometry.popup.size()).move_to(geometry.popup.position()), + input, + ], + ) + } + fn operate(&mut self, layout: Layout<'_>, renderer: &R, operation: &mut dyn Operation) { + self.input.as_widget_mut().operate( + self.input_tree, + layout.children().nth(1).unwrap(), + renderer, + operation, + ); + } + fn update( + &mut self, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) { + let geometry = self.geometry(layout); + match event { + Event::Mouse(mouse::Event::ButtonPressed(button)) => { + self.state.held_mouse = Some(*button) + } + Event::Mouse(mouse::Event::ButtonReleased(button)) + if self.state.held_mouse == Some(*button) => + { + self.state.held_mouse = None + } + Event::Touch(touch::Event::FingerPressed { id, .. }) => { + self.state.held_touch = Some(*id) + } + Event::Touch( + touch::Event::FingerLifted { id, .. } | touch::Event::FingerLost { id, .. }, + ) if self.state.held_touch == Some(*id) => self.state.held_touch = None, + _ => {} + } + if self.state.swallow(event) { + shell.capture_event(); + return; + } + if self.state.closing { + if is_input(event) { + shell.capture_event(); + } + return; + } + let mut handled = false; + match event { + Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => { + use keyboard::key::Named; + let action = match key { + keyboard::Key::Named(Named::Escape) => Some(Action::Close(Close::Escape)), + keyboard::Key::Named(Named::Tab) => Some(Action::Close(Close::Tab { + backwards: modifiers.shift(), + })), + keyboard::Key::Named(Named::ArrowDown) => Some(Action::Move { + delta: 1, + rows: geometry.rows, + }), + keyboard::Key::Named(Named::ArrowUp) => Some(Action::Move { + delta: -1, + rows: geometry.rows, + }), + keyboard::Key::Named(Named::PageDown) => Some(Action::Page { + delta: 1, + rows: geometry.rows, + }), + keyboard::Key::Named(Named::PageUp) => Some(Action::Page { + delta: -1, + rows: geometry.rows, + }), + keyboard::Key::Named(Named::Enter) => { + handled = true; + self.chooser + .highlighted() + .filter(|c| self.chooser.visible(geometry.rows).any(|v| v.id == c.id)) + .map(|c| Action::Choose(c.id)) + } + keyboard::Key::Character(c) + if modifiers.control() + && modifiers.shift() + && c.eq_ignore_ascii_case("c") => + { + clipboard.write( + iced::advanced::clipboard::Kind::Standard, + self.chooser + .highlighted() + .map_or_else(|| self.chooser.current.label(), |c| c.detail()), + ); + handled = true; + None + } + _ => None, + }; + if let Some(action) = action { + self.publish(action, shell); + handled = true; + } + } + Event::Window(window::Event::Resized(_) | window::Event::Unfocused) => { + self.publish(Action::Close(Close::WindowChanged), shell); + handled = true; + } + Event::Mouse(mouse::Event::ButtonPressed(button)) => { + if let Some(point) = cursor.position() { + if !geometry.popup.contains(point) { + self.state.swallow_mouse = Some(*button); + self.press(point, Pointer::Mouse, geometry, shell); + handled = true; + } else if *button == mouse::Button::Left && !geometry.search.contains(point) { + self.press(point, Pointer::Mouse, geometry, shell); + handled = true; + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + if self.state.pressed.is_some() => + { + if let Some(point) = cursor.position() { + self.release(point, Pointer::Mouse, geometry, shell); + } else { + self.state.pressed = None; + } + handled = true; + } + Event::Mouse(mouse::Event::CursorMoved { position }) + if self.state.pressed.is_none() => + { + if let Some(Action::Choose(id)) = geometry.hit(self.chooser, *position) + && self + .chooser + .highlighted() + .is_none_or(|candidate| candidate.id != id) + { + self.publish(Action::Highlight(id), shell); + } + } + Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + let (x, y) = match delta { + mouse::ScrollDelta::Lines { x, y } => (*x * 24., *y * 24.), + mouse::ScrollDelta::Pixels { x, y } => (*x, *y), + }; + self.state.pressed = None; + if cursor + .position() + .is_some_and(|p| geometry.detail.contains(p)) + { + let detail = self + .chooser + .highlighted() + .map_or_else(|| self.chooser.current.label(), |c| c.detail()); + let max = (measure::(&detail, 11., renderer.default_font()) + - geometry.detail.width) + .max(0.); + self.state.detail_scroll = + (self.state.detail_scroll - if x != 0. { x } else { y }).clamp(0., max); + shell.request_redraw(); + } else if y != 0. { + self.publish( + Action::Page { + delta: if y < 0. { 1 } else { -1 }, + rows: geometry.rows, + }, + shell, + ); + } + handled = true; + } + Event::Touch(touch::Event::FingerPressed { id, position }) + if !geometry.search.contains(*position) => + { + if !geometry.popup.contains(*position) { + self.state.swallow_touch = Some(*id); + } + self.press(*position, Pointer::Touch(*id), geometry, shell); + handled = true; + } + Event::Touch(touch::Event::FingerLifted { id, position }) + if self.state.pressed.is_some() => + { + self.release(*position, Pointer::Touch(*id), geometry, shell); + handled = true; + } + Event::Touch(touch::Event::FingerLost { .. } | touch::Event::FingerMoved { .. }) => { + self.state.pressed = None; + } + _ => {} + } + if !handled { + let mut messages = Vec::new(); + let mut input_shell = Shell::new(&mut messages); + self.input.as_widget_mut().update( + self.input_tree, + event, + layout.children().nth(1).unwrap(), + cursor, + renderer, + clipboard, + &mut input_shell, + &geometry.popup, + ); + let serial = Cell::new(self.state.serial); + shell.merge(input_shell, |text| { + serial.set(serial.get().saturating_add(1)); + (self.on_event)(self.chooser.event(Action::Query { + text, + serial: serial.get(), + })) + }); + self.state.serial = serial.get(); + } + if is_input(event) { + shell.capture_event(); + } + } + fn draw( + &self, + renderer: &mut R, + theme: &Theme, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + let geometry = self.geometry(layout); + renderer.fill_quad( + renderer::Quad { + bounds: geometry.popup, + border: Border { + color: style::LINE, + width: 1., + radius: 8.into(), + }, + ..Default::default() + }, + Color::WHITE, + ); + renderer.with_layer(geometry.popup, |renderer| { + label( + renderer, + &self.chooser.scope_label, + 11., + style::MUTED, + Rectangle { + x: geometry.popup.x + 8., + y: geometry.popup.y + 4., + width: (geometry.popup.width - 78.).max(0.), + height: 22., + }, + ); + label(renderer, "Close", 11., style::TEAL, geometry.close); + label( + renderer, + &self.chooser.current.label(), + 11., + style::MUTED, + geometry.current, + ); + for (i, candidate) in self.chooser.visible(geometry.rows).enumerate() { + let row = geometry.row(i); + if self + .chooser + .highlighted() + .is_some_and(|c| c.id == candidate.id) + { + renderer.fill_quad( + renderer::Quad { + bounds: row, + ..Default::default() + }, + Color::from_rgb8(218, 238, 235), + ); + } + let alias_width = (row.width * 0.42).min( + measure::( + &format!("@{}", candidate.alias), + 11., + renderer.default_font(), + ) + 4., + ); + label( + renderer, + &candidate.name, + 12., + style::INK, + Rectangle { + width: (row.width - alias_width - 6.).max(0.), + height: 17., + ..row + }, + ); + label( + renderer, + &format!("@{}", candidate.alias), + 11., + style::INK, + Rectangle { + x: row.x + row.width - alias_width, + width: alias_width, + height: 17., + ..row + }, + ); + label( + renderer, + &candidate.context_label(), + 10., + style::MUTED, + Rectangle { + y: row.y + 17., + height: 17., + ..row + }, + ); + } + if self.chooser.count() == 0 { + label( + renderer, + "No compatible matches. Clear the query.", + 11., + style::MUTED, + geometry.row(0), + ); + } + let detail = self + .chooser + .highlighted() + .map_or_else(|| self.chooser.current.label(), |c| c.detail()); + renderer.with_layer(geometry.detail, |renderer| { + raw_label( + renderer, + detail, + 11., + style::MUTED, + Rectangle { + x: geometry.detail.x - self.state.detail_scroll, + width: geometry.detail.width + self.state.detail_scroll, + height: 16., + ..geometry.detail + }, + ); + label( + renderer, + "Scroll details · Ctrl+Shift+C copies", + 10., + style::MUTED, + Rectangle { + y: geometry.detail.y + 16., + height: 16., + ..geometry.detail + }, + ); + }); + label( + renderer, + "‹", + 15., + if self.chooser.start() > 0 { + style::TEAL + } else { + style::MUTED + }, + geometry.previous, + ); + label( + renderer, + "›", + 15., + if self.chooser.start() + geometry.rows < self.chooser.count() { + style::TEAL + } else { + style::MUTED + }, + geometry.next, + ); + let count = if self.chooser.count() == 0 { + "0 results".into() + } else { + format!( + "{}–{} / {}", + self.chooser.start() + 1, + (self.chooser.start() + geometry.rows).min(self.chooser.count()), + self.chooser.count() + ) + }; + label( + renderer, + &count, + 11., + style::MUTED, + Rectangle { + x: geometry.next.x + 30., + width: (geometry.clear.x - geometry.next.x - 32.).max(0.), + ..geometry.next + }, + ); + if self.chooser.optional() { + label( + renderer, + "Clear reference", + 10., + style::TEAL, + geometry.clear, + ); + } + self.input.as_widget().draw( + self.input_tree, + renderer, + theme, + renderer_style, + layout.children().nth(1).unwrap(), + cursor, + &geometry.popup, + ); + }); + } + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + ) -> mouse::Interaction { + let geometry = self.geometry(layout); + if cursor + .position() + .is_some_and(|p| geometry.hit(self.chooser, p).is_some()) + { + mouse::Interaction::Pointer + } else { + self.input.as_widget().mouse_interaction( + self.input_tree, + layout.children().nth(1).unwrap(), + cursor, + &geometry.popup, + renderer, + ) + } + } +} + +fn is_input(event: &Event) -> bool { + matches!( + event, + Event::Mouse(_) | Event::Touch(_) | Event::Keyboard(_) | Event::InputMethod(_) + ) +} +fn measure>(content: &str, size: f32, font: Font) -> f32 { + R::Paragraph::with_text(text::Text { + content, + bounds: Size::INFINITE, + size: size.into(), + line_height: text::LineHeight::default(), + font, + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::None, + }) + .min_width() +} +fn label>( + renderer: &mut R, + content: &str, + size: f32, + color: Color, + bounds: Rectangle, +) { + let font = renderer.default_font(); + let mut display = content.to_owned(); + if measure::(content, size, font) > bounds.width { + let ends: Vec<_> = content + .char_indices() + .map(|(i, _)| i) + .chain(std::iter::once(content.len())) + .collect(); + let (mut low, mut high) = (0, ends.len()); + while low + 1 < high { + let middle = (low + high) / 2; + if measure::(&format!("{}…", &content[..ends[middle]]), size, font) <= bounds.width + { + low = middle; + } else { + high = middle; + } + } + display = format!("{}…", &content[..ends[low]]); + } + raw_label(renderer, display, size, color, bounds); +} +fn raw_label>( + renderer: &mut R, + content: String, + size: f32, + color: Color, + bounds: Rectangle, +) { + renderer.fill_text( + text::Text { + content, + bounds: bounds.size(), + size: size.into(), + line_height: text::LineHeight::default(), + font: renderer.default_font(), + align_x: text::Alignment::Left, + align_y: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::None, + }, + Point::new(bounds.x, bounds.center_y()), + color, + bounds, + ); +} diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index 1d72185a..3b73b357 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -445,28 +445,32 @@ fn article(topic: Topic) -> Article { walkthrough( "Describe a path through exchanges", &[ - "A blank path offers Add first activity/function contribution. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.", + "On an empty path, choose Work (W) from the right palette and click the canvas. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.", "Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.", - "Open Tools and choose Work or Exchange, then a highlighted position. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.", - "In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.", - "Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.", + "Choose Work (W) or Exchange (E) from the permanent palette on the right, then a highlighted position. Hover a palette button to read its tool name. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.", + "Choose Sequence link (->) and click or drag from the preceding contribution to the following contribution. Choose Select contributions or paths (S) to drag a selected path endpoint and reconnect it. A contribution's ↕ handle reorders contributions within the same column; links determine the columns. These are local contributions and sequence links, not new architecture exchanges.", + "Select a contribution, then add another after it. Details → Two paths... creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions.", "Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.", ], ), + explain( + "Navigate the canvas", + "The header and palette stay available while Details and creation forms overlay the canvas. Fit frames the contents; −/+ changes zoom and 100% restores working scale. Focus the canvas to zoom with the wheel or pan with middle-button drag or Space + left drag. F6 enters or leaves canvas controls; Tab moves to the next control, Enter/Space activates it, and Escape leaves canvas controls or cancels a gesture.", + ), explain( "Choose the right behavioral description", "A chain concentrates on a route through existing exchanges. A scenario can also carry actions, state observations, lifelines and nested fragments. The architecture diagram remains a view of the possible relationships rather than an execution trace.", ), explain( "Review the path as an engineer", - "Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Tools → Reuse to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Append exchange… continues that path; Convert ordered path to explicit links exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.", + "Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Reuse process / chain (ref) in the palette to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Details → Append exchange... continues that path; Edit path links and guards exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.", ), walkthrough( "Connect a particular use of a procedure", &[ - "Choose Focus canvas for more working space; Restore panels brings the explorer and properties back. Select a contribution inside a reusable preview, then open Selection details to read its complete caller and reference identity.", - "In Selection details, use Expand nested reference or Fold nested reference to inspect that instance. Open source or Open contribution definition visits the shared definition; Back to caller returns to the original caller context.", - "Open Tools → Qualified link / assembly… and choose its source and target. Use Enter reference to open an instance's contributions, Up one reference for its caller scope, or Root for the link owner. Search and page through all contributions in that scope, then select the exact endpoint. The first and second uses of a procedure remain different choices even when their work has the same name.", + "Choose Focus canvas for more working space; Restore panels brings the explorer and properties back. Select a contribution inside a reusable preview, then open Details to read its complete caller and reference identity.", + "In Details, use Expand nested reference or Fold nested reference to inspect that instance. Open source or Open contribution definition visits the shared definition; Back to caller returns to the original caller context.", + "Open Details → Qualified link / assembly... and choose its source and target. Use Enter reference to open an instance's contributions, Up one reference for its caller scope, or Root for the link owner. Search and page through all contributions in that scope, then select the exact endpoint. The first and second uses of a procedure remain different choices even when their work has the same name.", "Choose Precedence to describe order and its guard, Exchange assembly to identify a work exchange between the contributions, or Common work incidence to state that both use the same work. Select the engineering identity when required, then Apply link.", ], ), @@ -490,19 +494,23 @@ fn article(topic: Topic) -> Article { "Create a scenario", &[ "Open Behavior & scenarios → New → Scenario and enter its name. Explicitly choose Activities or Participants in OA, or Functions or System boundary in SA. Add optional context only when needed: this is a reference, while the scenario itself belongs at the layer root. Create and open starts the empty scenario.", - "Use Add first lifeline on an empty scenario, or + Lifeline. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.", - "Choose a message style in the tool selector. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.", - "Call + correlated reply creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.", - "Choose Add action, Observe state, Reuse scenario or Add fragment and click the intended sequence position. Clicking an action or observation on a lifeline also selects that lifeline. Complete any remaining choices before Create.", - "In Select / reorder, click an occurrence and drag its ↕ handle to another position within the same sequence. The ordered list's up/down controls provide another way to reorder. Select an occurrence to inspect its particular use of the exchange, including its message values.", + "Choose Lifeline (L) in the permanent palette on the right, then click the canvas. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.", + "Choose a message style from the palette; hover a button to read its tool name. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.", + "Call and correlated reply (C/R) creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.", + "Choose Action (A), State observation (St), Reuse scenario (ref) or Fragment (alt) and click the intended sequence position. Clicking an action or observation on a lifeline also selects that lifeline. Complete any remaining choices before Create.", + "Choose Select / reorder occurrences (S) and drag an occurrence itself to another position within the same sequence. A drag into another fragment operand is refused. Open Details for the ordered list's up/down controls or Properties for the selected occurrence, including its message values.", ], ), + explain( + "Navigate the canvas", + "The header and palette stay available while Details and creation forms overlay the canvas. Fit frames the contents; −/+ changes zoom and 100% restores working scale. Focus the canvas to zoom with the wheel or pan with middle-button drag or Space + left drag. F6 enters or leaves canvas controls; Tab moves to the next control, Enter/Space activates it, and Escape leaves canvas controls or cancels a gesture.", + ), walkthrough( "Describe alternatives", &[ - "Choose Add fragment, click its sequence position and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.", + "Choose Fragment (alt), click its sequence position and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.", "Click the highlighted position inside an operand to place the next message, action or reference there. The form shows its destination and insertion position. Append to this sequence instead explicitly moves the pending insertion to that sequence's end.", - "Choose Reuse scenario and click its position, then bind every referenced lifeline to a compatible local lifeline explicitly. Select the resulting use and choose Open definition. Back to caller returns to that exact use and its viewport after you apply or cancel pending edits.", + "Choose Reuse scenario and click its position, then bind every referenced lifeline to a compatible local lifeline explicitly. Select the resulting use and choose Details → Open definition. Back returns to that exact use and its viewport after you apply or cancel pending edits.", "Inspect the scenario projection and Model checks. State exactly what each guard means, including units and assumptions where relevant.", ], ), diff --git a/crates/oa-desktop/src/semantic_review.rs b/crates/oa-desktop/src/semantic_review.rs index 414f3ccd..d7bba064 100644 --- a/crates/oa-desktop/src/semantic_review.rs +++ b/crates/oa-desktop/src/semantic_review.rs @@ -87,6 +87,9 @@ pub enum RowEvent { ValuePath(String, crate::data_values::FieldPathKey), ValueBrowse(String, Option, usize), ReferenceBrowse(String, String, usize), + ReferenceOpen(crate::forms::reference_chooser::adapter::Open), + ReferenceEvent(crate::forms::reference_chooser::Event), + ReferenceSet(crate::forms::reference_chooser::adapter::Set), OperationOmission(bool), OperationRationale(String), Tab(Tab), diff --git a/crates/oa-desktop/src/semantic_review/batch.rs b/crates/oa-desktop/src/semantic_review/batch.rs index cf30f56e..c68fb48e 100644 --- a/crates/oa-desktop/src/semantic_review/batch.rs +++ b/crates/oa-desktop/src/semantic_review/batch.rs @@ -296,6 +296,51 @@ impl State { } Ok(()) } + pub(crate) fn reference_form(&self, project: &Project) -> Option<&Draft> { + let facet = self.active.selected.as_ref()?; + if self.frozen(project).is_err() + || self.active.tab != Tab::Facts + || self.active.decisions.get(facet) != Some(&SemanticDecision::Candidate) + { + return None; + } + self.active.candidate_draft(facet) + } + pub(crate) fn reference_draft( + &self, + project: &Project, + address: Option, + facet: &FacetSelector, + path: &str, + ) -> Option<&Draft> { + if self.frozen(project).is_err() + || self.active_address != address + || self.active.tab != Tab::Facts + { + return None; + } + self.active.reference_draft(project, facet, path) + } + pub(crate) fn set_reference( + &mut self, + project: &Project, + address: Option, + facet: &FacetSelector, + intent: crate::forms::reference_chooser::adapter::Set, + ) -> Result<(), String> { + self.reference_draft(project, address, facet, &intent.context.path) + .ok_or("The originating review row or property is no longer active.")? + .reference_value(project, &intent)?; + self.update( + project, + Event::Row { + address, + facet: Some(facet.clone()), + event: RowEvent::ReferenceSet(intent), + }, + ); + Ok(()) + } pub fn update(&mut self, project: &Project, event: impl Into) { let event = event.into(); #[cfg(test)] @@ -303,6 +348,30 @@ impl State { Event::Editor(event) => self.address_event(event), other => other, }; + match &event { + Event::Row { + event: RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_), + .. + } + | Event::Editor(RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_)) => return, + Event::Row { + address, + facet, + event: RowEvent::ReferenceSet(intent), + } => { + let valid = facet + .as_ref() + .and_then(|facet| { + self.reference_draft(project, *address, facet, &intent.context.path) + }) + .is_some_and(|draft| draft.reference_value(project, intent).is_ok()); + if !valid { + return; + } + } + Event::Editor(RowEvent::ReferenceSet(_)) => return, + _ => {} + } if let Err(error) = self.frozen(project) { // Withdrawing a pending reset is authored intent only. Retain it while // keeping the original accepted base and disabling every stale plan. @@ -464,7 +533,8 @@ impl State { ); let scoped_edit = matches!( event, - RowEvent::Input(_, _) + RowEvent::ReferenceSet(_) + | RowEvent::Input(_, _) | RowEvent::Text(_, _) | RowEvent::Shape(_, _) | RowEvent::Variant(_, _) @@ -624,7 +694,7 @@ impl State { Fill, widget::{button, checkbox, column, pick_list, row, text}, }; - let message = |event| Message::Workflow(crate::workflow::Event::Semantic(event)); + let message = |event| Message::Workflow(crate::workflow::Event::semantic(event)); let mut available: Vec<_> = self .base .traces @@ -747,13 +817,19 @@ impl State { if let Some(notice) = &self.notice { header = header.push(text(notice).size(11)); } - self.active + let view = self + .active .view(&self.base, header.into(), self.can_apply()) .map(move |message| match message { - Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) => { - Message::Workflow(crate::workflow::Event::Semantic(self.address_event(event))) + Message::Workflow(crate::workflow::Event::Semantic(event)) => { + let event = match *event { + Event::Editor(event) => self.address_event(event), + other => other, + }; + Message::Workflow(crate::workflow::Event::semantic(event)) } other => other, - }) + }); + crate::forms::reference_chooser::trigger::modal(view) } } diff --git a/crates/oa-desktop/src/semantic_review/batch_tests.rs b/crates/oa-desktop/src/semantic_review/batch_tests.rs index 3cee5d5a..235ec6b9 100644 --- a/crates/oa-desktop/src/semantic_review/batch_tests.rs +++ b/crates/oa-desktop/src/semantic_review/batch_tests.rs @@ -522,10 +522,10 @@ fn batch_apply_is_one_exact_source_and_history_undo_redo_roundtrip() { &mut app, RowEvent::Input(path(f.direction, "minimum"), "120".into()), ); - let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic( + let _ = app.update(Message::Workflow(crate::workflow::Event::semantic( Event::ChooseTrace(view::choice(&f.project, f.traces[1])), ))); - let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic( + let _ = app.update(Message::Workflow(crate::workflow::Event::semantic( Event::OpenTrace, ))); tests::decide( diff --git a/crates/oa-desktop/src/semantic_review/row.rs b/crates/oa-desktop/src/semantic_review/row.rs index 092922cf..a29d22b3 100644 --- a/crates/oa-desktop/src/semantic_review/row.rs +++ b/crates/oa-desktop/src/semantic_review/row.rs @@ -166,6 +166,14 @@ impl ReviewRow { pub fn update(&mut self, project: &Project, event: RowEvent) { let mut replan = true; let result = match event { + // Browser messages belong to the app overlay and must never replan. + RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_) => return, + RowEvent::ReferenceSet(intent) => { + let path = intent.context.path.clone(); + self.edit(project, &path, |draft| { + draft.reference_set(project, &intent) + }) + } RowEvent::OperationOmission(enabled) => { self.operation_omission = enabled.then(|| self.operation_omission.clone().unwrap_or_default()); @@ -518,6 +526,25 @@ impl ReviewRow { } } + pub(super) fn reference_draft( + &self, + project: &Project, + facet: &FacetSelector, + path: &str, + ) -> Option<&Draft> { + if self.selected.as_ref() != Some(facet) + || self.decisions.get(facet) != Some(&SemanticDecision::Candidate) + || matches!(path, "/id" | "/alias" | "/body" | "/layer") + || !self + .candidate_roots(project, facet)? + .iter() + .any(|root| path == root || path.starts_with(&format!("{root}/"))) + { + return None; + } + self.candidate_draft(facet) + } + fn edit( &mut self, project: &Project, @@ -895,7 +922,9 @@ pub fn form_event(message: Message) -> Message { Message::FieldReferenceBrowse(path, query, page) => { RowEvent::ReferenceBrowse(path, query, page) } + Message::FieldReferenceOpen(request) => RowEvent::ReferenceOpen(request), + Message::FieldReferenceEvent(event) => RowEvent::ReferenceEvent(event), _ => RowEvent::Preview, }; - Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) + Message::Workflow(crate::workflow::Event::semantic(Event::Editor(event))) } diff --git a/crates/oa-desktop/src/semantic_review/tests.rs b/crates/oa-desktop/src/semantic_review/tests.rs index 7439376e..0202a21c 100644 --- a/crates/oa-desktop/src/semantic_review/tests.rs +++ b/crates/oa-desktop/src/semantic_review/tests.rs @@ -3,7 +3,7 @@ use crate::{app::App, document::Document, workflow::Workflow}; use serde_json::json; pub(super) fn send(app: &mut App, event: RowEvent) { - let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic( + let _ = app.update(Message::Workflow(crate::workflow::Event::semantic( state(app).address_event(event), ))); } diff --git a/crates/oa-desktop/src/semantic_review/view.rs b/crates/oa-desktop/src/semantic_review/view.rs index 19962619..85856886 100644 --- a/crates/oa-desktop/src/semantic_review/view.rs +++ b/crates/oa-desktop/src/semantic_review/view.rs @@ -13,7 +13,7 @@ use iced::{ mod reserved_artifact_tests; fn event(event: RowEvent) -> Message { - Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) + Message::Workflow(crate::workflow::Event::semantic(Event::Editor(event))) } fn action<'a>( label: impl Into, @@ -867,7 +867,13 @@ impl ReviewRow { ) { detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED)); } - detail.into() + if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate) + && let Some(draft) = self.candidate_draft(&fact.selector) + { + crate::forms::reference_chooser::trigger::scope(detail, draft.reference_session()) + } else { + detail.into() + } } fn members_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> { diff --git a/crates/oa-desktop/src/workflow.rs b/crates/oa-desktop/src/workflow.rs index e4f7626f..3f9de83f 100644 --- a/crates/oa-desktop/src/workflow.rs +++ b/crates/oa-desktop/src/workflow.rs @@ -19,7 +19,7 @@ mod transition_tests; #[derive(Debug, Clone)] pub enum Event { - Semantic(crate::semantic_review::Event), + Semantic(Box), Port(crate::port_workflow::Event), ExchangeItem(Id, bool), Endpoint(bool, EndpointChoice), @@ -41,6 +41,11 @@ pub enum Event { Resolve(Id, TransitionField, FieldDecision), Preview, } +impl Event { + pub fn semantic(event: crate::semantic_review::Event) -> Self { + Self::Semantic(Box::new(event)) + } +} pub enum Workflow { Semantic(Box), Ports(Box),