Merge PR #5940: History panel: scroll to the current state on document change (#5909)

This commit is contained in:
David Mann
2026-10-21 17:48:02 +02:00
2 changed files with 189 additions and 0 deletions
@@ -58,3 +58,170 @@ fn clicking_a_state_jumps_there_with_one_command() {
h.run_steps(2);
assert_eq!(state(&h), (4, 0, rev + 2));
}
#[test]
fn new_history_steps_scroll_the_current_state_into_view() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
for n in 0..18 {
app.run("layer.new.layer", json!({"name": format!("Layer {n}")})).unwrap();
}
let mut ready = false;
let mut h = Harness::builder().with_size(vec2(280.0, 120.0)).build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium);
ready = true;
return;
}
history(app, ui);
},
app,
);
h.run_steps(4);
let row = h.get_all_by_label("New Layer").last().expect("the latest New Layer state").rect();
let view = h.ctx.input(|i| i.viewport_rect());
assert!(view.contains_rect(row), "the current history row must scroll into view: row {row:?} view {view:?}",);
}
/// Open plus 18 New Layer states in a dock that shows only a few rows.
fn long_harness() -> Harness<'static, PhotocraftApp> {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
for n in 0..18 {
app.run("layer.new.layer", json!({"name": format!("Layer {n}")})).unwrap();
}
let mut ready = false;
let mut h = Harness::builder().with_size(vec2(280.0, 220.0)).build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium);
ready = true;
return;
}
history(app, ui);
},
app,
);
h.run_steps(4);
h
}
/// Top of the `n`th New Layer row from the end (0 = the last).
fn row_top(h: &Harness<'_, PhotocraftApp>, n: usize) -> f32 {
let rows: Vec<_> = h.get_all_by_label("New Layer").map(|r| r.rect()).collect();
rows[rows.len() - 1 - n].top()
}
/// The rows' visible band: below the window top, above the footer.
fn shown(h: &Harness<'_, PhotocraftApp>, n: usize) -> bool {
let footer = h.get_by_label("Delete current state").rect().top();
let top = row_top(h, n);
top >= h.ctx.input(|i| i.viewport_rect()).top() && top + 24.0 <= footer + 0.5
}
#[test]
fn clicking_a_visible_state_leaves_the_list_still() {
let mut h = long_harness();
assert!(shown(&h, 0) && shown(&h, 1), "the newest states are in view");
let before = row_top(&h, 1);
// A click on the state above the current one goes back one step; the row stays under the pointer.
let rows: Vec<_> = h.get_all_by_label("New Layer").collect();
rows[rows.len() - 2].click();
h.run_steps(4);
assert_eq!(h.state().session.active().unwrap().history.redo_labels().count(), 1);
assert_eq!(row_top(&h, 1), before, "the clicked row must not move");
}
#[test]
fn undo_and_a_layer_click_keep_the_redo_state_in_view() {
let mut h = long_harness();
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(4);
assert!(shown(&h, 0), "the greyed redo state stays in view after Undo");
let before = row_top(&h, 0);
// Selecting a layer bumps the revision but adds no state: the list stays where it is.
let id = h.state().session.active().unwrap().doc.layers[0].id;
h.state_mut().session.select_layer(id).unwrap();
h.run_steps(4);
assert_eq!(row_top(&h, 0), before, "a layer click must not scroll History");
}
#[test]
fn scrolling_away_survives_a_change_that_adds_no_state() {
let mut h = long_harness();
let view = h.ctx.input(|i| i.viewport_rect());
h.event(egui::Event::PointerMoved(view.center()));
for _ in 0..3 {
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, 3.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
});
h.run_steps(1);
}
h.run_steps(4);
let scrolled = row_top(&h, 0);
assert!(!shown(&h, 0), "the wheel scrolled the current state out of view");
let id = h.state().session.active().unwrap().doc.layers[0].id;
h.state_mut().session.select_layer(id).unwrap();
h.run_steps(4);
assert_eq!(row_top(&h, 0), scrolled, "the user's scroll position is kept");
// A new state brings the current row back into view.
h.state_mut().run("layer.new.layer", json!({})).unwrap();
h.run_steps(4);
assert!(shown(&h, 0), "a new state scrolls into view");
}
#[test]
fn clicking_a_snapshot_keeps_it_in_view() {
let mut h = long_harness();
h.state_mut().run("history.newSnapshot", json!({})).unwrap();
h.run_steps(2);
// Scroll to the top, where the snapshots are.
let view = h.ctx.input(|i| i.viewport_rect());
h.event(egui::Event::PointerMoved(view.center()));
for _ in 0..10 {
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, 5.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
});
h.run_steps(1);
}
h.run_steps(4);
let before = h.get_all_by_label("Snapshot 1").next().expect("the snapshot row").rect().top();
// Selecting it adds a "Snapshot 1" state at the bottom; the selected snapshot row must not scroll away.
h.get_all_by_label("Snapshot 1").next().expect("the snapshot row").click();
h.run_steps(4);
assert_eq!(h.state().session.active().unwrap().history.entries().last().map(String::as_str), Some("Snapshot 1"));
assert_eq!(h.get_all_by_label("Snapshot 1").next().expect("the snapshot row").rect().top(), before, "the clicked snapshot stays put");
}
#[test]
fn a_new_state_at_the_history_limit_still_scrolls_into_view() {
let mut h = long_harness();
// At the limit a new step drops the oldest: the count and the (same) name stay as they were.
let st = h.state_mut().session.active_mut().unwrap();
st.history.max_states = st.history.past_len();
let view = h.ctx.input(|i| i.viewport_rect());
h.event(egui::Event::PointerMoved(view.center()));
for _ in 0..3 {
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, 3.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
});
h.run_steps(1);
}
h.run_steps(4);
assert!(!shown(&h, 0), "the wheel scrolled the current state out of view");
let past = h.state().session.active().unwrap().history.past_len();
h.state_mut().run("layer.new.layer", json!({})).unwrap();
assert_eq!(h.state().session.active().unwrap().history.past_len(), past, "the oldest state was dropped");
h.run_steps(4);
assert!(shown(&h, 0), "a new state at the limit scrolls into view");
}
+22
View File
@@ -3360,6 +3360,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let redo: Vec<String> = st.history.redo_labels().map(str::to_string).collect();
let source = photocraft_engine::history_source_cmds::source(st);
let selected_snap = selected_snapshot(ui.ctx(), st);
// The newest past state is a new document on every step, Undo and Redo, also at the state
// limit, where the counts and a repeated name stay the same.
let newest = st.history.past_len().checked_sub(1).and_then(|i| st.history.state(i)).map(|d| std::sync::Arc::as_ptr(&d) as usize);
let spot = (doc.id, history_spot(st), newest);
let ctx = ui.ctx().clone();
// A row dragged onto the trash (#5324); a drag that never saw its release must not outlive it.
if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) {
@@ -3387,10 +3391,21 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// The dock gives History a fixed height: the rows scroll above the footer.
let footer = if t.pro { widgets::footer_height(ui) + ui.spacing().item_spacing.y } else { 0.0 };
let max_h = (ui.available_height() - footer).max(40.0);
// Follow the current state when the history moves (a new step, Undo, a state row) or another
// document comes up (`spot`), scrolling only if its row is hidden (#5909). Other changes (a
// layer click, a channel eye) and a selected snapshot leave the list where the user put it.
let follow_id = egui::Id::new("history-follow");
let moved = ui.ctx().data_mut(|d| {
let moved = d.get_temp::<(photocraft_doc::DocId, HistorySpot, Option<usize>)>(follow_id).as_ref() != Some(&spot);
d.insert_temp(follow_id, spot);
moved
});
let follow = moved && selected_snap.is_none();
egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| {
// Snapshots head the list and scroll with the states, so many of them never push the
// footer out of a short dock.
snap_cmd = snapshot_rows(app, ui, selected_snap);
let mut current_rect = None;
for (i, (e, is_redo)) in all.enumerate() {
// The current state shows selected unless a snapshot is (Photoshop highlights one row).
let selected = i == current && selected_snap.is_none();
@@ -3401,6 +3416,9 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
if resp.drag_started() {
ui.ctx().data_mut(|d| d.insert_temp(history_drag_key(), HistoryDrag::State(i)));
}
if i == current {
current_rect = Some(rect);
}
if selected {
ui.painter().rect_filled(rect, if t.pro { 0.0 } else { t.radius_sm }, if t.pro { t.row_selected } else { t.accent_soft });
} else if resp.hovered() {
@@ -3435,6 +3453,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ctx.data_mut(|d| d.remove::<SelectedSnapshot>(selected_snapshot_key(doc.id)));
}
}
if follow && let Some(rect) = current_rect {
// No alignment: a row already in view stays put. No animation, as the Layers panel reveal.
ui.scroll_to_rect_animation(rect, None, egui::style::ScrollAnimation::none());
}
});
if let Some(i) = set_source
&& let Err(e) = app.run("history.setSource", json!({"state": i}))