501 lines
18 KiB
Rust
501 lines
18 KiB
Rust
//! Keyboard ownership is exercised on the mounted App, not raw Canvas Program.
|
|
use super::*;
|
|
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
|
use iced::advanced::widget::{Operation, operation};
|
|
use iced::{Point, Rectangle, Size, Vector, keyboard::key::Named, mouse};
|
|
|
|
struct FocusField(Rectangle);
|
|
impl Operation for FocusField {
|
|
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
|
visit(self);
|
|
}
|
|
fn focusable(
|
|
&mut self,
|
|
_: Option<&iced::advanced::widget::Id>,
|
|
bounds: Rectangle,
|
|
state: &mut dyn operation::Focusable,
|
|
) {
|
|
if bounds == self.0 {
|
|
state.focus();
|
|
} else {
|
|
state.unfocus();
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn f2(mounted: &mut Mounted, app: &mut App) -> Vec<Message> {
|
|
mounted
|
|
.batch(app, &[key(Named::F2)], mouse::Cursor::Unavailable)
|
|
.await
|
|
}
|
|
|
|
fn rename_emitted(messages: &[Message]) -> bool {
|
|
messages.iter().any(|message| {
|
|
matches!(
|
|
message,
|
|
Message::Architecture(architecture_authoring::Event::Rename(..))
|
|
)
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mounted_f2_requires_canvas_focus_and_yields_to_clean_inspector() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
for programmatic in [true, false] {
|
|
let mut project = Project::empty();
|
|
let object = project
|
|
.add_element(
|
|
if layer == Layer::Oa {
|
|
Kind::Entity
|
|
} else {
|
|
Kind::System
|
|
},
|
|
layer,
|
|
"Original team",
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let diagram = project.ensure_diagram(layer);
|
|
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
|
let node = project.place(diagram, object, Position { x: 80., y: 80. });
|
|
let mut app = App::with_project(project);
|
|
app.page = Page::Diagram;
|
|
app.layer = layer;
|
|
app.active_diagram = Some(diagram);
|
|
app.window_size = Size::new(2000., 1400.);
|
|
let _ = app.update(Message::Select(object));
|
|
let before = app.document.snapshot();
|
|
let mut mounted = Mounted::new(&mut app).await;
|
|
app.zoom = 1.;
|
|
app.pan = Vector::ZERO;
|
|
app.fit_to_view = false;
|
|
mounted.settle(&mut app).await;
|
|
|
|
// Selection alone does not grant the canvas keyboard ownership.
|
|
assert!(!rename_emitted(&f2(&mut mounted, &mut app).await));
|
|
assert!(app.architecture.is_none());
|
|
assert_eq!(app.document.bundle(), &before.bundle);
|
|
|
|
let bounds = mounted
|
|
.geometry(&app)
|
|
.bounds(crate::allocation_input::CANVAS_ID);
|
|
let projected = projection::project_view(app.project(), diagram)
|
|
.nodes
|
|
.into_iter()
|
|
.find(|item| item.id == node)
|
|
.unwrap();
|
|
let point = Point::new(
|
|
bounds.x + projected.position.x + 40.,
|
|
bounds.y + projected.position.y + 35.,
|
|
);
|
|
mounted.click(&mut app, point).await;
|
|
assert_eq!((app.selected, app.selected_node), (object, Some(node)));
|
|
assert!(rename_emitted(&f2(&mut mounted, &mut app).await));
|
|
let rename = app.architecture.as_ref().expect("canvas F2 opens rename");
|
|
assert!(rename.inline());
|
|
assert!(matches!(
|
|
&rename.saved.intent,
|
|
architecture_authoring::Intent::Rename { element, name }
|
|
if *element == object && name == "Original team"
|
|
));
|
|
assert_eq!(app.document.bundle(), &before.bundle);
|
|
assert!(
|
|
!app.document.can_undo(),
|
|
"opening rename is not a model edit"
|
|
);
|
|
|
|
let task = app.update(Message::Escape);
|
|
mounted.perform(&mut app, task).await;
|
|
assert!(app.architecture.is_none());
|
|
mounted.click(&mut app, point).await;
|
|
let field = mounted
|
|
.geometry(&app)
|
|
.text_inputs
|
|
.into_iter()
|
|
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
|
.min_by(|a, b| a.y.total_cmp(&b.y))
|
|
.expect("Inspector name field is mounted");
|
|
if programmatic {
|
|
mounted.operate(&app, &mut FocusField(field));
|
|
} else {
|
|
let messages = mounted
|
|
.batch(
|
|
&mut app,
|
|
&[key(Named::Tab), key(Named::F2)],
|
|
mouse::Cursor::Unavailable,
|
|
)
|
|
.await;
|
|
assert!(
|
|
!rename_emitted(&messages),
|
|
"Tab must retire F2 admission before its queued focus task"
|
|
);
|
|
}
|
|
let geometry = mounted.geometry(&app);
|
|
assert!(
|
|
geometry
|
|
.focused
|
|
.iter()
|
|
.any(|(_, bounds, focused)| { *bounds == field && *focused })
|
|
);
|
|
assert!(geometry.focused.iter().any(|(id, _, focused)| {
|
|
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
|
&& !*focused
|
|
}));
|
|
assert!(!app.draft_dirty(), "the Inspector is still clean");
|
|
assert!(!rename_emitted(&f2(&mut mounted, &mut app).await));
|
|
assert!(app.architecture.is_none());
|
|
assert_eq!(
|
|
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
|
"Original team"
|
|
);
|
|
assert_eq!(app.project(), &before.decoded.project);
|
|
assert_eq!(app.document.bundle(), &before.bundle);
|
|
assert!(!app.document.can_undo());
|
|
}
|
|
}
|
|
}
|
|
// Append to app/diagram_focus_tests.rs after the frozen desktop gate.
|
|
// Reuses its imports and FocusField, plus Mounted/key from canvas_workspace tests.
|
|
|
|
async fn modifier_focus_fixture() -> (App, Mounted, Id, Id, Id) {
|
|
let mut project = Project::empty();
|
|
let a = project
|
|
.add_element(Kind::Entity, Layer::Oa, "First team", None)
|
|
.unwrap();
|
|
let b = project
|
|
.add_element(Kind::Entity, Layer::Oa, "Second team", None)
|
|
.unwrap();
|
|
let diagram = project.ensure_diagram(Layer::Oa);
|
|
let first = project.place(diagram, a, Position { x: 80., y: 80. });
|
|
let second = project.place(diagram, b, Position { x: 500., y: 80. });
|
|
let mut app = App::with_project(project);
|
|
app.page = Page::Diagram;
|
|
app.layer = Layer::Oa;
|
|
app.active_diagram = Some(diagram);
|
|
app.window_size = Size::new(2000., 1400.);
|
|
let _ = app.update(Message::Select(a));
|
|
let mut mounted = Mounted::new(&mut app).await;
|
|
app.zoom = 1.;
|
|
app.pan = Vector::ZERO;
|
|
app.fit_to_view = false;
|
|
mounted.settle(&mut app).await;
|
|
(app, mounted, diagram, first, second)
|
|
}
|
|
|
|
fn modifier_node_point(mounted: &mut Mounted, app: &App, diagram: Id, placement: Id) -> Point {
|
|
let bounds = mounted
|
|
.geometry(app)
|
|
.bounds(crate::allocation_input::CANVAS_ID);
|
|
let node = projection::project_view(app.project(), diagram)
|
|
.nodes
|
|
.into_iter()
|
|
.find(|n| n.id == placement)
|
|
.unwrap();
|
|
Point::new(
|
|
bounds.x + node.position.x + 40.,
|
|
bounds.y + node.position.y + 35.,
|
|
)
|
|
}
|
|
|
|
fn modifier_canvas_focused(mounted: &mut Mounted, app: &App) -> bool {
|
|
mounted
|
|
.geometry(app)
|
|
.focused
|
|
.iter()
|
|
.find(|(id, _, _)| {
|
|
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
|
})
|
|
.expect("production focus::Owner is mounted")
|
|
.2
|
|
}
|
|
|
|
fn modifier_event(shift: bool) -> iced::Event {
|
|
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if shift {
|
|
iced::keyboard::Modifiers::SHIFT
|
|
} else {
|
|
iced::keyboard::Modifiers::default()
|
|
}))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mounted_shift_before_canvas_focus_toggles_exact_node_without_granting_keyboard_focus() {
|
|
use crate::diagram_usage::Target;
|
|
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
|
let before = app.document.snapshot();
|
|
let field = mounted
|
|
.geometry(&app)
|
|
.text_inputs
|
|
.into_iter()
|
|
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
|
.min_by(|a, b| a.y.total_cmp(&b.y))
|
|
.expect("clean Inspector name input");
|
|
mounted.operate(&app, &mut FocusField(field));
|
|
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
|
assert_eq!(
|
|
app.multiselect_snapshot().unwrap().targets(),
|
|
&[Target::Placement(first)]
|
|
);
|
|
mounted
|
|
.batch(
|
|
&mut app,
|
|
&[modifier_event(true)],
|
|
mouse::Cursor::Unavailable,
|
|
)
|
|
.await;
|
|
assert!(
|
|
!modifier_canvas_focused(&mut mounted, &app),
|
|
"modifier tracking must not acquire focus"
|
|
);
|
|
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
|
let messages = mounted.click(&mut app, at).await;
|
|
assert!(messages.iter().any(|m| matches!(m,
|
|
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
|
|
)), "Shift already held before the first canvas press must reach its exact-target adapter");
|
|
assert_eq!(
|
|
app.multiselect_snapshot().unwrap().targets(),
|
|
&[Target::Placement(first), Target::Placement(second)]
|
|
);
|
|
assert_eq!(app.selected_node, Some(second));
|
|
assert_eq!(app.project(), &before.decoded.project);
|
|
assert_eq!(app.document.bundle(), &before.bundle);
|
|
assert!(!app.document.can_undo());
|
|
assert!(!app.draft_dirty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mounted_unfocused_typing_and_delete_remain_denied_after_passive_modifiers() {
|
|
use crate::diagram_usage::Target;
|
|
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
|
mounted
|
|
.batch(
|
|
&mut app,
|
|
&[modifier_event(true)],
|
|
mouse::Cursor::Unavailable,
|
|
)
|
|
.await;
|
|
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
|
mounted.click(&mut app, at).await;
|
|
assert_eq!(
|
|
app.multiselect_snapshot().unwrap().targets(),
|
|
&[Target::Placement(first), Target::Placement(second)]
|
|
);
|
|
// Revoke every field's focus through the real widget operation. No widget
|
|
// has this off-window rectangle, so neither Inspector nor canvas owns keys.
|
|
mounted.operate(
|
|
&app,
|
|
&mut FocusField(Rectangle {
|
|
x: -1000.,
|
|
y: -1000.,
|
|
width: 1.,
|
|
height: 1.,
|
|
}),
|
|
);
|
|
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
|
let before = app.document.snapshot();
|
|
let selection = app.multiselect_snapshot().unwrap();
|
|
let messages = mounted
|
|
.batch(
|
|
&mut app,
|
|
&[
|
|
modifier_event(false),
|
|
crate::behavior::canvas_workspace::tests::character("x"),
|
|
key(Named::Delete),
|
|
key(Named::F2),
|
|
],
|
|
mouse::Cursor::Unavailable,
|
|
)
|
|
.await;
|
|
assert!(!messages.iter().any(|m| matches!(
|
|
m,
|
|
Message::DiagramSelection(_)
|
|
| Message::DiagramRemove(_)
|
|
| Message::HideSelected
|
|
| Message::Delete
|
|
)));
|
|
assert!(!rename_emitted(&messages));
|
|
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
|
assert_eq!(
|
|
app.multiselect_snapshot().unwrap().targets(),
|
|
selection.targets()
|
|
);
|
|
assert_eq!(app.project(), &before.decoded.project);
|
|
assert_eq!(app.document.bundle(), &before.bundle);
|
|
assert!(!app.document.can_undo());
|
|
assert!(!app.draft_dirty());
|
|
assert!(!app.delete_pending);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mounted_suspended_modifiers_are_passive_and_survive_until_resumed_click() {
|
|
use crate::diagram_usage::Target;
|
|
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
|
let before = app.document.snapshot();
|
|
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
|
app.busy = true;
|
|
mounted.settle(&mut app).await;
|
|
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
|
mounted
|
|
.batch(
|
|
&mut app,
|
|
&[modifier_event(true), key(Named::Delete)],
|
|
mouse::Cursor::Unavailable,
|
|
)
|
|
.await;
|
|
mounted.click(&mut app, at).await;
|
|
assert!(
|
|
!modifier_canvas_focused(&mut mounted, &app),
|
|
"passive I/O cannot grant canvas focus"
|
|
);
|
|
assert_eq!(
|
|
app.multiselect_snapshot().unwrap().targets(),
|
|
&[Target::Placement(first)]
|
|
);
|
|
assert_eq!(app.document.bundle(), &before.bundle);
|
|
app.busy = false;
|
|
mounted.settle(&mut app).await;
|
|
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
|
mounted.click(&mut app, at).await;
|
|
assert_eq!(
|
|
app.multiselect_snapshot().unwrap().targets(),
|
|
&[Target::Placement(first), Target::Placement(second)]
|
|
);
|
|
assert_eq!(app.project(), &before.decoded.project);
|
|
assert_eq!(app.document.bundle(), &before.bundle);
|
|
assert!(!app.document.can_undo());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mounted_shift_held_before_suspension_survives_without_another_modifier_event() {
|
|
use crate::diagram_usage::Target;
|
|
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
|
let before = app.document.snapshot();
|
|
let field = mounted
|
|
.geometry(&app)
|
|
.text_inputs
|
|
.into_iter()
|
|
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
|
.min_by(|a, b| a.y.total_cmp(&b.y))
|
|
.expect("clean Inspector name input");
|
|
mounted.operate(&app, &mut FocusField(field));
|
|
mounted
|
|
.batch(
|
|
&mut app,
|
|
&[modifier_event(true)],
|
|
mouse::Cursor::Unavailable,
|
|
)
|
|
.await;
|
|
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
|
app.busy = true;
|
|
mounted.settle(&mut app).await;
|
|
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
|
app.busy = false;
|
|
mounted.settle(&mut app).await;
|
|
assert!(
|
|
!modifier_canvas_focused(&mut mounted, &app),
|
|
"restoring passive modifiers must not grant keyboard focus"
|
|
);
|
|
assert_eq!(
|
|
app.multiselect_snapshot().unwrap().targets(),
|
|
&[Target::Placement(first)]
|
|
);
|
|
// No modifier transition follows the subtree replacement during I/O.
|
|
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
|
let messages = mounted.click(&mut app, at).await;
|
|
assert!(messages.iter().any(|m| matches!(m,
|
|
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
|
|
)));
|
|
assert_eq!(
|
|
app.multiselect_snapshot().unwrap().targets(),
|
|
&[Target::Placement(first), Target::Placement(second)]
|
|
);
|
|
assert_eq!(app.project(), &before.decoded.project);
|
|
assert_eq!(app.document.bundle(), &before.bundle);
|
|
assert!(!app.document.can_undo());
|
|
assert!(!app.draft_dirty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mounted_shift_release_or_window_unfocus_during_suspension_does_not_restore_stale_shift() {
|
|
use crate::diagram_usage::Target;
|
|
for release in [
|
|
modifier_event(false),
|
|
iced::Event::Window(iced::window::Event::Unfocused),
|
|
] {
|
|
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
|
let before = app.document.snapshot();
|
|
mounted
|
|
.batch(
|
|
&mut app,
|
|
&[modifier_event(true)],
|
|
mouse::Cursor::Unavailable,
|
|
)
|
|
.await;
|
|
app.busy = true;
|
|
mounted.settle(&mut app).await;
|
|
mounted
|
|
.batch(&mut app, &[release], mouse::Cursor::Unavailable)
|
|
.await;
|
|
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
|
app.busy = false;
|
|
mounted.settle(&mut app).await;
|
|
// A further I/O cycle must restore the updated owner state too.
|
|
app.busy = true;
|
|
mounted.settle(&mut app).await;
|
|
app.busy = false;
|
|
mounted.settle(&mut app).await;
|
|
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
|
assert_eq!(
|
|
app.multiselect_snapshot().unwrap().targets(),
|
|
&[Target::Placement(first)]
|
|
);
|
|
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
|
let messages = mounted.click(&mut app, at).await;
|
|
assert!(!messages.iter().any(|m| matches!(
|
|
m,
|
|
Message::DiagramSelection(multiselect::Event::Choose(
|
|
_,
|
|
_,
|
|
multiselect::Choice::Toggle
|
|
))
|
|
)));
|
|
assert_eq!(
|
|
app.multiselect_snapshot().unwrap().targets(),
|
|
&[Target::Placement(second)]
|
|
);
|
|
assert_eq!(app.project(), &before.decoded.project);
|
|
assert_eq!(app.document.bundle(), &before.bundle);
|
|
assert!(!app.document.can_undo());
|
|
assert!(!app.draft_dirty());
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mounted_window_unfocus_resets_held_shift_before_next_canvas_press() {
|
|
use crate::diagram_usage::Target;
|
|
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
|
let at_first = modifier_node_point(&mut mounted, &app, diagram, first);
|
|
mounted.click(&mut app, at_first).await;
|
|
let before = app.document.snapshot();
|
|
mounted
|
|
.batch(
|
|
&mut app,
|
|
&[
|
|
modifier_event(true),
|
|
iced::Event::Window(iced::window::Event::Unfocused),
|
|
],
|
|
mouse::Cursor::Unavailable,
|
|
)
|
|
.await;
|
|
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
|
// No ModifiersChanged(empty) follows: a lost window must itself reset it.
|
|
let at_second = modifier_node_point(&mut mounted, &app, diagram, second);
|
|
mounted.click(&mut app, at_second).await;
|
|
assert_eq!(
|
|
app.multiselect_snapshot().unwrap().targets(),
|
|
&[Target::Placement(second)]
|
|
);
|
|
assert_eq!(app.project(), &before.decoded.project);
|
|
assert_eq!(app.document.bundle(), &before.bundle);
|
|
assert!(!app.document.can_undo());
|
|
}
|