mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 18:47:42 -06:00
LRC-LIB-KEYWORDS: the Keyword List holds its place as the Keywords box above it changes
Scrolled down a long Keyword List, ticking or unticking a keyword grew or shrank the Keywords box above it (a row of chips, suggestions) and slid the tree under the pointer. The panel now moves its scroll offset by the same amount and draws the pass again, so the list stays put. It holds only while the box's top is scrolled out of sight and the change does not come from the box itself (the pointer on it, or typing in its field). The offset is set on the scroll area as the redrawn pass starts, from the offset the pass before started at: a scroll asked for during a pass lands a frame later, and egui pulls a shrunken panel's offset back by the end of a pass. Ticks clicked one after another (a script, a quick hand) then land on the rows clicked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3b4279fc24
commit
648b1b5a93
@@ -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>(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::<f32>(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<f32> {
|
||||
let grew = ctx.data_mut(|d| d.remove_temp::<f32>(egui::Id::new(HOLD)))?;
|
||||
let offset = ctx.data(|d| d.get_temp::<f32>(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) {
|
||||
|
||||
@@ -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<String> = (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<String> = (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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user