fix(scenario): restore focus only to the current parent form

This commit is contained in:
Codex
2026-09-11 08:07:41 -07:00
parent 8d55d2d2eb
commit 8895dd2e0f
12 changed files with 1126 additions and 60 deletions
+38 -4
View File
@@ -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()