Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e0babf4a | ||
|
|
3e916f623c | ||
|
|
e9167f5cc8 | ||
|
|
301126f609 | ||
|
|
cf37c36be1 | ||
|
|
17f1f89fef | ||
|
|
4fbdf6f385 | ||
|
|
bfc259854f |
@@ -14,6 +14,7 @@ mod diagram_reveal;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod diagram_selection_tests;
|
mod diagram_selection_tests;
|
||||||
mod diagram_usages;
|
mod diagram_usages;
|
||||||
|
pub(crate) mod escape;
|
||||||
mod hosted;
|
mod hosted;
|
||||||
mod reference_authoring;
|
mod reference_authoring;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -22,6 +23,8 @@ pub(crate) mod source_rename;
|
|||||||
mod source_symbols;
|
mod source_symbols;
|
||||||
pub(crate) mod text_workspace;
|
pub(crate) mod text_workspace;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod traceability_navigation_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod transition_context_tests;
|
mod transition_context_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod view_copy_tests;
|
mod view_copy_tests;
|
||||||
@@ -359,6 +362,7 @@ pub enum Message {
|
|||||||
Page(Page),
|
Page(Page),
|
||||||
Guide(crate::guide::Event),
|
Guide(crate::guide::Event),
|
||||||
Traceability(crate::views::traceability::Event),
|
Traceability(crate::views::traceability::Event),
|
||||||
|
ReviewLayerTraces,
|
||||||
Behavior(crate::behavior::Event),
|
Behavior(crate::behavior::Event),
|
||||||
Layer(Layer),
|
Layer(Layer),
|
||||||
Select(Id),
|
Select(Id),
|
||||||
@@ -1161,7 +1165,7 @@ impl App {
|
|||||||
crate::model_menu::popup(self).or_else(|| self.text_workspace_menu()),
|
crate::model_menu::popup(self).or_else(|| self.text_workspace_menu()),
|
||||||
)
|
)
|
||||||
.into();
|
.into();
|
||||||
crate::source_navigation::interaction::observe(
|
let content = crate::source_navigation::interaction::observe(
|
||||||
crate::behavior::scenario::focus::root(
|
crate::behavior::scenario::focus::root(
|
||||||
behavior::canvas_workspace::focus_root(
|
behavior::canvas_workspace::focus_root(
|
||||||
crate::hosted::view::overlay(
|
crate::hosted::view::overlay(
|
||||||
@@ -1173,7 +1177,8 @@ impl App {
|
|||||||
self.scenario_focus_owner(),
|
self.scenario_focus_owner(),
|
||||||
),
|
),
|
||||||
self.source_symbols.raw_interaction.clone(),
|
self.source_symbols.raw_interaction.clone(),
|
||||||
)
|
);
|
||||||
|
escape::root(self, content)
|
||||||
}
|
}
|
||||||
pub fn subscription(&self) -> Subscription<Message> {
|
pub fn subscription(&self) -> Subscription<Message> {
|
||||||
Subscription::batch([
|
Subscription::batch([
|
||||||
@@ -1215,7 +1220,6 @@ impl App {
|
|||||||
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
|
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
|
||||||
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
|
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
|
||||||
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
|
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
|
||||||
architecture_authoring::subscription(self),
|
|
||||||
iced::event::listen_with(|event, _, _| {
|
iced::event::listen_with(|event, _, _| {
|
||||||
matches!(
|
matches!(
|
||||||
event,
|
event,
|
||||||
@@ -1223,20 +1227,6 @@ impl App {
|
|||||||
)
|
)
|
||||||
.then_some(Message::WorkDrag(allocation_drag::Event::KeyboardBoundary))
|
.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, _| {
|
iced::event::listen_with(|event, status, _| {
|
||||||
if matches!(event, iced::Event::Window(window::Event::Unfocused)) {
|
if matches!(event, iced::Event::Window(window::Event::Unfocused)) {
|
||||||
return Some(Message::WorkDrag(allocation_drag::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();
|
"Apply or revert pending edits before using modeling actions.".into();
|
||||||
return Task::none();
|
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_node = menu.request.placement;
|
||||||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||||||
return self.update(*action);
|
return self.update(*action);
|
||||||
@@ -2508,6 +2521,23 @@ impl App {
|
|||||||
iced::widget::scrollable::RelativeOffset::START,
|
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) => {
|
Message::Search(value) => {
|
||||||
self.search = value;
|
self.search = value;
|
||||||
self.explorer_page = 0;
|
self.explorer_page = 0;
|
||||||
@@ -4461,6 +4491,14 @@ impl App {
|
|||||||
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
|
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
|
||||||
return;
|
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);
|
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||||
let context = self.route_context().unwrap();
|
let context = self.route_context().unwrap();
|
||||||
self.connection = Some(Session::new(
|
self.connection = Some(Session::new(
|
||||||
@@ -4491,14 +4529,32 @@ impl App {
|
|||||||
if let Some(source) = session.source {
|
if let Some(source) = session.source {
|
||||||
match session.tool.command(self.project(), source, id) {
|
match session.tool.command(self.project(), source, id) {
|
||||||
Ok(command) => {
|
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) {
|
if self.execute(command) {
|
||||||
self.selected_node = None;
|
self.selected_node = None;
|
||||||
self.inspector_tab = Default::default();
|
self.inspector_tab = Default::default();
|
||||||
self.status = format!(
|
self.status = if session.tool
|
||||||
"{} created · edit its name and carried items in Properties · Undo restores the complete operation",
|
== crate::connection_tool::Tool::Allocation
|
||||||
session.tool.label()
|
{
|
||||||
);
|
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 {
|
} else {
|
||||||
|
self.layer = previous_layer;
|
||||||
self.connection = Some(session);
|
self.connection = Some(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4513,7 +4569,18 @@ impl App {
|
|||||||
&project_view(self.project(), context.diagram),
|
&project_view(self.project(), context.diagram),
|
||||||
id,
|
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() {
|
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()
|
"No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -27,30 +27,11 @@ pub enum Event {
|
|||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscription(app: &App) -> Subscription<Message> {
|
pub(super) fn captured_escape_message(app: &App) -> Option<Message> {
|
||||||
if app.architecture.as_ref().is_some_and(State::inline) {
|
app.architecture
|
||||||
iced::event::listen_with(captured_escape)
|
.as_ref()
|
||||||
} else {
|
.is_some_and(State::inline)
|
||||||
Subscription::none()
|
.then_some(Message::Architecture(Event::CapturedEscape))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -301,6 +282,17 @@ impl App {
|
|||||||
_ => return Task::none(),
|
_ => return Task::none(),
|
||||||
};
|
};
|
||||||
let placing = point.is_none() && matches!(intent, Intent::Create { .. });
|
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 {
|
let saved = Saved {
|
||||||
version: 1,
|
version: 1,
|
||||||
base: oa_core::transitions::project_fingerprint(self.project()),
|
base: oa_core::transitions::project_fingerprint(self.project()),
|
||||||
@@ -309,7 +301,7 @@ impl App {
|
|||||||
point,
|
point,
|
||||||
placing,
|
placing,
|
||||||
query: String::new(),
|
query: String::new(),
|
||||||
filter: Filter::Context,
|
filter,
|
||||||
show_placed: false,
|
show_placed: false,
|
||||||
page: 0,
|
page: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -229,13 +229,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
|||||||
content = content.push(group("Connections"));
|
content = content.push(group("Connections"));
|
||||||
for connection in crate::connection_tool::Tool::for_layer(app.layer) {
|
for connection in crate::connection_tool::Tool::for_layer(app.layer) {
|
||||||
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
|
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
|
||||||
let kind = match connection {
|
if !connection.available_in(app.layer, d.projection) {
|
||||||
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) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
content = content.push(tool(
|
content = content.push(tool(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
mod traceability;
|
||||||
|
|
||||||
fn setup(layer: Layer) -> App {
|
fn setup(layer: Layer) -> App {
|
||||||
let mut app = App::with_project(Project::empty());
|
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]
|
#[test]
|
||||||
fn architecture_first_focused_input_escape_cancels_create_and_rename() {
|
fn architecture_first_focused_input_escape_cancels_create_and_rename() {
|
||||||
for rename in [false, true] {
|
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 雨 ";
|
let name = " Unaccepted long engineering name 雨 ";
|
||||||
send(&mut app, Event::Name(name.into()));
|
send(&mut app, Event::Name(name.into()));
|
||||||
assert!(app.architecture.as_ref().unwrap().inline());
|
assert!(app.architecture.as_ref().unwrap().inline());
|
||||||
let message = focused_text_input_escape(name);
|
let mut messages = crate::app::escape::tests::focused_input_messages(&app);
|
||||||
let _ = app.update(message);
|
assert_eq!(messages.len(), 1);
|
||||||
|
let _ = app.update(messages.remove(0));
|
||||||
assert!(app.architecture.is_none());
|
assert!(app.architecture.is_none());
|
||||||
assert!(inline_editor(&app).is_none());
|
assert!(inline_editor(&app).is_none());
|
||||||
assert_eq!(app.status, "Cancelled · Select / move");
|
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 before = app.document.bundle().clone();
|
||||||
let status = app.status.clone();
|
let status = app.status.clone();
|
||||||
let epoch = app.canvas_epoch;
|
let epoch = app.canvas_epoch;
|
||||||
let message = focused_text_input_escape("Other field or existing-element search");
|
let messages = crate::app::escape::tests::focused_input_messages(&app);
|
||||||
let _ = app.update(message);
|
assert!(messages.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.architecture
|
app.architecture
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -354,19 +297,7 @@ fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
|
|||||||
assert_eq!(app.canvas_epoch, epoch);
|
assert_eq!(app.canvas_epoch, epoch);
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
captured_escape(
|
crate::app::escape::tests::focused_input_key(&app, iced::keyboard::key::Named::Enter,)
|
||||||
key_event(iced::keyboard::key::Named::Escape),
|
.is_empty()
|
||||||
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()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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> {
|
fn captured_escape(app: &App) -> Vec<Message> {
|
||||||
use iced::advanced::{Layout, Shell, layout, subscription, widget};
|
crate::app::escape::tests::focused_input_messages(app)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -864,99 +864,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
|
fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
|
||||||
use iced::advanced::{Layout, Shell, layout, subscription, widget};
|
crate::app::escape::tests::input_messages(app, iced::keyboard::key::Named::Escape, focused)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 pending in [false, true] {
|
||||||
for modal in 0..4 {
|
for modal in 0..4 {
|
||||||
let mut app = fixture();
|
let mut app = fixture();
|
||||||
@@ -1053,7 +965,7 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let mut app = fixture();
|
||||||
app.text_workspace.show_drawer(Some(DrawerTab::References));
|
app.text_workspace.show_drawer(Some(DrawerTab::References));
|
||||||
let project = app.project().clone();
|
let project = app.project().clone();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use super::*;
|
|||||||
mod attachment_escape;
|
mod attachment_escape;
|
||||||
#[path = "state_bend_tests.rs"]
|
#[path = "state_bend_tests.rs"]
|
||||||
mod bends;
|
mod bends;
|
||||||
|
#[path = "state_picker_escape_tests.rs"]
|
||||||
|
mod picker_escape;
|
||||||
use crate::behavior::{
|
use crate::behavior::{
|
||||||
self,
|
self,
|
||||||
state_edit::{self, Context, Endpoint, Event, Tool},
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,32 +10,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
const PAGE_SIZE: usize = 6;
|
const PAGE_SIZE: usize = 6;
|
||||||
pub const NAME_INPUT: &str = "behavior-creation-name";
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum Family {
|
pub enum Family {
|
||||||
Sequence,
|
Sequence,
|
||||||
|
|||||||
@@ -18,59 +18,11 @@ fn begin(app: &mut App, layer: Layer, family: Family, name: &str) {
|
|||||||
send(app, Event::Name(name.into()));
|
send(app, Event::Name(name.into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn focused_escape() -> Message {
|
fn focused_escape(app: &App) -> Message {
|
||||||
use iced::advanced::{Layout, Shell, layout, widget};
|
let mut messages = crate::app::escape::tests::focused_input_messages(app);
|
||||||
use iced::keyboard::{self, key::Named};
|
assert_eq!(messages.len(), 1);
|
||||||
let mut input: iced::Element<'_, (), iced::Theme, ()> = text_input("Name", "Unfinished name")
|
assert!(matches!(messages[0], Message::Escape));
|
||||||
.id(NAME_INPUT)
|
messages.remove(0)
|
||||||
.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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -94,12 +46,12 @@ fn app_first_captured_escape_dismisses_file_or_pending_overlay_before_creation()
|
|||||||
} else {
|
} else {
|
||||||
app.pending = Some(Pending::Walkthrough);
|
app.pending = Some(Pending::Walkthrough);
|
||||||
}
|
}
|
||||||
let _ = app.update(focused_escape());
|
let _ = app.update(focused_escape(&app));
|
||||||
assert!(app.file_prompt.is_none());
|
assert!(app.file_prompt.is_none());
|
||||||
assert!(app.pending.is_none());
|
assert!(app.pending.is_none());
|
||||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
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!(!app.behavior.has_pending());
|
||||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||||
assert!(!app.document.can_undo());
|
assert!(!app.document.can_undo());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -52,6 +52,8 @@ mod tests {
|
|||||||
use crate::app::{App, Message, Page};
|
use crate::app::{App, Message, Page};
|
||||||
use oa_core::*;
|
use oa_core::*;
|
||||||
|
|
||||||
|
include!("connection_allocation_tests.rs");
|
||||||
|
|
||||||
fn fixture() -> (App, Id, Id, Id) {
|
fn fixture() -> (App, Id, Id, Id) {
|
||||||
let mut p = Project::empty();
|
let mut p = Project::empty();
|
||||||
let a = p.add(Kind::Function, "Send command", 0., 0.);
|
let a = p.add(Kind::Function, "Send command", 0., 0.);
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ pub enum Tool {
|
|||||||
CommunicationMean,
|
CommunicationMean,
|
||||||
FunctionalExchange,
|
FunctionalExchange,
|
||||||
BoundaryExchange,
|
BoundaryExchange,
|
||||||
|
Allocation,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tool {
|
impl Tool {
|
||||||
pub fn for_layer(layer: Layer) -> &'static [Self] {
|
pub fn for_layer(layer: Layer) -> &'static [Self] {
|
||||||
match layer {
|
match layer {
|
||||||
Layer::Oa => &[Self::Interaction, Self::CommunicationMean],
|
Layer::Oa => &[Self::Interaction, Self::CommunicationMean, Self::Allocation],
|
||||||
Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
|
Layer::Sa => &[
|
||||||
|
Self::FunctionalExchange,
|
||||||
|
Self::BoundaryExchange,
|
||||||
|
Self::Allocation,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +30,15 @@ impl Tool {
|
|||||||
Self::CommunicationMean => "Communication means",
|
Self::CommunicationMean => "Communication means",
|
||||||
Self::FunctionalExchange => "Functional exchange",
|
Self::FunctionalExchange => "Functional exchange",
|
||||||
Self::BoundaryExchange => "Boundary exchange",
|
Self::BoundaryExchange => "Boundary exchange",
|
||||||
|
Self::Allocation => "Allocation",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hint(self) -> &'static str {
|
pub fn hint(self) -> &'static str {
|
||||||
match self {
|
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::Interaction => "Click a source activity, then a target activity.",
|
||||||
Self::CommunicationMean => {
|
Self::CommunicationMean => {
|
||||||
"Click an operational entity or actor, then another participant. Communication is bidirectional unless you change its orientation."
|
"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 {
|
pub fn can_start(self, project: &Project, source: Id) -> bool {
|
||||||
match self {
|
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| {
|
Self::CommunicationMean => project.elements.get(&source).is_some_and(|e| {
|
||||||
e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor)
|
e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor)
|
||||||
}),
|
}),
|
||||||
@@ -65,6 +78,9 @@ impl Tool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
match self {
|
match self {
|
||||||
|
Self::Allocation => {
|
||||||
|
validation::can_connect(project, RelationKind::Allocation, source, target)
|
||||||
|
}
|
||||||
Self::CommunicationMean => {
|
Self::CommunicationMean => {
|
||||||
validation::can_connect(project, RelationKind::CommunicationMean, source, target)
|
validation::can_connect(project, RelationKind::CommunicationMean, source, target)
|
||||||
}
|
}
|
||||||
@@ -140,6 +156,7 @@ impl Tool {
|
|||||||
}
|
}
|
||||||
let name = engineering_name(project, self, source, target);
|
let name = engineering_name(project, self, source, target);
|
||||||
let body = match self {
|
let body = match self {
|
||||||
|
Self::Allocation => RelationBody::new(RelationKind::Allocation, source, target),
|
||||||
Self::CommunicationMean => {
|
Self::CommunicationMean => {
|
||||||
RelationBody::new(RelationKind::CommunicationMean, source, target)
|
RelationBody::new(RelationKind::CommunicationMean, source, target)
|
||||||
}
|
}
|
||||||
@@ -162,6 +179,28 @@ impl Tool {
|
|||||||
prepare_command(project, &command)?;
|
prepare_command(project, &command)?;
|
||||||
Ok(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
|
/// A clicked port determines its own function owner. It cannot be paired with
|
||||||
@@ -291,7 +330,7 @@ mod tests {
|
|||||||
let before = p.clone();
|
let before = p.clone();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Tool::for_layer(Layer::Oa),
|
Tool::for_layer(Layer::Oa),
|
||||||
&[Tool::Interaction, Tool::CommunicationMean]
|
&[Tool::Interaction, Tool::CommunicationMean, Tool::Allocation]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Tool::Interaction.targets(&p, source),
|
Tool::Interaction.targets(&p, source),
|
||||||
@@ -592,6 +631,8 @@ mod tests {
|
|||||||
(Tool::CommunicationMean, oa_team, oa_actor),
|
(Tool::CommunicationMean, oa_team, oa_actor),
|
||||||
(Tool::FunctionalExchange, sa_a, sa_b),
|
(Tool::FunctionalExchange, sa_a, sa_b),
|
||||||
(Tool::BoundaryExchange, boundary_a, boundary_b),
|
(Tool::BoundaryExchange, boundary_a, boundary_b),
|
||||||
|
(Tool::Allocation, oa_team, oa_a),
|
||||||
|
(Tool::Allocation, system, sa_a),
|
||||||
] {
|
] {
|
||||||
let before = document.bundle().clone();
|
let before = document.bundle().clone();
|
||||||
let original = document.project().clone();
|
let original = document.project().clone();
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"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.",
|
"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",
|
"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.",
|
"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.",
|
"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 an Allocation relationship to change responsibility. Apply changes validates the semantic change.",
|
"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.",
|
"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.",
|
"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",
|
"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.",
|
"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(
|
walkthrough(
|
||||||
"Move work between performers",
|
"Move work between performers",
|
||||||
&[
|
&[
|
||||||
|
|||||||
@@ -458,7 +458,8 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
|||||||
entries.push(item(
|
entries.push(item(
|
||||||
"Show in this diagram",
|
"Show in this diagram",
|
||||||
Message::PlaceSelected,
|
Message::PlaceSelected,
|
||||||
d.layer == e.layer() && d.projection.includes(e.kind()),
|
(d.layer == e.layer() || d.projection == ProjectionKind::Traceability)
|
||||||
|
&& d.projection.includes(e.kind()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,6 +590,7 @@ fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)]
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
include!("model_menu_traceability_tests.rs");
|
||||||
fn send(app: &mut App, message: Message) {
|
fn send(app: &mut App, message: Message) {
|
||||||
let _ = app.update(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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -599,6 +599,28 @@ fn diagram(app: &App) -> View<'_, Message> {
|
|||||||
}
|
}
|
||||||
if let Some(diagram) = app.active_diagram {
|
if let Some(diagram) = app.active_diagram {
|
||||||
let view = oa_core::projection::project_view(app.project(), 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 edge = view.edges.iter().find(|edge| edge.id == app.selected);
|
||||||
let promoted = edge.is_some_and(|edge| {
|
let promoted = edge.is_some_and(|edge| {
|
||||||
oa_core::projection::edge_endpoint_placements(
|
oa_core::projection::edge_endpoint_placements(
|
||||||
|
|||||||
+23
-4
@@ -7,7 +7,10 @@ https://raw.githubusercontent.com/iced-rs/iced/54abf81d13fec06d4d9ac754b03ce2c33
|
|||||||
SHA-256 `fc9086ba4eba4b77e4c603906362742bc3715ffa2c7810c6074fe7780d3a0398`.
|
SHA-256 `fc9086ba4eba4b77e4c603906362742bc3715ffa2c7810c6074fe7780d3a0398`.
|
||||||
|
|
||||||
All copied src/assets and Cargo manifests retain their upstream bytes except
|
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:
|
callback. Original file SHA-256:
|
||||||
`cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1`.
|
`cecf8686c90aa92066d55075beba545dfb7dd5a44bad0c1519fcb110a56070e1`.
|
||||||
The callback borrows the existing renderer editor and releases the borrow when
|
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;
|
editor identity and geometry plus retained Word/Line selection and scroll;
|
||||||
application integration/native validation are separately required.
|
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,
|
UPSTREAM-MANIFEST.json records all files from the local pinned registry source,
|
||||||
including registry bookkeeping and the upstream crate lockfile not copied here.
|
including registry bookkeeping and the upstream crate lockfile not copied here.
|
||||||
Those omitted files are not package inputs. The workspace root Cargo.lock and
|
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
|
the unmodified registry checksum. Keep LICENSE and this provenance in source
|
||||||
captures and generated dependency notices.
|
captures and generated dependency notices.
|
||||||
|
|
||||||
On upgrades compare the pristine upstream module and replay only this accessor.
|
On upgrades compare all three modules with their pristine upstream versions
|
||||||
Recheck actual editor layout, selection/input continuity and annotation tests.
|
and reassess each change. Recheck actual editor layout, selection/input
|
||||||
Remove this patch if upstream provides equivalent read-only layout access.
|
continuity, annotations, picker dismissal, same-batch queued input and close
|
||||||
|
notification behavior. Remove each local change when upstream provides its
|
||||||
|
equivalent behavior.
|
||||||
|
|||||||
+49
@@ -1,6 +1,7 @@
|
|||||||
//! Build and show dropdown menus.
|
//! Build and show dropdown menus.
|
||||||
use crate::core::alignment;
|
use crate::core::alignment;
|
||||||
use crate::core::border::{self, Border};
|
use crate::core::border::{self, Border};
|
||||||
|
use crate::core::keyboard;
|
||||||
use crate::core::layout::{self, Layout};
|
use crate::core::layout::{self, Layout};
|
||||||
use crate::core::mouse;
|
use crate::core::mouse;
|
||||||
use crate::core::overlay;
|
use crate::core::overlay;
|
||||||
@@ -15,6 +16,7 @@ use crate::core::{
|
|||||||
};
|
};
|
||||||
use crate::core::{Element, Shell, Widget};
|
use crate::core::{Element, Shell, Widget};
|
||||||
use crate::scrollable::{self, Scrollable};
|
use crate::scrollable::{self, Scrollable};
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
/// A list of selectable options.
|
/// A list of selectable options.
|
||||||
pub struct Menu<
|
pub struct Menu<
|
||||||
@@ -34,6 +36,7 @@ pub struct Menu<
|
|||||||
hovered_option: &'a mut Option<usize>,
|
hovered_option: &'a mut Option<usize>,
|
||||||
on_selected: Box<dyn FnMut(T) -> Message + 'a>,
|
on_selected: Box<dyn FnMut(T) -> Message + 'a>,
|
||||||
on_option_hovered: Option<&'a dyn Fn(T) -> Message>,
|
on_option_hovered: Option<&'a dyn Fn(T) -> Message>,
|
||||||
|
escape: Option<(&'a Cell<bool>, Option<Message>)>,
|
||||||
width: f32,
|
width: f32,
|
||||||
padding: Padding,
|
padding: Padding,
|
||||||
text_size: Option<Pixels>,
|
text_size: Option<Pixels>,
|
||||||
@@ -68,6 +71,7 @@ where
|
|||||||
hovered_option,
|
hovered_option,
|
||||||
on_selected: Box::new(on_selected),
|
on_selected: Box::new(on_selected),
|
||||||
on_option_hovered,
|
on_option_hovered,
|
||||||
|
escape: None,
|
||||||
width: 0.0,
|
width: 0.0,
|
||||||
padding: Padding::ZERO,
|
padding: Padding::ZERO,
|
||||||
text_size: None,
|
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`].
|
/// Sets the width of the [`Menu`].
|
||||||
pub fn width(mut self, width: f32) -> Self {
|
pub fn width(mut self, width: f32) -> Self {
|
||||||
self.width = width;
|
self.width = width;
|
||||||
@@ -170,6 +185,7 @@ where
|
|||||||
viewport: Rectangle,
|
viewport: Rectangle,
|
||||||
tree: &'a mut Tree,
|
tree: &'a mut Tree,
|
||||||
list: Scrollable<'a, Message, Theme, Renderer>,
|
list: Scrollable<'a, Message, Theme, Renderer>,
|
||||||
|
escape: Option<(&'a Cell<bool>, Option<Message>)>,
|
||||||
width: f32,
|
width: f32,
|
||||||
target_height: f32,
|
target_height: f32,
|
||||||
class: &'a <Theme as Catalog>::Class<'b>,
|
class: &'a <Theme as Catalog>::Class<'b>,
|
||||||
@@ -198,6 +214,7 @@ where
|
|||||||
hovered_option,
|
hovered_option,
|
||||||
on_selected,
|
on_selected,
|
||||||
on_option_hovered,
|
on_option_hovered,
|
||||||
|
escape,
|
||||||
width,
|
width,
|
||||||
padding,
|
padding,
|
||||||
font,
|
font,
|
||||||
@@ -228,6 +245,7 @@ where
|
|||||||
viewport,
|
viewport,
|
||||||
tree: &mut state.tree,
|
tree: &mut state.tree,
|
||||||
list,
|
list,
|
||||||
|
escape,
|
||||||
width,
|
width,
|
||||||
target_height,
|
target_height,
|
||||||
class,
|
class,
|
||||||
@@ -278,6 +296,31 @@ where
|
|||||||
clipboard: &mut dyn Clipboard,
|
clipboard: &mut dyn Clipboard,
|
||||||
shell: &mut Shell<'_, Message>,
|
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();
|
let bounds = layout.bounds();
|
||||||
|
|
||||||
self.list.update(
|
self.list.update(
|
||||||
@@ -292,6 +335,9 @@ where
|
|||||||
cursor: mouse::Cursor,
|
cursor: mouse::Cursor,
|
||||||
renderer: &Renderer,
|
renderer: &Renderer,
|
||||||
) -> mouse::Interaction {
|
) -> mouse::Interaction {
|
||||||
|
if self.escape.as_ref().is_some_and(|(open, _)| !open.get()) {
|
||||||
|
return mouse::Interaction::None;
|
||||||
|
}
|
||||||
self.list.mouse_interaction(
|
self.list.mouse_interaction(
|
||||||
self.tree,
|
self.tree,
|
||||||
layout,
|
layout,
|
||||||
@@ -309,6 +355,9 @@ where
|
|||||||
layout: Layout<'_>,
|
layout: Layout<'_>,
|
||||||
cursor: mouse::Cursor,
|
cursor: mouse::Cursor,
|
||||||
) {
|
) {
|
||||||
|
if self.escape.as_ref().is_some_and(|(open, _)| !open.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let bounds = layout.bounds();
|
let bounds = layout.bounds();
|
||||||
|
|
||||||
let style = Catalog::style(theme, self.class);
|
let style = Catalog::style(theme, self.class);
|
||||||
|
|||||||
+24
-10
@@ -78,6 +78,7 @@ use crate::core::{
|
|||||||
use crate::overlay::menu::{self, Menu};
|
use crate::overlay::menu::{self, Menu};
|
||||||
|
|
||||||
use std::borrow::Borrow;
|
use std::borrow::Borrow;
|
||||||
|
use std::cell::Cell;
|
||||||
use std::f32;
|
use std::f32;
|
||||||
|
|
||||||
/// A widget for selecting a single value from a list of options.
|
/// 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>>();
|
let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
|
||||||
|
|
||||||
match event {
|
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::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||||
| Event::Touch(touch::Event::FingerPressed { .. }) => {
|
| 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
|
// 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.
|
// 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 {
|
if let Some(on_close) = &self.on_close {
|
||||||
shell.publish(on_close.clone());
|
shell.publish(on_close.clone());
|
||||||
@@ -463,7 +476,7 @@ where
|
|||||||
} else if cursor.is_over(layout.bounds()) {
|
} else if cursor.is_over(layout.bounds()) {
|
||||||
let selected = self.selected.as_ref().map(Borrow::borrow);
|
let selected = self.selected.as_ref().map(Borrow::borrow);
|
||||||
|
|
||||||
state.is_open = true;
|
state.is_open.set(true);
|
||||||
state.hovered_option = self
|
state.hovered_option = self
|
||||||
.options
|
.options
|
||||||
.borrow()
|
.borrow()
|
||||||
@@ -482,7 +495,7 @@ where
|
|||||||
}) => {
|
}) => {
|
||||||
if state.keyboard_modifiers.command()
|
if state.keyboard_modifiers.command()
|
||||||
&& cursor.is_over(layout.bounds())
|
&& cursor.is_over(layout.bounds())
|
||||||
&& !state.is_open
|
&& !state.is_open.get()
|
||||||
{
|
{
|
||||||
fn find_next<'a, T: PartialEq>(
|
fn find_next<'a, T: PartialEq>(
|
||||||
selected: &'a T,
|
selected: &'a T,
|
||||||
@@ -528,7 +541,7 @@ where
|
|||||||
let status = {
|
let status = {
|
||||||
let is_hovered = cursor.is_over(layout.bounds());
|
let is_hovered = cursor.is_over(layout.bounds());
|
||||||
|
|
||||||
if state.is_open {
|
if state.is_open.get() {
|
||||||
Status::Opened { is_hovered }
|
Status::Opened { is_hovered }
|
||||||
} else if is_hovered {
|
} else if is_hovered {
|
||||||
Status::Hovered
|
Status::Hovered
|
||||||
@@ -612,7 +625,7 @@ where
|
|||||||
shaping,
|
shaping,
|
||||||
}) => Some((*font, *code_point, *size, *line_height, *shaping)),
|
}) => Some((*font, *code_point, *size, *line_height, *shaping)),
|
||||||
Handle::Dynamic { open, closed } => {
|
Handle::Dynamic { open, closed } => {
|
||||||
if state.is_open {
|
if state.is_open.get() {
|
||||||
Some((
|
Some((
|
||||||
open.font,
|
open.font,
|
||||||
open.code_point,
|
open.code_point,
|
||||||
@@ -703,7 +716,7 @@ where
|
|||||||
let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
|
let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
|
||||||
let font = self.font.unwrap_or_else(|| renderer.default_font());
|
let font = self.font.unwrap_or_else(|| renderer.default_font());
|
||||||
|
|
||||||
if state.is_open {
|
if state.is_open.get() {
|
||||||
let bounds = layout.bounds();
|
let bounds = layout.bounds();
|
||||||
|
|
||||||
let on_select = &self.on_select;
|
let on_select = &self.on_select;
|
||||||
@@ -713,13 +726,14 @@ where
|
|||||||
self.options.borrow(),
|
self.options.borrow(),
|
||||||
&mut state.hovered_option,
|
&mut state.hovered_option,
|
||||||
|option| {
|
|option| {
|
||||||
state.is_open = false;
|
state.is_open.set(false);
|
||||||
|
|
||||||
(on_select)(option)
|
(on_select)(option)
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
&self.menu_class,
|
&self.menu_class,
|
||||||
)
|
)
|
||||||
|
.close_on_escape(&state.is_open, self.on_close.clone())
|
||||||
.width(bounds.width)
|
.width(bounds.width)
|
||||||
.padding(self.padding)
|
.padding(self.padding)
|
||||||
.font(font)
|
.font(font)
|
||||||
@@ -763,7 +777,7 @@ where
|
|||||||
struct State<P: text::Paragraph> {
|
struct State<P: text::Paragraph> {
|
||||||
menu: menu::State,
|
menu: menu::State,
|
||||||
keyboard_modifiers: keyboard::Modifiers,
|
keyboard_modifiers: keyboard::Modifiers,
|
||||||
is_open: bool,
|
is_open: Cell<bool>,
|
||||||
hovered_option: Option<usize>,
|
hovered_option: Option<usize>,
|
||||||
options: Vec<paragraph::Plain<P>>,
|
options: Vec<paragraph::Plain<P>>,
|
||||||
placeholder: paragraph::Plain<P>,
|
placeholder: paragraph::Plain<P>,
|
||||||
@@ -775,7 +789,7 @@ impl<P: text::Paragraph> State<P> {
|
|||||||
Self {
|
Self {
|
||||||
menu: menu::State::default(),
|
menu: menu::State::default(),
|
||||||
keyboard_modifiers: keyboard::Modifiers::default(),
|
keyboard_modifiers: keyboard::Modifiers::default(),
|
||||||
is_open: bool::default(),
|
is_open: Cell::new(false),
|
||||||
hovered_option: Option::default(),
|
hovered_option: Option::default(),
|
||||||
options: Vec::new(),
|
options: Vec::new(),
|
||||||
placeholder: paragraph::Plain::default(),
|
placeholder: paragraph::Plain::default(),
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user