mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 12:08:10 -06:00
Merge PR #1755 (rebased)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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