fix(scenario): restore focus only to the current parent form
This commit is contained in:
@@ -1032,7 +1032,10 @@ impl App {
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::hosted::view::overlay(self, self.reference_overlay(base))
|
||||
crate::behavior::scenario::focus::root(
|
||||
crate::hosted::view::overlay(self, self.reference_overlay(base)),
|
||||
self.scenario_focus_owner(),
|
||||
)
|
||||
}
|
||||
pub fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
@@ -1381,6 +1384,20 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
|
||||
if self.busy
|
||||
|| self.hosted.visible
|
||||
|| self.workflow.is_some()
|
||||
|| self.architecture.is_some()
|
||||
|| self.model_menu.is_some()
|
||||
|| self.file_prompt.is_some()
|
||||
|| self.delete_pending
|
||||
|| self.pending.is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.behavior.scenario.focus_stamp(self.scenario_context()?)
|
||||
}
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Architecture(event) => return self.architecture_event(event),
|
||||
@@ -1398,14 +1415,15 @@ impl App {
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
if let CanvasEvent::FormShown(_, session) = event {
|
||||
if let CanvasEvent::FormShown(_, mount) = event {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
&& current.revision == context.revision
|
||||
}) && self.behavior.scenario.take_form_focus(session)
|
||||
}) && self.scenario_focus_owner().is_some()
|
||||
&& self.behavior.scenario.take_form_focus(mount)
|
||||
{
|
||||
return iced::widget::operation::focus(scenario::NAME_INPUT);
|
||||
return scenario::focus::task(self.scenario_focus_owner().unwrap());
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
@@ -306,8 +306,13 @@ fn chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
app.source_workspace
|
||||
.edit("unfinished source".into(), Default::default());
|
||||
app.source_workspace.edit(
|
||||
"unfinished source".into(),
|
||||
iced::widget::text_editor::Cursor {
|
||||
position: iced::widget::text_editor::Position { line: 0, column: 0 },
|
||||
selection: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let expected = raw(&app);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use super::*;
|
||||
use iced::futures::StreamExt;
|
||||
use iced::{
|
||||
Event as Input, Point, Size, Theme, Vector,
|
||||
Element, Event as Input, Point, Size, Theme, Vector,
|
||||
advanced::{
|
||||
Layout, Shell, layout,
|
||||
widget::{Id as WidgetId, Operation, Tree, operation},
|
||||
@@ -11,6 +11,7 @@ use iced::{
|
||||
keyboard, mouse,
|
||||
widget::{Space, column, container, opaque, scrollable, stack, text_input},
|
||||
};
|
||||
use oa_core::transitions::TransitionDirection;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Clipboard;
|
||||
|
||||
@@ -417,6 +417,7 @@ mod scenario_scroll {
|
||||
area: scenario::ScrollArea::Canvas,
|
||||
process: false,
|
||||
restore: None,
|
||||
reveal: None,
|
||||
content: scrollable(iced::widget::Space::new().width(200.).height(height))
|
||||
.id("observer-test")
|
||||
.width(200.)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -35,10 +35,10 @@ pub fn modal<'a, Message: 'a, R: renderer::Renderer + 'a>(
|
||||
}
|
||||
|
||||
pub fn field_id(generation: ModelId, path: &str) -> Id {
|
||||
Id::new(format!("reference:{generation}:{path}"))
|
||||
Id::from(format!("reference:{generation}:{path}"))
|
||||
}
|
||||
pub fn scope_id(session: ModelId) -> Id {
|
||||
Id::new(format!("reference-form:{session}"))
|
||||
Id::from(format!("reference-form:{session}"))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -437,7 +437,7 @@ pub fn focus_operation(
|
||||
) -> impl Operation + 'static {
|
||||
focus::exact(session, generation, path, step)
|
||||
}
|
||||
pub fn focus<Message>(
|
||||
pub fn focus<Message: Send + 'static>(
|
||||
session: ModelId,
|
||||
generation: ModelId,
|
||||
path: &str,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Scenario parent focus correction — source handoff
|
||||
|
||||
Codex-956atf `/root/scenario_focus_fix`, 2026-09-11, root checkout at supplied base `8d55d2d`. Implements the two source-derived findings in the frozen `harness/release-chooser-contextual-integration-review-20260911.md`. That earlier review is unchanged. Coordination: Gitea issue 1 comment 292; the isolated canvas worker retains its own App/workspace changes.
|
||||
|
||||
## Changes
|
||||
|
||||
- I1: `State::form_mount` is a separate presentation identity. The first mount uses the new session token; successful source Back, Use and Escape create another mount/focus intent without changing `form_session`, invocation, source identities, raw parent fields or model history. `form_mount()` is absent while the source child owns the editor. The production parent sensor reports the mount token; its editor scroll identity remains the form session.
|
||||
- I2: `App::view` wraps the complete current tree in a small root ownership marker. The live owner includes document session, semantic owner, document revision, form session and parent mount, and is absent for source children, busy state, Review, hosted UI and other App modal owners. The exact name widget ID contains the same stamp. A delayed operation first collects the live marker and mounted exact target without modifying focus. Only a match produces the apply traversal; it rechecks the live marker before touching focus or ancestor scroll offsets. An absent target produces no apply traversal and leaves other inputs alone. Geometry epoch changes do not change the parent identity.
|
||||
- The focus operation reveals its exact field through ancestor scrollables. Existing scenario canvas pending-marker reveal, parent editor scroll key, and source-versus-parent branches remain intact.
|
||||
|
||||
## Prepared regression gates — not executed
|
||||
|
||||
All five tests below are in `crates/oa-desktop/src/behavior/scenario/workspace/tests.rs`, under filter `scenario_form_focus`. Four use the actual production `App::view` at 1100×700 with `iced::Renderer` through its headless API; a missing backend is an explicit test failure, never a silent skip.
|
||||
|
||||
1. `scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_modals`: existing stale-dispatch coverage now executes current focus against the production root and checks exclusive Name focus, unchanged raw draft/source/history and one-shot delivery. Viewport epoch changes remain accepted. The scroll gate requires an actually overflowing parent with measured initial offset greater than 64, then requires the Name's effective bounds to lie within the measured parent viewport after focus. This replaces the first, potentially vacuous final-offset assertion identified by the independent reviewer.
|
||||
2. `scenario_form_focus_returns_from_back_use_and_escape_after_initial_focus_is_consumed`: consume actual initial sensor/task; mount and focus actual source Name; return by Back, valid Use or Escape; execute the newly mounted production parent's sensor task. Checks stable semantic/raw identities and form session, changed mount, correct parent focus, unchanged recovery after focus, identical accepted Project/source bundle and untouched Undo/Redo.
|
||||
3. `scenario_form_focus_saved_action_is_inert_on_replacement_child_review_and_changed_context`: obtain S1's real widget Action before changing state. Delay it across S2, source child, real Review candidate input, changed document session/revision/owner, same-session child return to a new mount, and hosted UI. Operate it on the newly mounted production tree; require no chained mutation, unchanged current focus/raw/source/history. Child and Review cases explicitly focus their own mounted inputs.
|
||||
4. `scenario_form_focus_missing_exact_target_leaves_other_inputs_untouched`: matching root owner with absent target must leave a separate focused input untouched.
|
||||
5. `scenario_form_focus_chained_apply_rechecks_live_review_owner`: execute current Collect, retain its chained Apply, open and focus a real Review candidate input, then execute the retained Apply. Requires unchanged modal focus, raw draft/source/history and no further chained mutation.
|
||||
|
||||
The async action helper deliberately does not poll task completion while retaining its widget operation: that operation owns the task channel until drop. It executes any chained operation against the current production tree.
|
||||
|
||||
## Validation and integration
|
||||
|
||||
Only source inspection and `git diff --check` were performed by this worker; the whitespace check passed before handoff. No Cargo, rustc, native UI, install, formatting, commit or release was run. Source still requires root's scoped rustfmt, compiler, `scenario_form_focus`, complete workspace tests, contextual-source tests and relevant Clippy gates. The headless renderer/backend requirement and actual minimum-size/native focus timing remain explicit runtime gates. Independent source review has been requested separately and must record its own verdict.
|
||||
|
||||
Owned implementation paths: `crates/oa-desktop/src/app.rs`, `behavior/scenario.rs`, `behavior/scenario/workspace.rs`, `behavior/scenario/workspace/tests.rs`, and new `behavior/scenario/focus.rs`. The last four paths are relative to `crates/oa-desktop/src`. Only this new report was written under `harness`; shared ledgers and the frozen prior review were not edited by this worker.
|
||||
|
||||
When merging the isolated canvas candidate, preserve the new outer App marker and `scenario_focus_owner`, guarded FormShown handler, scenario mount/state methods, generated exact Name ID and parent sensor key/payload. The canvas worker changes the surrounding workspace host and its old layout tests: union these five focus regressions and helpers instead of replacing the entire test file. Root owns the combined freeze, validation and any versioned publication; no acceptance or release completion is claimed here.
|
||||
|
||||
Final pre-format SHA256 fingerprints (source-only; root formatting will legitimately change them):
|
||||
|
||||
| Path below desktop src | SHA256 |
|
||||
| --- | --- |
|
||||
| `app.rs` | `4B700DBC6F8C0E8258B0F409C0B8F28266838F66ADAEABBEACE6B37E6DCAF736` |
|
||||
| `behavior/scenario.rs` | `6E26E0EDD44D11D473B67E9DFE634185D77AE080E26776E9F55230D587ABA083` |
|
||||
| `behavior/scenario/workspace.rs` | `D58CD2EA3F4603801BBD268906141076C5F70A83FB87A102B19A8806B083DEAE` |
|
||||
| `behavior/scenario/workspace/tests.rs` | `CBA10D3A5C2EC0DF9E5FA67EA2B2570412DACE45BBD1D351431CC1579970C56D` |
|
||||
| `behavior/scenario/focus.rs` | `48E1F227527E66CF0B56D75B23E05A1F4F89C3D0681760BE471D3E31362F2FBA` |
|
||||
@@ -0,0 +1,56 @@
|
||||
# Scenario parent focus — local execution gate
|
||||
|
||||
Codex-956atf `/root`, Windows, 2026-09-11. The corrected root source passes
|
||||
**all five `scenario_form_focus` regressions**, zero failures or ignored tests.
|
||||
This is the working patch over8d55d2d, not a claim that the unchanged base commit
|
||||
contains these corrections. Four tests mount the actual App root at1100×700
|
||||
through the headless WGPU renderer; missing backend support fails explicitly.
|
||||
|
||||
The [implementation handoff](release-scenario-focus-fix-20260911.md) records I1
|
||||
parent rearming after source Back/Use/Escape and I2 delayed-operation ownership.
|
||||
The [independent source review](release-scenario-focus-review-20260911.md) has no
|
||||
unresolved material findings. Root preserved its strengthened overflow assertion:
|
||||
initial parent offset must exceed64 and the focused Name must become visible.
|
||||
|
||||
## Actual integration and execution
|
||||
|
||||
The first desktop compile exited101 with12 installed-Iced API/test integration
|
||||
errors. Root corrected owned dynamic widget IDs to `Id::from(String)`; added
|
||||
the chooser's `Send + 'static` task bound; supplied the existing test Observer's
|
||||
`reveal: None`; disambiguated Iced Element and core TransitionDirection imports;
|
||||
and constructed the editor Cursor explicitly. The source-child and parent-focus
|
||||
contracts were retained. No dependency or public model schema changed.
|
||||
|
||||
Scoped rustfmt and `git diff --check` passed. The second command was:
|
||||
|
||||
```text
|
||||
cargo test --locked -p oa-desktop --bin arcadia-studio scenario_form_focus -- --test-threads=1
|
||||
```
|
||||
|
||||
Exit0, warning-free compilation7m55s, test execution9.61s. Exactly5 passed;
|
||||
715 other tests were filtered out. The current binary contains720 tests, but a
|
||||
full720-suite result has not yet been established by this focused command.
|
||||
Root selected the existing Rust/Cargo1.98.1 Windows GNU/LLVM toolchain and one
|
||||
Cargo job. No test stack or renderer skip setting was added.
|
||||
|
||||
## Preserved evidence and limits
|
||||
|
||||
Immutable private directory `.local/scenario-focus-gate-20260911/` contains the
|
||||
test executable, verified runtime DLL, failed and successful logs, and a275-input
|
||||
manifest of actual source/build files, including the new untracked focus module.
|
||||
The manifest explicitly identifies working-source provenance rather than claiming
|
||||
all inputs were identical to base HEAD.
|
||||
|
||||
| Artifact | SHA256 |
|
||||
| --- | --- |
|
||||
| 135,520,256-byte test executable | `C9C4210EA36854F20C0DC9F38137CEFE5C3BC9CD19E10E1AD41DCB410CC92D04` |
|
||||
| 190,464-byte libunwind.dll | `AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A` |
|
||||
| First failed compile log | `D6552F4FB6A325E312241BF9035CD7F71877C2852D60D6FD7F425CB4394F1C27` |
|
||||
| Passing focused log | `03DC0F104F1E4ADC7B26E25191ED9936A194EDBDF170182CAE02B4114B4E018D` |
|
||||
| Input/artifact manifest | `C2D26460AF67D668F8159E1B2461C07110A0B2FD61938E410016FEADA7FB2D34` |
|
||||
|
||||
Full current desktop regression, current desktop Clippy, matching native GUI
|
||||
focus timing/visual behavior and Linux desktop acceptance remain separate gates.
|
||||
The isolated shared canvas candidate is not part of this binary; its future union
|
||||
must preserve the exact parent marker, session/mount split, Name identity, sensor
|
||||
and all five regressions. Prior676-suite/native80cf197 evidence remains historical.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Scenario parent-focus correction — independent source review
|
||||
|
||||
Codex-956atf `/root/focus_review`, 2026-09-11. **No unresolved material source finding in the bounded I1/I2 correction. Runtime acceptance remains pending.** This reviews the working patch over HEAD `8d55d2d2eb05aed826b96a917cf776e1d8c3b690`, frozen with author `scenario_focus_fix`, including the final test-only strengthening below. The [pre-reboot integration review](release-chooser-contextual-integration-review-20260911.md) remains unchanged history. See the [implementation handoff](release-scenario-focus-fix-20260911.md).
|
||||
|
||||
Read AGENTS, current Gitea issue 1 coordination, empty open PR inventory, BehaviorAuthoring20260911 and ContextualSourceDrafts20260911 Wiki pages. Review ownership was posted at [issue 1/comment 302](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-302). Only this new report was written. No production edits, Cargo, rustc, rustfmt, tests, renderer creation, native UI, install or commit occurred. `git diff --check` passed for tracked working changes; it is not compilation or runtime evidence.
|
||||
|
||||
## Findings and resolution
|
||||
|
||||
**I1, previous P2 — correction supported by source.** `scenario.rs:317`, `:490`, `:669` and `:710` now request a fresh parent mount on successful Escape/Back/Use return. This changes only `form_mount` and `form_focus_pending`; `form_session` remains stable. Source staging and its existing admission/validation are retained. In `workspace.rs:362` the Name ID includes the current stamp; at `:642` the parent-only sensor is keyed by mount, while `editor_panel` remains keyed by editing session. The child still replaces the parent branch. An ordinary parent whose initial focus has already been consumed can therefore publish a fresh FormShown after the child returns. Raw semantic identities are not rotated to obtain focus.
|
||||
|
||||
**I2, previous P2 — correction supported by source.** `App::view` (`app.rs:1029`) wraps the final reference/hosted content with the scenario owner marker. `scenario_focus_owner` (`:1387`) computes the live document/scenario/revision/session/mount stamp and excludes busy, hosted, workflow, architecture, model-menu, file-prompt, deletion and pending-confirmation ownership. The marker is published from the current view on every operation traversal, rather than retained widget state. FormShown checks the context and consumes the exact mount before constructing the guarded task (`:1410`). Geometry epoch is deliberately absent from the stamp, so a same-mount viewport notification does not invalidate first focus.
|
||||
|
||||
`focus.rs:84–119` collects without changing any focusable or scrollable. A mismatched owner or missing exact Name target ends with `Outcome::None`. Only a valid target yields Apply, initially invalid; Apply rechecks the current root owner before changing focus or scroll (`:129–145`). Thus a saved S1 action operated after replacement, child opening, same-session remount, document/owner/revision change or modal ownership change cannot act on S2 or clear another field. The scoped Name ID also prevents the former global-ID collision.
|
||||
|
||||
**R1, P2 test weakness — corrected during review, execution pending.** The initial reveal test scrolled to 1000 and asserted only a final offset at most 64. Without proving actual overflow, that sequence could pass with a broken reveal. Author changed the production viewport to 1100×700, requires measured pre-focus offset greater than 64, and measures the Name's effective bounds inside the actual form viewport afterward (`workspace/tests.rs:217`). Existing raw/history assertions remain. No claim is made that this stronger fixture has passed; missing overflow will now fail explicitly. Author also added a direct chained-Apply owner-change falsifier (`:602`) after review feedback.
|
||||
|
||||
## Prepared regression scope
|
||||
|
||||
Five `scenario_form_focus_*` tests are present in `workspace/tests.rs`; four use the production headless renderer and actual `App::view`. Backend creation uses `expect`, with no absent-renderer/GPU skip. Tests remain under the existing debug-test module gate.
|
||||
|
||||
- The current-focus test retains stale-dispatch, repeated-dispatch, busy/hosted, epoch-only-change, raw snapshot, accepted-Project pointer and history assertions; it now executes the operation chain on the mounted production view and checks real reveal geometry.
|
||||
- The return test (`:507`) first runs and consumes ordinary initial focus, mounts and focuses the actual source Name, then uses Back, valid Use and Escape independently. It checks stable session, changed mount, retained raw name/invocation/owner/slot/source-origin fields, focused parent, unchanged accepted Project/source bundle/history, and an unchanged returned recovery snapshot after focus. Use intentionally stages its source; the focus step must preserve that resulting snapshot.
|
||||
- The saved-action matrix (`:553`) extracts S1's actual Widget action before eight live-state changes: replacement session, open source child, real Review candidate modal, document token, document revision, owner, same-session source-return mount, and hosted visibility. It operates on the rebuilt current `App::view`, preserving the focused child/Review or other actual input, source bundle, recovery state and Undo/Redo availability. This exercises delayed execution, not merely rejection of stale FormShown messages.
|
||||
- The chained-Apply test (`:602`) successfully collects on the current parent, saves its Chain result, mounts/focuses a real Review candidate, and applies the saved operation there. It requires unchanged focus, recovery/source and history.
|
||||
- Missing exact target (`:626`) deliberately uses a small generic marker/input tree with a valid stamp and an unrelated focused input. This is a narrow missing-target test, not claimed to mount the entire App.
|
||||
|
||||
`focus_action` extracts the first Widget action without waiting for task-stream completion while that action retains the channel. The execution helper follows `Outcome::Chain`. This matches the relevant synchronous chain behavior in the locally read Iced 0.14 runtime; no event-loop execution is claimed.
|
||||
|
||||
## Scroll, chooser and integration boundaries
|
||||
|
||||
Compared `behavior/scenario/focus.rs` with `forms/reference_chooser/trigger/focus.rs`. Both translate bounds by accumulated ancestor offsets, restore traversal state when leaving ancestors, calculate reveal from inner to outer scrollables, clamp shifts to content capacity, and apply shifts by traversal index. Local Iced `Scrollable::operate` reports its translation before traversing content, matching that calculation. Scenario Apply focuses the exact stamped Name; chooser focus has separate form scope/relative navigation/modal policy. Their root marker types are distinct. No concrete sign, ancestor-scope or retained-child conflict was found in this readback; native pixels and broader nested-scroll behavior remain execution work.
|
||||
|
||||
The production test helper calls `App::view`'s widget operation traversal. It does not invoke Iced UserInterface's additional overlay traversal. `reference_chooser::Popup::operate` (`widget.rs:252`) traverses its content, while its separate search input belongs to the overlay operation (`:484`). This report does **not** claim generic content traversal reaches that separate input. Existing child-open focus tasks and other focus systems were not redesigned or exhaustively audited.
|
||||
|
||||
Root owns scoped formatting, compilation, all five focus gates, related workspace/chooser/contextual-source tests, broader checks, matching GUI and native acceptance. Canvas integration must preserve the outer live owner marker, stamped Name, parent-only mount sensor and session/mount distinction. Root/author should link this review from their shared Wiki evidence; source review alone does not close the release or replace the frozen pre-reboot report.
|
||||
|
||||
## Reviewed byte fingerprints
|
||||
|
||||
SHA-256, actual pre-format freeze. Paths are relative to `crates/oa-desktop/src`. Formatting or further changes require fresh gate provenance.
|
||||
|
||||
| File | SHA-256 |
|
||||
| --- | --- |
|
||||
| `app.rs` | `4B700DBC6F8C0E8258B0F409C0B8F28266838F66ADAEABBEACE6B37E6DCAF736` |
|
||||
| `behavior/scenario.rs` | `6E26E0EDD44D11D473B67E9DFE634185D77AE080E26776E9F55230D587ABA083` |
|
||||
| `behavior/scenario/workspace.rs` | `D58CD2EA3F4603801BBD268906141076C5F70A83FB87A102B19A8806B083DEAE` |
|
||||
| `behavior/scenario/focus.rs` | `48E1F227527E66CF0B56D75B23E05A1F4F89C3D0681760BE471D3E31362F2FBA` |
|
||||
| `behavior/scenario/workspace/tests.rs` | `CBA10D3A5C2EC0DF9E5FA67EA2B2570412DACE45BBD1D351431CC1579970C56D` |
|
||||
| `forms/reference_chooser/trigger/focus.rs` | `D489FA00F445BC603B3326CAD8721E2B608456ECF543954538904BF188C6C5FA` |
|
||||
| `forms/reference_chooser/widget.rs` | `D66CC17AF7A48BB8598BE683F084BB08FA7AF7304F9636F0A1BEA94C5CF6C727` |
|
||||
Reference in New Issue
Block a user