diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index bf7693da..646b0415 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -14,6 +14,7 @@ mod diagram_reveal; #[cfg(test)] mod diagram_selection_tests; mod diagram_usages; +pub(crate) mod escape; mod hosted; pub(crate) mod multiselect; #[cfg(test)] @@ -35,6 +36,8 @@ mod state_multiselect_focus_tests; mod state_multiselect_tests; pub(crate) mod text_workspace; #[cfg(test)] +mod traceability_navigation_tests; +#[cfg(test)] mod transition_context_tests; #[cfg(test)] mod view_copy_tests; @@ -374,6 +377,7 @@ pub enum Message { Page(Page), Guide(crate::guide::Event), Traceability(crate::views::traceability::Event), + ReviewLayerTraces, Behavior(crate::behavior::Event), Layer(Layer), Select(Id), @@ -1235,7 +1239,7 @@ impl App { crate::model_menu::popup(self).or_else(|| self.text_workspace_menu()), ) .into(); - crate::source_navigation::interaction::observe( + let content = crate::source_navigation::interaction::observe( crate::behavior::scenario::focus::root( behavior::canvas_workspace::focus_root( crate::hosted::view::overlay( @@ -1247,7 +1251,8 @@ impl App { self.scenario_focus_owner(), ), self.source_symbols.raw_interaction.clone(), - ) + ); + escape::root(self, content) } pub fn subscription(&self) -> Subscription { Subscription::batch([ @@ -1289,7 +1294,6 @@ impl App { iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick), window::close_requests().map(|id| Message::Request(Pending::Close(id))), window::resize_events().map(|(_, size)| Message::WindowResized(size)), - architecture_authoring::subscription(self), iced::event::listen_with(|event, _, _| { matches!( event, @@ -1297,20 +1301,6 @@ impl App { ) .then_some(Message::WorkDrag(allocation_drag::Event::KeyboardBoundary)) }), - // Use one captured-Escape bridge: a focused Find input consumes - // Escape, while modal priority remains in the ordinary handler. - behavior::creation::subscription( - self.text_workspace.menu().is_none() - && (self.behavior.creation.has_pending() - || self.diagram_usages.is_some() - || self.behavior.source_active() - || (self.page == Page::Text - && (self.text_workspace.drawer().is_some() - || self.source_navigation.open - || self.source_rename.open()) - && self.architecture.is_none())), - ) - .map(|()| Message::Escape), iced::event::listen_with(|event, status, _| { if matches!(event, iced::Event::Window(window::Event::Unfocused)) { return Some(Message::WorkDrag(allocation_drag::Event::Unfocused)); @@ -2690,7 +2680,30 @@ impl App { { return self.update(*action); } - self.select(menu.request.object); + // Show acts on the diagram captured by the validated menu. + // Ordinary cross-layer selection would switch to that element's + // preferred same-layer view before the placement command runs. + let show_in_mixed_view = matches!(&*action, Message::PlaceSelected) + && self + .active_diagram + .and_then(|id| self.project().diagrams.get(&id)) + .is_some_and(|d| { + d.projection == ProjectionKind::Traceability + && self + .project() + .elements + .get(&menu.request.object) + .is_some_and(|e| { + e.kind() != Kind::Port && d.projection.includes(e.kind()) + }) + }); + if show_in_mixed_view { + self.selected = menu.request.object; + self.selected_attachment = None; + self.refresh_draft(); + } else { + self.select(menu.request.object); + } if self.page == Page::Behavior && menu.request.canvas.is_some() { self.behavior.diagram.placement = menu.request.placement; self.selected_node = None; @@ -2895,6 +2908,23 @@ impl App { iced::widget::scrollable::RelativeOffset::START, ); } + Message::ReviewLayerTraces => { + // Reuse Page navigation so modal, placement and behavior-form + // guards run before changing the transient inventory filter. + let navigation = self.update(Message::Page(Page::Traces)); + if self.page == Page::Traces && !self.behavior.has_pending() { + self.traceability.filter = crate::views::traceability::Filter::Traces; + self.traceability.query.clear(); + return Task::batch([ + navigation, + iced::widget::operation::snap_to( + "traceability-list", + iced::widget::scrollable::RelativeOffset::START, + ), + ]); + } + return navigation; + } Message::Search(value) => { self.search = value; self.explorer_page = 0; @@ -4957,6 +4987,14 @@ impl App { if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) { return; } + if tool == crate::connection_tool::Tool::Allocation + && !tool.available_in( + self.layer, + self.project().diagrams[&context.diagram].projection, + ) + { + return; + } self.canvas_epoch = self.canvas_epoch.wrapping_add(1); let context = self.route_context().unwrap(); self.connection = Some(Session::new( @@ -4987,14 +5025,32 @@ impl App { if let Some(source) = session.source { match session.tool.command(self.project(), source, id) { Ok(command) => { + let previous_layer = self.layer; + if session.tool == crate::connection_tool::Tool::Allocation { + // Accepted edits belong to the captured view. A + // retained mixed view can differ from the current + // perspective; sync must not choose another view. + self.layer = self.project().diagrams[&context.diagram].layer; + } if self.execute(command) { self.selected_node = None; self.inspector_tab = Default::default(); - self.status = format!( - "{} created · edit its name and carried items in Properties · Undo restores the complete operation", - session.tool.label() - ); + self.status = if session.tool + == crate::connection_tool::Tool::Allocation + { + format!( + "Allocated {} to {} · view filters are unchanged · Undo restores the complete operation", + self.project().named(id), + self.project().named(source) + ) + } else { + format!( + "{} created · edit its name and carried items in Properties · Undo restores the complete operation", + session.tool.label() + ) + }; } else { + self.layer = previous_layer; self.connection = Some(session); } } @@ -5009,7 +5065,18 @@ impl App { &project_view(self.project(), context.diagram), id, ); - self.select(id); + if session.tool == crate::connection_tool::Tool::Allocation + && self.project().diagrams[&context.diagram].projection + == ProjectionKind::Traceability + { + // Mixed views can allocate either perspective. Choosing + // a visible source must not navigate to its preferred view. + self.selected = id; + self.selected_attachment = None; + self.refresh_draft(); + } else { + self.select(id); + } self.status = if session.candidates.is_empty() { "No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into() } else { diff --git a/crates/oa-desktop/src/app/architecture_authoring.rs b/crates/oa-desktop/src/app/architecture_authoring.rs index 26a8c00b..00284cef 100644 --- a/crates/oa-desktop/src/app/architecture_authoring.rs +++ b/crates/oa-desktop/src/app/architecture_authoring.rs @@ -27,30 +27,11 @@ pub enum Event { Cancel, } -pub fn subscription(app: &App) -> Subscription { - if app.architecture.as_ref().is_some_and(State::inline) { - iced::event::listen_with(captured_escape) - } else { - Subscription::none() - } -} - -fn captured_escape( - event: iced::Event, - status: iced::event::Status, - _: iced::window::Id, -) -> Option { - // A focused Iced text input captures Escape while dropping its focus. Only - // inline architecture naming opts into also cancelling that same keypress. - (status == iced::event::Status::Captured - && matches!( - event, - iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { - key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), - .. - }) - )) - .then_some(Message::Architecture(Event::CapturedEscape)) +pub(super) fn captured_escape_message(app: &App) -> Option { + app.architecture + .as_ref() + .is_some_and(State::inline) + .then_some(Message::Architecture(Event::CapturedEscape)) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -313,6 +294,17 @@ impl App { _ => return Task::none(), }; let placing = point.is_none() && matches!(intent, Intent::Create { .. }); + let filter = if matches!(intent, Intent::Existing { .. }) + && self + .project() + .diagrams + .get(&diagram) + .is_some_and(|d| d.projection == ProjectionKind::Traceability) + { + Filter::AllCompatible + } else { + Filter::Context + }; let saved = Saved { version: 1, base: oa_core::transitions::project_fingerprint(self.project()), @@ -321,7 +313,7 @@ impl App { point, placing, query: String::new(), - filter: Filter::Context, + filter, show_placed: false, page: 0, }; diff --git a/crates/oa-desktop/src/app/architecture_authoring/palette.rs b/crates/oa-desktop/src/app/architecture_authoring/palette.rs index 16880120..a2bbb3df 100644 --- a/crates/oa-desktop/src/app/architecture_authoring/palette.rs +++ b/crates/oa-desktop/src/app/architecture_authoring/palette.rs @@ -229,13 +229,7 @@ pub fn view(app: &App) -> Element<'_, Message> { content = content.push(group("Connections")); for connection in crate::connection_tool::Tool::for_layer(app.layer) { let d = &app.project().diagrams[&app.active_diagram.unwrap()]; - let kind = match connection { - crate::connection_tool::Tool::Interaction => Kind::Activity, - crate::connection_tool::Tool::FunctionalExchange => Kind::Function, - crate::connection_tool::Tool::CommunicationMean => Kind::Entity, - crate::connection_tool::Tool::BoundaryExchange => Kind::System, - }; - if !d.projection.includes(kind) { + if !connection.available_in(app.layer, d.projection) { continue; } content = content.push(tool( diff --git a/crates/oa-desktop/src/app/architecture_authoring/tests.rs b/crates/oa-desktop/src/app/architecture_authoring/tests.rs index 2716b2ae..0dbb14e2 100644 --- a/crates/oa-desktop/src/app/architecture_authoring/tests.rs +++ b/crates/oa-desktop/src/app/architecture_authoring/tests.rs @@ -1,4 +1,5 @@ use super::*; +mod traceability; fn setup(layer: Layer) -> App { let mut app = App::with_project(Project::empty()); @@ -233,65 +234,6 @@ fn architecture_stale_submit_keeps_create_and_rename_visible_and_exact() { } } -fn key_event(named: iced::keyboard::key::Named) -> iced::Event { - use iced::keyboard; - iced::Event::Keyboard(keyboard::Event::KeyPressed { - key: keyboard::Key::Named(named), - modified_key: keyboard::Key::Named(named), - physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape), - location: keyboard::Location::Standard, - modifiers: keyboard::Modifiers::empty(), - text: None, - repeat: false, - }) -} - -fn focused_text_input_escape(name: &str) -> Message { - use iced::advanced::{Layout, Shell, layout, widget}; - let mut field: iced::Element<'_, (), iced::Theme, ()> = text_input("Engineering name", name) - .id("architecture-name") - .on_input(|_| ()) - .into(); - let mut tree = widget::Tree::new(field.as_widget()); - let size = iced::Size::new(300., 40.); - let node = - field - .as_widget_mut() - .layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size)); - tree.state - .downcast_mut::>() - .focus(); - let escape = key_event(iced::keyboard::key::Named::Escape); - let mut messages = Vec::new(); - let mut shell = Shell::new(&mut messages); - field.as_widget_mut().update( - &mut tree, - &escape, - Layout::new(&node), - iced::mouse::Cursor::Unavailable, - &(), - &mut iced::advanced::clipboard::Null, - &mut shell, - &iced::Rectangle::new(iced::Point::ORIGIN, size), - ); - assert!( - shell.is_event_captured(), - "Iced consumes focused-input Escape" - ); - assert!( - !tree - .state - .downcast_ref::>() - .is_focused() - ); - captured_escape( - escape, - iced::event::Status::Captured, - iced::window::Id::unique(), - ) - .unwrap() -} - #[test] fn architecture_first_focused_input_escape_cancels_create_and_rename() { for rename in [false, true] { @@ -311,8 +253,9 @@ fn architecture_first_focused_input_escape_cancels_create_and_rename() { let name = " Unaccepted long engineering name 雨 "; send(&mut app, Event::Name(name.into())); assert!(app.architecture.as_ref().unwrap().inline()); - let message = focused_text_input_escape(name); - let _ = app.update(message); + let mut messages = crate::app::escape::tests::focused_input_messages(&app); + assert_eq!(messages.len(), 1); + let _ = app.update(messages.remove(0)); assert!(app.architecture.is_none()); assert!(inline_editor(&app).is_none()); assert_eq!(app.status, "Cancelled · Select / move"); @@ -342,8 +285,8 @@ fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() { let before = app.document.bundle().clone(); let status = app.status.clone(); let epoch = app.canvas_epoch; - let message = focused_text_input_escape("Other field or existing-element search"); - let _ = app.update(message); + let messages = crate::app::escape::tests::focused_input_messages(&app); + assert!(messages.is_empty()); assert_eq!( app.architecture .as_ref() @@ -355,19 +298,7 @@ fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() { assert_eq!(app.canvas_epoch, epoch); } assert!( - captured_escape( - key_event(iced::keyboard::key::Named::Escape), - iced::event::Status::Ignored, - iced::window::Id::unique(), - ) - .is_none() - ); - assert!( - captured_escape( - key_event(iced::keyboard::key::Named::Enter), - iced::event::Status::Captured, - iced::window::Id::unique(), - ) - .is_none() + crate::app::escape::tests::focused_input_key(&app, iced::keyboard::key::Named::Enter,) + .is_empty() ); } diff --git a/crates/oa-desktop/src/app/architecture_authoring/tests/traceability.rs b/crates/oa-desktop/src/app/architecture_authoring/tests/traceability.rs new file mode 100644 index 00000000..9298450d --- /dev/null +++ b/crates/oa-desktop/src/app/architecture_authoring/tests/traceability.rs @@ -0,0 +1,124 @@ +use super::*; + +#[test] +fn traceability_existing_defaults_show_opposite_layer_and_place_only_one_node() { + let mut project = Project::empty(); + let sa = project + .add_element(Kind::Function, Layer::Sa, "Deliver report", None) + .unwrap(); + let system = project + .add_element(Kind::System, Layer::Sa, "System", None) + .unwrap(); + let diagram = project.ensure_diagram(Layer::Oa); + project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability; + let mut app = App::with_project(project); + app.page = Page::Diagram; + app.layer = Layer::Oa; + app.active_diagram = Some(diagram); + let before = app.document.snapshot(); + send(&mut app, Event::Existing(None)); + assert_eq!( + app.architecture.as_ref().unwrap().saved.filter, + Filter::AllCompatible + ); + let listed = candidates(&app); + assert!(listed.iter().any(|(id, _, shown)| *id == sa && !shown)); + assert!(listed.iter().any(|(id, _, shown)| *id == system && !shown)); + send(&mut app, Event::Pick(sa)); + assert!(app.architecture.as_ref().unwrap().saved.placing); + assert_eq!(app.project(), &before.decoded.project); + let context = app.route_context().unwrap(); + let point = Position { x: 345., y: 215. }; + send(&mut app, Event::Click(context, point)); + assert!(app.architecture.is_none(), "{}", app.status); + assert_eq!(app.active_diagram, Some(diagram)); + let placed = app.project().diagrams[&diagram] + .nodes + .values() + .find(|n| n.element == sa) + .unwrap() + .clone(); + assert_eq!(placed.position, point); + let mut expected = before.decoded.project.clone(); + expected + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .insert(placed.id, placed); + assert_eq!(app.project(), &expected); + let accepted = app.document.bundle().clone(); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo()); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected); + assert_eq!(app.document.bundle(), &accepted); +} + +#[test] +fn traceability_default_does_not_change_creation_or_ordinary_picker_guards() { + let mut app = setup(Layer::Oa); + let diagram = app.active_diagram.unwrap(); + send(&mut app, Event::Existing(None)); + assert_eq!( + app.architecture.as_ref().unwrap().saved.filter, + Filter::Context + ); + send(&mut app, Event::Cancel); + let _ = app.update(Message::Projection(ProjectionKind::Traceability)); + send(&mut app, Event::Start(Kind::Entity, None)); + assert_eq!( + app.architecture.as_ref().unwrap().saved.filter, + Filter::Context + ); + send(&mut app, Event::Cancel); + assert_eq!(app.active_diagram, Some(diagram)); + + let mut project = app.project().clone(); + let sa = project + .add_element(Kind::Function, Layer::Sa, "Other layer", None) + .unwrap(); + let port = Id::new_v4(); + project.elements.insert( + port, + oa_core::Element { + id: port, + alias: "legacy_port".into(), + name: "Port".into(), + description: String::new(), + parent: Some(sa), + body: ElementBody::shared_in( + Layer::Sa, + SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::InOut, + interface: None, + }), + }, + ), + }, + ); + oa_core::validate_candidate(&project).unwrap(); + for projection in [ProjectionKind::Overview, ProjectionKind::Traceability] { + let mut p = project.clone(); + p.diagrams.get_mut(&diagram).unwrap().projection = projection; + let mut trial = App::with_project(p); + trial.page = Page::Diagram; + trial.active_diagram = Some(diagram); + trial.layer = Layer::Oa; + let before = trial.document.bundle().clone(); + send(&mut trial, Event::Existing(None)); + send(&mut trial, Event::Filter(Filter::AllCompatible)); + assert_eq!( + candidates(&trial).iter().any(|(id, _, _)| *id == sa), + projection == ProjectionKind::Traceability + ); + assert!(!candidates(&trial).iter().any(|(id, _, _)| *id == port)); + send(&mut trial, Event::Pick(port)); + assert!(!trial.architecture.as_ref().unwrap().saved.placing); + send(&mut trial, Event::Cancel); + assert_eq!(trial.document.bundle(), &before); + } +} diff --git a/crates/oa-desktop/src/app/escape.rs b/crates/oa-desktop/src/app/escape.rs new file mode 100644 index 00000000..8683f30a --- /dev/null +++ b/crates/oa-desktop/src/app/escape.rs @@ -0,0 +1,198 @@ +//! Keep overlay Escape ahead of modal cancellation in the ordinary widget tree. +//! +//! Iced skips root.update when an overlay captures an event. Keeping this +//! wrapper present even when inactive preserves the child's widget state. A +//! current-overlay preflight also handles pickers opened mid-batch. Passive +//! overlays may see an ignored Escape again: the raw-input observer only makes +//! pending focus requests stale; it does not publish modal cancellation. +use super::{App, Message, Page, architecture_authoring}; +use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget}; +use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse}; + +pub(crate) fn root<'a, R: renderer::Renderer + 'a>( + app: &App, + content: Element<'a, Message, Theme, R>, +) -> Element<'a, Message, Theme, R> { + let modal = app.text_workspace.menu().is_none() + && (app.behavior.creation.has_pending() + || app.diagram_usages.is_some() + || app.behavior.source_active() + || (app.page == Page::Text + && (app.text_workspace.drawer().is_some() + || app.source_navigation.open + || app.source_rename.open()) + && app.architecture.is_none())); + Element::new(Bridge { + content, + messages: [ + architecture_authoring::captured_escape_message(app), + modal.then_some(Message::Escape), + ], + }) +} + +struct Bridge<'a, R> { + content: Element<'a, Message, Theme, R>, + messages: [Option; 2], +} + +impl Widget for Bridge<'_, R> { + fn children(&self) -> Vec { + vec![widget::Tree::new(self.content.as_widget())] + } + + fn diff(&self, tree: &mut widget::Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + + fn layout( + &mut self, + tree: &mut widget::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 widget::Tree, + layout: Layout<'_>, + renderer: &R, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let escape = matches!( + event, + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Escape), + .. + }) + ); + // Iced collects overlays before processing a batch of base events. A + // preceding click in that batch can therefore open a picker whose + // Escape would otherwise reach focused fields underneath it. Offer only + // Escape to the current overlay before updating the ordinary tree. + if escape { + if let Some(element) = self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + Vector::ZERO, + ) { + let mut overlay = overlay::Nested::new(element); + let node = overlay.layout(renderer, viewport.size()); + overlay.update( + event, + Layout::new(&node), + cursor, + renderer, + clipboard, + shell, + ); + } + if shell.is_event_captured() { + return; + } + } + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + if shell.is_event_captured() && escape { + for message in self.messages.iter().flatten() { + shell.publish(message.clone()); + } + } + } + + fn draw( + &self, + tree: &widget::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: &widget::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 widget::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, + ) + } +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/oa-desktop/src/app/escape/tests.rs b/crates/oa-desktop/src/app/escape/tests.rs new file mode 100644 index 00000000..d980b510 --- /dev/null +++ b/crates/oa-desktop/src/app/escape/tests.rs @@ -0,0 +1,618 @@ +use super::*; +use crate::behavior::canvas_workspace::tests::key; +use iced::advanced::{subscription, widget::operation}; +use iced::futures::{StreamExt, stream, task::noop_waker_ref}; +use iced::keyboard::key::Named; +use iced::widget::{column, container, pick_list, text_input}; +use iced_runtime::user_interface::{Cache, UserInterface}; +use oa_core::{Layer, Project}; +use std::task::{Context, Poll}; + +struct FocusInputs; +impl widget::Operation for FocusInputs { + 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 operation::Focusable, + ) { + state.focus(); + } +} + +pub(crate) fn subscriptions( + app: &App, + event: Event, + status: iced::event::Status, + messages: &mut Vec, +) { + let interaction = subscription::Event::Interaction { + window: iced::window::Id::unique(), + event, + status, + }; + let mut boundaries = 0; + for recipe in subscription::into_recipes(app.subscription()) { + let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()]))); + let mut cx = Context::from_waker(noop_waker_ref()); + while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) { + if matches!( + message, + Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary) + ) { + boundaries += 1; + } else { + messages.push(message); + } + } + } + assert_eq!( + boundaries, 1, + "one passive allocation keyboard notification" + ); +} + +/// A real focused text input, production root bridge, UserInterface dispatch, +/// and actual subscriptions. Existing app-policy tests use this instead of +/// pretending every captured event came from a text control. +pub(crate) fn focused_input_messages(app: &App) -> Vec { + focused_input_key(app, Named::Escape) +} + +pub(crate) fn focused_input_key(app: &App, named: Named) -> Vec { + input_messages(app, named, true) +} + +pub(crate) fn input_messages(app: &App, named: Named, focused: bool) -> Vec { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let _entered = runtime.enter(); + let input: Element<'_, Message, Theme, ()> = text_input("Retained input", "Uncommitted 雨") + .on_input(|_| Message::Escape) + .into(); + let mut renderer = (); + let mut ui = UserInterface::build( + root(app, input), + Size::new(400., 100.), + Cache::new(), + &mut renderer, + ); + if focused { + ui.operate(&renderer, &mut FocusInputs); + } + let event = key(named); + let mut messages = vec![]; + let (_, statuses) = ui.update( + std::slice::from_ref(&event), + mouse::Cursor::Unavailable, + &mut renderer, + &mut iced::advanced::clipboard::Null, + &mut messages, + ); + if named == Named::Escape { + assert_eq!( + statuses, + [if focused { + iced::event::Status::Captured + } else { + iced::event::Status::Ignored + }] + ); + } + subscriptions(app, event, statuses[0], &mut messages); + messages +} + +#[derive(Clone, Debug, PartialEq)] +enum PickMessage { + Selected(&'static str), + Opened, + Closed, +} + +fn picker( + selected: Option<&'static str>, + notify: bool, +) -> Element<'static, PickMessage, Theme, ()> { + let list = pick_list(["Alpha", "Beta"], selected, PickMessage::Selected) + .width(200) + .padding(10) + .text_size(16) + .on_open(PickMessage::Opened); + if notify { + list.on_close(PickMessage::Closed).into() + } else { + list.into() + } +} + +fn update( + ui: &mut UserInterface<'_, M, Theme, ()>, + events: &[Event], + cursor: mouse::Cursor, +) -> (Vec, Vec) { + let mut messages = vec![]; + let (_, statuses) = ui.update( + events, + cursor, + &mut (), + &mut iced::advanced::clipboard::Null, + &mut messages, + ); + (messages, statuses) +} + +fn press_at(point: iced::Point) -> (Vec, mouse::Cursor) { + ( + vec![ + Event::Mouse(mouse::Event::CursorMoved { position: point }), + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ], + mouse::Cursor::Available(point), + ) +} + +#[test] +fn picker_overlay_escape_closes_once_reopens_selects_and_retains_outside_click() { + for selected in [None, Some("Alpha")] { + for notify in [false, true] { + let mut ui = UserInterface::build( + picker(selected, notify), + Size::new(400., 300.), + Cache::new(), + &mut (), + ); + let (click, cursor) = press_at(iced::Point::new(50., 15.)); + assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]); + let cache = ui.into_cache(); + let mut ui = UserInterface::build( + picker(selected, notify), + Size::new(400., 300.), + cache, + &mut (), + ); + let (messages, statuses) = update( + &mut ui, + &[key(Named::Escape), key(Named::Escape)], + mouse::Cursor::Unavailable, + ); + assert_eq!( + messages, + if notify { + vec![PickMessage::Closed] + } else { + vec![] + } + ); + assert_eq!( + statuses, + [iced::event::Status::Captured, iced::event::Status::Ignored] + ); + let cache = ui.into_cache(); + let mut ui = UserInterface::build( + picker(selected, notify), + Size::new(400., 300.), + cache, + &mut (), + ); + assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]); + let cache = ui.into_cache(); + let mut ui = UserInterface::build( + picker(selected, notify), + Size::new(400., 300.), + cache, + &mut (), + ); + let (outside, cursor) = press_at(iced::Point::new(350., 250.)); + assert_eq!( + update(&mut ui, &outside, cursor).0, + if notify { + vec![PickMessage::Closed] + } else { + vec![] + } + ); + let cache = ui.into_cache(); + let mut ui = UserInterface::build( + picker(selected, notify), + Size::new(400., 300.), + cache, + &mut (), + ); + let (click, cursor) = press_at(iced::Point::new(50., 15.)); + assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]); + let cache = ui.into_cache(); + let mut ui = UserInterface::build( + picker(selected, notify), + Size::new(400., 300.), + cache, + &mut (), + ); + // Default 16px text uses 1.3 line-height: button/option height 40.8. + let (option, cursor) = press_at(iced::Point::new(50., 100.)); + assert_eq!( + update(&mut ui, &option, cursor).0, + [PickMessage::Selected("Beta")] + ); + let cache = ui.into_cache(); + let mut ui = UserInterface::build( + picker(Some("Beta"), notify), + Size::new(400., 300.), + cache, + &mut (), + ); + assert_eq!( + update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable), + (vec![], vec![iced::event::Status::Ignored]) + ); + } + } +} + +#[test] +fn initially_closed_picker_handles_open_then_escape_in_one_runtime_batch() { + for notify in [false, true] { + let mut ui = UserInterface::build( + picker(Some("Alpha"), notify), + Size::new(400., 300.), + Cache::new(), + &mut (), + ); + let (mut events, cursor) = press_at(iced::Point::new(50., 15.)); + events.push(key(Named::Escape)); + let (messages, statuses) = update(&mut ui, &events, cursor); + assert_eq!( + messages, + if notify { + vec![PickMessage::Opened, PickMessage::Closed] + } else { + vec![PickMessage::Opened] + } + ); + assert_eq!(statuses.last(), Some(&iced::event::Status::Captured)); + assert_eq!( + update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable), + (vec![], vec![iced::event::Status::Ignored]) + ); + } +} + +#[test] +fn dismissed_picker_ignores_a_queued_click_in_its_old_option_area() { + let mut ui = UserInterface::build( + picker(Some("Alpha"), true), + Size::new(400., 300.), + Cache::new(), + &mut (), + ); + let (click, cursor) = press_at(iced::Point::new(50., 15.)); + assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]); + let cache = ui.into_cache(); + let mut ui = UserInterface::build( + picker(Some("Alpha"), true), + Size::new(400., 300.), + cache, + &mut (), + ); + let (click, cursor) = press_at(iced::Point::new(50., 100.)); + let mut batch = vec![key(Named::Escape)]; + batch.extend(click); + let (messages, _) = update(&mut ui, &batch, cursor); + assert_eq!( + messages, + [PickMessage::Closed], + "queued old-option click must not select Beta" + ); +} + +fn draft_with_picker(app: &App) -> Element<'_, Message, Theme, ()> { + // This composition deliberately keeps a focused input beneath the actual + // picker overlay. It tests event ownership, not a claimed production form. + root( + app, + crate::source_navigation::interaction::observe( + column![ + container( + text_input("Raw draft", "Keep exact bytes 雨").on_input(|_| Message::Escape) + ) + .height(40), + pick_list([Layer::Oa, Layer::Sa], Some(Layer::Oa), Message::Layer) + .width(200) + .padding(10), + ] + .into(), + app.source_symbols.raw_interaction.clone(), + ), + ) +} + +#[test] +fn picker_overlay_precedes_focused_input_and_creation_source_drafts() { + use crate::behavior::{Event as BehaviorEvent, creation, source_draft}; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let _entered = runtime.enter(); + for (source, same_batch) in [(false, false), (true, false), (false, true), (true, true)] { + let mut app = App::with_project(Project::empty()); + let send = |app: &mut App, event| { + let _ = app.update(Message::Behavior(BehaviorEvent::Creation(event))); + }; + send(&mut app, creation::Event::Open(Layer::Oa)); + send(&mut app, creation::Event::Choose(creation::Family::Machine)); + send( + &mut app, + creation::Event::Name("Retained machine 雨".into()), + ); + if source { + let recovery = app.behavior.creation.recovery_snapshot().unwrap().unwrap(); + let invocation = serde_json::from_value(recovery["invocation"].clone()).unwrap(); + send( + &mut app, + creation::Event::Source(source_draft::Event::Open(invocation)), + ); + let recovery = app.behavior.creation.recovery_snapshot().unwrap().unwrap(); + let token = + serde_json::from_value(recovery["source"]["editing"]["token"].clone()).unwrap(); + send( + &mut app, + creation::Event::Source(source_draft::Event::Edit( + token, + source_draft::Edit::Name("Uncommitted owner 雨".into()), + )), + ); + assert_eq!( + app.behavior.creation.recovery_snapshot().unwrap().unwrap()["source"]["editing"]["name"], + "Uncommitted owner 雨" + ); + } + let draft = app.behavior.recovery_snapshot().unwrap(); + let project = app.project().clone(); + let bundle = app.document.bundle().clone(); + let revision = app.document.revision(); + let selection = (app.selected, app.selected_node, app.active_diagram); + let camera = (app.pan, app.zoom); + let raw_epoch = app + .source_symbols + .raw_interaction + .load(std::sync::atomic::Ordering::Acquire); + let mut ui = UserInterface::build( + draft_with_picker(&app), + Size::new(500., 400.), + Cache::new(), + &mut (), + ); + let event = key(Named::Escape); + let (mut click, cursor) = press_at(iced::Point::new(50., 55.)); + let (mut messages, statuses) = if same_batch { + ui.operate(&(), &mut FocusInputs); + click.push(event.clone()); + update(&mut ui, &click, cursor) + } else { + assert!(update(&mut ui, &click, cursor).0.is_empty()); + let cache = ui.into_cache(); + ui = UserInterface::build( + draft_with_picker(&app), + Size::new(500., 400.), + cache, + &mut (), + ); + ui.operate(&(), &mut FocusInputs); + update( + &mut ui, + std::slice::from_ref(&event), + mouse::Cursor::Unavailable, + ) + }; + assert_eq!(statuses.last(), Some(&iced::event::Status::Captured)); + subscriptions(&app, event, *statuses.last().unwrap(), &mut messages); + assert!( + messages.is_empty(), + "picker must not forward global modal cancellation" + ); + let cache = ui.into_cache(); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), draft); + assert!(!crate::source_navigation::interaction::is_current( + &app.source_symbols.raw_interaction, + raw_epoch, + )); + let mut ui = UserInterface::build( + draft_with_picker(&app), + Size::new(500., 400.), + cache, + &mut (), + ); + let event = key(Named::Escape); + let (mut messages, statuses) = update( + &mut ui, + std::slice::from_ref(&event), + mouse::Cursor::Unavailable, + ); + subscriptions(&app, event, statuses[0], &mut messages); + assert_eq!(messages.len(), 1); + assert!(matches!(messages[0], Message::Escape)); + drop(ui); + let _ = app.update(messages.remove(0)); + if source { + assert!(app.behavior.creation.has_pending()); + assert!(!app.behavior.creation.source_open()); + assert_eq!( + app.behavior.creation.recovery_snapshot().unwrap().unwrap()["name"], + "Retained machine 雨" + ); + } else { + assert!(!app.behavior.creation.has_pending()); + } + assert_eq!(app.project(), &project); + assert_eq!(app.document.bundle(), &bundle); + assert_eq!(app.document.revision(), revision); + assert_eq!( + (app.selected, app.selected_node, app.active_diagram), + selection + ); + assert_eq!((app.pan, app.zoom), camera); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[test] +fn ignored_tooltip_escape_preserves_modal_priority_and_rejects_stale_focus() { + use crate::source_navigation::interaction; + use iced::widget::{text, tooltip}; + use std::sync::atomic::Ordering; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let _entered = runtime.enter(); + let mut app = App::with_project(Project::empty()); + app.page = Page::Text; + app.source_navigation.open = true; + let project = app.project().clone(); + let source = app.document.bundle().clone(); + let context = ( + app.selected, + app.selected_node, + app.active_diagram, + app.pan, + app.zoom, + ); + let content = || -> Element<'_, Message, Theme, ()> { + let field = text_input("Find", "Retained query 雨").on_input(|_| Message::Escape); + let tip = tooltip(field, text("Help"), tooltip::Position::Bottom) + .delay(std::time::Duration::ZERO); + root( + &app, + interaction::observe(tip.into(), app.source_symbols.raw_interaction.clone()), + ) + }; + let mut ui = UserInterface::build(content(), Size::new(400., 180.), Cache::new(), &mut ()); + let point = iced::Point::new(10., 10.); + let cursor = mouse::Cursor::Available(point); + assert!( + update( + &mut ui, + &[Event::Mouse(mouse::Event::CursorMoved { position: point })], + cursor + ) + .0 + .is_empty() + ); + let cache = ui.into_cache(); + let mut ui = UserInterface::build(content(), Size::new(400., 180.), cache, &mut ()); + ui.operate(&(), &mut FocusInputs); + let epoch = app.source_symbols.raw_interaction.load(Ordering::Acquire); + let event = key(Named::Escape); + let (mut messages, statuses) = update(&mut ui, std::slice::from_ref(&event), cursor); + assert_eq!(statuses, [iced::event::Status::Captured]); + subscriptions(&app, event, statuses[0], &mut messages); + assert_eq!(messages.len(), 1); + assert!(matches!(messages[0], Message::Escape)); + assert!(!interaction::is_current( + &app.source_symbols.raw_interaction, + epoch + )); + drop(ui); + let _ = app.update(messages.remove(0)); + assert!(!app.source_navigation.open); + assert_eq!(app.project(), &project); + assert_eq!(app.document.bundle(), &source); + assert_eq!( + ( + app.selected, + app.selected_node, + app.active_diagram, + app.pan, + app.zoom + ), + context + ); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn toggling_bridge_policy_retains_focused_child_state() { + let mut app = App::with_project(Project::empty()); + let content = || -> Element<'static, Message, Theme, ()> { + text_input("Raw field", "Still focused") + .on_input(|_| Message::Escape) + .into() + }; + let mut ui = UserInterface::build( + root(&app, content()), + Size::new(400., 100.), + Cache::new(), + &mut (), + ); + ui.operate(&(), &mut FocusInputs); + let cache = ui.into_cache(); + app.page = Page::Text; + app.source_navigation.open = true; + let mut ui = UserInterface::build(root(&app, content()), Size::new(400., 100.), cache, &mut ()); + let (messages, status) = update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable); + assert_eq!(status, [iced::event::Status::Captured]); + assert_eq!(messages.len(), 1); + assert!(matches!(messages[0], Message::Escape)); +} + +#[tokio::test] +async fn mounted_creation_name_escape_keeps_pending_modal_priority() { + use crate::behavior::canvas_workspace::tests::Mounted; + use crate::behavior::{Event as BehaviorEvent, creation}; + for pending in [false, true] { + let mut app = App::with_project(Project::empty()); + for event in [ + creation::Event::Open(Layer::Oa), + creation::Event::Choose(creation::Family::Sequence), + creation::Event::Name("Exact unfinished process 雨".into()), + ] { + let _ = app.update(Message::Behavior(BehaviorEvent::Creation(event))); + } + app.page = Page::Behavior; + let mut mounted = Mounted::new(&mut app).await; + mounted + .perform( + &mut app, + iced::widget::operation::focus(creation::NAME_INPUT), + ) + .await; + if pending { + app.pending = Some(super::super::Pending::Walkthrough); + } + let before = app.document.snapshot(); + let draft = app.behavior.recovery_snapshot().unwrap(); + let event = key(Named::Escape); + let (mut messages, statuses) = mounted.input_with_status( + &app, + std::slice::from_ref(&event), + mouse::Cursor::Unavailable, + ); + subscriptions(&app, event, statuses[0], &mut messages); + assert_eq!( + messages + .iter() + .filter(|message| matches!(message, Message::Escape)) + .count(), + 1 + ); + for message in messages { + let _ = app.update(message); + } + if pending { + assert!(app.pending.is_none()); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), draft); + } else { + assert!(!app.behavior.creation.has_pending()); + } + assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} diff --git a/crates/oa-desktop/src/app/source_rename/tests.rs b/crates/oa-desktop/src/app/source_rename/tests.rs index afc3fca7..49280a6d 100644 --- a/crates/oa-desktop/src/app/source_rename/tests.rs +++ b/crates/oa-desktop/src/app/source_rename/tests.rs @@ -754,76 +754,7 @@ fn invalid_open_retains_accepted_document_but_refuses_alias_rename() { } fn captured_escape(app: &App) -> Vec { - use iced::advanced::{Layout, Shell, layout, subscription, widget}; - use iced::futures::{StreamExt, stream, task::noop_waker_ref}; - use iced::keyboard::{self, key::Named}; - use std::task::{Context as PollContext, Poll}; - let mut input: iced::Element<'_, (), iced::Theme, ()> = - iced::widget::text_input("Source alias", "retained_alias") - .on_input(|_| ()) - .into(); - let mut tree = widget::Tree::new(input.as_widget()); - let size = iced::Size::new(300., 40.); - let node = - input - .as_widget_mut() - .layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size)); - tree.state - .downcast_mut::>() - .focus(); - let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { - key: keyboard::Key::Named(Named::Escape), - modified_key: keyboard::Key::Named(Named::Escape), - physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape), - location: keyboard::Location::Standard, - modifiers: keyboard::Modifiers::empty(), - text: None, - repeat: false, - }); - let mut widget_messages = Vec::new(); - let mut shell = Shell::new(&mut widget_messages); - input.as_widget_mut().update( - &mut tree, - &event, - Layout::new(&node), - iced::mouse::Cursor::Unavailable, - &(), - &mut iced::advanced::clipboard::Null, - &mut shell, - &iced::Rectangle::new(iced::Point::ORIGIN, size), - ); - assert!(shell.is_event_captured()); - let event = subscription::Event::Interaction { - window: iced::window::Id::unique(), - event, - status: iced::event::Status::Captured, - }; - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_time() - .build() - .unwrap(); - let _entered = runtime.enter(); - let mut messages = Vec::new(); - let mut keyboard_boundaries = 0; - for recipe in subscription::into_recipes(app.subscription()) { - let mut output = recipe.stream(Box::pin(stream::iter([event.clone()]))); - let mut context = PollContext::from_waker(noop_waker_ref()); - while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) { - if matches!( - &message, - Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary) - ) { - keyboard_boundaries += 1; - } else { - messages.push(message); - } - } - } - assert_eq!( - keyboard_boundaries, 1, - "each key has exactly one passive allocation notification" - ); - messages + crate::app::escape::tests::focused_input_messages(app) } #[test] diff --git a/crates/oa-desktop/src/app/source_symbols/tests.rs b/crates/oa-desktop/src/app/source_symbols/tests.rs index 3bab7195..866e465f 100644 --- a/crates/oa-desktop/src/app/source_symbols/tests.rs +++ b/crates/oa-desktop/src/app/source_symbols/tests.rs @@ -864,99 +864,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec { } fn input_subscription_messages(app: &App, focused: bool) -> Vec { - use iced::advanced::{Layout, Shell, layout, subscription, widget}; - use iced::futures::{StreamExt, stream, task::noop_waker_ref}; - use iced::keyboard::{self, key::Named}; - use std::task::{Context, Poll}; - - let mut input: iced::Element<'_, (), iced::Theme, ()> = - iced::widget::text_input("Find / modal input", "Retain exact text 雨") - .on_input(|_| ()) - .into(); - let mut tree = widget::Tree::new(input.as_widget()); - let size = iced::Size::new(300., 40.); - let node = - input - .as_widget_mut() - .layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size)); - if focused { - tree.state - .downcast_mut::>() - .focus(); - } - let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { - key: keyboard::Key::Named(Named::Escape), - modified_key: keyboard::Key::Named(Named::Escape), - physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape), - location: keyboard::Location::Standard, - modifiers: keyboard::Modifiers::empty(), - text: None, - repeat: false, - }); - let mut input_messages = Vec::new(); - let mut shell = Shell::new(&mut input_messages); - input.as_widget_mut().update( - &mut tree, - &event, - Layout::new(&node), - iced::mouse::Cursor::Unavailable, - &(), - &mut iced::advanced::clipboard::Null, - &mut shell, - &iced::Rectangle::new(iced::Point::ORIGIN, size), - ); - let captured = shell.is_event_captured(); - assert_eq!(captured, focused); - assert!(input_messages.is_empty()); - assert!( - !tree - .state - .downcast_ref::>() - .is_focused() - ); - let event = subscription::Event::Interaction { - window: iced::window::Id::unique(), - event, - status: if captured { - iced::event::Status::Captured - } else { - iced::event::Status::Ignored - }, - }; - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_time() - .build() - .unwrap(); - let _entered = runtime.enter(); - let mut messages = Vec::new(); - let mut keyboard_boundaries = 0; - for recipe in subscription::into_recipes(app.subscription()) { - let mut output = recipe.stream(Box::pin(stream::iter([event.clone()]))); - let mut context = Context::from_waker(noop_waker_ref()); - while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) { - assert!( - !matches!(message, Message::CheckpointTick), - "timer must remain pending" - ); - if matches!( - &message, - Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary) - ) { - keyboard_boundaries += 1; - } else { - messages.push(message); - } - } - } - assert_eq!( - keyboard_boundaries, 1, - "each key has exactly one passive allocation notification" - ); - messages + crate::app::escape::tests::input_messages(app, iced::keyboard::key::Named::Escape, focused) } #[test] -fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pending_work() { +fn captured_find_and_modal_escape_use_one_actual_delivery_and_preserve_pending_work() { for pending in [false, true] { for modal in 0..4 { let mut app = fixture(); @@ -1053,7 +965,7 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi } #[test] -fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_routes() { +fn captured_delivery_does_not_duplicate_existing_behavior_or_architecture_routes() { let mut app = fixture(); app.text_workspace.show_drawer(Some(DrawerTab::References)); let project = app.project().clone(); diff --git a/crates/oa-desktop/src/app/state_diagram_tests.rs b/crates/oa-desktop/src/app/state_diagram_tests.rs index ba4f9aae..c25de676 100644 --- a/crates/oa-desktop/src/app/state_diagram_tests.rs +++ b/crates/oa-desktop/src/app/state_diagram_tests.rs @@ -4,6 +4,8 @@ use super::*; mod attachment_escape; #[path = "state_bend_tests.rs"] mod bends; +#[path = "state_picker_escape_tests.rs"] +mod picker_escape; use crate::behavior::{ self, state_edit::{self, Context, Endpoint, Event, Tool}, diff --git a/crates/oa-desktop/src/app/state_picker_escape_tests.rs b/crates/oa-desktop/src/app/state_picker_escape_tests.rs new file mode 100644 index 00000000..63cbfcaf --- /dev/null +++ b/crates/oa-desktop/src/app/state_picker_escape_tests.rs @@ -0,0 +1,105 @@ +//! The actual State workspace picker, App root and subscriptions. +use super::*; +use crate::behavior::canvas_workspace::tests::{Mounted, key}; +use iced::{keyboard::key::Named, mouse}; + +async fn press_escape(mounted: &mut Mounted, app: &mut App) -> Vec { + let input = key(Named::Escape); + let (mut messages, statuses) = mounted.input_with_status( + app, + std::slice::from_ref(&input), + mouse::Cursor::Unavailable, + ); + crate::app::escape::tests::subscriptions(app, input, statuses[0], &mut messages); + for message in messages.iter().cloned() { + let task = app.update(message); + mounted.perform(app, task).await; + } + mounted.settle(app).await; + messages +} + +#[tokio::test] +async fn mounted_state_missing_picker_escape_preserves_exact_document_then_reopens_and_places() { + let mut f = fixture(); + let diagram = create_view(&mut f).diagram; + let mut project = f.app.project().clone(); + project + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .retain(|_, node| node.element != f.b); + let mut app = App::with_project(project); + app.window_size = iced::Size::new(1800., 1100.); + send(&mut app, Message::Select(f.a)); + send(&mut app, Message::Page(Page::Behavior)); + let choice = Choice { + id: diagram, + label: app.project().diagrams[&diagram].name.clone(), + }; + send(&mut app, Message::BehaviorDiagram(choice)); + let mut mounted = Mounted::new(&mut app).await; + assert_eq!(state_edit::unplaced(app.project(), diagram), vec![f.b]); + let focus_id = iced::advanced::widget::Id::from("diagram-keyboard-owner"); + let (_, canvas, _) = mounted + .geometry(&app) + .focused + .into_iter() + .find(|(id, _, _)| id.as_ref() == Some(&focus_id)) + .unwrap(); + // The existing picker is immediately above the measured canvas, separated + // by 8px. Its 12px text and 8px padding give a 31.6px row. + let picker = iced::Point::new(canvas.center_x(), canvas.y - 24.); + let option = iced::Point::new(picker.x, canvas.y + 8.); + let before = app.document.snapshot(); + let original = app.project().clone(); + let source = app.document.bundle().clone(); + let selection = ( + app.selected, + app.selected_node, + app.behavior.diagram.placement, + ); + let camera = ( + app.behavior.diagram.zoom, + app.behavior.diagram.pan, + app.behavior.diagram.fit, + ); + let revision = app.document.revision(); + let history = (app.document.can_undo(), app.document.can_redo()); + mounted.click(&mut app, picker).await; + assert!( + press_escape(&mut mounted, &mut app).await.is_empty(), + "open picker owns Escape, not the State canvas or App" + ); + assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &source); + assert_eq!( + ( + app.selected, + app.selected_node, + app.behavior.diagram.placement + ), + selection + ); + assert_eq!( + ( + app.behavior.diagram.zoom, + app.behavior.diagram.pan, + app.behavior.diagram.fit + ), + camera + ); + assert_eq!(app.document.revision(), revision); + assert_eq!((app.document.can_undo(), app.document.can_redo()), history); + // If Escape left the menu open this click would close it, and the following + // option click would fail to place. This checks actual overlay dismissal. + mounted.click(&mut app, picker).await; + let messages = mounted.click(&mut app, option).await; + assert!(messages.iter().any(|message| matches!(message, Message::StateDiagram(Event::Place { element, .. }) if *element == f.b))); + assert!(state_edit::unplaced(app.project(), diagram).is_empty()); + assert_eq!(app.project().elements, original.elements); + assert_eq!(app.project().relations, original.relations); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &source); +} diff --git a/crates/oa-desktop/src/app/traceability_navigation_tests.rs b/crates/oa-desktop/src/app/traceability_navigation_tests.rs new file mode 100644 index 00000000..e9558874 --- /dev/null +++ b/crates/oa-desktop/src/app/traceability_navigation_tests.rs @@ -0,0 +1,277 @@ +use super::*; +use crate::{context_menu::Entry, model_menu, views::traceability::Filter}; + +fn send(app: &mut App, message: Message) { + let _ = app.update(message); +} + +fn fixture() -> (App, Id, Id, Id, Id) { + let mut p = Project::empty(); + let oa = p + .add_element(Kind::Activity, Layer::Oa, "Observe", None) + .unwrap(); + let sa = p + .add_element(Kind::Function, Layer::Sa, "Detect", None) + .unwrap(); + let mixed = p.ensure_diagram(Layer::Oa); + p.diagrams.get_mut(&mixed).unwrap().projection = ProjectionKind::Traceability; + p.place(mixed, oa, Position { x: 300., y: 200. }); + let system = p.ensure_diagram(Layer::Sa); + p.place(system, sa, Position { x: 700., y: 200. }); + oa_core::validate_candidate(&p).unwrap(); + let mut app = App::with_project(p); + app.page = Page::Diagram; + app.layer = Layer::Oa; + app.active_diagram = Some(mixed); + app.selected = oa; + app.refresh_draft(); + app.traceability.filter = Filter::Artifacts; + app.traceability.query = "stale artifact query".into(); + (app, mixed, system, oa, sa) +} + +fn show_action(app: &mut App, element: Id) -> Message { + send(app, model_menu::open(element, iced::Point::ORIGIN)); + model_menu::popup(app) + .unwrap() + .entries + .into_iter() + .find_map(|entry| match entry { + Entry::Action { label, message, .. } if label == "Show in this diagram" => { + Some(message) + } + _ => None, + }) + .expect("eligible real menu action") + .expect("eligible menu action must be enabled") +} + +#[test] +fn review_layer_traces_resets_inventory_without_applying_property_or_source_drafts() { + for source_draft in [false, true] { + let (mut app, mixed, _, oa, _) = fixture(); + if source_draft { + send(&mut app, Message::Page(Page::Text)); + send( + &mut app, + Message::SourceEdit(iced::widget::text_editor::Action::Edit( + iced::widget::text_editor::Edit::Insert('x'), + )), + ); + assert!(app.source_dirty()); + } else { + send( + &mut app, + Message::Field("/alias".into(), "invalid alias".into()), + ); + assert!(app.draft_dirty()); + } + let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + let source = app.source_editor.text(); + let before = app.document.snapshot(); + let camera = (app.zoom, app.pan); + send(&mut app, Message::ReviewLayerTraces); + assert_eq!(app.page, Page::Traces); + assert_eq!(app.traceability.filter, Filter::Traces); + assert!(app.traceability.query.is_empty()); + assert_eq!( + (app.layer, app.active_diagram, app.selected), + (Layer::Oa, Some(mixed), oa) + ); + assert_eq!((app.zoom, app.pan), camera); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + assert_eq!(app.source_editor.text(), source); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert!(!app.document.can_undo()); + } +} + +#[test] +fn review_layer_traces_respects_busy_modal_and_active_placement_guards() { + for already_traces in [false, true] { + for guard in 0..5 { + let (mut app, mixed, _, oa, _) = fixture(); + match guard { + 0 => app.busy = true, + 1 => app.delete_pending = true, + 2 => app.hosted.visible = true, + 3 => send( + &mut app, + Message::Architecture(architecture_authoring::Event::Existing(None)), + ), + 4 => { + app.page = Page::Behavior; + let p = app.project().clone(); + app.behavior + .creation + .update(&p, behavior::creation::Event::Open(Layer::Oa)) + .unwrap(); + assert!(app.behavior.has_pending()); + } + _ => unreachable!(), + } + if already_traces { + app.page = Page::Traces; + } + let original_page = app.page; + let before = app.document.snapshot(); + let behavior_before = app.behavior.recovery_snapshot().unwrap(); + send(&mut app, Message::ReviewLayerTraces); + assert_eq!( + app.page, original_page, + "guard {guard}, already traces {already_traces}" + ); + assert_eq!(app.traceability.filter, Filter::Artifacts); + assert_eq!(app.traceability.query, "stale artifact query"); + assert_eq!( + (app.layer, app.active_diagram, app.selected), + (Layer::Oa, Some(mixed), oa) + ); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), behavior_before); + if guard == 3 { + assert!(app.architecture.is_some()); + } + } + } +} + +#[test] +fn mixed_view_show_keeps_its_context_for_every_diagram_app_and_target_layer() { + for diagram_layer in [Layer::Oa, Layer::Sa] { + for app_layer in [Layer::Oa, Layer::Sa] { + for target_layer in [Layer::Oa, Layer::Sa] { + let mut p = Project::empty(); + let oa = p + .add_element(Kind::Activity, Layer::Oa, "Observe", None) + .unwrap(); + let sa = p + .add_element(Kind::Function, Layer::Sa, "Detect", None) + .unwrap(); + let target = if target_layer == Layer::Oa { oa } else { sa }; + let other = if target_layer == Layer::Oa { sa } else { oa }; + let mixed = p.ensure_diagram(diagram_layer); + p.diagrams.get_mut(&mixed).unwrap().projection = ProjectionKind::Traceability; + // An attractive same-layer alternative makes accidental + // semantic-selection navigation observable in both directions. + let mut alternative = p.diagrams[&mixed].clone(); + alternative.id = Id::new_v4(); + alternative.alias = "same_layer_alternative".into(); + alternative.layer = target_layer; + alternative.projection = ProjectionKind::Overview; + let alternative_id = alternative.id; + p.diagrams.insert(alternative_id, alternative); + p.place(alternative_id, target, Position { x: 700., y: 200. }); + oa_core::validate_candidate(&p).unwrap(); + let mut app = App::with_project(p); + app.page = Page::Diagram; + app.layer = app_layer; + app.active_diagram = Some(mixed); + app.selected = other; + app.refresh_draft(); + let before = app.project().clone(); + let action = show_action(&mut app, target); + send(&mut app, action); + // The existing RevealInDiagram action synchronizes the layer + // to the captured view, even if the prior perspective differed. + assert_eq!( + (app.active_diagram, app.layer, app.selected), + (Some(mixed), diagram_layer, target) + ); + let added = app.project().diagrams[&mixed] + .nodes + .values() + .find(|n| n.element == target) + .unwrap() + .clone(); + assert_eq!(app.selected_node, Some(added.id)); + let mut expected = before; + expected + .diagrams + .get_mut(&mixed) + .unwrap() + .nodes + .insert(added.id, added); + assert_eq!(app.project(), &expected); + } + } + } +} + +#[test] +fn mixed_view_show_menu_places_in_captured_view_with_exact_undo_redo() { + let (mut app, mixed, system, _, sa) = fixture(); + let before = app.document.snapshot(); + let action = show_action(&mut app, sa); + send(&mut app, action); + assert_eq!( + (app.active_diagram, app.layer, app.selected), + (Some(mixed), Layer::Oa, sa) + ); + let added = app.project().diagrams[&mixed] + .nodes + .values() + .find(|n| n.element == sa) + .unwrap() + .clone(); + assert_eq!(added.position, Position { x: 60., y: 60. }); + let mut expected = before.decoded.project.clone(); + expected + .diagrams + .get_mut(&mixed) + .unwrap() + .nodes + .insert(added.id, added); + assert_eq!(app.project(), &expected); + assert_eq!( + app.project().diagrams[&system], + before.decoded.project.diagrams[&system] + ); + let after = app.document.snapshot(); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo()); + send(&mut app, Message::Redo); + assert_eq!(app.project(), &after.decoded.project); + assert_eq!(app.document.bundle(), &after.bundle); +} + +#[test] +fn mixed_view_show_menu_rejects_stale_context_and_pending_property_changes() { + for refusal in 0..3 { + let (mut app, mixed, system, oa, sa) = fixture(); + let action = show_action(&mut app, sa); + match refusal { + 0 => app.active_diagram = Some(system), + 1 => app.canvas_epoch = app.canvas_epoch.wrapping_add(1), + 2 => { + // A queued action must not apply a draft that became dirty + // after the menu was rendered, even when its context is valid. + app.draft + .as_mut() + .unwrap() + .input("/name", "Unapplied name".into()); + assert!(app.draft_dirty()); + } + _ => unreachable!(), + } + let before = app.document.snapshot(); + let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + send(&mut app, action); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.selected, oa); + assert_eq!( + app.active_diagram, + Some(if refusal == 0 { system } else { mixed }) + ); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + assert!(!app.document.can_undo()); + } +} diff --git a/crates/oa-desktop/src/behavior/creation.rs b/crates/oa-desktop/src/behavior/creation.rs index 6940c7b9..60af0d2c 100644 --- a/crates/oa-desktop/src/behavior/creation.rs +++ b/crates/oa-desktop/src/behavior/creation.rs @@ -10,32 +10,6 @@ use serde::{Deserialize, Serialize}; const PAGE_SIZE: usize = 6; pub const NAME_INPUT: &str = "behavior-creation-name"; -// A focused text input consumes Escape. Preserve the application's modal -// priority by emitting its ordinary Escape message from this narrow bridge. -pub fn subscription(active: bool) -> iced::Subscription<()> { - if active { - iced::event::listen_with(captured_escape) - } else { - iced::Subscription::none() - } -} - -fn captured_escape( - event: iced::Event, - status: iced::event::Status, - _: iced::window::Id, -) -> Option<()> { - (status == iced::event::Status::Captured - && matches!( - event, - iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { - key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), - .. - }) - )) - .then_some(()) -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Family { Sequence, diff --git a/crates/oa-desktop/src/behavior/creation/app_tests.rs b/crates/oa-desktop/src/behavior/creation/app_tests.rs index 06db4c0c..b7652456 100644 --- a/crates/oa-desktop/src/behavior/creation/app_tests.rs +++ b/crates/oa-desktop/src/behavior/creation/app_tests.rs @@ -18,59 +18,11 @@ fn begin(app: &mut App, layer: Layer, family: Family, name: &str) { send(app, Event::Name(name.into())); } -fn focused_escape() -> Message { - use iced::advanced::{Layout, Shell, layout, widget}; - use iced::keyboard::{self, key::Named}; - let mut input: iced::Element<'_, (), iced::Theme, ()> = text_input("Name", "Unfinished name") - .id(NAME_INPUT) - .on_input(|_| ()) - .into(); - let mut tree = widget::Tree::new(input.as_widget()); - let size = iced::Size::new(300., 40.); - let node = - input - .as_widget_mut() - .layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size)); - tree.state - .downcast_mut::>() - .focus(); - let event = iced::Event::Keyboard(keyboard::Event::KeyPressed { - key: keyboard::Key::Named(Named::Escape), - modified_key: keyboard::Key::Named(Named::Escape), - physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape), - location: keyboard::Location::Standard, - modifiers: keyboard::Modifiers::empty(), - text: None, - repeat: false, - }); - let mut messages = Vec::new(); - let mut shell = Shell::new(&mut messages); - input.as_widget_mut().update( - &mut tree, - &event, - Layout::new(&node), - iced::mouse::Cursor::Unavailable, - &(), - &mut iced::advanced::clipboard::Null, - &mut shell, - &iced::Rectangle::new(iced::Point::ORIGIN, size), - ); - assert!(shell.is_event_captured()); - assert_eq!( - captured_escape( - event.clone(), - iced::event::Status::Ignored, - iced::window::Id::unique() - ), - None - ); - captured_escape( - event, - iced::event::Status::Captured, - iced::window::Id::unique(), - ) - .unwrap(); - Message::Escape +fn focused_escape(app: &App) -> Message { + let mut messages = crate::app::escape::tests::focused_input_messages(app); + assert_eq!(messages.len(), 1); + assert!(matches!(messages[0], Message::Escape)); + messages.remove(0) } #[test] @@ -94,12 +46,12 @@ fn app_first_captured_escape_dismisses_file_or_pending_overlay_before_creation() } else { app.pending = Some(Pending::Walkthrough); } - let _ = app.update(focused_escape()); + let _ = app.update(focused_escape(&app)); assert!(app.file_prompt.is_none()); assert!(app.pending.is_none()); assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); - let _ = app.update(focused_escape()); + let _ = app.update(focused_escape(&app)); assert!(!app.behavior.has_pending()); assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); assert!(!app.document.can_undo()); diff --git a/crates/oa-desktop/src/connection_allocation_tests.rs b/crates/oa-desktop/src/connection_allocation_tests.rs new file mode 100644 index 00000000..2f8c93e6 --- /dev/null +++ b/crates/oa-desktop/src/connection_allocation_tests.rs @@ -0,0 +1,480 @@ +// Included in connection_edit::tests: exercise the real application event path. +use oa_core::projection::project_view; + +fn allocation_fixture(layer: Layer, projection: ProjectionKind) -> (App, Id, Id, Id) { + let mut project = Project::empty(); + let (performer_kind, work_kind) = match layer { + Layer::Oa => (Kind::Entity, Kind::Activity), + Layer::Sa => (Kind::System, Kind::Function), + }; + let performer = project.add(performer_kind, "Assigned performer", 20., 20.); + let work = project.add(work_kind, "Existing leaf work", 420., 20.); + let diagram = project.ensure_diagram(layer); + project.diagrams.get_mut(&diagram).unwrap().projection = projection; + let reference_id = Id::new_v4(); + let mut reference = project.diagrams[&diagram].clone(); + reference.id = reference_id; + reference.alias = project.fresh_alias("Reference view", "diagram", reference_id); + reference.name = "Reference view".into(); + reference.nodes.clear(); + project.diagrams.insert(reference_id, reference); + project.place(reference_id, performer, Position { x: 80., y: 100. }); + project.place(reference_id, work, Position { x: 500., y: 300. }); + let mut app = App::with_project(project); + let _ = app.update(Message::Diagram(crate::app::Choice { + id: diagram, + label: app.project().diagrams[&diagram].name.clone(), + })); + (app, performer, work, diagram) +} + +fn start_allocation(app: &mut App, performer: Id) -> Context { + send(app, Event::Choose(Some(Tool::Allocation))); + let context = app.route_context().unwrap(); + assert!( + app.connection + .as_ref() + .unwrap() + .candidates + .contains(&performer) + ); + send(app, Event::Endpoint(context, performer)); + assert_eq!(app.connection.as_ref().unwrap().source, Some(performer)); + context +} + +#[test] +fn allocation_projection_eligibility_requires_both_endpoint_categories() { + for layer in [Layer::Oa, Layer::Sa] { + for projection in [ + ProjectionKind::Overview, + ProjectionKind::Decomposition, + ProjectionKind::Traceability, + ProjectionKind::Allocation, + ] { + assert!(Tool::Allocation.available_in(layer, projection)); + } + for projection in [ + ProjectionKind::FunctionalDataflow, + ProjectionKind::Capabilities, + ProjectionKind::StateMachine { + machine: Id::new_v4(), + }, + ] { + assert!(!Tool::Allocation.available_in(layer, projection)); + } + for projection in [ + ProjectionKind::FunctionalDataflow, + ProjectionKind::Capabilities, + ] { + let (mut app, _, _, _) = allocation_fixture(layer, projection); + let before = app.document.bundle().clone(); + send(&mut app, Event::Choose(Some(Tool::Allocation))); + assert!(app.connection.is_none()); + assert_eq!(app.document.bundle(), &before); + } + } + // The new predicate retains the four established exchange-tool policies. + assert!(Tool::Interaction.available_in(Layer::Oa, ProjectionKind::FunctionalDataflow)); + assert!(Tool::FunctionalExchange.available_in(Layer::Sa, ProjectionKind::FunctionalDataflow)); + assert!(Tool::CommunicationMean.available_in(Layer::Oa, ProjectionKind::Capabilities)); + assert!(Tool::BoundaryExchange.available_in(Layer::Sa, ProjectionKind::Capabilities)); + assert!(!Tool::FunctionalExchange.available_in(Layer::Oa, ProjectionKind::Overview)); +} + +#[test] +fn allocation_canvas_commits_only_one_relation_with_exact_source_undo_redo() { + use oa_core::source_project::{ProjectLimits, compile_bundle}; + for layer in [Layer::Oa, Layer::Sa] { + for projection in [ + ProjectionKind::Overview, + ProjectionKind::Decomposition, + ProjectionKind::Traceability, + ProjectionKind::Allocation, + ] { + let (mut app, performer, work, diagram) = allocation_fixture(layer, projection); + let original = app.project().clone(); + let before = app.document.bundle().clone(); + let revision = app.document.revision(); + let context = start_allocation(&mut app, performer); + assert_eq!(app.document.bundle(), &before); + send(&mut app, Event::Endpoint(context, work)); + assert!(app.connection.is_none()); + assert_eq!(app.document.revision(), revision + 1); + let relation = &app.project().relations[&app.selected]; + let name = "Allocation: Assigned performer → Existing leaf work"; + let expected_relation = Relation { + id: relation.id, + alias: original.fresh_alias(name, "relation", relation.id), + name: name.into(), + description: String::new(), + body: RelationBody::new(RelationKind::Allocation, performer, work), + }; + let relation_id = relation.id; + let mut expected = original.clone(); + expected.relations.insert(relation_id, expected_relation); + assert_eq!(app.project(), &expected, "Only the new relation may change"); + assert_eq!(app.active_diagram, Some(diagram)); + assert!( + app.status + .contains("Allocated Existing leaf work to Assigned performer") + ); + let projected = project_view(app.project(), diagram); + if projection == ProjectionKind::Allocation { + assert!(!projected.edges.iter().any(|edge| edge.id == relation_id)); + let owner = projected + .nodes + .iter() + .find(|node| node.element == performer) + .unwrap(); + let child = projected + .nodes + .iter() + .find(|node| node.element == work) + .unwrap(); + assert_eq!(child.visual_parent, Some(owner.id)); + } else { + assert!(projected.edges.iter().any(|edge| edge.id == relation_id)); + } + let after = app.document.bundle().clone(); + assert_eq!( + compile_bundle(&after, ProjectLimits::default()) + .unwrap() + .decoded + .project, + expected + ); + send(&mut app, Event::Endpoint(context, work)); + assert_eq!( + app.document.revision(), + revision + 1, + "Repeated release is not another transaction" + ); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &original); + assert_eq!(app.document.bundle(), &before); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected); + assert_eq!(app.document.bundle(), &after); + } + } +} + +#[test] +fn allocation_candidates_reject_nonleaf_occupied_cross_layer_port_and_hidden_work() { + for layer in [Layer::Oa, Layer::Sa] { + let (base, performer, work, diagram) = + allocation_fixture(layer, ProjectionKind::Traceability); + let mut project = base.project().clone(); + let work_kind = project.elements[&work].kind(); + let composite = project + .add_element(work_kind, layer, "Composite work", None) + .unwrap(); + project + .add_element(work_kind, layer, "Child work", Some(composite)) + .unwrap(); + let occupied = project + .add_element(work_kind, layer, "Occupied work", None) + .unwrap(); + let allocation = Tool::Allocation + .command(&project, performer, occupied) + .unwrap(); + project = prepare_command(&project, &allocation).unwrap().project; + let hidden = project + .add_element(work_kind, layer, "Unplaced work", None) + .unwrap(); + let other_layer = if layer == Layer::Oa { + Layer::Sa + } else { + Layer::Oa + }; + let other_kind = if other_layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let foreign = project + .add_element(other_kind, other_layer, "Other perspective", None) + .unwrap(); + let foreign_performer_kind = if other_layer == Layer::Oa { + Kind::Actor + } else { + Kind::SystemActor + }; + let foreign_performer = project + .add_element(foreign_performer_kind, other_layer, "Other performer", None) + .unwrap(); + for (index, id) in [composite, occupied, foreign, foreign_performer] + .into_iter() + .enumerate() + { + project.place( + diagram, + id, + Position { + x: index as f32 * 300., + y: 400., + }, + ); + } + let port_owner = if layer == Layer::Sa { work } else { foreign }; + let port_id = Id::new_v4(); + let port = make_port( + &project, + port_id, + port_owner, + "Work input", + PortContract::FunctionInput { + incoming_items: vec![], + }, + ) + .unwrap(); + project.elements.insert(port_id, port); + let mut app = App::with_project(project); + let _ = app.update(Message::Diagram(crate::app::Choice { + id: diagram, + label: app.project().diagrams[&diagram].name.clone(), + })); + let before = app.document.bundle().clone(); + let original = app.project().clone(); + let revision = app.document.revision(); + send(&mut app, Event::Choose(Some(Tool::Allocation))); + assert!( + app.connection + .as_ref() + .unwrap() + .candidates + .contains(&foreign_performer) + ); + let context = app.route_context().unwrap(); + send(&mut app, Event::Endpoint(context, performer)); + assert!(app.connection.as_ref().unwrap().candidates.contains(&work)); + assert!(Tool::Allocation.can_connect(app.project(), performer, hidden)); + for target in [composite, occupied, foreign, port_id, hidden, performer] { + assert!( + !app.connection + .as_ref() + .unwrap() + .candidates + .contains(&target) + ); + send(&mut app, Event::Endpoint(context, target)); + assert_eq!(app.document.revision(), revision); + assert_eq!(app.project(), &original); + assert_eq!(app.document.bundle(), &before); + } + for target in [composite, occupied, foreign, port_id, performer] { + assert!( + Tool::Allocation + .command(app.project(), performer, target) + .is_err() + ); + } + send(&mut app, Event::Endpoint(context, work)); + assert_eq!( + app.document.revision(), + revision + 1, + "Invalid clicks do not prevent recovery" + ); + } +} + +#[test] +fn allocation_creation_preserves_saved_edge_filters() { + let (base, performer, work, diagram) = + allocation_fixture(Layer::Oa, ProjectionKind::Decomposition); + let mut project = base.project().clone(); + project + .diagrams + .get_mut(&diagram) + .unwrap() + .view_options + .relation_kinds = oa_core::view_options::RelationKinds::Only(vec![]); + let diagrams = project.diagrams.clone(); + let mut app = App::with_project(project); + let _ = app.update(Message::Diagram(crate::app::Choice { + id: diagram, + label: app.project().diagrams[&diagram].name.clone(), + })); + let context = start_allocation(&mut app, performer); + send(&mut app, Event::Endpoint(context, work)); + assert_eq!( + app.project().relations[&app.selected].kind(), + RelationKind::Allocation + ); + assert_eq!(app.project().diagrams, diagrams); + assert!(project_view(app.project(), diagram).edges.is_empty()); + assert!(app.status.contains("view filters are unchanged")); +} + +#[test] +fn allocation_mixed_view_keeps_captured_canvas_for_opposite_layer_pairs() { + for owner_layer in [Layer::Oa, Layer::Sa] { + for app_layer in [Layer::Oa, Layer::Sa] { + let (base, owner_performer, owner_work, diagram) = + allocation_fixture(owner_layer, ProjectionKind::Traceability); + let mut project = base.project().clone(); + let layer = if owner_layer == Layer::Oa { + Layer::Sa + } else { + Layer::Oa + }; + let (performer_kind, work_kind) = if layer == Layer::Oa { + (Kind::Actor, Kind::Activity) + } else { + (Kind::SystemActor, Kind::Function) + }; + let performer = project.add(performer_kind, "Opposite-layer performer", 40., 60.); + let work = project.add(work_kind, "Opposite-layer work", 420., 60.); + project.place(diagram, performer, Position { x: 40., y: 350. }); + project.place(diagram, work, Position { x: 420., y: 350. }); + let mut app = App::with_project(project); + let _ = app.update(Message::Diagram(crate::app::Choice { + id: diagram, + label: app.project().diagrams[&diagram].name.clone(), + })); + let _ = app.update(Message::Pan(iced::Vector::new(73., 29.))); + app.layer = app_layer; + let camera = (app.pan, app.zoom); + let before = app.document.bundle().clone(); + let original = app.project().clone(); + let revision = app.document.revision(); + let cancelled = start_allocation(&mut app, performer); + let _ = app.update(Message::Escape); + send(&mut app, Event::Endpoint(cancelled, work)); + assert!(app.connection.is_none()); + assert_eq!(app.layer, app_layer); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!((app.pan, app.zoom), camera); + assert_eq!(app.document.bundle(), &before); + let context = start_allocation(&mut app, performer); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!(app.layer, app_layer); + assert_eq!(app.route_context(), Some(context)); + assert_eq!((app.pan, app.zoom), camera); + assert_eq!(app.selected, performer); + assert!(app.connection.as_ref().unwrap().candidates.contains(&work)); + for wrong_target in [owner_performer, owner_work] { + assert!( + !app.connection + .as_ref() + .unwrap() + .candidates + .contains(&wrong_target) + ); + send(&mut app, Event::Endpoint(context, wrong_target)); + assert_eq!(app.project(), &original); + assert_eq!(app.layer, app_layer); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!((app.pan, app.zoom), camera); + assert_eq!(app.document.bundle(), &before); + } + send(&mut app, Event::Endpoint(context, work)); + assert!(app.connection.is_none()); + assert_eq!(app.document.revision(), revision + 1); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!(app.layer, owner_layer); + assert_eq!((app.pan, app.zoom), camera); + let relation = &app.project().relations[&app.selected]; + assert_eq!( + relation.body, + RelationBody::new(RelationKind::Allocation, performer, work) + ); + let mut expected = original.clone(); + expected.relations.insert(relation.id, relation.clone()); + assert_eq!(app.project(), &expected); + let after = app.document.bundle().clone(); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &original); + assert_eq!(app.document.bundle(), &before); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!((app.pan, app.zoom), camera); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected); + assert_eq!(app.document.bundle(), &after); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!((app.pan, app.zoom), camera); + } + } +} + +#[test] +fn allocation_cancel_navigation_stale_context_and_pending_edits_preserve_model() { + let (mut app, performer, work, diagram) = + allocation_fixture(Layer::Oa, ProjectionKind::Decomposition); + let original = app.project().clone(); + let before = app.document.bundle().clone(); + let old = start_allocation(&mut app, performer); + let _ = app.update(Message::Escape); + assert!(app.connection.is_none()); + send(&mut app, Event::Endpoint(old, work)); + let current = start_allocation(&mut app, performer); + send(&mut app, Event::Endpoint(old, work)); + assert_eq!(app.connection.as_ref().unwrap().context, current); + let _ = app.update(Message::Page(Page::Catalog)); + assert!(app.connection.is_none()); + let _ = app.update(Message::Page(Page::Diagram)); + let _ = app.update(Message::Diagram(crate::app::Choice { + id: diagram, + label: app.project().diagrams[&diagram].name.clone(), + })); + start_allocation(&mut app, performer); + send(&mut app, Event::Choose(None)); + assert!(app.connection.is_none()); + start_allocation(&mut app, performer); + send(&mut app, Event::Choose(Some(Tool::Interaction))); + assert_eq!(app.connection.as_ref().unwrap().tool, Tool::Interaction); + assert_eq!(app.project(), &original); + let current = start_allocation(&mut app, performer); + for stale in [ + Context { + document: Id::new_v4(), + ..current + }, + Context { + diagram: Id::new_v4(), + ..current + }, + Context { + revision: current.revision + 1, + ..current + }, + ] { + send(&mut app, Event::Endpoint(stale, work)); + assert_eq!(app.connection.as_ref().unwrap().context, current); + } + app.delete_pending = true; + send(&mut app, Event::Endpoint(current, work)); + assert_eq!(app.project(), &original); + let _ = app.update(Message::CancelDelete); + let _ = app.update(Message::Escape); + let _ = app.update(Message::Select(performer)); + let _ = app.update(Message::Field( + "/name".into(), + "Unapplied performer name".into(), + )); + send(&mut app, Event::Choose(Some(Tool::Allocation))); + assert!(app.connection.is_none()); + assert!(app.draft_dirty()); + assert_eq!(app.project(), &original); + assert_eq!(app.document.bundle(), &before); + + let (mut source_app, performer, work, _) = + allocation_fixture(Layer::Sa, ProjectionKind::Decomposition); + let before = source_app.document.bundle().clone(); + let context = start_allocation(&mut source_app, performer); + use iced::widget::text_editor; + let _ = source_app.update(Message::SourceEdit(text_editor::Action::SelectAll)); + let _ = source_app.update(Message::SourceEdit(text_editor::Action::Edit( + text_editor::Edit::Paste(std::sync::Arc::new("invalid raw source retained".into())), + ))); + send(&mut source_app, Event::Endpoint(context, work)); + send(&mut source_app, Event::Choose(Some(Tool::Allocation))); + assert!(source_app.connection.is_none()); + assert!(source_app.source_dirty()); + assert_eq!( + source_app.source_editor.text(), + "invalid raw source retained" + ); + assert_eq!(source_app.document.bundle(), &before); +} diff --git a/crates/oa-desktop/src/connection_edit.rs b/crates/oa-desktop/src/connection_edit.rs index bd627fae..8166be23 100644 --- a/crates/oa-desktop/src/connection_edit.rs +++ b/crates/oa-desktop/src/connection_edit.rs @@ -52,6 +52,8 @@ mod tests { use crate::app::{App, Message, Page}; use oa_core::*; + include!("connection_allocation_tests.rs"); + fn fixture() -> (App, Id, Id, Id) { let mut p = Project::empty(); let a = p.add(Kind::Function, "Send command", 0., 0.); diff --git a/crates/oa-desktop/src/connection_tool.rs b/crates/oa-desktop/src/connection_tool.rs index 83392c75..10910961 100644 --- a/crates/oa-desktop/src/connection_tool.rs +++ b/crates/oa-desktop/src/connection_tool.rs @@ -9,13 +9,18 @@ pub enum Tool { CommunicationMean, FunctionalExchange, BoundaryExchange, + Allocation, } impl Tool { pub fn for_layer(layer: Layer) -> &'static [Self] { match layer { - Layer::Oa => &[Self::Interaction, Self::CommunicationMean], - Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange], + Layer::Oa => &[Self::Interaction, Self::CommunicationMean, Self::Allocation], + Layer::Sa => &[ + Self::FunctionalExchange, + Self::BoundaryExchange, + Self::Allocation, + ], } } @@ -25,11 +30,15 @@ impl Tool { Self::CommunicationMean => "Communication means", Self::FunctionalExchange => "Functional exchange", Self::BoundaryExchange => "Boundary exchange", + Self::Allocation => "Allocation", } } pub fn hint(self) -> &'static str { match self { + Self::Allocation => { + "Click a performer, then highlighted unallocated leaf work. In allocated-work views, allocation appears as nesting rather than a line." + } Self::Interaction => "Click a source activity, then a target activity.", Self::CommunicationMean => { "Click an operational entity or actor, then another participant. Communication is bidirectional unless you change its orientation." @@ -45,6 +54,10 @@ impl Tool { pub fn can_start(self, project: &Project, source: Id) -> bool { match self { + Self::Allocation => project + .elements + .get(&source) + .is_some_and(|e| e.kind().is_performer()), Self::CommunicationMean => project.elements.get(&source).is_some_and(|e| { e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor) }), @@ -65,6 +78,9 @@ impl Tool { return false; } match self { + Self::Allocation => { + validation::can_connect(project, RelationKind::Allocation, source, target) + } Self::CommunicationMean => { validation::can_connect(project, RelationKind::CommunicationMean, source, target) } @@ -140,6 +156,7 @@ impl Tool { } let name = engineering_name(project, self, source, target); let body = match self { + Self::Allocation => RelationBody::new(RelationKind::Allocation, source, target), Self::CommunicationMean => { RelationBody::new(RelationKind::CommunicationMean, source, target) } @@ -162,6 +179,28 @@ impl Tool { prepare_command(project, &command)?; Ok(command) } + + /// Both endpoint categories must be supported before offering Allocation. + /// Preserve the established projection eligibility of the exchange tools. + pub fn available_in(self, layer: Layer, projection: ProjectionKind) -> bool { + if !Self::for_layer(layer).contains(&self) { + return false; + } + let kind = match self { + Self::Interaction => Kind::Activity, + Self::CommunicationMean => Kind::Entity, + Self::FunctionalExchange => Kind::Function, + Self::BoundaryExchange => Kind::System, + Self::Allocation => { + let (performer, work) = match layer { + Layer::Oa => (Kind::Entity, Kind::Activity), + Layer::Sa => (Kind::System, Kind::Function), + }; + return projection.includes(performer) && projection.includes(work); + } + }; + projection.includes(kind) + } } /// A clicked port determines its own function owner. It cannot be paired with @@ -291,7 +330,7 @@ mod tests { let before = p.clone(); assert_eq!( Tool::for_layer(Layer::Oa), - &[Tool::Interaction, Tool::CommunicationMean] + &[Tool::Interaction, Tool::CommunicationMean, Tool::Allocation] ); assert_eq!( Tool::Interaction.targets(&p, source), @@ -592,6 +631,8 @@ mod tests { (Tool::CommunicationMean, oa_team, oa_actor), (Tool::FunctionalExchange, sa_a, sa_b), (Tool::BoundaryExchange, boundary_a, boundary_b), + (Tool::Allocation, oa_team, oa_a), + (Tool::Allocation, system, sa_a), ] { let before = document.bundle().clone(); let original = document.project().clone(); diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index b39aadf4..6f478f0f 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -263,7 +263,8 @@ fn article(topic: Topic) -> Article { &[ "Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.", "In the diagram's side palette, choose Activity in OA or Function in SA, click its position, enter a name and press Enter. Escape cancels without creating an element. Select a card and press F2 to rename it in place. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.", - "Right-click a participant and choose Add activity or Add function to create new work and its allocation together. In Architecture · allocated work, drag existing work into a performer to assign it, between performers to reassign it, or onto empty canvas to remove its direct assignment. + Relationship with Allocation and Model explorer's + Allocated provide explicit alternatives.", + "To allocate existing leaf work in Containment, choose Allocation in the diagram palette. Click a visible performer, then the highlighted Activity or Function it performs. The work must belong to the performer's layer and have no direct performer yet. In a mixed OA ↔ SA traceability view, either layer's performer can be used and the current view stays open. Completing the allocation sets the application perspective to that view's layer; choosing only a source or canceling leaves the prior perspective unchanged. Escape cancels. This creates a responsibility relationship without changing the work's semantic parent.", + "Right-click a participant and choose Add activity or Add function to create new work and its allocation together. In Architecture · allocated work, drag existing work into a performer to assign it, between performers to reassign it, or onto empty canvas to remove its direct assignment. Create relationship… with Allocation and Model explorer's + Allocated provide explicit alternatives.", "Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.", ], ), @@ -307,8 +308,8 @@ fn article(topic: Topic) -> Article { "Choose and edit the relationship you mean", &[ "Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective, projection and Saved view visibility. Apply changes commits the view settings.", - "Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships is a broader exploration view; OA ↔ SA traceability displays counterparts.", - "Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.", + "Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships explores one layer; OA ↔ SA traceability can show placed elements from both layers and their correspondence lines alongside ordinary relationships.", + "Select an element and edit Semantic parent to change ownership. Use the Allocation palette tool to connect a performer to existing unallocated leaf work in Containment. The relationship can appear as a line there; Architecture · allocated work shows responsibility through nesting instead. Saved visibility filters can hide a line without removing the relationship.", "Scroll to zoom and use Fit to frame the current view. In allocated-work views, dragging work changes its assignment and placement together. Choose Arrange next drag or press Alt+A to move work without changing responsibility for that one drag; Escape cancels the armed action. Auto-layout arranges the whole view. Export SVG produces a shareable view.", "Select a diagram node to access Placement, Fold / unfold and Hide. With the canvas focused, Delete hides its placement; Shift+Delete asks to remove the model element. Other views retain a hidden element. Undo restores either change.", ], @@ -317,6 +318,16 @@ fn article(topic: Topic) -> Article { "Keep diagrams readable", "Use separate views for responsibility, decomposition and cross-layer coverage. Place existing elements with Show in this diagram. A placement's Diagram field can relocate it while preserving its identity and artifact references. Do not duplicate a semantic element merely to show it in another view.", ), + walkthrough( + "Show a correspondence between OA and SA", + &[ + "Choose OA ↔ SA traceability in View content. Changing this setting does not create counterparts or add their placements, so an existing single-layer diagram can initially look unchanged.", + "Use Review layer traces to inspect named Operational and System endpoints and the meaning of their link. This opens the model's layer-trace list with no search restriction; traces between relationships are reviewed here rather than drawn between element cards.", + "In the chosen architecture view, use Add existing elements. The chooser offers compatible elements from both layers. Select an existing endpoint and click its position in the canvas; repeat for the other end if needed. This adds a placement, not a new semantic element or trace. For port correspondences, place the ports' owners.", + "The view shows correspondence lines alongside ordinary relationships. The shown-line count reflects the current projection and visibility settings, not trace coverage. If a line is hidden, use View visibility and inspect Saved view visibility; placing an endpoint does not remove explicit hides or filters.", + "If the counterpart is already placed, use Include already shown (Locate) in the chooser. Undo restores an added placement; saving preserves each view's placements independently. Use the Traceability page's existing counterpart or manual-trace workflow when the semantic link itself does not yet exist.", + ], + ), walkthrough( "Move work between performers", &[ diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index 212d063e..054b71b2 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -669,7 +669,8 @@ pub fn popup(app: &App) -> Option> { entries.push(item( "Show in this diagram", Message::PlaceSelected, - d.layer == e.layer() && d.projection.includes(e.kind()), + (d.layer == e.layer() || d.projection == ProjectionKind::Traceability) + && d.projection.includes(e.kind()), )); } } @@ -800,6 +801,7 @@ fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)] #[cfg(test)] mod tests { use super::*; + include!("model_menu_traceability_tests.rs"); fn send(app: &mut App, message: Message) { let _ = app.update(message); } diff --git a/crates/oa-desktop/src/model_menu_traceability_tests.rs b/crates/oa-desktop/src/model_menu_traceability_tests.rs new file mode 100644 index 00000000..27046e5f --- /dev/null +++ b/crates/oa-desktop/src/model_menu_traceability_tests.rs @@ -0,0 +1,62 @@ +#[test] +fn traceability_show_menu_matches_cross_layer_and_port_placement_eligibility() { + let mut project = Project::empty(); + let sa = project + .add_element(Kind::Function, Layer::Sa, "Counterpart", None) + .unwrap(); + let port = Id::new_v4(); + project.elements.insert( + port, + Element { + id: port, + alias: "legacy_port".into(), + name: "Port".into(), + description: String::new(), + parent: Some(sa), + body: ElementBody::shared_in( + Layer::Sa, + SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::InOut, + interface: None, + }), + }, + ), + }, + ); + let diagram = project.ensure_diagram(Layer::Oa); + oa_core::validate_candidate(&project).unwrap(); + for projection in [ProjectionKind::Overview, ProjectionKind::Traceability] { + let mut p = project.clone(); + p.diagrams.get_mut(&diagram).unwrap().projection = projection; + let mut app = App::with_project(p); + app.page = Page::Diagram; + app.layer = Layer::Oa; + app.active_diagram = Some(diagram); + for object in [sa, port] { + send(&mut app, open(object, Point::ORIGIN)); + let action = popup(&app) + .unwrap() + .entries + .into_iter() + .find_map(|e| match e { + Entry::Action { label, message, .. } if label == "Show in this diagram" => { + Some(message) + } + _ => None, + }); + if object == port { + assert!( + action.is_none(), + "Ports never acquire independent node placements" + ); + } else { + assert_eq!( + action.expect("non-port placement entry").is_some(), + projection == ProjectionKind::Traceability + ); + } + } + } +} diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 7cc2ac23..6781b123 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -613,6 +613,28 @@ fn diagram(app: &App) -> View<'_, Message> { } if let Some(diagram) = app.active_diagram { let view = oa_core::projection::project_view(app.project(), diagram); + if projection == Some(ProjectionKind::Traceability) { + let correspondence_lines = view.edges.iter().filter(|edge| edge.trace).count(); + tools = tools.push( + column![ + label("Shows OA and SA elements together, with correspondence lines alongside ordinary relationships."), + label("Place both ends of a trace to show its line. Port traces use their owner cards; traces between relationships are reviewed on the Traceability page."), + row![ + label(format!("{correspondence_lines} correspondence lines in this view")), + action( + "Add existing elements…", + Message::Architecture(crate::app::architecture_authoring::Event::Existing(None)) + ), + action("Review layer traces", Message::ReviewLayerTraces), + action("View visibility…", Message::Select(diagram)), + ] + .spacing(7) + .align_y(iced::Center) + .wrap(), + ] + .spacing(4), + ); + } let edge = view.edges.iter().find(|edge| edge.id == app.selected); let promoted = edge.is_some_and(|edge| { oa_core::projection::edge_endpoint_placements( diff --git a/crates/vendor/iced_widget/src/overlay/menu.rs b/crates/vendor/iced_widget/src/overlay/menu.rs index 7eb67c0c..6dce55fa 100644 --- a/crates/vendor/iced_widget/src/overlay/menu.rs +++ b/crates/vendor/iced_widget/src/overlay/menu.rs @@ -1,6 +1,7 @@ //! Build and show dropdown menus. use crate::core::alignment; use crate::core::border::{self, Border}; +use crate::core::keyboard; use crate::core::layout::{self, Layout}; use crate::core::mouse; use crate::core::overlay; @@ -15,6 +16,7 @@ use crate::core::{ }; use crate::core::{Element, Shell, Widget}; use crate::scrollable::{self, Scrollable}; +use std::cell::Cell; /// A list of selectable options. pub struct Menu< @@ -34,6 +36,7 @@ pub struct Menu< hovered_option: &'a mut Option, on_selected: Box Message + 'a>, on_option_hovered: Option<&'a dyn Fn(T) -> Message>, + escape: Option<(&'a Cell, Option)>, width: f32, padding: Padding, text_size: Option, @@ -68,6 +71,7 @@ where hovered_option, on_selected: Box::new(on_selected), on_option_hovered, + escape: None, width: 0.0, padding: Padding::ZERO, text_size: None, @@ -78,6 +82,17 @@ where } } + // PickList owns this flag. Other Menu consumers retain their existing + // keyboard behavior unless they explicitly opt into this private bridge. + pub(crate) fn close_on_escape( + mut self, + open: &'a Cell, + on_close: Option, + ) -> Self { + self.escape = Some((open, on_close)); + self + } + /// Sets the width of the [`Menu`]. pub fn width(mut self, width: f32) -> Self { self.width = width; @@ -170,6 +185,7 @@ where viewport: Rectangle, tree: &'a mut Tree, list: Scrollable<'a, Message, Theme, Renderer>, + escape: Option<(&'a Cell, Option)>, width: f32, target_height: f32, class: &'a ::Class<'b>, @@ -198,6 +214,7 @@ where hovered_option, on_selected, on_option_hovered, + escape, width, padding, font, @@ -228,6 +245,7 @@ where viewport, tree: &mut state.tree, list, + escape, width, target_height, class, @@ -278,6 +296,31 @@ where clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, ) { + // UserInterface may retain this overlay for the rest of an event batch. + // A dismissed owner must not select an option on a queued later click. + if self.escape.as_ref().is_some_and(|(open, _)| !open.get()) { + return; + } + // Consume dismissal in the overlay so Iced never sends the same key + // into focused controls or canvases beneath this picker. The shared + // owner flag also prevents duplicate close notifications in one batch. + if matches!( + event, + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Escape), + .. + }) + ) && let Some((open, on_close)) = &mut self.escape + && open.replace(false) + { + if let Some(message) = on_close.take() { + shell.publish(message); + } + shell.capture_event(); + shell.request_redraw(); + return; + } + let bounds = layout.bounds(); self.list.update( @@ -292,6 +335,9 @@ where cursor: mouse::Cursor, renderer: &Renderer, ) -> mouse::Interaction { + if self.escape.as_ref().is_some_and(|(open, _)| !open.get()) { + return mouse::Interaction::None; + } self.list.mouse_interaction( self.tree, layout, @@ -309,6 +355,9 @@ where layout: Layout<'_>, cursor: mouse::Cursor, ) { + if self.escape.as_ref().is_some_and(|(open, _)| !open.get()) { + return; + } let bounds = layout.bounds(); let style = Catalog::style(theme, self.class); diff --git a/crates/vendor/iced_widget/src/pick_list.rs b/crates/vendor/iced_widget/src/pick_list.rs index ba39143f..adf2a4ce 100644 --- a/crates/vendor/iced_widget/src/pick_list.rs +++ b/crates/vendor/iced_widget/src/pick_list.rs @@ -78,6 +78,7 @@ use crate::core::{ use crate::overlay::menu::{self, Menu}; use std::borrow::Borrow; +use std::cell::Cell; use std::f32; /// A widget for selecting a single value from a list of options. @@ -448,12 +449,24 @@ where let state = tree.state.downcast_mut::>(); match event { + Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Escape), + .. + }) if state.is_open.replace(false) => { + // The picker may have opened earlier in this base-event batch, + // after UserInterface collected its overlays. + if let Some(on_close) = &self.on_close { + shell.publish(on_close.clone()); + } + shell.capture_event(); + shell.request_redraw(); + } Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { - if state.is_open { + if state.is_open.get() { // Event wasn't processed by overlay, so cursor was clicked either outside its // bounds or on the drop-down, either way we close the overlay. - state.is_open = false; + state.is_open.set(false); if let Some(on_close) = &self.on_close { shell.publish(on_close.clone()); @@ -463,7 +476,7 @@ where } else if cursor.is_over(layout.bounds()) { let selected = self.selected.as_ref().map(Borrow::borrow); - state.is_open = true; + state.is_open.set(true); state.hovered_option = self .options .borrow() @@ -482,7 +495,7 @@ where }) => { if state.keyboard_modifiers.command() && cursor.is_over(layout.bounds()) - && !state.is_open + && !state.is_open.get() { fn find_next<'a, T: PartialEq>( selected: &'a T, @@ -528,7 +541,7 @@ where let status = { let is_hovered = cursor.is_over(layout.bounds()); - if state.is_open { + if state.is_open.get() { Status::Opened { is_hovered } } else if is_hovered { Status::Hovered @@ -612,7 +625,7 @@ where shaping, }) => Some((*font, *code_point, *size, *line_height, *shaping)), Handle::Dynamic { open, closed } => { - if state.is_open { + if state.is_open.get() { Some(( open.font, open.code_point, @@ -703,7 +716,7 @@ where let state = tree.state.downcast_mut::>(); let font = self.font.unwrap_or_else(|| renderer.default_font()); - if state.is_open { + if state.is_open.get() { let bounds = layout.bounds(); let on_select = &self.on_select; @@ -713,13 +726,14 @@ where self.options.borrow(), &mut state.hovered_option, |option| { - state.is_open = false; + state.is_open.set(false); (on_select)(option) }, None, &self.menu_class, ) + .close_on_escape(&state.is_open, self.on_close.clone()) .width(bounds.width) .padding(self.padding) .font(font) @@ -763,7 +777,7 @@ where struct State { menu: menu::State, keyboard_modifiers: keyboard::Modifiers, - is_open: bool, + is_open: Cell, hovered_option: Option, options: Vec>, placeholder: paragraph::Plain

