471 lines
17 KiB
Rust
471 lines
17 KiB
Rust
//! Execute the controller's scroll operations on a retained, scrolled dialog.
|
|
use super::*;
|
|
use crate::views::source_tests::FontLayout;
|
|
use iced::advanced::{Layout, layout, widget};
|
|
use iced::futures::StreamExt;
|
|
use iced::{Rectangle, Size, Vector};
|
|
|
|
const HOST: Size = Size::new(720., 660.);
|
|
|
|
fn paged_fixture() -> App {
|
|
let mut source = bundle();
|
|
let name = "Long engineering name 雨 ".repeat(18);
|
|
source.files.get_mut("defs.arcadia").unwrap().replace_range(
|
|
..,
|
|
&format!(
|
|
"oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\
|
|
oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n"
|
|
),
|
|
);
|
|
let mut uses = String::new();
|
|
for index in 0..16 {
|
|
if index == 12 {
|
|
uses.push_str(&"// unchanged gap\r\n".repeat(10));
|
|
}
|
|
uses.push_str(&format!(
|
|
"interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n"
|
|
));
|
|
}
|
|
source.files.insert("uses.arcadia".into(), uses);
|
|
let mut app = fixture();
|
|
app.window_size = HOST;
|
|
app.document = Document::from_bundle(source, LIMITS).unwrap();
|
|
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
|
app.source_workspace.active = "uses.arcadia".into();
|
|
app.show_source_file(false);
|
|
let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len();
|
|
app.source_editor
|
|
.move_to(cursor(app.source_workspace.text(), start, start + 4));
|
|
begin(&mut app);
|
|
input(&mut app, "pump_long");
|
|
let _ = preview(&mut app);
|
|
app
|
|
}
|
|
|
|
fn scroll_id(app: &App) -> widget::Id {
|
|
let form = app.source_rename.form.as_ref().unwrap();
|
|
preview_scroll_id(form, form.approval.as_ref().unwrap().id)
|
|
}
|
|
|
|
struct Geometry {
|
|
id: widget::Id,
|
|
offset: Option<Vector>,
|
|
bounds: Option<Rectangle>,
|
|
}
|
|
impl widget::Operation for Geometry {
|
|
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
|
visit(self);
|
|
}
|
|
fn scrollable(
|
|
&mut self,
|
|
id: Option<&widget::Id>,
|
|
bounds: Rectangle,
|
|
_: Rectangle,
|
|
offset: Vector,
|
|
_: &mut dyn widget::operation::Scrollable,
|
|
) {
|
|
if id == Some(&self.id) {
|
|
self.offset = Some(offset);
|
|
self.bounds = Some(bounds);
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Mounted {
|
|
editor: text_editor::Content<FontLayout>,
|
|
tree: widget::Tree,
|
|
}
|
|
impl Mounted {
|
|
fn new(app: &App) -> Self {
|
|
let editor = text_editor::Content::<FontLayout>::new();
|
|
let tree = widget::Tree::new(dialog_content(app, &editor).as_widget());
|
|
Self { editor, tree }
|
|
}
|
|
fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
|
|
let mut view = dialog_content(app, &self.editor);
|
|
// Preserve the actual scroll state across the new page's view diff.
|
|
self.tree.diff(view.as_widget());
|
|
let node = view.as_widget_mut().layout(
|
|
&mut self.tree,
|
|
&FontLayout,
|
|
&layout::Limits::new(Size::ZERO, HOST),
|
|
);
|
|
view.as_widget_mut()
|
|
.operate(&mut self.tree, Layout::new(&node), &FontLayout, operation);
|
|
}
|
|
fn offset(&mut self, app: &App) -> f32 {
|
|
let mut geometry = Geometry {
|
|
id: scroll_id(app),
|
|
offset: None,
|
|
bounds: None,
|
|
};
|
|
self.operate(app, &mut geometry);
|
|
geometry.offset.expect("mounted preview scrollable").y
|
|
}
|
|
fn scroll_down(&mut self, app: &App) -> f32 {
|
|
self.operate(
|
|
app,
|
|
&mut widget::operation::scrollable::scroll_to(
|
|
scroll_id(app),
|
|
widget::operation::scrollable::AbsoluteOffset {
|
|
x: None,
|
|
y: Some(10_000.),
|
|
},
|
|
),
|
|
);
|
|
let offset = self.offset(app);
|
|
assert!(offset > 10., "fixture must really be scrolled");
|
|
offset
|
|
}
|
|
fn wheel(&mut self, app: &App) -> Vec<Message> {
|
|
let mut geometry = Geometry {
|
|
id: scroll_id(app),
|
|
offset: None,
|
|
bounds: None,
|
|
};
|
|
self.operate(app, &mut geometry);
|
|
let position = geometry.bounds.expect("visible preview bounds").center();
|
|
let mut view = dialog_content(app, &self.editor);
|
|
self.tree.diff(view.as_widget());
|
|
let node = view.as_widget_mut().layout(
|
|
&mut self.tree,
|
|
&FontLayout,
|
|
&layout::Limits::new(Size::ZERO, HOST),
|
|
);
|
|
let mut messages = Vec::new();
|
|
let mut shell = iced::advanced::Shell::new(&mut messages);
|
|
view.as_widget_mut().update(
|
|
&mut self.tree,
|
|
&iced::Event::Mouse(iced::mouse::Event::WheelScrolled {
|
|
delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -120. },
|
|
}),
|
|
Layout::new(&node),
|
|
iced::mouse::Cursor::Available(position),
|
|
&FontLayout,
|
|
&mut iced::advanced::clipboard::Null,
|
|
&mut shell,
|
|
&Rectangle::with_size(HOST),
|
|
);
|
|
assert!(
|
|
shell.is_event_captured(),
|
|
"preview must consume wheel input"
|
|
);
|
|
messages
|
|
}
|
|
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
|
|
let mut count = 0;
|
|
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
|
|
while let Some(action) = actions.next().await {
|
|
let iced_runtime::Action::Widget(mut operation) = action else {
|
|
panic!("preview paging must only schedule a widget operation");
|
|
};
|
|
self.operate(app, operation.as_mut());
|
|
count += 1;
|
|
}
|
|
}
|
|
count
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() {
|
|
let mut app = paged_fixture();
|
|
let before = evidence(&app);
|
|
let form = app.source_rename.form.as_ref().unwrap();
|
|
let invocation = form.invocation;
|
|
let approval = form.approval.as_ref().unwrap().id;
|
|
let mut mounted = Mounted::new(&app);
|
|
mounted.scroll_down(&app);
|
|
let old_id = scroll_id(&app);
|
|
let task = app.update(Message::SourceRename(Event::File(
|
|
invocation, approval, true,
|
|
)));
|
|
assert_ne!(old_id, scroll_id(&app));
|
|
// Negative control: even after diff/layout with the new id and new file,
|
|
// Iced retains the previous offset until the returned operation executes.
|
|
assert!(mounted.offset(&app) > 10.);
|
|
assert_eq!(mounted.execute(&app, task).await, 1);
|
|
assert_eq!(mounted.offset(&app), 0.);
|
|
|
|
for navigation in [
|
|
Event::Line(invocation, approval, true),
|
|
Event::Segment(invocation, approval, true),
|
|
Event::Hunk(invocation, approval, true),
|
|
Event::File(invocation, approval, false),
|
|
Event::Search(invocation, "uses".into()),
|
|
Event::Search(invocation, "no-path-match".into()),
|
|
Event::Search(invocation, String::new()),
|
|
] {
|
|
mounted.scroll_down(&app);
|
|
let task = app.update(Message::SourceRename(navigation));
|
|
assert_eq!(mounted.execute(&app, task).await, 1);
|
|
assert_eq!(mounted.offset(&app), 0.);
|
|
assert_eq!(evidence(&app), before);
|
|
}
|
|
// Passive redraw and unchanged filter input preserve deliberate scrolling.
|
|
let offset = mounted.scroll_down(&app);
|
|
assert_eq!(mounted.offset(&app), offset);
|
|
let task = app.update(Message::SourceRename(Event::Search(
|
|
invocation,
|
|
String::new(),
|
|
)));
|
|
assert_eq!(mounted.execute(&app, task).await, 0);
|
|
assert_eq!(mounted.offset(&app), offset);
|
|
let task = app.update(Message::SourceRename(Event::Input(
|
|
invocation,
|
|
"pump_long".into(),
|
|
)));
|
|
assert_eq!(mounted.execute(&app, task).await, 0);
|
|
assert_eq!(mounted.offset(&app), offset);
|
|
let task = app.update(Message::SourceRename(Event::File(
|
|
invocation, approval, false,
|
|
)));
|
|
assert_eq!(
|
|
mounted.execute(&app, task).await,
|
|
0,
|
|
"clamped first-page request is a no-op"
|
|
);
|
|
assert_eq!(mounted.offset(&app), offset);
|
|
assert_eq!(evidence(&app), before);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() {
|
|
let mut app = paged_fixture();
|
|
let before = evidence(&app);
|
|
let form = app.source_rename.form.as_ref().unwrap();
|
|
let invocation = form.invocation;
|
|
let old_approval = form.approval.as_ref().unwrap().id;
|
|
let mut mounted = Mounted::new(&app);
|
|
let old_page_task = app.update(Message::SourceRename(Event::File(
|
|
invocation,
|
|
old_approval,
|
|
true,
|
|
)));
|
|
let next_page_task = app.update(Message::SourceRename(Event::Line(
|
|
invocation,
|
|
old_approval,
|
|
true,
|
|
)));
|
|
assert_eq!(mounted.execute(&app, next_page_task).await, 1);
|
|
let offset = mounted.scroll_down(&app);
|
|
assert_eq!(mounted.execute(&app, old_page_task).await, 1);
|
|
assert_eq!(mounted.offset(&app), offset, "old page target is absent");
|
|
|
|
let old_approval_task = app.update(Message::SourceRename(Event::Search(
|
|
invocation,
|
|
"defs".into(),
|
|
)));
|
|
input(&mut app, "pump_new");
|
|
let _ = preview(&mut app); // Real new approval in the same invocation/page.
|
|
assert_ne!(
|
|
app.source_rename
|
|
.form
|
|
.as_ref()
|
|
.unwrap()
|
|
.approval
|
|
.as_ref()
|
|
.unwrap()
|
|
.id,
|
|
old_approval
|
|
);
|
|
let offset = mounted.scroll_down(&app);
|
|
assert_eq!(mounted.execute(&app, old_approval_task).await, 1);
|
|
assert_eq!(
|
|
mounted.offset(&app),
|
|
offset,
|
|
"old approval target is absent"
|
|
);
|
|
let task = app.update(Message::SourceRename(Event::File(
|
|
invocation,
|
|
old_approval,
|
|
true,
|
|
)));
|
|
assert_eq!(
|
|
mounted.execute(&app, task).await,
|
|
0,
|
|
"stale envelope is rejected before scheduling"
|
|
);
|
|
assert_eq!(mounted.offset(&app), offset);
|
|
assert_eq!(evidence(&app), before);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() {
|
|
let mut app = paged_fixture();
|
|
let before = evidence(&app);
|
|
let form = app.source_rename.form.as_ref().unwrap();
|
|
let invocation = form.invocation;
|
|
let approval = form.approval.as_ref().unwrap().id;
|
|
let mut mounted = Mounted::new(&app);
|
|
|
|
let first_visit = app.update(Message::SourceRename(Event::File(
|
|
invocation, approval, true,
|
|
)));
|
|
let next_page = app.update(Message::SourceRename(Event::Line(
|
|
invocation, approval, true,
|
|
)));
|
|
assert_eq!(mounted.execute(&app, next_page).await, 1);
|
|
let return_visit = app.update(Message::SourceRename(Event::Line(
|
|
invocation, approval, false,
|
|
)));
|
|
assert_eq!(mounted.execute(&app, return_visit).await, 1);
|
|
let offset = mounted.scroll_down(&app);
|
|
assert_eq!(mounted.execute(&app, first_visit).await, 1);
|
|
assert_eq!(
|
|
mounted.offset(&app),
|
|
offset,
|
|
"old visit to the identical page is stale"
|
|
);
|
|
|
|
let old_filter = app.update(Message::SourceRename(Event::Search(
|
|
invocation,
|
|
"uses".into(),
|
|
)));
|
|
let new_filter = app.update(Message::SourceRename(Event::Search(
|
|
invocation,
|
|
"us".into(),
|
|
)));
|
|
assert_eq!(mounted.execute(&app, new_filter).await, 1);
|
|
let offset = mounted.scroll_down(&app);
|
|
assert_eq!(mounted.execute(&app, old_filter).await, 1);
|
|
assert_eq!(
|
|
mounted.offset(&app),
|
|
offset,
|
|
"old filter on the same page is stale"
|
|
);
|
|
|
|
input(&mut app, "pump_new");
|
|
let job = start_preview(&mut app);
|
|
let completion = preview_completion(&app, job);
|
|
// No intermediate frame mounts the temporary no-approval view. The old
|
|
// Scrollable survives even though the new approval has a different id.
|
|
let ready = app.update(Message::SourceRenameReady(completion));
|
|
assert!(mounted.offset(&app) > 10.);
|
|
assert_eq!(mounted.execute(&app, ready).await, 1);
|
|
assert_eq!(mounted.offset(&app), 0.);
|
|
assert_eq!(evidence(&app), before);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated_visibility_scroll()
|
|
{
|
|
// Exercise both an actually mounted Details view and a fast round trip
|
|
// between frames, where Iced retains the same preview Scrollable state.
|
|
for mount_details in [true, false] {
|
|
let mut app = paged_fixture();
|
|
let before = evidence(&app);
|
|
let form = app.source_rename.form.as_ref().unwrap();
|
|
let invocation = form.invocation;
|
|
let approval = form.approval.as_ref().unwrap().id;
|
|
let mut mounted = Mounted::new(&app);
|
|
mounted.scroll_down(&app);
|
|
let held_reset = app.update(Message::SourceRename(Event::File(
|
|
invocation, approval, true,
|
|
)));
|
|
let old_preview = scroll_id(&app);
|
|
let form = app.source_rename.form.as_ref().unwrap();
|
|
let page = (form.file, form.hunk, form.line, form.segment);
|
|
|
|
let show_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
|
|
assert_eq!(mounted.execute(&app, show_details).await, 0);
|
|
let details_visit = scroll_id(&app);
|
|
assert_ne!(details_visit, old_preview);
|
|
if mount_details {
|
|
let mut hidden = Geometry {
|
|
id: old_preview.clone(),
|
|
offset: None,
|
|
bounds: None,
|
|
};
|
|
mounted.operate(&app, &mut hidden);
|
|
assert!(
|
|
hidden.offset.is_none(),
|
|
"Details actually replaced the preview"
|
|
);
|
|
}
|
|
let repeat_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
|
|
assert_eq!(mounted.execute(&app, repeat_details).await, 0);
|
|
assert_eq!(
|
|
scroll_id(&app),
|
|
details_visit,
|
|
"unchanged visibility keeps its visit"
|
|
);
|
|
|
|
let show_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
|
|
assert_eq!(
|
|
mounted.execute(&app, show_preview).await,
|
|
0,
|
|
"returning does not force a scroll reset"
|
|
);
|
|
let returned_visit = scroll_id(&app);
|
|
assert_ne!(returned_visit, old_preview);
|
|
assert_ne!(returned_visit, details_visit);
|
|
let form = app.source_rename.form.as_ref().unwrap();
|
|
assert_eq!((form.file, form.hunk, form.line, form.segment), page);
|
|
assert_eq!(form.approval.as_ref().unwrap().id, approval);
|
|
|
|
let offset = mounted.scroll_down(&app);
|
|
let repeat_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
|
|
assert_eq!(mounted.execute(&app, repeat_preview).await, 0);
|
|
assert_eq!(scroll_id(&app), returned_visit);
|
|
assert_eq!(
|
|
mounted.offset(&app),
|
|
offset,
|
|
"repeated Preview preserves deliberate scroll"
|
|
);
|
|
assert_eq!(mounted.execute(&app, held_reset).await, 1);
|
|
assert_eq!(
|
|
mounted.offset(&app),
|
|
offset,
|
|
"the previous visit cannot reset the returned preview"
|
|
);
|
|
assert_eq!(evidence(&app), before);
|
|
}
|
|
}
|
|
|
|
// Portable before-fix proof: no new controller field/event assumption.
|
|
#[tokio::test]
|
|
async fn details_remount_preserves_visible_offset_without_controller_assumptions() {
|
|
let mut app = paged_fixture();
|
|
let before = evidence(&app);
|
|
let form = app.source_rename.form.as_ref().unwrap();
|
|
let invocation = form.invocation;
|
|
let approval = form.approval.as_ref().unwrap().id;
|
|
let page = (form.file, form.hunk, form.line, form.segment);
|
|
let mut mounted = Mounted::new(&app);
|
|
let reports = mounted.wheel(&app);
|
|
let offset = mounted.offset(&app);
|
|
assert!(
|
|
offset > 10.,
|
|
"real wheel input must move the overflowing fixture"
|
|
);
|
|
for message in reports {
|
|
let task = app.update(message);
|
|
let _ = mounted.execute(&app, task).await;
|
|
}
|
|
let task = app.update(Message::SourceRename(Event::Details(invocation, true)));
|
|
let _ = mounted.execute(&app, task).await;
|
|
let mut hidden = Geometry {
|
|
id: scroll_id(&app),
|
|
offset: None,
|
|
bounds: None,
|
|
};
|
|
mounted.operate(&app, &mut hidden);
|
|
assert!(
|
|
hidden.offset.is_none(),
|
|
"Details actually removed the preview subtree"
|
|
);
|
|
let task = app.update(Message::SourceRename(Event::Details(invocation, false)));
|
|
let _ = mounted.execute(&app, task).await;
|
|
// This assertion must execute before any second wheel or scroll operation.
|
|
assert_eq!(
|
|
mounted.offset(&app),
|
|
offset,
|
|
"Details remount must preserve visible offset"
|
|
);
|
|
let form = app.source_rename.form.as_ref().unwrap();
|
|
assert_eq!((form.file, form.hunk, form.line, form.segment), page);
|
|
assert_eq!(form.approval.as_ref().unwrap().id, approval);
|
|
assert_eq!(evidence(&app), before);
|
|
}
|