Reset alias preview scroll for each explicit destination

This commit is contained in:
2026-09-12 15:51:09 -07:00
parent 4686d05003
commit cbc49a7802
4 changed files with 372 additions and 6 deletions
+44 -1
View File
@@ -111,6 +111,7 @@ struct Form {
hunk: usize,
line: usize,
segment: usize,
preview_destination: Id,
details: bool,
detail_segment: usize,
detail_editor: text_editor::Content,
@@ -311,6 +312,7 @@ impl App {
}
pub(super) fn source_rename_event(&mut self, event: Event) -> Task<Message> {
let mut reset_preview_scroll = false;
match event {
Event::Begin(_) => return self.begin_source_rename(),
Event::Close(_) => self.close_source_rename(),
@@ -359,7 +361,11 @@ impl App {
}
Event::Search(_, value) => {
let form = self.source_rename.form.as_mut().unwrap();
if form.search == value {
return Task::none();
}
form.search = value;
reset_preview_scroll = form.approval.is_some();
if let Some(approval) = &form.approval
&& let Some(index) = approval
.preview
@@ -379,6 +385,7 @@ impl App {
| Event::Segment(_, _, next) => {
let form = self.source_rename.form.as_mut().unwrap();
let ready = form.approval.as_ref().unwrap();
let previous_page = (form.file, form.hunk, form.line, form.segment);
match event {
Event::File(..) => {
let matching: Vec<_> = ready
@@ -414,6 +421,8 @@ impl App {
}
_ => form.segment = step(form.segment, segment_total(form), next),
}
reset_preview_scroll =
previous_page != (form.file, form.hunk, form.line, form.segment);
}
Event::Details(_, value) => {
let form = self.source_rename.form.as_mut().unwrap();
@@ -441,6 +450,9 @@ impl App {
}
}
}
if reset_preview_scroll && let Some(form) = &mut self.source_rename.form {
return reset_preview_position(form);
}
Task::none()
}
@@ -492,6 +504,7 @@ impl App {
hunk: 0,
line: 0,
segment: 0,
preview_destination: Id::new_v4(),
details: false,
detail_segment: 0,
detail_editor: text_editor::Content::new(),
@@ -591,6 +604,7 @@ impl App {
}
let form = self.source_rename.form.as_mut().unwrap();
form.pending = None;
let mut reset_preview_scroll = false;
match completion.payload {
Payload::Target(Ok(target)) => {
form.replacement = target.alias.clone();
@@ -619,6 +633,7 @@ impl App {
form.line = 0;
form.segment = 0;
form.search.clear();
reset_preview_scroll = true;
}
}
Payload::Target(Err(error)) | Payload::Preview(Err(error)) => form.notice = error,
@@ -626,6 +641,9 @@ impl App {
if form.details {
refresh_details(form);
}
if reset_preview_scroll {
return reset_preview_position(form);
}
Task::none()
}
@@ -920,6 +938,31 @@ fn input_id(invocation: Id) -> iced::widget::Id {
iced::widget::Id::from(format!("source-rename-{invocation}"))
}
// Each navigation gets a fresh destination even when returning to the same page
// or changing a filter that leaves the page tuple unchanged (the ABA case).
fn reset_preview_position(form: &mut Form) -> Task<Message> {
form.preview_destination = Id::new_v4();
if !form.details
&& let Some(ready) = &form.approval
{
iced::widget::operation::snap_to(
preview_scroll_id(form, ready.id),
iced::widget::scrollable::RelativeOffset::START,
)
} else {
Task::none()
}
}
// The operation targets this visit, not a later page, visit or approval.
// A new widget id alone does not reset Iced's retained Scrollable state.
fn preview_scroll_id(form: &Form, approval: Id) -> iced::widget::Id {
iced::widget::Id::from(format!(
"source-rename-diff-{}-{approval}-{}-{}-{}-{}-{}",
form.invocation, form.preview_destination, form.file, form.hunk, form.line, form.segment
))
}
/// Complete indexed preview with bounded rendering: eight admitted source rows
/// and one UTF-8 segment per side at a time. No candidate content is discarded.
pub(crate) fn dialog(app: &App) -> Element<'_, Message> {
@@ -1064,7 +1107,7 @@ where
pager(format!("Row segments {}/{} · both sides", form.segment + 1, segments.max(1)),
(form.segment > 0).then(|| message(Event::Segment(invocation, preview_id, false))),
(form.segment + 1 < segments).then(|| message(Event::Segment(invocation, preview_id, true)))),
scrollable(displayed).id("source-rename-diff").height(Fill).width(Fill).spacing(8),
scrollable(displayed).id(preview_scroll_id(form, preview_id)).height(Fill).width(Fill).spacing(8),
].spacing(6).height(Fill).into()
} else {
scrollable(column![
@@ -152,6 +152,7 @@ impl shaped::Renderer for GlyphRecorder {
#[derive(Default)]
struct ScrollGeometry {
id: Option<widget::Id>,
bounds: Option<Rectangle>,
content: Option<Rectangle>,
offset: Vector,
@@ -168,7 +169,7 @@ impl widget::Operation for ScrollGeometry {
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("source-rename-diff")) {
if id == self.id.as_ref() {
self.bounds = Some(bounds);
self.content = Some(content);
self.offset = offset;
@@ -262,7 +263,10 @@ fn assert_page_glyphs_visible(app: &App, host: Size) {
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host));
let mut geometry = ScrollGeometry::default();
let mut geometry = ScrollGeometry {
id: Some(preview_scroll_id(form, ready.id)),
..ScrollGeometry::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
let bounds = geometry.bounds.unwrap();
@@ -279,7 +283,7 @@ fn assert_page_glyphs_visible(app: &App, host: Size) {
Layout::new(&node),
&renderer,
&mut widget::operation::scrollable::scroll_to(
widget::Id::new("source-rename-diff"),
preview_scroll_id(form, ready.id),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
@@ -0,0 +1,310 @@
//! 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>,
}
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>,
_: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&self.id) {
self.offset = Some(offset);
}
}
}
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,
};
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
}
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);
}
@@ -6,6 +6,10 @@ use std::collections::BTreeMap;
#[path = "preview_glyph_tests.rs"]
mod preview_glyph_tests;
#[cfg(debug_assertions)]
#[path = "preview_scroll_tests.rs"]
mod preview_scroll_tests;
fn bundle() -> SourceBundle {
SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
("project.arcadia".into(), concat!("arcadia 7;\r\nproject model \"救援\" id \"00000000-0000-0000-0000-000000000001\" {\r\n",
@@ -976,6 +980,7 @@ fn actual_dialog_real_paragraph_geometry_keeps_input_preview_and_footer_reachabl
use iced::advanced::{Layout, layout, widget};
#[derive(Default)]
struct Bounds {
diff_id: Option<widget::Id>,
input: Option<iced::Rectangle>,
preview: Option<iced::Rectangle>,
footer: Option<iced::Rectangle>,
@@ -1004,7 +1009,7 @@ fn actual_dialog_real_paragraph_geometry_keeps_input_preview_and_footer_reachabl
_: iced::Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("source-rename-diff")) {
if id == self.diff_id.as_ref() {
self.diff = Some(bounds);
}
}
@@ -1042,7 +1047,11 @@ fn actual_dialog_real_paragraph_geometry_keeps_input_preview_and_footer_reachabl
&FontLayout,
&layout::Limits::new(iced::Size::ZERO, host),
);
let mut bounds = Bounds::default();
let form = app.source_rename.form.as_ref().unwrap();
let mut bounds = Bounds {
diff_id: Some(preview_scroll_id(form, form.approval.as_ref().unwrap().id)),
..Bounds::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut bounds);
let input = bounds.input.unwrap();