, @@ -775,7 +789,7 @@ impl State

{ Self { menu: menu::State::default(), keyboard_modifiers: keyboard::Modifiers::default(), - is_open: bool::default(), + is_open: Cell::new(false), hovered_option: Option::default(), options: Vec::new(), placeholder: paragraph::Plain::default(), diff --git a/harness/allocation-drawing-20260923.md b/harness/allocation-drawing-20260923.md new file mode 100644 index 00000000..895f686d --- /dev/null +++ b/harness/allocation-drawing-20260923.md @@ -0,0 +1,83 @@ +# Direct Allocation drawing — issue 30 + +Branch-specific design above `17f1f89fef4a6bf520f4b09a2116548b967d030e`, +on `codex/allocation-drawing-ytr83r-20260923`. +[Issue 30](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/30) +holds live scope and acceptance. This note records the intended interaction and +verification criteria, not completed tests, native acceptance or integration. + +The user reports that Allocation cannot be drawn from the tools menu in +Containment. Core semantics and the existing relationship form already support +the relationship there; the direct canvas tool is the missing affordance. +Adding that tool follows the same source/target workflow as neighboring tools. +A form shortcut alone would still require a dropdown instead of clicking the +visible endpoints, leaving the reported drawing task unresolved. + +## Interaction and scope + +Choose Allocation in the diagram palette, click any visible eligible performer, +then click compatible unallocated leaf work in the same semantic layer as that +performer. The performer's layer need not match a mixed traceability view's owner +layer. OA uses an Entity or Actor and an +Activity; SA uses a System or Actor and a Function. Highlighting and the current +source identify the proposed semantic direction: performer to work, independent +of where their cards are drawn. Escape cancels the unfinished connection. +The existing Create relationship form remains an alternative for explicit +relationship entry; no new Activity or Function is created by this tool. + +Projection eligibility requires support for both performer and work cards: +Containment, All relationships, OA ↔ SA traceability and Architecture · allocated +work offer Allocation; Functional dataflow, Missions & capabilities and dedicated +State-machine views do not. Containment and All relationships can display the resulting Allocation line +when its placements and filters permit. Architecture · allocated work represents +the same responsibility through nesting, not an additional Allocation arrow. +OA ↔ SA traceability can contain both layers, but Allocation endpoints must +still belong to the same layer as each other. Selecting an opposite-layer source +retains the captured mixed diagram and prior application perspective; cancellation +also preserves that perspective. After successful creation, the ordinary model +synchronization sets the application perspective to the captured diagram's owner +layer while keeping that diagram open. A projection that omits one endpoint category +must not offer an unusable drawing workflow. Existing filters remain unchanged; +successful creation does not promise that a filtered line is visible. + +## Preservation rules + +- Allocate only eligible leaf work with no existing direct performer. Reject + duplicate, occupied, composite, cross-layer and Port endpoints; do not treat + drawing a new relation as permission to reassign existing work. +- Core validation is authoritative at completion, in addition to candidate + highlighting. Candidates must be actual projected endpoints, not hidden + children or computed parent summaries. +- Create one Allocation in one document transaction. Preserve semantic parents, + elements, other relationships, traces and every saved placement, route and + view option. Derived projection geometry may change because responsibility + changed; that is distinct from rewriting stored layout. +- Arming, choosing only a source, refusal and cancellation do not alter model + history. Stale document/revision/diagram, navigation and pending property or + source edits must prevent delayed completion without discarding those drafts. +- Moving cards in Containment remains ordinary arrangement. Assignment-changing + drag and Arrange next drag retain their existing allocated-work-view scope. + +No general relationship browser, new reassignment policy, port generation or +change to ownership semantics is introduced. The existing connection lifecycle +and ordinary validated command boundary are preferred over a parallel gesture +framework. Broader form-only relationship kinds are outside this bounded fix. + +## Acceptance criteria + +Exercise real connection events for valid OA and SA endpoint pairs, rejected +endpoint classes, cancel/retry, hidden endpoints, stale captures and pending +drafts. Compare the complete Project so the only semantic difference is the new +Allocation; bind its fresh identity explicitly. Check exact source Undo/Redo +and unchanged stored reference-view records. Verify projection eligibility for +the new tool without regressing the existing four connection tools. + +In the actual application, find Allocation in Containment's palette, choose a +performer and existing leaf work directly, inspect the resulting relation, +Undo/Redo, Save, close normally and reopen. Confirm feedback explains nesting +when using the allocated-work projection, rather than promising a new line. +Inspect nested-card hit selection and filtered-result feedback. Native evidence +must distinguish observed interactions from automated coverage and bind source, +binary, process and predeclared full-Project expectations. No successful result +is asserted here. Linux remains user-paused; combined release acceptance belongs +to the integration owner. diff --git a/harness/picker-escape-20260923.md b/harness/picker-escape-20260923.md new file mode 100644 index 00000000..76f38441 --- /dev/null +++ b/harness/picker-escape-20260923.md @@ -0,0 +1,74 @@ +# Picker Escape ownership — issue 35 + +Branch-specific design above `301126f6098286bf440c1bd1b1ed685753984723`. +[Issue 35](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/35) +owns live scope, progress and acceptance. This document records the selected +contract; it does not assert completed implementation, execution or integration. + +## Required interaction + +An open picker owns Escape: close its popup, request redraw and consume the key. +Publish its optional close notification at most once; do not select an option +or change the surrounding draft, model, source, history, selection or camera. +A later Escape follows the existing surrounding workflow's policy. Reopening, +selection and outside-click dismissal retain their existing behavior. Selection +does not gain a new close callback as an incidental change to this repair. + +Event batches matter. A popup already closed by Escape must not select a stale +option or emit another close notification when later queued events reach the +same overlay object. This does not introduce a new global keyboard-repeat policy. + +## Decision and evidence + +The shared picker and its menu overlay currently omit Escape handling. Adding +widget capture alone is insufficient: application subscriptions translate some +captured Escape events back into modal cancellation. Those bridges also preserve +legitimate cancellation from focused text inputs, so simply removing them loses +existing behavior. + +The selected design gives dismissal to the picker overlay and moves the existing +captured-key forwarding into a scoped wrapper around the ordinary application +widget tree. Keep the wrapper structurally present even when inactive, preserve +the existing activation predicates and message priorities, and delegate widget +state, operations, layout and overlays transparently. + +The locked Iced 0.14 runtime processes overlays first and skips ordinary root +updates for events captured by an overlay (`user_interface.rs`). Nested overlays +likewise give their deepest child first refusal (`overlay/nested.rs`). Thus the +Escape from an already mounted picker cannot reach ordinary-tree forwarding, +while focused text-input cancellation can retain its previous policy. The runtime +can reuse an overlay across an event batch; close-state checks must cover that +case. Conversely, opening a picker and pressing Escape in the same batch can +reach the ordinary tree before the runtime constructs its overlay. The wrapper +therefore offers the currently available nested overlay first refusal for Escape +before forwarding to ordinary children; the picker also handles base delivery. +An overlay that already ignored Escape may receive this narrow preflight again. +Audited reachable passive overlays perform no selection or draft action; the +source-navigation observer can advance its conservative focus-freshness epoch +again. Regression checks must retain stale-focus rejection and observable state. +Do not generalize this replay to other keyboard or pointer events. + +Alternatives rejected: a widget-only patch leaves outer-draft cancellation; +global open-picker bookkeeping duplicates widget state and requires coordinating +every open/select/close/removal path; per-text-input forwarding can work but +spreads the existing policy across many controls. Revisit the wrapper choice if +runtime delivery changes or actual mounted tests contradict these assumptions. + +## Acceptance + +Use mounted runtime delivery for open/dismiss/reopen/select, optional callbacks, +outside click and same-batch Escape/click sequences. Exercise the real application +subscriptions with returned event status, including focused text cancellation, +architecture editing, behavior drafts and Text workflows. Compare complete model +and source state where dismissal must conserve them; direct helper calls alone +do not establish event ownership. + +Native coverage should include the State hidden-element picker and the Add +existing chooser's filter dropdown with a retained query: first Escape dismisses +the picker, then a distinct Escape follows the chooser policy. Behavior creation, +behavior source forms and Text Find/rename currently have no internal dropdown; +picker coexistence there is composed mounted coverage, not a claim of a native +control that does not exist. Verify their ordinary focused-text Escape behavior. + +Linux remains user-paused. Shared integration and release acceptance remain with +the integration owner; this branch does not establish combined-release behavior. diff --git a/harness/traceability-view-20260923.md b/harness/traceability-view-20260923.md new file mode 100644 index 00000000..13f76426 --- /dev/null +++ b/harness/traceability-view-20260923.md @@ -0,0 +1,73 @@ +# Traceability view comprehension — issue 31 + +Branch-specific work above `b8685416f56e53e30bd11ecf93a54cea10ac8458`. +[Issue31](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/31) +owns live progress. This note records the chosen design and acceptance plan, +not completed validation or integration. + +The user's observed problem is that OA↔SA traceability looks like All +relationships and its purpose is unclear. Existing core behavior already +projects both layers' placed elements, ordinary relationships and explicit +traces with available endpoints. Switching projection neither creates semantic +counterparts nor adds missing placements. Existing Add existing supports both +layers, but its default suggested-type filter conceals opposite-layer choices. +Show in this diagram also has inconsistent cross-layer eligibility and selects +an opposite-layer element before placement, potentially changing the target view. + +## Decision and criteria + +Make the existing workflow understandable and reachable before introducing a +second counterpart browser. Criteria: a new user can explain the distinction, +find both named ends, place existing counterparts in the chosen saved diagram, +see/inspect correspondence, understand absent lines, and preserve semantic data. +Keep the established mixed ordinary/trace contract, explicit semantic creation, +saved view filters and single-operation placement history. + +Use concise projection-local explanation, the actual count of projected trace +edges, Add existing elements, Review layer traces, and existing View visibility. +Default the Existing chooser to All compatible types only for Traceability; +creation and ordinary-view defaults stay scoped to their existing contexts. +Align Show in this diagram with actual supported placement and retain the +captured diagram across cross-layer selection. Review layer traces opens a +predictable layer-trace inventory without a stale artifact filter/search, through +existing navigation/draft guards. It does not promise that subsequently choosing +an opposite-layer endpoint preserves the originating diagram. + +The count is exactly `project_view(...).edges.filter(trace).count()`, after saved +filters. It is neither a project-wide trace total nor a coverage denominator. +No overlapping missing/hidden reasons are added into invented totals. Port +correspondences use owner-card placements; relation-to-relation correspondences +remain reviewable semantic records and are not promised a diagram line. + +An expandable view-local counterpart browser with named Place/Locate actions +was independently proposed. It can reduce name memorization, but introduces +another scope, owner-mapping, omission-reason and targeted-placement interface. +The existing chooser already completes the bounded placement task once its +default and shortcut are corrected. Revisit the richer browser if the actual +native task remains confusing. Tooltip-only, traces-only replacement, automatic +counterpart creation/placement and forced OA/SA layout were rejected: respectively +weak task completion, broken mixed-context contract, surprising semantics/history, +and unwanted authored-layout changes. + +## Verification plan + +Exercise the real Existing chooser and actual Show menu path for opposite-layer +elements, including stale/pending refusal and ordinary-view restrictions. Only +the current diagram's intended placement may change; preserve semantic maps, +other views, route/visibility choices, exact source Undo/Redo and save/reopen. +Ports cannot become node placements. An already placed element must not acquire +a duplicate placement. Predictable trace review must preserve drafts and obey +the usual modal/navigation guards. + +Use core projection/filter evidence for no trace, missing endpoints, both placed, +ordinary OA/SA edges, multiple traces, explicit hides plus disabled Trace kind, +port owners and relation correspondence. Do not infer visible lines from +unfiltered edge_availability or confuse unrelated model traces with counterparts. + +Native acceptance starts with an OA-owned mixed view, one already visible +correspondence pair and another existing named SA counterpart not yet placed. +Find/place it through the visible controls, see/select the additional trace, +inspect an absent-line visibility reason, Undo/Redo and Save/reopen. Inspect +toolbar/canvas space and chooser clarity at a realistic window size. Different +help text or count tests alone do not resolve the user's comprehension complaint. +Linux remains user-paused; Codex-956atf retains shared integration ownership.