diff --git a/crates/ui-egui/src/history_panel_select_tests.rs b/crates/ui-egui/src/history_panel_select_tests.rs index 92c458e37f..14bfb344ca 100644 --- a/crates/ui-egui/src/history_panel_select_tests.rs +++ b/crates/ui-egui/src/history_panel_select_tests.rs @@ -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"); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1626eff450..5a430205c8 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3360,6 +3360,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let redo: Vec = 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)>(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::(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}))