Compare commits

...
38 changed files with 4372 additions and 419 deletions
@@ -0,0 +1,101 @@
//! Read-only saved-State geometry evidence for a predeclared native bend trial.
//!
//! Usage:
//! state_bend_routing_snapshot INPUT.arcadia NEW_OUTPUT.json
//!
//! Loads and validates the complete Project, then independently projects/routes
//! every saved State view through core APIs. The complete Project permits exact
//! semantic and presentation comparison; derived geometry permits declaring a
//! segment and insertion position before native actions. No desktop command,
//! planner, gesture, or pin insertion helper is called. Input is never written;
//! output uses create_new and fails rather than overwriting an existing file.
//!
//! Limits: this shares production core routing, so it is not an independent
//! mathematical test of that router, nor evidence of UI picking/gesture success.
//! It does not generate expected mutations or adapt an oracle to observed edits.
//! The acceptance recipe must declare the intended segment/index/point and exact
//! Project mutation before UI execution, and bind this executable/source/input
//! and output hashes externally. Warning strings are diagnostic Debug output,
//! not a stable interchange schema. Missing/hidden/internal transitions have no
//! routed edge; their semantic records remain in the complete Project snapshot.
use oa_core::{persistence, projection, routing, validate_candidate};
use serde_json::json;
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err("Expected input source/JSON and a new output JSON path".into());
}
let project = persistence::load(Path::new(&args[0]))?;
validate_candidate(&project)?;
let mut views = Vec::new();
for (diagram_id, diagram) in &project.diagrams {
let Some(machine) = diagram.projection.state_machine() else {
continue;
};
let projected = projection::project_diagram(&project, diagram);
let routed = routing::route_diagram(&project, diagram, &projected);
let edges: Vec<_> = routed
.edges
.iter()
.map(|(edge_id, edge)| {
json!({
"edge": edge_id,
"id": edge.id,
"points": edge.points,
"pins": edge.pins,
"pin_fractions": edge.pin_fractions,
"custom_suspended": edge.custom_suspended,
"warnings": edge.warnings.iter().map(|warning| format!("{warning:?}")).collect::<Vec<_>>(),
"label": {
"text": edge.label.text,
"fraction": edge.label.fraction,
"anchor": edge.label.anchor,
"center": edge.label.center,
"bounds": {
"position": edge.label.bounds.position,
"size": edge.label.bounds.size
}
}
})
})
.collect();
views.push(json!({
"diagram": diagram_id,
"machine": machine,
"projected": projected,
"routed_bounds": {
"position": routed.bounds.position,
"size": routed.bounds.size
},
"routed_edges": edges
}));
}
// Preserve the canonical typed Project serialization (including f32
// formatting). Serializing it through Value directly promotes f32 to f64.
let project_json: serde_json::Value = serde_json::from_slice(&serde_json::to_vec(&project)?)?;
let snapshot = json!({
"schema": "state-bend-routing-snapshot/v1",
"project": project_json,
"state_views": views
});
// Finish all reading, validation, routing and serialization before creating
// the new evidence file. A failed load cannot leave an apparent snapshot.
let mut bytes = serde_json::to_vec_pretty(&snapshot)?;
bytes.push(b'\n');
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.sync_all()?;
println!(
"Validated {} objects and {} saved views; snapshotted {} State views and the complete Project",
project.identities().len(),
project.diagrams.len(),
views.len()
);
Ok(())
}
+99 -23
View File
@@ -14,6 +14,7 @@ mod diagram_reveal;
#[cfg(test)]
mod diagram_selection_tests;
mod diagram_usages;
pub(crate) mod escape;
mod hosted;
mod reference_authoring;
#[cfg(test)]
@@ -22,6 +23,8 @@ pub(crate) mod source_rename;
mod source_symbols;
pub(crate) mod text_workspace;
#[cfg(test)]
mod traceability_navigation_tests;
#[cfg(test)]
mod transition_context_tests;
#[cfg(test)]
mod view_copy_tests;
@@ -359,6 +362,7 @@ pub enum Message {
Page(Page),
Guide(crate::guide::Event),
Traceability(crate::views::traceability::Event),
ReviewLayerTraces,
Behavior(crate::behavior::Event),
Layer(Layer),
Select(Id),
@@ -1161,7 +1165,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(
@@ -1173,7 +1177,8 @@ impl App {
self.scenario_focus_owner(),
),
self.source_symbols.raw_interaction.clone(),
)
);
escape::root(self, content)
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
@@ -1215,7 +1220,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,
@@ -1223,20 +1227,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));
@@ -2310,7 +2300,30 @@ impl App {
"Apply or revert pending edits before using modeling actions.".into();
return Task::none();
}
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);
}
self.selected_node = menu.request.placement;
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
return self.update(*action);
@@ -2334,6 +2347,15 @@ impl App {
} else if self.behavior.creation.has_pending() {
self.behavior.creation.acknowledge_commit();
self.status = "Behavior creation cancelled".into();
} else if self.page == Page::Behavior
&& matches!(
self.behavior.diagram.tool,
behavior::state_edit::Tool::AdjustAttachment { .. }
)
{
// Menu arming invalidates canvas keyboard ownership. Escape
// must also cancel before the next canvas click grants it.
self.behavior.diagram.tool = behavior::state_edit::Tool::Select;
}
if self.connection.take().is_some() {
self.status = "Drawing cancelled · Select / move is active".into();
@@ -2499,6 +2521,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;
@@ -4452,6 +4491,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(
@@ -4482,14 +4529,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);
}
}
@@ -4504,7 +4569,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 {
@@ -27,30 +27,11 @@ pub enum Event {
Cancel,
}
pub fn subscription(app: &App) -> Subscription<Message> {
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<Message> {
// 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<Message> {
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)]
@@ -301,6 +282,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()),
@@ -309,7 +301,7 @@ impl App {
point,
placing,
query: String::new(),
filter: Filter::Context,
filter,
show_placed: false,
page: 0,
};
@@ -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(
@@ -1,4 +1,5 @@
use super::*;
mod traceability;
fn setup(layer: Layer) -> App {
let mut app = App::with_project(Project::empty());
@@ -232,65 +233,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::<iced::widget::text_input::State<()>>()
.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::<iced::widget::text_input::State<()>>()
.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] {
@@ -310,8 +252,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");
@@ -341,8 +284,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()
@@ -354,19 +297,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()
);
}
@@ -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);
}
}
+198
View File
@@ -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<Message>; 2],
}
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
fn children(&self) -> Vec<widget::Tree> {
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<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
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<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[cfg(test)]
pub(crate) mod tests;
+618
View File
@@ -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<Message>,
) {
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<Message> {
focused_input_key(app, Named::Escape)
}
pub(crate) fn focused_input_key(app: &App, named: Named) -> Vec<Message> {
input_messages(app, named, true)
}
pub(crate) fn input_messages(app: &App, named: Named, focused: bool) -> Vec<Message> {
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<M>(
ui: &mut UserInterface<'_, M, Theme, ()>,
events: &[Event],
cursor: mouse::Cursor,
) -> (Vec<M>, Vec<iced::event::Status>) {
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<Event>, 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());
}
}
@@ -754,76 +754,7 @@ fn invalid_open_retains_accepted_document_but_refuses_alias_rename() {
}
fn captured_escape(app: &App) -> Vec<Message> {
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::<iced::widget::text_input::State<()>>()
.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]
@@ -864,99 +864,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
}
fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
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::<iced::widget::text_input::State<()>>()
.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::<iced::widget::text_input::State<()>>()
.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();
@@ -0,0 +1,160 @@
//! Production App tree and its actual keyboard subscription, without a canvas click.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{
advanced::subscription,
futures::{StreamExt, stream, task::noop_waker_ref},
keyboard::key::Named,
mouse,
};
use std::task::{Context as PollContext, Poll};
async fn press(mounted: &mut Mounted, app: &mut App, named: Named) -> Vec<Message> {
let input = key(named);
let (mut messages, statuses) = mounted.input_with_status(
app,
std::slice::from_ref(&input),
mouse::Cursor::Unavailable,
);
let interaction = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event: input,
status: statuses[0],
};
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()])));
let mut cx = PollContext::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) {
messages.push(message);
}
}
for message in messages.iter().cloned() {
let task = app.update(message);
mounted.perform(app, task).await;
}
mounted.settle(app).await;
messages
}
async fn armed() -> (App, Mounted, Id) {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let relation = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
let mut app = App::with_project(project);
app.window_size = iced::Size::new(1800., 1100.);
send(&mut app, Message::RevealInDiagram(diagram, relation));
let mut mounted = Mounted::new(&mut app).await;
app.behavior.diagram.fit = false;
let context = app.state_context().unwrap();
event(
&mut app,
Event::ContextMenu {
context,
object: relation,
placement: None,
anchor: iced::Point::new(450., 400.),
bend: None,
route_segment: None,
},
);
mounted.settle(&mut app).await;
let token = app.model_menu.as_ref().unwrap().token;
send(
&mut app,
Message::Menu(crate::model_menu::Event::Invoke(
token,
Box::new(Message::StateDiagram(Event::Choose(
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source,
},
))),
)),
);
mounted.settle(&mut app).await;
assert!(app.model_menu.is_none());
assert_eq!(
app.behavior.diagram.tool,
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source
}
);
(app, mounted, relation)
}
fn canvas_focus(mounted: &mut Mounted, app: &App) -> (iced::Rectangle, bool) {
let id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
let (_, bounds, focused) = mounted
.geometry(app)
.focused
.into_iter()
.find(|(candidate, _, _)| candidate.as_ref() == Some(&id))
.unwrap();
(bounds, focused)
}
#[tokio::test]
async fn mounted_menu_armed_attachment_escape_without_canvas_click_preserves_document_and_focus() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
let history = (app.document.can_undo(), app.document.can_redo());
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
assert!(!canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(messages.iter().any(|m| matches!(m, Message::Escape)));
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(!canvas_focus(&mut mounted, &app).1);
press(&mut mounted, &mut app, Named::Delete).await;
assert!(
!app.delete_pending,
"Escape must not grant canvas Delete ownership"
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
}
#[tokio::test]
async fn mounted_attachment_escape_preserves_modal_priority_and_focused_canvas_control() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
// Existing modal Escape consumes the first key without canceling the tool underneath.
send(&mut app, Message::Delete);
assert!(app.delete_pending && app.delete_target.is_some());
mounted.settle(&mut app).await;
press(&mut mounted, &mut app, Named::Escape).await;
assert!(!app.delete_pending);
assert!(matches!(
app.behavior.diagram.tool,
Tool::AdjustAttachment { .. }
));
// A real canvas click grants ownership; inner-canvas Escape remains live.
let bounds = canvas_focus(&mut mounted, &app).0;
mounted
.click(&mut app, iced::Point::new(bounds.x + 10., bounds.y + 10.))
.await;
assert!(canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::StateDiagram(Event::Choose(Tool::Select))))
);
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
@@ -0,0 +1,480 @@
use super::*;
#[test]
fn state_bend_delayed_commit_after_same_view_selection_is_inert_and_live_commit_works() {
let (mut app, _, relation, _) = setup();
let context = app.state_context().unwrap();
let next = app.project().relations[&relation].target();
let waypoints = vec![Position { x: 715., y: -425. }];
let delayed = Event::SetBends {
context,
relation,
waypoints: waypoints.clone(),
};
send(&mut app, Message::Select(next));
assert_eq!(app.selected, next);
assert_eq!(
app.state_context(),
Some(context),
"same-view selection does not retire the context stamp"
);
let before = app.document.snapshot();
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
event(&mut app, delayed);
assert_eq!(app.selected, next);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
send(&mut app, Message::Select(relation));
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: waypoints.clone(),
},
);
let mut expected = before.decoded.project.clone();
let route = expected
.diagrams
.get_mut(&context.diagram)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == relation)
.unwrap();
route.waypoints = waypoints;
assert_eq!(app.project(), &expected);
assert_source_model_agree(&app);
send(&mut app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(
!app.document.can_undo(),
"live drag remains a single transaction"
);
}
#[test]
fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action() {
let (mut app, _, relation, _) = setup();
let context = app.state_context().unwrap();
let point = Position { x: 500., y: -400. };
event(
&mut app,
Event::ContextMenu {
context,
object: relation,
placement: None,
anchor: iced::Point::ORIGIN,
bend: Some(1),
route_segment: Some((2, point)),
},
);
let menu = crate::model_menu::popup(&app).unwrap();
let mut add = false;
let mut remove = false;
let mut delete = false;
for entry in menu.entries {
if let crate::context_menu::Entry::Action {
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, message))),
..
} = entry
{
match *message {
Message::StateDiagram(Event::AddBend {
relation: id,
segment: Some((2, at)),
..
}) => {
assert_eq!(id, relation);
assert_eq!(at, point);
add = true;
}
Message::StateDiagram(Event::RemoveBend {
relation: id,
index: 1,
..
}) => {
assert_eq!(id, relation);
remove = true;
}
Message::Delete => delete = true,
_ => {}
}
}
}
assert!(add && remove && delete);
}
fn setup() -> (App, Id, Id, Id) {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let relation = project.relate(RelationKind::StateTransition, "Bend trial", f.leaf, f.b);
let second = state_edit::create_view(&project, f.machine).unwrap();
project = prepare_command(&project, &second.command).unwrap().project;
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: relation,
waypoints: vec![
Position { x: 650., y: -400. },
Position { x: 300., y: -400. },
Position { x: 650., y: -400. },
],
label: Some(EdgeLabel {
fraction: 0.35,
offset: Position { x: 9., y: -23. },
}),
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.234567,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.75,
}),
});
let mut app = App::with_project(project);
send(&mut app, Message::RevealInDiagram(diagram, relation));
(app, diagram, relation, f.a)
}
#[test]
fn state_bend_commands_preserve_other_fields_views_and_exact_history() {
let (mut app, diagram, relation, _) = setup();
let baseline = app.document.snapshot();
let points = vec![Position { x: 715., y: 425. }];
let mut expected = app.project().clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points.clone();
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: points.clone(),
},
);
assert_eq!(app.project(), &expected);
assert_source_model_agree(&app);
let changed = app.document.snapshot();
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: points,
},
);
assert!(
Arc::ptr_eq(&changed, &app.document.snapshot()),
"equal bends must not add history"
);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
send(&mut app, Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &changed.bundle);
let context = app.state_context().unwrap();
event(&mut app, Event::ClearBends { context, relation });
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.clear();
assert_eq!(app.project(), &expected);
let cleared = app.document.snapshot();
let context = app.state_context().unwrap();
event(&mut app, Event::ClearBends { context, relation });
assert!(Arc::ptr_eq(&cleared, &app.document.snapshot()));
}
#[test]
fn state_bend_self_loop_add_move_remove_clear_preserves_project_and_source_history() {
let (app, diagram, relation, _) = setup();
let mut project = app.project().clone();
let RelationBody::StateTransition { source, target, .. } =
&mut project.relations.get_mut(&relation).unwrap().body
else {
panic!("State transition fixture")
};
*target = *source;
project.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.clear();
oa_core::validate_candidate(&project).unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::RevealInDiagram(diagram, relation));
let baseline = app.document.snapshot();
let geometry = state_edit::bend_route(app.project(), diagram, relation).unwrap();
assert!(!geometry.custom_suspended);
assert!(geometry.pins.is_empty());
assert!(
geometry.points.len() >= 4,
"actual external self-loop routing"
);
let segment = 1;
let point = Position {
x: (geometry.points[segment].x + geometry.points[segment + 1].x) / 2.,
y: (geometry.points[segment].y + geometry.points[segment + 1].y) / 2.,
};
// Each operation must alter only the complete Project's waypoint list;
// Undo and Redo must restore exact serialized source, not just geometry.
fn check(app: &mut App, diagram: Id, mutation: Event, points: Vec<Position>) {
let before = app.document.snapshot();
let mut expected = before.decoded.project.clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points;
event(app, mutation);
assert_eq!(app.project(), &expected);
assert_source_model_agree(app);
let after = app.document.snapshot();
assert_ne!(after.bundle, before.bundle);
send(app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
send(app, Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &after.bundle);
assert_source_model_agree(app);
}
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::AddBend {
context,
relation,
segment: Some((segment, point)),
},
vec![point],
);
let moved = Position {
x: point.x + 45.,
y: point.y - 35.,
};
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::SetBends {
context,
relation,
waypoints: vec![moved],
},
vec![moved],
);
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::RemoveBend {
context,
relation,
index: 0,
},
vec![],
);
assert_eq!(app.project(), &baseline.decoded.project);
send(&mut app, Message::Undo);
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::ClearBends { context, relation },
vec![],
);
assert_eq!(app.project(), &baseline.decoded.project);
assert_source_model_agree(&app);
// Clear is a new source-preserving edit and may retain empty-list trivia.
// Exact original bytes are restored by history, not by reauthoring a value.
for _ in 0..3 {
assert!(app.document.can_undo());
send(&mut app, Message::Undo);
}
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn state_bend_insertion_uses_captured_traversal_segment_and_removal_only_changes_pins() {
let (app, diagram, relation, _) = setup();
let context = app.state_context().unwrap();
let p = app.project();
let geometry = state_edit::bend_route(p, diagram, relation).unwrap();
let original = &p.diagrams[&diagram].routes[0];
let reverse = geometry
.points
.windows(2)
.position(|pair| pair[0] == original.waypoints[0] && pair[1] == original.waypoints[1])
.unwrap();
for (segment, expected_index) in [(reverse, 1), (reverse + 1, 2)] {
let point = Position { x: 500., y: -400. };
let planned = state_edit::plan(
p,
context,
&Event::AddBend {
context,
relation,
segment: Some((segment, point)),
},
)
.unwrap();
let actual = prepare_command(p, &planned.command).unwrap().project;
let mut expected = p.clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.insert(expected_index, point);
assert_eq!(actual, expected);
let removed = state_edit::plan(
&actual,
context,
&Event::RemoveBend {
context,
relation,
index: expected_index,
},
)
.unwrap();
assert_eq!(
prepare_command(&actual, &removed.command).unwrap().project,
*p
);
}
let default = state_edit::plan(
p,
context,
&Event::AddBend {
context,
relation,
segment: None,
},
)
.unwrap();
let Command::SetEdgeRoute { route, .. } = default.command else {
panic!("route command")
};
assert_eq!(route.waypoints.len(), original.waypoints.len() + 1);
assert_eq!(route.source_attachment, original.source_attachment);
assert_eq!(route.target_attachment, original.target_attachment);
assert_eq!(route.label, original.label);
}
#[test]
fn state_bend_stale_nonfinite_capacity_hidden_and_folded_requests_are_inert() {
let (mut app, diagram, relation, composite) = setup();
let context = app.state_context().unwrap();
let baseline = app.document.snapshot();
for mutation in [
Event::SetBends {
context: Context {
epoch: context.epoch + 1,
..context
},
relation,
waypoints: vec![],
},
Event::SetBends {
context,
relation,
waypoints: vec![Position { x: f32::NAN, y: 0. }],
},
Event::SetBends {
context,
relation,
waypoints: vec![Position { x: 0., y: 0. }; 129],
},
Event::AddBend {
context,
relation,
segment: Some((usize::MAX, Position { x: 0., y: 0. })),
},
Event::RemoveBend {
context,
relation,
index: usize::MAX,
},
] {
event(&mut app, mutation);
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
}
let mut p = app.project().clone();
p.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints =
vec![Position { x: 500., y: -400. }; 128];
assert!(
state_edit::plan(
&p,
context,
&Event::AddBend {
context,
relation,
segment: Some((1, Position { x: 500., y: -400. }))
}
)
.is_err()
);
let mut hidden = app.project().clone();
hidden
.diagrams
.get_mut(&diagram)
.unwrap()
.view_options
.hidden_edges
.push(relation);
assert!(state_edit::bend_route(&hidden, diagram, relation).is_none());
assert!(state_edit::plan(&hidden, context, &Event::ClearBends { context, relation }).is_err());
let owner = node(&app, diagram, composite);
let mut folded = app.project().clone();
folded
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owner)
.unwrap()
.collapsed = true;
assert!(state_edit::bend_route(&folded, diagram, relation).is_none());
assert!(state_edit::plan(&folded, context, &Event::ClearBends { context, relation }).is_err());
let mut internal = app.project().clone();
internal.diagrams.get_mut(&diagram).unwrap().routes.clear();
if let RelationBody::StateTransition {
source,
target,
transition_kind,
..
} = &mut internal.relations.get_mut(&relation).unwrap().body
{
*target = *source;
*transition_kind = TransitionKind::Internal;
}
assert!(state_edit::bend_route(&internal, diagram, relation).is_none());
send(
&mut app,
Message::Field("/name".into(), "Unapplied bend owner name".into()),
);
let draft = app.draft.as_ref().unwrap().value.clone();
event(&mut app, Event::ClearBends { context, relation });
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
assert_eq!(app.draft.as_ref().unwrap().value, draft);
}
@@ -157,10 +157,14 @@ impl App {
object,
placement,
anchor,
bend,
route_segment,
} => {
let mut request = crate::model_menu::Request::object(object, anchor);
request.canvas = Some(context);
request.placement = placement;
request.bend = bend;
request.route_segment = route_segment;
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
}
Event::EditTransition(_, id) => {
@@ -228,6 +232,11 @@ impl App {
let Some(context) = self.state_context() else {
return Task::none();
};
if let Event::SetBends { relation, .. } = &mutation
&& *relation != self.selected
{
return Task::none();
}
if let Event::SetAttachment {
relation,
end,
@@ -246,6 +255,14 @@ impl App {
}
match state_edit::plan(self.project(), context, &mutation) {
Ok(plan) => {
if let Command::SetEdgeRoute { diagram, route } = &plan.command
&& crate::route_edit::stored_route(
&self.project().diagrams[diagram],
route.edge,
) == *route
{
return Task::none();
}
if self.execute(plan.command) {
self.behavior.diagram.diagram = Some(plan.diagram);
self.behavior.diagram.placement = plan.placement;
@@ -1,5 +1,11 @@
//! App transaction and draft boundaries for captured state-canvas messages.
use super::*;
#[path = "state_attachment_escape_tests.rs"]
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},
@@ -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<Message> {
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);
}
@@ -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());
}
}
@@ -158,6 +158,14 @@ impl Mounted {
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
self.input_with_status(app, events, cursor).0
}
pub fn input_with_status(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> (Vec<Message>, Vec<iced::event::Status>) {
let mut messages = vec![];
let mut ui = UserInterface::build(
app.view(),
@@ -165,7 +173,7 @@ impl Mounted {
std::mem::take(&mut self.cache),
&mut self.renderer,
);
let _ = ui.update(
let (_, statuses) = ui.update(
events,
cursor,
&mut self.renderer,
@@ -173,7 +181,7 @@ impl Mounted {
&mut messages,
);
self.cache = ui.into_cache();
messages
(messages, statuses)
}
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
let mut ready = vec![];
@@ -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,
@@ -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::<iced::widget::text_input::State<()>>()
.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());
@@ -0,0 +1,656 @@
// Included in state_canvas::tests: exercise the real canvas event path.
fn bend_fixture(authored: bool) -> (Fixture, Id) {
let mut f = fixture();
let relation = f.project.relate(
RelationKind::StateTransition,
"Standby to stopped",
f.b,
f.c,
);
if authored {
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: relation,
waypoints: vec![Position {
x: 650.25,
y: 340.75,
}],
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.5,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
}),
label: Some(EdgeLabel {
fraction: 0.43,
offset: Position { x: 28., y: -16. },
}),
});
}
validate_candidate(&f.project).unwrap();
(f, relation)
}
fn bend_segment(scene: &Scene, relation: Id) -> (usize, Point) {
let edge = &scene.routed.edges[&relation];
edge.points
.windows(2)
.enumerate()
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
.map(|(i, p)| {
(
i,
Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.),
(p[1].x - p[0].x).abs() + (p[1].y - p[0].y).abs(),
)
})
.filter(|(_, p, length)| {
*length > 40. && edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.)
})
.max_by(|a, b| a.2.total_cmp(&b.2))
.map(|(i, p, _)| (i, p))
.expect("fixture has an exposed interior route segment")
}
fn bend_press(canvas: &StateCanvas<'_>, state: &mut State, at: Point) {
motion(canvas, state, at, at);
let event = input(
canvas,
state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
);
assert!(
!matches!(event, Some(Event::SetBends { .. })),
"press alone cannot commit"
);
}
#[test]
fn state_bends_segment_drag_inserts_one_pin_using_ordered_pointer_at_three_zooms() {
for zoom in [0.5, 1., 2.] {
let (f, relation) = bend_fixture(false);
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let (_, midpoint) = bend_segment(&baseline, relation);
let start = screen(&canvas, midpoint);
let destination = Position {
x: ((midpoint.x + 45.) / 5.).round() * 5.,
y: ((midpoint.y + 30.) / 5.).round() * 5.,
};
let release = screen(&canvas, point(destination));
let later_batch_cursor = release + Vector::new(137., 83.);
motion(&canvas, &mut state, start, later_batch_cursor);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
later_batch_cursor,
);
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
motion(&canvas, &mut state, release, later_batch_cursor);
assert_eq!(
state.bend_preview.as_ref().unwrap().waypoints,
vec![destination]
);
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].pins,
vec![destination]
);
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
later_batch_cursor,
)
.unwrap();
assert!(
matches!(&event, Event::SetBends { context, relation: id, waypoints }
if *context == canvas.context && *id == relation && *waypoints == vec![destination])
);
assert!(state.bend_preview.is_none());
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
later_batch_cursor
)
.is_none()
);
let after = prepare_command(
&f.project,
&state_edit::plan(&f.project, canvas.context, &event)
.unwrap()
.command,
)
.unwrap()
.project;
let stored = &after.diagrams[&f.diagram].routes[0];
assert_eq!(stored.waypoints, vec![destination]);
assert!(
stored.source_attachment.is_none()
&& stored.target_attachment.is_none()
&& stored.label.is_none()
);
assert_eq!(after.relations, f.project.relations);
}
}
#[test]
fn state_bends_authored_pin_move_preserves_full_project_and_exact_undo_redo() {
for zoom in [0.5, 1., 2.] {
let (mut f, relation) = bend_fixture(true);
let mut other = f.project.diagrams[&f.diagram].clone();
other.id = Id::new_v4();
other.alias = "bend_reference_view".into();
other.nodes = other
.nodes
.into_values()
.map(|mut node| {
node.id = Id::new_v4();
node.alias = format!("bend_reference_{}", node.alias);
(node.id, node)
})
.collect();
let other_id = other.id;
f.project.diagrams.insert(other_id, other.clone());
validate_candidate(&f.project).unwrap();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let start = screen(&canvas, point(original.waypoints[0]));
let destination = Position { x: 690., y: 365. };
let release = screen(&canvas, point(destination));
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
let preview = state.bend_preview.as_ref().unwrap();
let mut intended = original.clone();
intended.waypoints[0] = destination;
assert_eq!(preview, &intended);
assert_eq!(
f.project.diagrams[&f.diagram].routes[0], original,
"preview is transient"
);
let preview_scene = canvas.scene(&state).unwrap();
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
)
.unwrap();
assert!(
matches!(&event, Event::SetBends { waypoints, .. } if waypoints == &intended.waypoints)
);
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
let mut expected = f.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = intended;
let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap();
let before = document.bundle().clone();
document.execute(plan.command).unwrap();
assert_eq!(document.project(), &expected);
assert_eq!(document.project().diagrams[&other_id], other);
let committed_view = projection::project_view(document.project(), f.diagram);
let committed = routing::route_diagram(
document.project(),
&document.project().diagrams[&f.diagram],
&committed_view,
);
let preview_edge = &preview_scene.routed.edges[&relation];
let committed_edge = &committed.edges[&relation];
assert_eq!(preview_edge.points, committed_edge.points);
assert_eq!(preview_edge.pin_fractions, committed_edge.pin_fractions);
assert_eq!(preview_edge.warnings, committed_edge.warnings);
let edited = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.project(), &f.project);
assert_eq!(document.bundle(), &before);
assert!(!document.undo(), "one drag adds one history operation");
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &edited);
assert!(!document.redo());
}
}
#[test]
fn state_bends_stationary_and_out_and_back_restore_off_grid_or_automatic_route() {
for authored in [false, true] {
for zoom in [0.5, 1., 2.] {
for travel in [false, true] {
let (f, relation) = bend_fixture(authored);
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let world = if authored {
point(scene.routed.edges[&relation].pins[0])
} else {
bend_segment(&scene, relation).1
};
let start = screen(&canvas, world);
bend_press(&canvas, &mut state, start);
if travel {
let away = start + Vector::new(45., 30.) * zoom;
motion(&canvas, &mut state, away, away);
assert!(state.bend_preview.is_some());
motion(&canvas, &mut state, start, start);
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
start
)
.is_none()
);
assert!(state.bend_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
scene.routed.edges[&relation].points
);
assert_eq!(
f.project.diagrams[&f.diagram].routes.len(),
usize::from(authored)
);
if authored {
assert_eq!(
f.project.diagrams[&f.diagram].routes[0].waypoints,
vec![Position {
x: 650.25,
y: 340.75
}]
);
}
}
}
}
}
#[test]
fn state_bends_cancel_on_camera_context_focus_and_pointer_interruptions() {
for cancellation in 0..16 {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let start = screen(&canvas, point(original.waypoints[0]));
let release = start + Vector::new(45., 30.);
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
assert!(state.bend_preview.is_some(), "case {cancellation}");
let mut release_bounds = bounds();
match cancellation {
0 => canvas.zoom = 2.,
1 => canvas.pan.x += 10.,
2 => canvas.context.epoch += 1,
3 => canvas.context.revision += 1,
4 => canvas.context.document = Id::new_v4(),
5 => canvas.tool = Tool::Transition,
6 => canvas.editable = false,
7 | 8 => {
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(if cancellation == 7 {
mouse::Button::Right
} else {
mouse::Button::Middle
}),
release,
);
}
9 => {
canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds(),
mouse::Cursor::Available(release),
);
}
10 => {
input(
&canvas,
&mut state,
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
},
release,
);
}
11 => {
input(&canvas, &mut state, mouse::Event::CursorLeft, release);
}
12 => {
motion(&canvas, &mut state, Point::new(-100., -100.), release);
}
13 => release_bounds.x += 25.,
15 => canvas.selected = f.b,
_ => {
let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
modified_key: keyboard::Key::Named(keyboard::key::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 event = canvas::Program::update(
&canvas,
&mut state,
&escape,
bounds(),
mouse::Cursor::Available(release),
)
.and_then(|action| action.into_inner().0);
assert!(matches!(event, Some(Event::Choose(Tool::Select))));
}
}
let event = input_at_bounds(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
release_bounds,
);
assert!(
!matches!(event, Some(Event::SetBends { .. })),
"late release committed case {cancellation}"
);
assert!(
state.bend_preview.is_none(),
"stale preview survived case {cancellation}"
);
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
}
}
#[test]
fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let pin = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
motion(&canvas, &mut state, pin, pin);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin),
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None })
if context == canvas.context && object == relation && anchor == pin)
);
let (index, midpoint) = bend_segment(&scene, relation);
let at = screen(&canvas, midpoint);
motion(&canvas, &mut state, at, at);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)) })
if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001)
);
}
#[test]
fn state_bends_internal_rows_and_folded_proxies_cannot_acquire_pins() {
for internal in [false, true] {
let mut f = fixture();
let relation = if internal {
let id = f
.project
.relate(RelationKind::StateTransition, "Internal check", f.a, f.a);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&id).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
id
} else {
let id = f.project.relate(
RelationKind::StateTransition,
"Hidden child to standby",
f.leaf,
f.b,
);
let parent = placement(&f, f.a);
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
id
};
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let world = if internal {
assert!(!scene.routed.edges.contains_key(&relation));
let node = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
internal_rows(&f.project, node)
.into_iter()
.find(|(r, _)| r.id == relation)
.unwrap()
.1
.center()
} else {
assert!(scene.routed.edges[&relation].custom_suspended);
bend_segment(&scene, relation).1
};
let start = screen(&canvas, world);
motion(&canvas, &mut state, start, start);
assert!(matches!(
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Right),
start
),
Some(Event::ContextMenu {
bend: None,
route_segment: None,
..
})
));
bend_press(&canvas, &mut state, start);
let away = start + Vector::new(30., 20.);
motion(&canvas, &mut state, away, away);
assert!(state.bend_preview.is_none());
assert!(!matches!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
away
),
Some(Event::SetBends { .. })
));
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
}
}
#[test]
fn state_bends_right_click_discards_provisional_indexes_before_building_menu() {
for click_pin in [true, false] {
let (mut f, relation) = bend_fixture(true);
let route = &mut f.project.diagrams.get_mut(&f.diagram).unwrap().routes[0];
route.waypoints = vec![Position { x: 650., y: 340. }, Position { x: 650., y: 390. }];
validate_candidate(&f.project).unwrap();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let edge = &baseline.routed.edges[&relation];
let candidates: Vec<_> = edge.points.windows(2).enumerate()
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
.map(|(i, p)| (i, Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.)))
.filter(|(_, p)| edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.))
.filter(|(_, p)| matches!(canvas.hit(&baseline, *p), Some(Hit::Transition(id)) if id == relation))
.filter(|(_, p)| !Rectangle::new(point(edge.label.bounds.position), Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height)).contains(*p))
.collect();
let (_, insertion_at) = candidates
.iter()
.copied()
.find(|(i, p)| routing::pin_insertion_index(edge, *i, position(*p)) == 0)
.expect("exposed segment before both authored pins");
let menu_segment = candidates
.iter()
.copied()
.find(|(_, p)| p.distance(insertion_at) > 30.)
.expect("a distinct committed segment for the menu control");
let start = screen(&canvas, insertion_at);
bend_press(&canvas, &mut state, start);
let away = start + Vector::new(45., 30.);
motion(&canvas, &mut state, away, away);
let preview = state.bend_preview.as_ref().unwrap();
assert_eq!(preview.waypoints.len(), 3);
assert_eq!(
&preview.waypoints[1..],
original.waypoints.as_slice(),
"provisional insertion shifts both stored indices"
);
// With the left gesture still active, moving to the last stored pin also
// moves provisional pin0 there. The preview's first hit is therefore
// pin0, but a menu built after cancellation must identify stored pin1.
let menu_world = if click_pin {
point(original.waypoints[1])
} else {
menu_segment.1
};
let menu_window = screen(&canvas, menu_world);
motion(&canvas, &mut state, menu_window, menu_window);
assert!(state.bend_preview.is_some());
let menu = input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Right),
menu_window,
)
.unwrap();
let Event::ContextMenu {
context,
object,
bend,
route_segment,
..
} = menu
else {
panic!("expected context menu after cancelling drag")
};
assert_eq!(context, canvas.context);
assert_eq!(object, relation);
assert!(state.bend_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
edge.points
);
if click_pin {
assert_eq!(
bend,
Some(1),
"menu index must address the stored route, not the provisional route"
);
assert!(route_segment.is_none());
let remove = Event::RemoveBend {
context,
relation,
index: bend.unwrap(),
};
let plan = state_edit::plan(&f.project, context, &remove).unwrap();
let after = prepare_command(&f.project, &plan.command).unwrap().project;
let mut expected = f.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0]
.waypoints
.remove(1);
assert_eq!(
after, expected,
"remove menu action must delete exactly the addressed original pin"
);
} else {
assert!(
bend.is_none(),
"provisional pin under cursor must not leak into menu"
);
let (segment, at) = route_segment.expect("committed route segment remains actionable");
assert_eq!(segment, menu_segment.0);
assert!(point(at).distance(menu_world) < 0.001);
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
menu_window
)
.is_none()
);
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
}
}
#[test]
fn state_bends_draw_before_update_excludes_preview_after_camera_or_bounds_change() {
for change in 0..4 {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
let start = screen(&canvas, point(baseline.routed.edges[&relation].pins[0]));
let release = start + Vector::new(45., 30.);
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
let displayed_preview = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
assert_ne!(
displayed_preview.routed.edges[&relation].pins,
baseline.routed.edges[&relation].pins
);
let mut changed_bounds = bounds();
match change {
0 => canvas.zoom = 2.,
1 => canvas.pan.y += 25.,
2 => changed_bounds.x += 35.,
_ => changed_bounds.width -= 90.,
}
// No update event between camera/layout change and this draw-time read.
let shown = canvas
.scene_with_bounds(&state, Some(changed_bounds))
.unwrap();
assert_eq!(
shown.routed.edges[&relation].pins, baseline.routed.edges[&relation].pins,
"stale draw-time pins for case {change}"
);
assert_eq!(
shown.routed.edges[&relation].points, baseline.routed.edges[&relation].points,
"stale cached preview for case {change}"
);
let event = input_at_bounds(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
changed_bounds,
);
assert!(!matches!(event, Some(Event::SetBends { .. })));
assert!(state.bend_preview.is_none());
}
}
+259 -21
View File
@@ -44,6 +44,8 @@ pub struct State {
source: Option<Id>,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
bend: Option<BendGesture>,
bend_preview: Option<EdgeRoute>,
pan_from: Option<(mouse::Button, Point)>,
viewport: Option<Size>,
attachment_viewport: Option<(f32, Vector, Rectangle)>,
@@ -84,6 +86,16 @@ enum Press {
placement: Id,
start: Point,
},
Bend {
relation: Id,
},
}
struct BendGesture {
start: Point,
route: EdgeRoute,
index: usize,
baseline: Arc<Scene>,
}
#[derive(Clone, Copy, PartialEq)]
@@ -103,6 +115,7 @@ struct CachedScene {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
bend_preview: Option<EdgeRoute>,
scene: Arc<Scene>,
}
#[derive(PartialEq)]
@@ -110,6 +123,7 @@ struct PaintKey {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
bend_preview: Option<EdgeRoute>,
selected: Id,
zoom: f32,
pan: Vector,
@@ -122,11 +136,16 @@ enum Hit {
}
impl State {
fn cancel_gesture(&mut self) {
fn cancel_edit(&mut self) {
self.press = None;
self.preview = None;
self.attachment_preview = None;
self.bend = None;
self.bend_preview = None;
self.attachment_viewport = None;
}
fn cancel_gesture(&mut self) {
self.cancel_edit();
self.pan_from = None;
}
fn reset(&mut self) {
@@ -669,25 +688,31 @@ impl StateCanvas<'_> {
&& state_edit::validate_context(self.project, self.context, self.context).is_ok()
}
fn scene(&self, state: &State) -> Option<Arc<Scene>> {
self.scene_with_bounds(state, None)
}
fn scene_with_bounds(&self, state: &State, bounds: Option<Rectangle>) -> Option<Arc<Scene>> {
if !self.valid() {
return None;
}
let route_active = self.gesture_active(state) && self.route_viewport_current(state, bounds);
let preview = self
.gesture_active(state)
.then_some(state.preview)
.flatten();
let attachment_preview = self
.gesture_active(state)
.then_some(state.attachment_preview)
.flatten();
let attachment_preview = route_active.then_some(state.attachment_preview).flatten();
let bend_preview = route_active.then(|| state.bend_preview.clone()).flatten();
if let Some(cached) = state.scene.borrow().as_ref()
&& cached.context == self.context
&& cached.preview == preview
&& cached.attachment_preview == attachment_preview
&& cached.bend_preview == bend_preview
{
return Some(cached.scene.clone());
}
let mut diagram = self.project.diagrams.get(&self.diagram)?.clone();
if let Some(route) = &bend_preview {
crate::route_edit::with_route(&mut diagram, route.clone());
}
if let Some(preview) = attachment_preview {
let mut route = crate::route_edit::stored_route(&diagram, preview.relation);
route.set_attachment(preview.end, Some(preview.attachment));
@@ -717,7 +742,17 @@ impl StateCanvas<'_> {
}
}
let view = projection::project_diagram(self.project, &diagram);
let routed = routing::route_diagram(self.project, &diagram, &view);
let routed = if let Some(gesture) = state.bend.as_ref().filter(|_| bend_preview.is_some()) {
routing::route_edge_preview(
self.project,
&diagram,
&view,
&gesture.baseline.routed,
gesture.route.edge,
)
} else {
routing::route_diagram(self.project, &diagram, &view)
};
let symbol_labels = symbol_labels(self.project, &view, &routed);
let scene = Arc::new(Scene {
view,
@@ -728,6 +763,7 @@ impl StateCanvas<'_> {
context: self.context,
preview,
attachment_preview,
bend_preview,
scene: scene.clone(),
});
Some(scene)
@@ -859,6 +895,84 @@ impl StateCanvas<'_> {
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(endpoint, _)| (route.id, endpoint))
}
fn bend_at(&self, scene: &Scene, world: Point) -> Option<usize> {
let edge = scene.routed.edges.get(&self.selected)?;
if edge.custom_suspended {
return None;
}
edge.pins
.iter()
.enumerate()
.filter_map(|(index, pin)| {
let distance = world.distance(point(*pin));
(distance <= 8. / self.scale()).then_some((index, distance))
})
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)))
.map(|(index, _)| index)
}
/// Preserve the rendered traversal index; overlapping runs can have the
/// same world point but different positions among the authored pins.
fn segment_at(&self, scene: &Scene, relation: Id, world: Point) -> Option<(usize, Position)> {
let edge = scene.routed.edges.get(&relation)?;
if edge.custom_suspended {
return None;
}
edge.points
.windows(2)
.enumerate()
.filter(|(index, _)| *index > 0 && index + 2 < edge.points.len())
.filter_map(|(index, span)| {
let projection = routing::project_onto_polyline(span, position(world))?;
(projection.distance <= 7. / self.scale()).then_some((index, projection.distance))
})
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)))
.map(|(index, _)| (index, position(world)))
}
fn begin_bend(
&self,
state: &mut State,
scene: Arc<Scene>,
window: Point,
route: EdgeRoute,
index: usize,
bounds: Rectangle,
) {
state.press = Some(Press::Bend {
relation: route.edge,
});
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
state.bend = Some(BendGesture {
start: window,
route,
index,
baseline: scene,
});
state.bend_preview = None;
}
fn move_bend_pointer(&self, state: &mut State, window: Point, local: Point) {
let Some(gesture) = &state.bend else {
return;
};
if window.distance(gesture.start) < 3. {
// Also discard a provisional insertion. The exact original route
// comes from the model, so an off-grid pin is never quantized here.
state.bend_preview = None;
return;
}
let world = self.world(local);
if !world.x.is_finite() || !world.y.is_finite() {
state.cancel_gesture();
return;
}
let mut changed = gesture.route.clone();
changed.waypoints[gesture.index] = Position {
x: (world.x / 5.).round() * 5.,
y: (world.y / 5.).round() * 5.,
};
let original =
crate::route_edit::stored_route(&self.project.diagrams[&self.diagram], changed.edge);
state.bend_preview = (changed != original).then_some(changed);
}
fn owner_at(&self, scene: &Scene, world: Point, kind: Kind) -> Option<Id> {
self.node_at(scene, world)
.filter(|n| state_edit::eligible_owner(self.project, self.diagram, n.element, kind))
@@ -887,7 +1001,22 @@ impl StateCanvas<'_> {
}
}
fn gesture_active(&self, state: &State) -> bool {
self.editable && state.context == Some(self.context) && state.tool == Some(self.tool)
self.editable
&& state.context == Some(self.context)
&& state.tool == Some(self.tool)
&& state
.bend
.as_ref()
.is_none_or(|g| g.route.edge == self.selected)
}
fn route_viewport_current(&self, state: &State, bounds: Option<Rectangle>) -> bool {
state
.attachment_viewport
.is_none_or(|(zoom, pan, captured)| {
zoom == self.zoom
&& pan == self.pan
&& bounds.is_none_or(|current| current == captured)
})
}
fn move_attachment_pointer(&self, state: &mut State, window: Point, local: Point) {
let Some(Press::Attachment {
@@ -941,6 +1070,23 @@ impl StateCanvas<'_> {
if !self.editable {
return "Pending edits · apply or cancel them before editing the diagram".into();
}
if state.bend.is_some()
&& self.gesture_active(state)
&& self.route_viewport_current(state, None)
{
return "Move bend · release to keep · Esc cancels".into();
}
if self.tool == Tool::Select
&& state.press.is_none()
&& scene
.routed
.edges
.get(&self.selected)
.is_some_and(|edge| !edge.custom_suspended)
{
return "Drag line to add a bend · squares move bends · right-click a square to remove"
.into();
}
if let Tool::AdjustAttachment { relation, end } = self.tool {
return format!(
"Adjust {end:?} attachment of {} · drag that tip around its existing vertex boundary · Esc cancels · relation unchanged",
@@ -1035,11 +1181,18 @@ impl canvas::Program<Event> for StateCanvas<'_> {
state.tool = Some(self.tool);
}
if !self.editable {
state.press = None;
state.preview = None;
state.attachment_preview = None;
// Pending edits block model changes, but read-only panning remains
// available and must survive successive pointer events.
state.cancel_edit();
state.source = None;
}
if state
.bend
.as_ref()
.is_some_and(|g| g.route.edge != self.selected)
{
state.cancel_gesture();
}
if state
.attachment_viewport
.is_some_and(|view| view != (self.zoom, self.pan, bounds))
@@ -1055,6 +1208,9 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
canvas::Event::Mouse(mouse::Event::CursorLeft) => {
state.pointer = None;
if state.bend.is_some() {
state.cancel_gesture();
}
cursor
}
canvas::Event::Mouse(
@@ -1150,10 +1306,23 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
if *button == mouse::Button::Right {
state.reset();
let (object, placement) = match self.hit(&scene, world) {
Some(Hit::Node { element, placement }) => (element, Some(placement)),
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
None => (self.diagram, None),
// A canceled provisional pin shifts indices in its preview.
// Menu commands must target the committed route instead.
let scene = self.scene(state)?;
let bend = self.bend_at(&scene, world);
let (object, placement) = if bend.is_some() {
(self.selected, None)
} else {
match self.hit(&scene, world) {
Some(Hit::Node { element, placement }) => (element, Some(placement)),
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
None => (self.diagram, None),
}
};
let route_segment = if bend.is_none() {
self.segment_at(&scene, object, world)
} else {
None
};
return Some(
Action::publish(Event::ContextMenu {
@@ -1161,6 +1330,8 @@ impl canvas::Program<Event> for StateCanvas<'_> {
object,
placement,
anchor: window,
bend,
route_segment,
})
.and_capture(),
);
@@ -1178,6 +1349,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
});
return Some(Action::capture());
}
if let Some(index) = self.bend_at(&scene, world) {
let route = crate::route_edit::stored_route(
&self.project.diagrams[&self.diagram],
self.selected,
);
self.begin_bend(state, scene.clone(), window, route, index, bounds);
return Some(Action::capture());
}
match self.hit(&scene, world) {
Some(Hit::Node { placement, element }) => {
let original = self
@@ -1198,6 +1377,36 @@ impl canvas::Program<Event> for StateCanvas<'_> {
)
}
Some(Hit::Transition(id) | Hit::StateAction(id)) => {
if id == self.selected
&& let Some(edge) = scene.routed.edges.get(&id)
&& !Rectangle::new(
point(edge.label.bounds.position),
Size::new(
edge.label.bounds.size.width,
edge.label.bounds.size.height,
),
)
.contains(world)
&& let Some((segment, at)) = self.segment_at(&scene, id, world)
{
let mut route = crate::route_edit::stored_route(
&self.project.diagrams[&self.diagram],
id,
);
if let Some(index) = crate::route_edit::insert_pin_on_segment(
&mut route, edge, segment, at,
) {
self.begin_bend(
state,
scene.clone(),
window,
route,
index,
bounds,
);
return Some(Action::capture());
}
}
Some(Action::publish(Event::Select(self.context, id)).and_capture())
}
None => {
@@ -1275,6 +1484,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
self.move_bend_pointer(state, window, local);
}
Some(Action::request_redraw())
}
@@ -1298,14 +1508,24 @@ impl canvas::Program<Event> for StateCanvas<'_> {
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
self.move_bend_pointer(state, window, local);
}
}
let press = state.press.take()?;
state.attachment_viewport = None;
let preview = state.preview.take();
let attachment_preview = state.attachment_preview.take();
let bend_preview = state.bend_preview.take();
state.bend = None;
let world = local.map(|p| self.world(p));
let event = match press {
Press::Bend { relation } if inside => bend_preview
.filter(|route| route.edge == relation)
.map(|route| Event::SetBends {
context: self.context,
relation,
waypoints: route.waypoints,
}),
Press::Move { node, .. } => preview.map(|(_, position)| Event::Move {
context: self.context,
node,
@@ -1416,19 +1636,19 @@ impl canvas::Program<Event> for StateCanvas<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let Some(scene) = self.scene(state) else {
let Some(scene) = self.scene_with_bounds(state, Some(bounds)) else {
return vec![];
};
let route_active =
self.gesture_active(state) && self.route_viewport_current(state, Some(bounds));
let key = PaintKey {
context: self.context,
preview: self
.gesture_active(state)
.then_some(state.preview)
.flatten(),
attachment_preview: self
.gesture_active(state)
.then_some(state.attachment_preview)
.flatten(),
attachment_preview: route_active.then_some(state.attachment_preview).flatten(),
bend_preview: route_active.then(|| state.bend_preview.clone()).flatten(),
selected: self.selected,
zoom: self.scale(),
pan: self.pan,
@@ -1508,6 +1728,22 @@ impl canvas::Program<Event> for StateCanvas<'_> {
style::MUTED,
);
if active {
if !edge.custom_suspended && self.tool == Tool::Select {
for pin in &edge.pins {
let radius = 4. / self.scale();
let handle = Path::rectangle(
point(*pin) - Vector::new(radius, radius),
Size::new(radius * 2., radius * 2.),
);
frame.fill(&handle, Color::WHITE);
frame.stroke(
&handle,
Stroke::default()
.with_color(style::TEAL)
.with_width(1.6 / self.scale()),
);
}
}
for end in [edge.points.first(), edge.points.last()]
.into_iter()
.flatten()
@@ -1922,6 +2158,8 @@ mod tests {
use super::*;
use crate::document::Document;
include!("state_bend_tests.rs");
struct Fixture {
project: Project,
diagram: Id,
@@ -2377,7 +2615,7 @@ mod tests {
motion(&canvas, &mut state, at, at);
assert!(matches!(input(&canvas, &mut state,
mouse::Event::ButtonPressed(mouse::Button::Right), at),
Some(Event::ContextMenu { context, object, placement: Some(node), anchor })
Some(Event::ContextMenu { context, object, placement: Some(node), anchor, .. })
if context == canvas.context && object == f.b
&& node == placement(&f, f.b) && anchor == at));
let background = Point::new(
@@ -2387,7 +2625,7 @@ mod tests {
motion(&canvas, &mut state, background, background);
assert!(matches!(input(&canvas, &mut state,
mouse::Event::ButtonPressed(mouse::Button::Right), background),
Some(Event::ContextMenu { context, object, placement: None, anchor })
Some(Event::ContextMenu { context, object, placement: None, anchor, .. })
if context == canvas.context && object == f.diagram && anchor == background));
}
+104 -1
View File
@@ -39,6 +39,8 @@ pub enum Event {
object: Id,
placement: Option<Id>,
anchor: iced::Point,
bend: Option<usize>,
route_segment: Option<(usize, Position)>,
},
CreateAt {
context: Context,
@@ -74,6 +76,25 @@ pub enum Event {
placement: Id,
attachment: Option<EdgeAttachment>,
},
SetBends {
context: Context,
relation: Id,
waypoints: Vec<Position>,
},
AddBend {
context: Context,
relation: Id,
segment: Option<(usize, Position)>,
},
RemoveBend {
context: Context,
relation: Id,
index: usize,
},
ClearBends {
context: Context,
relation: Id,
},
}
impl Event {
@@ -93,7 +114,11 @@ impl Event {
| Self::Move { context: c, .. }
| Self::Connect { context: c, .. }
| Self::Reconnect { context: c, .. }
| Self::SetAttachment { context: c, .. } => Some(*c),
| Self::SetAttachment { context: c, .. }
| Self::SetBends { context: c, .. }
| Self::AddBend { context: c, .. }
| Self::RemoveBend { context: c, .. }
| Self::ClearBends { context: c, .. } => Some(*c),
}
}
}
@@ -288,6 +313,24 @@ pub fn attachment_node(
view.nodes.iter().find(|node| node.id == owner).cloned()
}
/// Actual visible State transition geometry, never an internal row or folded proxy.
pub fn bend_route(
project: &Project,
diagram: Id,
relation: Id,
) -> Option<oa_core::routing::RoutedEdge> {
machine(project, diagram).ok()?;
if project.relations.get(&relation)?.kind() != RelationKind::StateTransition {
return None;
}
let stored = project.diagrams.get(&diagram)?;
let view = projection::project_diagram(project, stored);
let geometry = oa_core::routing::route_diagram(project, stored, &view)
.edges
.remove(&relation)?;
(!geometry.custom_suspended).then_some(geometry)
}
fn checked(project: &Project, plan: Plan) -> Result<Plan, ModelError> {
prepare_command(project, &plan.command)?;
Ok(plan)
@@ -547,6 +590,66 @@ pub fn plan(project: &Project, current: Context, event: &Event) -> Result<Plan,
route.set_attachment(end, attachment);
(Command::SetEdgeRoute { diagram, route }, relation, None)
}
Event::SetBends { relation, .. }
| Event::AddBend { relation, .. }
| Event::RemoveBend { relation, .. }
| Event::ClearBends { relation, .. } => {
let geometry = bend_route(project, diagram, relation)
.ok_or_else(|| error("Choose an actual visible transition route in this view."))?;
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
match event {
Event::SetBends { waypoints, .. } => {
if waypoints.len() > 128 {
return Err(error("A route supports at most 128 bends."));
}
for &point in waypoints {
finite(point)?;
}
route.waypoints = waypoints.clone();
}
Event::AddBend { segment, .. } => {
let (segment, point) = segment
.or_else(|| {
geometry
.points
.windows(2)
.enumerate()
.filter(|(index, _)| {
*index > 0 && index + 2 < geometry.points.len()
})
.max_by(|(_, a), (_, b)| {
let length = |s: &[Position]| {
(s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs()
};
length(a).total_cmp(&length(b))
})
.map(|(index, span)| {
(
index,
Position {
x: (span[0].x + span[1].x) / 2.,
y: (span[0].y + span[1].y) / 2.,
},
)
})
})
.ok_or_else(|| error("This route has no interior segment for a bend."))?;
crate::route_edit::insert_pin_on_segment(&mut route, &geometry, segment, point)
.ok_or_else(|| {
error("The bend segment is stale, invalid, or at capacity.")
})?;
}
Event::RemoveBend { index, .. } => {
if *index >= route.waypoints.len() {
return Err(error("This bend no longer exists."));
}
route.waypoints.remove(*index);
}
Event::ClearBends { .. } => route.waypoints.clear(),
_ => unreachable!(),
}
(Command::SetEdgeRoute { diagram, route }, relation, None)
}
_ => {
return Err(error(
"Handle this navigation or tool request without a model command.",
@@ -161,6 +161,53 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
);
entries.push(item("Edit transition", edit, true));
if let Some(context) = context {
let bend_geometry = state_edit::bend_route(project, context.diagram, id);
let bend_count = bend_geometry.as_ref().map_or(0, |route| route.pins.len());
let can_add = bend_geometry.as_ref().is_some_and(|route| {
bend_count < 128
&& request.route_segment.map_or_else(
|| route.points.len() >= 4,
|(index, point)| {
index
.checked_add(2)
.is_some_and(|end| end <= route.points.len())
&& point.x.is_finite()
&& point.y.is_finite()
},
)
});
entries.push(item(
if request.route_segment.is_some() {
"Add bend here"
} else {
"Add bend"
},
diagram_event(Event::AddBend {
context,
relation: id,
segment: request.route_segment,
}),
can_add,
));
if let Some(index) = request.bend {
entries.push(item(
"Remove bend",
diagram_event(Event::RemoveBend {
context,
relation: id,
index,
}),
bend_geometry.is_some() && index < bend_count,
));
}
entries.push(item(
"Clear bends",
diagram_event(Event::ClearBends {
context,
relation: id,
}),
bend_geometry.is_some() && bend_count > 0,
));
let visible = projection::project_view(project, context.diagram)
.edges
.iter()
@@ -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);
}
+2
View File
@@ -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.);
+44 -3
View File
@@ -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();
+15 -3
View File
@@ -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",
&[
@@ -565,6 +576,7 @@ fn article(topic: Topic) -> Article {
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex. To change only where a line touches its existing vertex, choose Adjust source attachment or Adjust target attachment, then drag the highlighted tip along the boundary. Reset source attachment or Reset target attachment restores automatic placement for that end without changing the transition's meaning or other route settings. Escape cancels an attachment drag.",
"To shape a selected transition, drag an exposed line segment to add a bend, or drag a square bend handle to move it. Right-click a square and choose Remove bend. The transition's Actions menu also offers Add bend and Clear bends; clearing bends keeps its attachment points and label placement. Bends belong to this saved view. A click alone changes nothing, Escape cancels a drag, and Undo restores the complete edit. Internal transition rows and folded connections do not expose bend editing while their route is hidden.",
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
],
),
+5 -1
View File
@@ -14,6 +14,7 @@ pub struct Request {
pub placement: Option<Id>,
pub bend: Option<usize>,
pub route_point: Option<Position>,
pub route_segment: Option<(usize, Position)>,
pub creation_point: Option<Position>,
pub canvas: Option<route_edit::Context>,
pub source_file: Option<String>,
@@ -26,6 +27,7 @@ impl Request {
placement: None,
bend: None,
route_point: None,
route_segment: None,
creation_point: None,
canvas: None,
source_file: None,
@@ -456,7 +458,8 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
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()),
));
}
}
@@ -587,6 +590,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);
}
@@ -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
);
}
}
}
}
+28
View File
@@ -137,6 +137,34 @@ pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
}
/// Insert after the pins preceding the clicked segment along the routed path.
pub fn insert_pin_on_segment(
route: &mut EdgeRoute,
geometry: &RoutedEdge,
segment: usize,
point: Position,
) -> Option<usize> {
if route.edge != geometry.id
|| geometry.custom_suspended
|| route.waypoints != geometry.pins
|| route.waypoints.len() >= 128
|| !point.x.is_finite()
|| !point.y.is_finite()
|| geometry
.points
.get(segment..segment.checked_add(2)?)
.is_none()
{
return None;
}
let index = pin_insertion_index(geometry, segment, point);
if index > route.waypoints.len() {
return None;
}
route.waypoints.insert(index, point);
Some(index)
}
/// Find a segment when an ordinary caller has only a point, not a captured hit.
pub fn insert_pin(route: &mut EdgeRoute, geometry: &RoutedEdge, point: Position) -> usize {
let segment = geometry
.points
+22
View File
@@ -599,6 +599,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(
+23 -4
View File
@@ -7,7 +7,10 @@ https://raw.githubusercontent.com/iced-rs/iced/54abf81d13fec06d4d9ac754b03ce2c33
SHA-256 `fc9086ba4eba4b77e4c603906362742bc3715ffa2c7810c6074fe7780d3a0398`.
All copied src/assets and Cargo manifests retain their upstream bytes except
`src/text_editor.rs`, which adds one documented read-only `Content::with_editor`
the three modules described below. Their original files are identified in
UPSTREAM-MANIFEST.json.
`src/text_editor.rs` adds one documented read-only `Content::with_editor`
callback. Original file SHA-256:
`cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1`.
The callback borrows the existing renderer editor and releases the borrow when
@@ -19,6 +22,20 @@ measurement. The isolated Windows CPU experiment verified the exact painted
editor identity and geometry plus retained Word/Line selection and scroll;
application integration/native validation are separately required.
`src/pick_list.rs` shares its open flag with the menu overlay and supplies a
base-event fallback for a picker opened earlier in the same event batch.
Escape dismisses the open picker, captures the key, requests redraw and sends
its optional close notification without selecting an option.
`src/overlay/menu.rs` adds the private opt-in bridge used by PickList. It handles
Escape before controls underneath the overlay receive it. Once its owner is
closed, the retained overlay ignores later queued events and no longer draws
or reports a mouse interaction. This prevents a queued click from selecting
an option after dismissal and prevents duplicate close notifications. Other
Menu consumers retain their existing behavior unless they opt into the bridge.
The application-level Escape routing and its regression tests are separate
from these vendored modules; source description alone is not runtime evidence.
UPSTREAM-MANIFEST.json records all files from the local pinned registry source,
including registry bookkeeping and the upstream crate lockfile not copied here.
Those omitted files are not package inputs. The workspace root Cargo.lock and
@@ -26,6 +43,8 @@ local patch path identify the resolved dependency; this patched package is not
the unmodified registry checksum. Keep LICENSE and this provenance in source
captures and generated dependency notices.
On upgrades compare the pristine upstream module and replay only this accessor.
Recheck actual editor layout, selection/input continuity and annotation tests.
Remove this patch if upstream provides equivalent read-only layout access.
On upgrades compare all three modules with their pristine upstream versions
and reassess each change. Recheck actual editor layout, selection/input
continuity, annotations, picker dismissal, same-batch queued input and close
notification behavior. Remove each local change when upstream provides its
equivalent behavior.
+49
View File
@@ -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<usize>,
on_selected: Box<dyn FnMut(T) -> Message + 'a>,
on_option_hovered: Option<&'a dyn Fn(T) -> Message>,
escape: Option<(&'a Cell<bool>, Option<Message>)>,
width: f32,
padding: Padding,
text_size: Option<Pixels>,
@@ -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<bool>,
on_close: Option<Message>,
) -> 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<bool>, Option<Message>)>,
width: f32,
target_height: f32,
class: &'a <Theme as Catalog>::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);
+24 -10
View File
@@ -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::<State<Renderer::Paragraph>>();
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::<State<Renderer::Paragraph>>();
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<P: text::Paragraph> {
menu: menu::State,
keyboard_modifiers: keyboard::Modifiers,
is_open: bool,
is_open: Cell<bool>,
hovered_option: Option<usize>,
options: Vec<paragraph::Plain<P>>,
placeholder: paragraph::Plain<P>,
@@ -775,7 +789,7 @@ impl<P: text::Paragraph> State<P> {
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(),
+83
View File
@@ -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.
+74
View File
@@ -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.
+90
View File
@@ -0,0 +1,90 @@
# State transition bend editing — issue 23
Branch-specific work above attachment source `53d01a3`, on
`codex/state-bends-ytr83r-20260923`.
[Issue 23](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/23)
owns live scope and acceptance. This document records the design; it does not
assert completed verification or integration.
The user asks to edit bends in State-machine connections as in architecture
diagrams. State already renders `EdgeRoute.waypoints`; its missing capability is
interactive authoring. Bends are positions in one saved diagram. They neither
change transition endpoints/meaning nor belong to every view of the transition.
## Interaction decision
Use direct manipulation of an already selected transition: drag an exposed line
segment to insert one bend, or drag an authored square handle to move that bend.
An initial selection click changes no route. Endpoint circles retain semantic
reconnect behavior; the separate attachment action retains its explicit mode.
State vertices and action rows retain selection/movement precedence. Clicking
a transition label selects it; this task does not introduce label dragging.
The transition menu offers Add bend here when invoked on an eligible segment,
or Add bend at the longest interior segment from Actions. Right-clicking an
authored square offers Remove bend. Clear bends removes all authored waypoints
in this view while retaining both attachment preferences and the label override.
Existing transition Delete behavior stays explicit and separate; this design
does not add a pin-specific Delete shortcut.
Alternatives considered: an Edit bends mode makes the distinction visible but
adds another persistent tool and cancellation/focus state; menu-only editing is
discoverable but too slow for repeated shaping. Direct dragging plus explicit
menu actions matches the existing architecture workflow without another mode.
The menu provides a recovery path when a label or endpoint covers a line hit.
## Invariants
- Only an actual visible, unsuspended routed State transition can be edited.
Internal transitions render as rows rather than lines. Folded endpoint proxies
suspend authored routing. In both cases existing dormant waypoints survive;
the UI disables operations instead of deleting metadata.
- Generated elbows, mandatory endpoint leads and computed self-loop detours are
routing output, not authored bends. Editing inserts one pin, never the entire
computed polyline. The picked rendered-segment index determines insertion
among stored pins, including reversed or overlapping traversals.
- Movement requires three screen pixels. Dragged pins snap to the existing
five-unit grid. A stationary press or return within three screen pixels
restores the exact baseline, including imported off-grid coordinates and
absence of a stored route. It creates no history entry.
- Preview uses the actual routing engine and captured baseline. The model stays
unchanged until release. A successful drag replaces only `waypoints` in the
current route, leaving attachments, label, relation metadata and other views
exact, in one existing `SetEdgeRoute` transaction.
- A gesture belongs to its document, diagram, revision, interaction epoch,
selected transition and viewport. Escape, pending edits, tool/context changes,
camera/bounds changes, focus loss, pointer exit, outside release and a pan/menu
interruption cancel it. A canceled preview must not supply menu pin indices.
A delayed drag result must not apply after selecting another object.
- Finite positions, valid indices and the existing 128-pin limit are checked at
the command boundary. Exact no-ops are suppressed before execution.
## Verification plan
Use real canvas event tests at multiple zooms for insertion/movement, ordered
cursor input, cancellation, exact return-to-press, preview invalidation and menu
targets. Planner/app tests compare entire Projects and source bundles across
Undo/Redo, preserve attachments/label/reference views, and refuse internal,
folded, nonfinite, stale, pending and capacity-invalid operations. Exercise
right-click during a provisional insertion and selection changes before queued
commit admission. Retain existing attachment/reconnect and focused Delete tests.
Then exercise the actual Windows application: add, move, remove and clear bends;
check unchanged attachments and independent views; Save/Undo/Redo and reopen;
observe menus, guidance, preview and normal close. Bind evidence to an identified
source/binary and predeclared complete-Project expectations. Compilation alone
does not establish interaction acceptance. Linux remains user-paused.
The first full workspace run exposed a read-only pan regression: canceling model
edits on every pending-draft event also canceled the middle-button pan. Keep
edit cancellation separate from navigation cancellation; the existing scoped
pan regression exercises this distinction. The self-loop test also distinguishes
typed-model restoration by Clear from byte-exact source restoration by Undo:
source-preserving edits may retain harmless empty-list whitespace, whereas Undo
must recover the original source bundle exactly. Failed run receipts are retained;
these findings do not assert that the corrected candidate has passed its gates.
Codex-956atf retains issue 16 resize and issue 27 integration ownership. This
work uses the existing State model/router and needs composition with those
contributions. It does not certify a combined release, change Process/Scenario
notation, redesign automatic arrangement or adopt a new routing schema.
+73
View File
@@ -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.