diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..612053308 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -15,6 +15,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); frame.show(ui, |ui| { + // (taken on every pass, so a hold the panel was not drawn for is forgotten) + let held = held_offset(ui.ctx()); let Some(id) = app.session.active() else { // the Keyword List is the library's, not a photo's: it is there without a selection if app.ui.right == RightPanel::Keywords { @@ -32,7 +34,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); return; }; - egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { + let mut area = egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]); + if let Some(offset) = held { + area = area.vertical_scroll_offset(offset); + } + // (where the pass starts: by its end egui may have pulled a shrunken panel's offset back) + let area_id = ui.data(|d| d.get_temp::(egui::Id::new(AREA))); + let start = held.or_else(|| area_id.and_then(|a| egui::scroll_area::State::load(ui.ctx(), a)).map(|s| s.offset.y)); + ui.data_mut(|d| match start { + Some(o) => { + d.insert_temp(egui::Id::new(OFFSET), o); + } + None => d.remove::(egui::Id::new(OFFSET)), + }); + let scrolled = area.show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; match app.ui.right { RightPanel::Edit => super::edit::show(app, ui, id), @@ -48,9 +63,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::None => {} } }); + ui.data_mut(|d| d.insert_temp(egui::Id::new(AREA), scrolled.id)); }); } +/// The panel's scroll area, its offset as the last pass started, and how far +/// [`hold_list_in_place`] asks it to move. +const AREA: &str = "right-scroll-id"; +const OFFSET: &str = "right-scroll-offset"; +const HOLD: &str = "right-scroll-hold"; + +/// Where [`hold_list_in_place`] holds the panel: its offset as the pass before started, moved by as much as +/// the Keywords box grew. Set on the scroll area as it starts, so the pass drawn again shows the list +/// in place (a scroll asked for during a pass only lands a frame later). +fn held_offset(ctx: &egui::Context) -> Option { + let grew = ctx.data_mut(|d| d.remove_temp::(egui::Id::new(HOLD)))?; + let offset = ctx.data(|d| d.get_temp::(egui::Id::new(OFFSET)))?; + Some((offset + grew).max(0.0)).filter(|o| o.is_finite()) +} + pub fn header(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); @@ -818,6 +849,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if app.session.catalog.photo(id).is_none() { return; } + let top = ui.cursor().top(); header(ui, "Keywords"); let t = Tokens::get(ui.ctx()); padded(ui, |ui| { @@ -897,9 +929,39 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); } }); + hold_list_in_place(ui, top); super::keyword_list::show(app, ui); } +/// Keeps the Keyword List where it is on screen when the Keywords box above it (starting at +/// `top`) grows or shrinks: a keyword ticked or unticked in the list, another selection. Without it +/// a long tree slides away from under the pointer by a row of chips each time. It holds only while +/// the box's top is scrolled out of sight (at the top of the panel the box is watched growing) and +/// while the change does not come from the box itself (the pointer on it, or typing in its field). +fn hold_list_in_place(ui: &mut egui::Ui, top: f32) { + let height = ui.cursor().top() - top; + let key = egui::Id::new("kw-box-height"); + let pass = ui.ctx().cumulative_pass_nr(); + // (the height drawn on the pass before: one from an earlier visit to the panel says nothing) + let before = ui.data(|d| d.get_temp::<(f32, u64)>(key)).filter(|&(_, drawn)| drawn.checked_add(1) == Some(pass)).map(|(h, _)| h); + ui.data_mut(|d| d.insert_temp(key, (height, pass))); + let Some(before) = before else { return }; + let grew = height - before; + if !grew.is_finite() || grew.abs() < 0.5 || top >= ui.clip_rect().top() - 0.5 { + return; + } + // (only the part of it in sight: the pointer on the top bar above a half-hidden box isn't on it) + let old_box = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + before).intersect(ui.clip_rect()); + let on_box = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| old_box.contains(p)); + let typing = ui.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))); + if on_box || typing { + return; + } + ui.data_mut(|d| d.insert_temp(egui::Id::new(HOLD), grew)); + // (drawn again at once at the new offset, so the list never shows a frame out of place) + ui.ctx().request_discard("the Keyword List holds its place"); +} + /// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the /// selected photos; "Save as Set…" keeps the current nine under a name. fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 657e5f337..bdeaef0dc 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -428,3 +428,69 @@ fn dropping_photos_counts_those_that_get_the_keyword() { let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); assert!(toast.contains("1 photo") && !toast.contains("2 photos"), "{toast}"); } + +/// Scrolled down a long list, ticking and unticking keywords leaves the tree where it is: the +/// Keywording box above grows and shrinks out of sight instead of pushing the rows down. +#[test] +fn ticking_a_keyword_keeps_the_list_in_place() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + for i in 0..40 { + run(&mut h, "keyword.create", json!({"name": format!("zz{i:02}")})); + } + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordList:topLevel"})); + let row = rect(&h, "keywordRow:zz30"); + ask(&mut h, "ui.scroll", json!({"dx": 0, "dy": -(row.top() - 300.0)})); + assert!(rect(&h, "field:keyword").top() < 0.0, "the box is scrolled out of sight"); + let mut box_moved = false; + let picked: Vec = (30..36).map(|i| format!("zz{i}")).collect(); + for (n, k) in picked.iter().chain(picked.iter().rev()).enumerate() { + let (before, box_top) = (rect(&h, &format!("keywordRow:{k}")), rect(&h, "field:keyword").top()); + ask(&mut h, "ui.clickWidget", json!({"id": format!("keywordCheck:{k}")})); + assert_eq!(rect(&h, &format!("keywordRow:{k}")), before, "{k} stayed under the pointer"); + // (the click found the row where it is drawn, not where a discarded pass had it) + assert_eq!(keywords_of(&h, ids[0]).contains(k), n < picked.len(), "{k} was the one ticked: {:?}", keywords_of(&h, ids[0])); + box_moved |= rect(&h, "field:keyword").top() != box_top; + } + assert!(keywords_of(&h, ids[0]).is_empty(), "ticked and unticked again"); + assert!(box_moved, "the box changed height (the list was held, not left alone)"); +} + +/// At the top of the panel the box is in sight: a ticked keyword's chip pushes the list down, +/// as before. +#[test] +fn at_the_top_the_box_grows_in_sight() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + let (title, box_top) = (rect(&h, "keywordList:topLevel"), rect(&h, "field:keyword")); + for k in ["aaa1", "aaa2", "aaa3", "aaa4", "aaa5", "aaa6"] { + run(&mut h, "keyword.create", json!({"name": k})); + ask(&mut h, "ui.clickWidget", json!({"id": format!("keywordCheck:{k}")})); + } + assert_eq!(rect(&h, "field:keyword"), box_top, "the panel stays at its top"); + assert!(rect(&h, "keywordList:topLevel").top() > title.top(), "the chips pushed the list down"); +} + +/// Ticks clicked one right after another land on the rows clicked: a click looks a row up where +/// it was last drawn, not where a pass drawn again (the list held in place) first had it. +#[test] +fn ticks_in_quick_succession_land_on_their_rows() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + for i in 0..40 { + run(&mut h, "keyword.create", json!({"name": format!("zz{i:02}")})); + } + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordList:topLevel"})); + let row = rect(&h, "keywordRow:zz30"); + ask(&mut h, "ui.scroll", json!({"dx": 0, "dy": -(row.top() - 300.0)})); + let picked: Vec = (30..36).map(|i| format!("zz{i}")).collect(); + for k in &picked { + // (no settling in between, as a script or a quick hand clicks) + let r = h.request("ui.clickWidget", json!({"id": format!("keywordCheck:{k}")}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + let mut have = keywords_of(&h, ids[0]); + have.sort(); + assert_eq!(have, picked); +}