fix(scenario): restore focus only to the current parent form
This commit is contained in:
@@ -4,6 +4,7 @@ use super::topology::{occurrence, unique_batch};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
pub(crate) mod focus;
|
||||
mod workspace;
|
||||
pub(crate) use workspace::NAME_INPUT;
|
||||
pub use workspace::{Shelf, view};
|
||||
@@ -204,6 +205,7 @@ const VALUE_PAGE_SIZE: usize = 24;
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
form_session: Id,
|
||||
form_mount: Id,
|
||||
form_focus_pending: bool,
|
||||
canvas_tool: Option<(Id, CanvasTool)>,
|
||||
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
|
||||
@@ -313,7 +315,11 @@ impl State {
|
||||
self.draft.as_ref().is_some_and(|d| d.source.is_open())
|
||||
}
|
||||
pub fn source_back(&mut self) -> bool {
|
||||
self.draft.as_mut().is_some_and(|d| d.source.back())
|
||||
let returned = self.draft.as_mut().is_some_and(|d| d.source.back());
|
||||
if returned {
|
||||
self.remount_form();
|
||||
}
|
||||
returned
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
@@ -457,9 +463,11 @@ impl State {
|
||||
|
||||
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
|
||||
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
let session = Id::new_v4();
|
||||
Ok(Self {
|
||||
draft: Some(draft),
|
||||
form_session: Id::new_v4(),
|
||||
form_session: session,
|
||||
form_mount: session,
|
||||
form_focus_pending: true,
|
||||
..Default::default()
|
||||
})
|
||||
@@ -469,8 +477,28 @@ impl State {
|
||||
self.draft.as_ref().map(|_| self.form_session)
|
||||
}
|
||||
|
||||
pub(crate) fn take_form_focus(&mut self, session: Id) -> bool {
|
||||
if self.form_session() == Some(session) && self.form_focus_pending {
|
||||
pub(crate) fn form_mount(&self) -> Option<Id> {
|
||||
self.draft
|
||||
.as_ref()
|
||||
.filter(|d| !d.source.is_open())
|
||||
.map(|_| self.form_mount)
|
||||
}
|
||||
|
||||
pub(crate) fn focus_stamp(&self, context: Context) -> Option<focus::Stamp> {
|
||||
(self.draft.as_ref()?.owner == context.scenario).then_some(focus::Stamp::new(
|
||||
context,
|
||||
self.form_session,
|
||||
self.form_mount()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn remount_form(&mut self) {
|
||||
self.form_mount = Id::new_v4();
|
||||
self.form_focus_pending = true;
|
||||
}
|
||||
|
||||
pub(crate) fn take_form_focus(&mut self, mount: Id) -> bool {
|
||||
if self.form_mount() == Some(mount) && self.form_focus_pending {
|
||||
self.form_focus_pending = false;
|
||||
true
|
||||
} else {
|
||||
@@ -643,6 +671,7 @@ impl State {
|
||||
.source
|
||||
.dismiss(&event)?
|
||||
{
|
||||
self.remount_form();
|
||||
return Ok(None);
|
||||
}
|
||||
let d = self
|
||||
@@ -683,7 +712,11 @@ impl State {
|
||||
next.target = Some(id);
|
||||
next.command(p)?;
|
||||
}
|
||||
let returned = d.source.is_open() && !next.source.is_open();
|
||||
self.draft = Some(next);
|
||||
if returned {
|
||||
self.remount_form();
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
if self.source_open() && !matches!(&event, Event::Cancel) {
|
||||
@@ -699,6 +732,7 @@ impl State {
|
||||
return Err("Choose a scenario.".into());
|
||||
}
|
||||
self.form_session = Id::new_v4();
|
||||
self.form_mount = self.form_session;
|
||||
self.form_focus_pending = true;
|
||||
self.draft = Some(Draft {
|
||||
source: Default::default(),
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! A delayed focus request must still own the live parent tree when executed.
|
||||
//! Collect first: a missing target must not unfocus unrelated fields.
|
||||
use super::Context;
|
||||
use iced::advanced::widget::{Id, Operation, Tree, operation};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Task, Theme, Vector, mouse};
|
||||
use oa_core::Id as ModelId;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Stamp {
|
||||
document: ModelId,
|
||||
owner: ModelId,
|
||||
revision: u64,
|
||||
session: ModelId,
|
||||
mount: ModelId,
|
||||
}
|
||||
impl Stamp {
|
||||
pub(crate) fn new(context: Context, session: ModelId, mount: ModelId) -> Self {
|
||||
Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
revision: context.revision,
|
||||
session,
|
||||
mount,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn name_id(stamp: Stamp) -> Id {
|
||||
Id::from(format!(
|
||||
"{}:{}:{}:{}:{}:{}",
|
||||
super::NAME_INPUT,
|
||||
stamp.document,
|
||||
stamp.owner,
|
||||
stamp.revision,
|
||||
stamp.session,
|
||||
stamp.mount
|
||||
))
|
||||
}
|
||||
struct Owner(Option<Stamp>);
|
||||
struct Root<'a, Message, R: renderer::Renderer> {
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
owner: Option<Stamp>,
|
||||
}
|
||||
pub(crate) fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
owner: Option<Stamp>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Root {
|
||||
content: content.into(),
|
||||
owner,
|
||||
})
|
||||
}
|
||||
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(&mut self, tree: &mut 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 Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
op: &mut dyn Operation,
|
||||
) {
|
||||
// This is computed from the current App view, never retained widget state.
|
||||
op.custom(None, layout.bounds(), &mut Owner(self.owner));
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, op);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &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: &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 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Scroll {
|
||||
index: usize,
|
||||
bounds: Rectangle,
|
||||
capacity: Vector,
|
||||
offset: Vector,
|
||||
}
|
||||
struct Collect {
|
||||
stamp: Stamp,
|
||||
valid: bool,
|
||||
translation: Vector,
|
||||
scrolls: Vec<Scroll>,
|
||||
pending: Option<Scroll>,
|
||||
index: usize,
|
||||
target: Option<(Rectangle, Vec<Scroll>)>,
|
||||
}
|
||||
pub(crate) fn task<Message: Send + 'static>(stamp: Stamp) -> Task<Message> {
|
||||
iced::advanced::widget::operate(Collect {
|
||||
stamp,
|
||||
valid: false,
|
||||
translation: Vector::ZERO,
|
||||
scrolls: vec![],
|
||||
pending: None,
|
||||
index: 0,
|
||||
target: None,
|
||||
})
|
||||
.discard()
|
||||
}
|
||||
impl Operation for Collect {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.stamp);
|
||||
}
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
let previous = (self.translation, self.scrolls.len());
|
||||
if let Some(scroll) = self.pending.take() {
|
||||
self.translation += scroll.offset;
|
||||
self.scrolls.push(scroll);
|
||||
}
|
||||
visit(self);
|
||||
self.translation = previous.0;
|
||||
self.scrolls.truncate(previous.1);
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
self.pending = Some(Scroll {
|
||||
index: self.index,
|
||||
bounds: translated(bounds, self.translation),
|
||||
capacity: Vector::new(
|
||||
(content.width - bounds.width).max(0.),
|
||||
(content.height - bounds.height).max(0.),
|
||||
),
|
||||
offset,
|
||||
});
|
||||
self.index += 1;
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, bounds: Rectangle, _: &mut dyn operation::Focusable) {
|
||||
if id == Some(&name_id(self.stamp)) {
|
||||
self.target = Some((translated(bounds, self.translation), self.scrolls.clone()));
|
||||
}
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<()> {
|
||||
if !self.valid {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let Some((mut bounds, ancestors)) = self.target.clone() else {
|
||||
return operation::Outcome::None;
|
||||
};
|
||||
let mut shifts = BTreeMap::new();
|
||||
for ancestor in ancestors.iter().rev() {
|
||||
let delta = Vector::new(
|
||||
(ancestor.offset.x
|
||||
+ reveal(
|
||||
bounds.x,
|
||||
bounds.width,
|
||||
ancestor.bounds.x,
|
||||
ancestor.bounds.width,
|
||||
))
|
||||
.clamp(0., ancestor.capacity.x)
|
||||
- ancestor.offset.x,
|
||||
(ancestor.offset.y
|
||||
+ reveal(
|
||||
bounds.y,
|
||||
bounds.height,
|
||||
ancestor.bounds.y,
|
||||
ancestor.bounds.height,
|
||||
))
|
||||
.clamp(0., ancestor.capacity.y)
|
||||
- ancestor.offset.y,
|
||||
);
|
||||
if delta != Vector::ZERO {
|
||||
shifts.insert(ancestor.index, delta);
|
||||
}
|
||||
bounds = translated(bounds, delta);
|
||||
}
|
||||
operation::Outcome::Chain(Box::new(Apply {
|
||||
stamp: self.stamp,
|
||||
valid: false,
|
||||
shifts,
|
||||
index: 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
|
||||
Rectangle {
|
||||
x: bounds.x - delta.x,
|
||||
y: bounds.y - delta.y,
|
||||
..bounds
|
||||
}
|
||||
}
|
||||
fn reveal(start: f32, length: f32, viewport: f32, size: f32) -> f32 {
|
||||
if start < viewport || length > size {
|
||||
start - viewport
|
||||
} else {
|
||||
(start + length - viewport - size).max(0.)
|
||||
}
|
||||
}
|
||||
struct Apply {
|
||||
stamp: Stamp,
|
||||
valid: bool,
|
||||
shifts: BTreeMap<usize, Vector>,
|
||||
index: usize,
|
||||
}
|
||||
impl Operation for Apply {
|
||||
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
|
||||
if let Some(owner) = state.downcast_ref::<Owner>() {
|
||||
self.valid = owner.0 == Some(self.stamp);
|
||||
}
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
|
||||
if self.valid {
|
||||
if id == Some(&name_id(self.stamp)) {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&Id>,
|
||||
bounds: Rectangle,
|
||||
content: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn operation::Scrollable,
|
||||
) {
|
||||
if self.valid
|
||||
&& let Some(delta) = self.shifts.get(&self.index)
|
||||
{
|
||||
state.scroll_by(
|
||||
operation::scrollable::AbsoluteOffset {
|
||||
x: delta.x,
|
||||
y: delta.y,
|
||||
},
|
||||
bounds,
|
||||
content,
|
||||
);
|
||||
}
|
||||
self.index += 1;
|
||||
}
|
||||
}
|
||||
@@ -359,7 +359,9 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
let mut form = column![
|
||||
text(d.tool.label()).size(16),
|
||||
text_input("Name (defaults to selected source)", &d.name)
|
||||
.id(NAME_INPUT)
|
||||
.id(focus::name_id(
|
||||
state.focus_stamp(context).expect("visible parent form")
|
||||
))
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12)
|
||||
@@ -638,9 +640,10 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
]
|
||||
.spacing(7);
|
||||
let session = state.form_session().expect("visible scenario draft");
|
||||
let mount = state.form_mount().expect("visible parent form");
|
||||
form_panel = sensor(editor_panel(session, footer.into(), form.into()))
|
||||
.key(session)
|
||||
.on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, session)))
|
||||
.key(mount)
|
||||
.on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, mount)))
|
||||
.into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ fn pending_marker_reveals_in_the_actual_smaller_canvas_once_without_horizontal_o
|
||||
|
||||
#[tokio::test]
|
||||
async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_modals() {
|
||||
use iced::futures::StreamExt;
|
||||
let renderer = focus_renderer().await;
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut app = App::with_project(f.project);
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
@@ -263,53 +263,36 @@ async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_moda
|
||||
let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown(
|
||||
context, session,
|
||||
)));
|
||||
let mut fields: iced::Element<'_, (), Theme, ()> = column![
|
||||
text_input("Scenario name", " retained 雨 ")
|
||||
.id(NAME_INPUT)
|
||||
.on_input(|_| ()),
|
||||
text_input("Other field", "other")
|
||||
.id("another-focus-target")
|
||||
.on_input(|_| ()),
|
||||
]
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(fields.as_widget());
|
||||
let node = fields.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(320., 100.)),
|
||||
let mut tree = widget::Tree::new(app.view().as_widget());
|
||||
focus_other(&app, &mut tree, &renderer);
|
||||
let mut away = widget::operation::scrollable::scroll_to::<()>(
|
||||
widget::Id::new("scenario-form"),
|
||||
widget::operation::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(1000.),
|
||||
},
|
||||
);
|
||||
mounted_operation(&app, &mut tree, &renderer, &mut away);
|
||||
let mut before_reveal = Geometry::default();
|
||||
mounted_operation(&app, &mut tree, &renderer, &mut before_reveal);
|
||||
assert!(
|
||||
before_reveal.scrolls["scenario-form"].1.y > 64.,
|
||||
"the production parent must actually overflow"
|
||||
);
|
||||
run_focus_task(&app, &mut tree, &renderer, task).await;
|
||||
assert_parent_focused(&app, &mut tree, &renderer);
|
||||
let mut revealed = ParentBounds::new(app.scenario_focus_owner().unwrap());
|
||||
mounted_operation(&app, &mut tree, &renderer, &mut revealed);
|
||||
let name = revealed.name.expect("mounted parent Name");
|
||||
let viewport = revealed.viewport.expect("actual parent scroll viewport");
|
||||
assert!(
|
||||
name.y >= viewport.y - 0.5 && name.y + name.height <= viewport.y + viewport.height + 0.5,
|
||||
"Name {name:?} must be visible inside {viewport:?}"
|
||||
);
|
||||
tree.children[1]
|
||||
.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
let mut stream = iced_runtime::task::into_stream(task).expect("valid form receives focus");
|
||||
let mut count = 0;
|
||||
while let Some(action) = stream.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("Form focus must not apply, lint or load a model");
|
||||
};
|
||||
fields
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), operation.as_mut());
|
||||
count += 1;
|
||||
}
|
||||
assert_eq!(count, 1);
|
||||
let repeated = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown(
|
||||
context, session,
|
||||
)));
|
||||
assert!(iced_runtime::task::into_stream(repeated).is_none());
|
||||
assert!(
|
||||
tree.children[0]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<()>>()
|
||||
.is_focused()
|
||||
);
|
||||
assert!(
|
||||
!tree.children[1]
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<()>>()
|
||||
.is_focused()
|
||||
);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
@@ -321,6 +304,555 @@ async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_moda
|
||||
)));
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
}
|
||||
// These gates mount the production App, including its modal and scenario
|
||||
// branches. A missing headless backend is a test failure, never a silent skip.
|
||||
const FOCUS_VIEWPORT: Size = Size::new(1100., 700.);
|
||||
async fn focus_renderer() -> iced::Renderer {
|
||||
<iced::Renderer as iced::advanced::renderer::Headless>::new(
|
||||
iced::Font::default(),
|
||||
16.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("mounted focus gates require the headless production renderer")
|
||||
}
|
||||
fn mounted_operation(
|
||||
app: &App,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &iced::Renderer,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
let mut view = app.view();
|
||||
tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
tree,
|
||||
renderer,
|
||||
&layout::Limits::new(Size::ZERO, FOCUS_VIEWPORT),
|
||||
);
|
||||
view.as_widget_mut()
|
||||
.operate(tree, Layout::new(&node), renderer, operation);
|
||||
}
|
||||
async fn focus_action(task: iced::Task<Message>) -> Box<dyn widget::Operation> {
|
||||
use iced::futures::StreamExt;
|
||||
let mut stream = iced_runtime::task::into_stream(task).expect("parent focus task");
|
||||
let Some(iced_runtime::Action::Widget(operation)) = stream.next().await else {
|
||||
panic!("focus must emit only a widget operation");
|
||||
};
|
||||
// The operation owns the task channel until it is dropped; polling its
|
||||
// completion while retaining this delayed action would wait forever.
|
||||
operation
|
||||
}
|
||||
async fn run_focus_task(
|
||||
app: &App,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &iced::Renderer,
|
||||
task: iced::Task<Message>,
|
||||
) {
|
||||
let mut operation = focus_action(task).await;
|
||||
loop {
|
||||
mounted_operation(app, tree, renderer, operation.as_mut());
|
||||
match operation.finish() {
|
||||
widget::operation::Outcome::Chain(next) => operation = next,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
struct Focused(Vec<(Option<widget::Id>, bool)>);
|
||||
impl widget::Operation for Focused {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
state: &mut dyn widget::operation::Focusable,
|
||||
) {
|
||||
self.0.push((id.cloned(), state.is_focused()));
|
||||
}
|
||||
}
|
||||
fn focused(app: &App, tree: &mut widget::Tree, renderer: &iced::Renderer) -> Focused {
|
||||
let mut result = Focused::default();
|
||||
mounted_operation(app, tree, renderer, &mut result);
|
||||
result
|
||||
}
|
||||
struct FocusIndex {
|
||||
target: usize,
|
||||
index: usize,
|
||||
}
|
||||
impl widget::Operation for FocusIndex {
|
||||
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 widget::operation::Focusable,
|
||||
) {
|
||||
if self.index == self.target {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
self.index += 1;
|
||||
}
|
||||
}
|
||||
fn focus_other(app: &App, tree: &mut widget::Tree, renderer: &iced::Renderer) {
|
||||
let parent = app.scenario_focus_owner().map(focus::name_id);
|
||||
let fields = focused(app, tree, renderer);
|
||||
let target = fields
|
||||
.0
|
||||
.iter()
|
||||
.rposition(|(id, _)| parent.is_none() || *id != parent)
|
||||
.expect("actual tree contains another focusable input");
|
||||
mounted_operation(app, tree, renderer, &mut FocusIndex { target, index: 0 });
|
||||
assert!(focused(app, tree, renderer).0[target].1);
|
||||
}
|
||||
fn focus_source(app: &App, tree: &mut widget::Tree, renderer: &iced::Renderer) {
|
||||
let id = widget::Id::new(crate::behavior::source_draft::NAME_INPUT);
|
||||
assert!(
|
||||
focused(app, tree, renderer)
|
||||
.0
|
||||
.iter()
|
||||
.any(|(field, _)| field.as_ref() == Some(&id))
|
||||
);
|
||||
let mut operation = widget::operation::focusable::focus::<()>(id.clone());
|
||||
mounted_operation(app, tree, renderer, &mut operation);
|
||||
assert!(focused(app, tree, renderer).0.contains(&(Some(id), true)));
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct ReviewField {
|
||||
inside: bool,
|
||||
entering: Option<bool>,
|
||||
index: usize,
|
||||
target: Option<usize>,
|
||||
}
|
||||
impl widget::Operation for ReviewField {
|
||||
fn container(&mut self, id: Option<&widget::Id>, _: Rectangle) {
|
||||
self.entering =
|
||||
Some(self.inside || id == Some(&widget::Id::new("reference-owning-review-modal")));
|
||||
}
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
let prior = self.inside;
|
||||
if let Some(inside) = self.entering.take() {
|
||||
self.inside = inside;
|
||||
}
|
||||
visit(self);
|
||||
self.inside = prior;
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
_: &mut dyn widget::operation::Focusable,
|
||||
) {
|
||||
if self.inside {
|
||||
self.target = Some(self.index);
|
||||
}
|
||||
self.index += 1;
|
||||
}
|
||||
}
|
||||
fn focus_review(app: &App, tree: &mut widget::Tree, renderer: &iced::Renderer) {
|
||||
let mut field = ReviewField::default();
|
||||
mounted_operation(app, tree, renderer, &mut field);
|
||||
let target = field
|
||||
.target
|
||||
.expect("production Review owns a mounted candidate input");
|
||||
mounted_operation(app, tree, renderer, &mut FocusIndex { target, index: 0 });
|
||||
assert!(focused(app, tree, renderer).0[target].1);
|
||||
}
|
||||
fn assert_parent_focused(app: &App, tree: &mut widget::Tree, renderer: &iced::Renderer) {
|
||||
let expected = focus::name_id(app.scenario_focus_owner().unwrap());
|
||||
let fields = focused(app, tree, renderer);
|
||||
assert_eq!(
|
||||
fields
|
||||
.0
|
||||
.iter()
|
||||
.filter(|(_, focused)| *focused)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![&(Some(expected), true)]
|
||||
);
|
||||
}
|
||||
struct ParentBounds {
|
||||
target: widget::Id,
|
||||
translation: Vector,
|
||||
pending: Option<Vector>,
|
||||
viewport: Option<Rectangle>,
|
||||
name: Option<Rectangle>,
|
||||
}
|
||||
impl ParentBounds {
|
||||
fn new(stamp: focus::Stamp) -> Self {
|
||||
Self {
|
||||
target: focus::name_id(stamp),
|
||||
translation: Vector::ZERO,
|
||||
pending: None,
|
||||
viewport: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
fn actual(&self, bounds: Rectangle) -> Rectangle {
|
||||
Rectangle {
|
||||
x: bounds.x - self.translation.x,
|
||||
y: bounds.y - self.translation.y,
|
||||
..bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
impl widget::Operation for ParentBounds {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
let previous = self.translation;
|
||||
if let Some(offset) = self.pending.take() {
|
||||
self.translation += offset;
|
||||
}
|
||||
visit(self);
|
||||
self.translation = previous;
|
||||
}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
translation: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&widget::Id::new("scenario-form")) {
|
||||
self.viewport = Some(self.actual(bounds));
|
||||
}
|
||||
self.pending = Some(translation);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
bounds: Rectangle,
|
||||
_: &mut dyn widget::operation::Focusable,
|
||||
) {
|
||||
if id == Some(&self.target) {
|
||||
self.name = Some(self.actual(bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn shown_task(
|
||||
app: &mut App,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &iced::Renderer,
|
||||
) -> iced::Task<Message> {
|
||||
let mut messages = vec![];
|
||||
{
|
||||
let mut view = app.view();
|
||||
tree.diff(view.as_widget());
|
||||
let size = FOCUS_VIEWPORT;
|
||||
let node =
|
||||
view.as_widget_mut()
|
||||
.layout(tree, renderer, &layout::Limits::new(Size::ZERO, size));
|
||||
view.as_widget_mut().update(
|
||||
tree,
|
||||
&iced::Event::Window(iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
)),
|
||||
Layout::new(&node),
|
||||
mouse::Cursor::Unavailable,
|
||||
renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut Shell::new(&mut messages),
|
||||
&Rectangle::with_size(size),
|
||||
);
|
||||
}
|
||||
let mut task = None;
|
||||
for message in messages {
|
||||
let shown = matches!(
|
||||
&message,
|
||||
Message::ScenarioCanvas(CanvasEvent::FormShown(..))
|
||||
);
|
||||
let result = app.update(message);
|
||||
if shown {
|
||||
assert!(task.replace(result).is_none());
|
||||
}
|
||||
}
|
||||
task.expect("production parent sensor reports its newly mounted identity")
|
||||
}
|
||||
fn focus_fixture() -> (App, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Operational flow", None)
|
||||
.unwrap();
|
||||
p.add_element(Kind::Scenario, Layer::Oa, "Another scenario", None)
|
||||
.unwrap();
|
||||
if let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
p.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
{
|
||||
*viewpoint = ScenarioViewpoint::OperationalActivity;
|
||||
}
|
||||
// A real Review candidate input is needed for the delayed-modal falsifier.
|
||||
let oa = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Operational number", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "System number", None)
|
||||
.unwrap();
|
||||
for (id, unit) in [(oa, "m"), (sa, "cm")] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
minimum: None,
|
||||
maximum: None,
|
||||
},
|
||||
default: None,
|
||||
unit: unit.into(),
|
||||
};
|
||||
}
|
||||
let trace = Id::new_v4();
|
||||
p.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: format!("review_{}", trace.simple()),
|
||||
name: "Numbers review".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
let _ = app.update(event(Event::Start(owner, Tool::Lifeline)));
|
||||
let _ = app.update(event(Event::Name(" retained parent 雨 ".into())));
|
||||
(app, owner, oa)
|
||||
}
|
||||
fn open_source(app: &mut App) -> Id {
|
||||
use crate::behavior::source_draft as source;
|
||||
let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation;
|
||||
let _ = app.update(event(Event::Source(source::Event::Open(invocation))));
|
||||
assert!(app.behavior.scenario.source_open(), "{}", app.status);
|
||||
let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap();
|
||||
serde_json::from_value(raw["source"]["editing"]["token"].clone()).unwrap()
|
||||
}
|
||||
fn open_review(app: &mut App, source: Id) {
|
||||
use crate::semantic_review::{self, RowEvent};
|
||||
let mut review = semantic_review::State::new(app.project(), source).unwrap();
|
||||
let facet = FacetSelector::Data(DataFacet::Unit);
|
||||
for event in [
|
||||
RowEvent::Select(facet.clone()),
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
] {
|
||||
let addressed = review.address_event(event);
|
||||
review.update(app.project(), addressed);
|
||||
}
|
||||
app.workflow = Some(crate::workflow::Workflow::Semantic(Box::new(review)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scenario_form_focus_returns_from_back_use_and_escape_after_initial_focus_is_consumed() {
|
||||
use crate::behavior::source_draft as source;
|
||||
let renderer = focus_renderer().await;
|
||||
for route in 0..3 {
|
||||
let (mut app, _, _) = focus_fixture();
|
||||
let mut tree = widget::Tree::new(app.view().as_widget());
|
||||
let initial = shown_task(&mut app, &mut tree, &renderer);
|
||||
run_focus_task(&app, &mut tree, &renderer, initial).await;
|
||||
assert_parent_focused(&app, &mut tree, &renderer);
|
||||
let first_mount = app.behavior.scenario.form_mount().unwrap();
|
||||
let session = app.behavior.scenario.form_session();
|
||||
let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let token = open_source(&mut app);
|
||||
focus_source(&app, &mut tree, &renderer); // mount the actual child
|
||||
match route {
|
||||
0 => {
|
||||
let _ = app.update(event(Event::Source(source::Event::Edit(
|
||||
token,
|
||||
source::Edit::Back,
|
||||
))));
|
||||
}
|
||||
1 => {
|
||||
let _ = app.update(event(Event::Source(source::Event::Edit(
|
||||
token,
|
||||
source::Edit::Name("New activity 雨".into()),
|
||||
))));
|
||||
let _ = app.update(event(Event::Source(source::Event::Edit(
|
||||
token,
|
||||
source::Edit::Use,
|
||||
))));
|
||||
}
|
||||
_ => {
|
||||
let _ = app.update(Message::Escape);
|
||||
}
|
||||
}
|
||||
assert!(!app.behavior.scenario.source_open(), "{}", app.status);
|
||||
assert_eq!(app.behavior.scenario.form_session(), session);
|
||||
assert_ne!(app.behavior.scenario.form_mount(), Some(first_mount));
|
||||
let returned = app.behavior.scenario.recovery_snapshot().unwrap().unwrap();
|
||||
for key in [
|
||||
"name",
|
||||
"invocation",
|
||||
"owner",
|
||||
"parent",
|
||||
"before",
|
||||
"source_origin",
|
||||
] {
|
||||
assert_eq!(returned[key], raw[key], "route {route}, raw field {key}");
|
||||
}
|
||||
let task = shown_task(&mut app, &mut tree, &renderer);
|
||||
run_focus_task(&app, &mut tree, &renderer, task).await;
|
||||
assert_parent_focused(&app, &mut tree, &renderer);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.recovery_snapshot().unwrap().unwrap(),
|
||||
returned
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
let repeated = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown(
|
||||
app.scenario_context().unwrap(),
|
||||
app.behavior.scenario.form_mount().unwrap(),
|
||||
)));
|
||||
assert!(iced_runtime::task::into_stream(repeated).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scenario_form_focus_saved_action_is_inert_on_replacement_child_review_and_changed_context()
|
||||
{
|
||||
let renderer = focus_renderer().await;
|
||||
for change in 0..8 {
|
||||
let (mut app, owner, review_source) = focus_fixture();
|
||||
let mut tree = widget::Tree::new(app.view().as_widget());
|
||||
let task = shown_task(&mut app, &mut tree, &renderer);
|
||||
// Obtain S1's actual Action before changing the live state; stale
|
||||
// FormShown rejection alone would not exercise this race.
|
||||
let mut operation = focus_action(task).await;
|
||||
match change {
|
||||
0 => {
|
||||
let _ = app.update(event(Event::Cancel));
|
||||
let _ = app.update(event(Event::Start(owner, Tool::Lifeline)));
|
||||
}
|
||||
1 => {
|
||||
open_source(&mut app);
|
||||
}
|
||||
2 => open_review(&mut app, review_source),
|
||||
3 => app.canvas_document = Id::new_v4(),
|
||||
4 => {
|
||||
app.document
|
||||
.execute(Command::Rename(owner, "Renamed scenario".into()))
|
||||
.unwrap();
|
||||
}
|
||||
5 => {
|
||||
let other = app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Scenario && e.id != owner)
|
||||
.unwrap()
|
||||
.id;
|
||||
let draft = app.behavior.scenario.draft.as_mut().unwrap();
|
||||
draft.owner = other;
|
||||
draft.parent = other;
|
||||
}
|
||||
6 => {
|
||||
open_source(&mut app);
|
||||
focus_source(&app, &mut tree, &renderer);
|
||||
let _ = app.update(Message::Escape); // same S1 session, different mount
|
||||
}
|
||||
_ => app.hosted.visible = true,
|
||||
}
|
||||
if change == 1 {
|
||||
focus_source(&app, &mut tree, &renderer);
|
||||
} else if change == 2 {
|
||||
focus_review(&app, &mut tree, &renderer);
|
||||
} else {
|
||||
focus_other(&app, &mut tree, &renderer);
|
||||
}
|
||||
let before = focused(&app, &mut tree, &renderer);
|
||||
let source = app.document.bundle().clone();
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let undo = app.document.can_undo();
|
||||
let redo = app.document.can_redo();
|
||||
mounted_operation(&app, &mut tree, &renderer, operation.as_mut());
|
||||
assert!(
|
||||
matches!(operation.finish(), widget::operation::Outcome::None),
|
||||
"change {change}"
|
||||
);
|
||||
assert_eq!(
|
||||
focused(&app, &mut tree, &renderer),
|
||||
before,
|
||||
"change {change}"
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert_eq!(
|
||||
(app.document.can_undo(), app.document.can_redo()),
|
||||
(undo, redo)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scenario_form_focus_chained_apply_rechecks_live_review_owner() {
|
||||
let renderer = focus_renderer().await;
|
||||
let (mut app, _, review_source) = focus_fixture();
|
||||
let mut tree = widget::Tree::new(app.view().as_widget());
|
||||
let task = shown_task(&mut app, &mut tree, &renderer);
|
||||
let mut collect = focus_action(task).await;
|
||||
mounted_operation(&app, &mut tree, &renderer, collect.as_mut());
|
||||
let widget::operation::Outcome::Chain(mut apply) = collect.finish() else {
|
||||
panic!("current parent produces the guarded apply traversal");
|
||||
};
|
||||
open_review(&mut app, review_source);
|
||||
focus_review(&app, &mut tree, &renderer);
|
||||
let before = focused(&app, &mut tree, &renderer);
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let source = app.document.bundle().clone();
|
||||
mounted_operation(&app, &mut tree, &renderer, apply.as_mut());
|
||||
assert!(matches!(apply.finish(), widget::operation::Outcome::None));
|
||||
assert_eq!(focused(&app, &mut tree, &renderer), before);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scenario_form_focus_missing_exact_target_leaves_other_inputs_untouched() {
|
||||
let (app, _, _) = focus_fixture();
|
||||
let stamp = app.scenario_focus_owner().unwrap();
|
||||
let mut view: iced::Element<'_, (), Theme, ()> = focus::root(
|
||||
text_input("Unrelated input", "still focused")
|
||||
.id("unrelated")
|
||||
.on_input(|_| ()),
|
||||
Some(stamp),
|
||||
);
|
||||
let mut tree = widget::Tree::new(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(320., 80.)),
|
||||
);
|
||||
view.as_widget_mut().operate(
|
||||
&mut tree,
|
||||
Layout::new(&node),
|
||||
&(),
|
||||
&mut FocusIndex {
|
||||
target: 0,
|
||||
index: 0,
|
||||
},
|
||||
);
|
||||
let mut operation = focus_action(focus::task(stamp)).await;
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), operation.as_mut());
|
||||
assert!(matches!(
|
||||
operation.finish(),
|
||||
widget::operation::Outcome::None
|
||||
));
|
||||
let mut fields = Focused::default();
|
||||
view.as_widget_mut()
|
||||
.operate(&mut tree, Layout::new(&node), &(), &mut fields);
|
||||
assert_eq!(fields.0, vec![(Some(widget::Id::new("unrelated")), true)]);
|
||||
}
|
||||
|
||||
fn geometry(view: &mut TestView, tree: &mut widget::Tree, size: Size) -> (layout::Node, Geometry) {
|
||||
let node = view
|
||||
.as_widget_mut()
|
||||
|
||||
Reference in New Issue
Block a user