Integrate scoped reference chooser and scenario form focus and reveal

This commit is contained in:
Codex
2026-09-11 06:24:20 -07:00
parent c7ed894900
commit 7f081ad457
27 changed files with 4544 additions and 79 deletions
@@ -1,7 +1,9 @@
//! Scenario presentation. Layout/camera controls never construct model commands.
use super::super::scenario_scroll;
use super::*;
use iced::widget::{Space, column, sensor};
use iced::widget::{Space, column, keyed_column, sensor};
pub(crate) const NAME_INPUT: &str = "scenario-form-name";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shelf {
@@ -82,10 +84,21 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
// The outline is a sibling shelf, not an ancestor of this canvas.
scene.scroll_offset = state.scroll_offset();
scene.pending_placement = state.pending_placement(owner);
let reveal = state.form_session().and_then(|token| {
let (slot, _) = scene.pending_placement?;
let marker = scene.slots.iter().find(|marker| marker.slot == slot)?;
Some(ScrollReveal {
token,
slot,
y: marker.y * scale,
padding: (24. * scale).max(12.),
})
});
let size = Size::new(scene.size.width * scale, scene.size.height * scale);
let canvas = canvas_pane(
context,
state.scroll_restore(context, ScrollArea::Canvas),
reveal,
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height)
@@ -287,7 +300,11 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
presentation.shelf.is_some(),
pending,
);
let mut form_panel = editor_panel(Space::new().height(0).into(), Space::new().height(0).into());
let mut form_panel = editor_panel(
Id::nil(),
Space::new().height(0).into(),
Space::new().height(0).into(),
);
if let Some(d) = app
.behavior
.scenario
@@ -298,6 +315,7 @@ 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)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12)
@@ -560,7 +578,11 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
action("Cancel", event(Event::Cancel))
]
.spacing(7);
form_panel = editor_panel(footer.into(), form.into());
let session = state.form_session().expect("visible scenario draft");
form_panel = sensor(editor_panel(session, footer.into(), form.into()))
.key(session)
.on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, session)))
.into();
}
workspace_layout(header.into(), details, canvas, form_panel, pending)
}
@@ -633,13 +655,14 @@ fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>(
fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>(
context: Context,
restore: Option<ScrollRestore>,
reveal: Option<ScrollReveal>,
content: iced::Element<'a, Message, Theme, R>,
) -> iced::Element<'a, Message, Theme, R> {
container(
sensor(scenario_scroll::observe(
sensor(scenario_scroll::observe_canvas(
context,
ScrollArea::Canvas,
restore,
reveal,
scrollable(content)
.id("scenario-canvas")
.direction(scrollable::Direction::Both {
@@ -661,16 +684,23 @@ fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>(
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
session: Id,
actions: iced::Element<'a, Message, Theme, R>,
fields: iced::Element<'a, Message, Theme, R>,
) -> iced::Element<'a, Message, Theme, R> {
container(
column![
container(actions).id("scenario-actions").width(Fill),
scrollable(fields)
.id("scenario-form")
.width(Fill)
.height(Fill),
keyed_column([(
session,
scrollable(fields)
.id("scenario-form")
.width(Fill)
.height(Fill)
.into(),
)])
.width(Fill)
.height(Fill),
]
.spacing(6),
)
@@ -49,15 +49,278 @@ fn fixture_view(state: &State, context: Context, pending: bool, header: f32) ->
canvas_pane(
context,
state.scroll_restore(context, ScrollArea::Canvas),
None,
Space::new().width(1800.).height(2400.).into(),
),
editor_panel(
state.form_session().unwrap_or_default(),
Space::new().height(32.).into(),
Space::new().height(2000.).into(),
),
pending,
)
}
#[test]
fn new_scenario_form_resets_only_its_scroll_and_recovery_gets_a_fresh_session() {
let f = super::super::direct_tests::fixture();
let mut app = App::with_project(f.project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let _ = app.update(event(Event::Start(f.owner, Tool::Action)));
let first = app.behavior.scenario.form_session().unwrap();
let size = Size::new(500., 570.);
let mut view = fixture_view(
&app.behavior.scenario,
app.scenario_context().unwrap(),
true,
132.,
);
let mut tree = widget::Tree::new(view.as_widget());
let (node, _) = geometry(&mut view, &mut tree, size);
scroll(
&mut view,
&mut tree,
&node,
"scenario-form",
Vector::new(0., 700.),
);
scroll(
&mut view,
&mut tree,
&node,
"scenario-canvas",
Vector::new(137., 850.),
);
redraw(&mut view, &mut tree, size, &mut app);
let accepted = app.document.snapshot();
let _ = app.update(event(Event::Name(" retained raw 雨 ".into())));
let raw = app.behavior.scenario.recovery_snapshot().unwrap();
view = fixture_view(
&app.behavior.scenario,
app.scenario_context().unwrap(),
true,
132.,
);
tree.diff(view.as_widget());
let (_, same_form) = geometry(&mut view, &mut tree, size);
assert_eq!(same_form.scrolls["scenario-form"].1.y, 700.);
assert_eq!(app.behavior.scenario.form_session(), Some(first));
// No intervening closed render: replacing the editing session itself must
// invalidate the old scroll, while preserving the canvas's actual offset.
let _ = app.update(event(Event::Cancel));
let _ = app.update(event(Event::Start(f.owner, Tool::Observation)));
assert_ne!(app.behavior.scenario.form_session(), Some(first));
view = fixture_view(
&app.behavior.scenario,
app.scenario_context().unwrap(),
true,
132.,
);
tree.diff(view.as_widget());
let (_, next_form) = geometry(&mut view, &mut tree, size);
assert_eq!(next_form.scrolls["scenario-form"].1, Vector::ZERO);
assert_eq!(
next_form.scrolls["scenario-canvas"].1,
Vector::new(137., 850.)
);
let recovered = State::from_recovery(raw.unwrap()).unwrap();
assert_ne!(recovered.form_session(), Some(first));
assert_eq!(
recovered.draft.as_ref().unwrap().name,
" retained raw 雨 "
);
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
#[test]
fn pending_marker_reveals_in_the_actual_smaller_canvas_once_without_horizontal_or_history_drift() {
let f = super::super::direct_tests::fixture();
let mut app = App::with_project(f.project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let context = app.scenario_context().unwrap();
let make = |reveal| -> TestView {
canvas_pane(
context,
None,
reveal,
Space::new().width(1800.).height(2400.).into(),
)
};
let mut view = make(None);
let mut tree = widget::Tree::new(view.as_widget());
let wide = Size::new(500., 500.);
let (node, _) = geometry(&mut view, &mut tree, wide);
scroll(
&mut view,
&mut tree,
&node,
"scenario-canvas",
Vector::new(137., 800.),
);
redraw(&mut view, &mut tree, wide, &mut app);
let accepted = app.document.snapshot();
let request = ScrollReveal {
token: Id::new_v4(),
slot: Slot {
parent: f.owner,
before: Some(f.reply),
},
y: 1100.,
padding: 24.,
};
view = make(Some(request));
tree.diff(view.as_widget());
let small = Size::new(500., 200.);
redraw(&mut view, &mut tree, small, &mut app);
let (node, shown) = geometry(&mut view, &mut tree, small);
let (bounds, offset) = shown.scrolls["scenario-canvas"];
assert_eq!(offset.x, 137.);
assert_eq!(offset.y, 1100. + 24. - bounds.height);
assert!(1100. - 24. >= offset.y);
assert!(1100. + 24. <= offset.y + bounds.height);
scroll(
&mut view,
&mut tree,
&node,
"scenario-canvas",
Vector::new(137., 960.),
);
redraw(&mut view, &mut tree, small, &mut app);
view = make(Some(request));
tree.diff(view.as_widget());
redraw(&mut view, &mut tree, small, &mut app);
assert_eq!(
geometry(&mut view, &mut tree, small).1.scrolls["scenario-canvas"].1,
Vector::new(137., 960.)
);
view = make(Some(ScrollReveal {
token: Id::new_v4(),
y: 780.,
..request
}));
tree.diff(view.as_widget());
redraw(&mut view, &mut tree, small, &mut app);
assert_eq!(
geometry(&mut view, &mut tree, small).1.scrolls["scenario-canvas"].1,
Vector::new(137., 756.)
);
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_modals() {
use iced::futures::StreamExt;
let f = super::super::direct_tests::fixture();
let mut app = App::with_project(f.project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let _ = app.update(event(Event::Start(f.owner, Tool::Fragment)));
let _ = app.update(event(Event::Name(" retained 雨 ".into())));
let context = app.scenario_context().unwrap();
let session = app.behavior.scenario.form_session().unwrap();
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
for mismatch in 0..4 {
let mut stale = context;
let mut token = session;
match mismatch {
0 => stale.document = Id::new_v4(),
1 => stale.scenario = Id::new_v4(),
2 => stale.revision += 1,
_ => token = Id::new_v4(),
}
let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown(
stale, token,
)));
assert!(iced_runtime::task::into_stream(task).is_none());
}
app.hosted.visible = true;
let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown(
context, session,
)));
assert!(iced_runtime::task::into_stream(task).is_none());
app.hosted.visible = false;
app.busy = true;
let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown(
context, session,
)));
assert!(iced_runtime::task::into_stream(task).is_none());
app.busy = false;
// The viewport report from the same mount may advance its geometry epoch.
// That does not change the identity of the field awaiting its first focus.
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
Size::new(480., 180.),
)));
assert_ne!(app.scenario_context(), Some(context));
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.)),
);
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());
let _ = app.update(event(Event::Cancel));
let _ = app.update(event(Event::Start(f.owner, Tool::Fragment)));
let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown(
app.scenario_context().unwrap(),
session,
)));
assert!(iced_runtime::task::into_stream(task).is_none());
}
fn geometry(view: &mut TestView, tree: &mut widget::Tree, size: Size) -> (layout::Node, Geometry) {
let node = view
.as_widget_mut()