Merge PR #1755 (rebased)

This commit is contained in:
Brandon Thomas
2026-10-15 00:52:28 -04:00
3 changed files with 142 additions and 7 deletions
+10 -6
View File
@@ -1071,13 +1071,17 @@ impl LightcraftApp {
if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
let now = ctx.input(|i| i.time);
let dt = now - self.last_time;
if dt > 0.0 {
self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;
// (once a frame: a pass drawn again after a discard has no input of its own, and still the
// widgets of the pass thrown away, where a request would find them out of place)
if ctx.current_pass_index() == 0 {
let now = ctx.input(|i| i.time);
let dt = now - self.last_time;
if dt > 0.0 {
self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;
}
self.last_time = now;
self.drain_control(ctx);
}
self.last_time = now;
self.drain_control(ctx);
self.apply_settings(ctx);
if !self.synthetic.is_empty() {
ctx.request_repaint();
+66 -1
View File
@@ -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 the panel is drawn; one from earlier than the pass before 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 {
@@ -33,7 +35,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),
@@ -49,9 +64,28 @@ 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, asked) = ctx.data_mut(|d| d.remove_temp::<(f32, u64)>(egui::Id::new(HOLD)))?;
if asked.checked_add(1) != Some(ctx.cumulative_pass_nr()) {
return None;
}
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());
@@ -876,9 +910,11 @@ 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();
if fold_header(app, ui, "keywording", "Keywords") {
keywording(app, ui);
}
hold_list_in_place(ui, top);
super::keyword_list::show(app, ui);
}
@@ -1063,6 +1099,35 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::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, pass)));
// (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) {
+66
View File
@@ -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);
}