619 lines
21 KiB
Rust
619 lines
21 KiB
Rust
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());
|
|
}
|
|
}
|