mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Merge PR #1114
This commit is contained in:
commit
963d4f327a
5 files changed
+289
-110
No files matched your search
@@ -236,63 +236,84 @@ pub struct KeywordNode {
|
||||
impl Catalog {
|
||||
/// The keyword tree: every keyword level with photo counts, sorted by name (case-insensitive).
|
||||
pub fn keyword_tree(&self) -> Vec<KeywordNode> {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
// every keyword level (`travel`, `travel|italy`…) by its lower-case path: the name and
|
||||
use std::collections::HashMap;
|
||||
// every keyword level (`travel`, `travel|italy`…), one per lower-case path: the name and
|
||||
// path as first written, and the photos counted (once per photo, even when two of its
|
||||
// keywords share a parent)
|
||||
// keywords share a parent: `seen` is the last photo counted); `sort` is the name in lower case
|
||||
struct Level {
|
||||
name: String,
|
||||
sort: String,
|
||||
path: String,
|
||||
count: usize,
|
||||
parent: Option<String>,
|
||||
seen: usize,
|
||||
}
|
||||
let mut levels: HashMap<String, Level> = HashMap::new();
|
||||
let mut this_photo: HashSet<String> = HashSet::new();
|
||||
let mut levels: Vec<Level> = Vec::new();
|
||||
let mut by_lower: HashMap<String, usize> = HashMap::new();
|
||||
// each level's children (by index), and the top level
|
||||
let mut kids: Vec<Vec<usize>> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
// the keyword list first (no photos counted): a listed keyword is there without photos
|
||||
let listed: Vec<String> = self.listed_keywords().map(|k| k.path.clone()).collect();
|
||||
// each keyword as written → its levels (its own and those above it), worked out once: a
|
||||
// large library repeats a few thousand keywords tens of thousands of times (issue #1011)
|
||||
let mut chains: HashMap<&str, Vec<usize>> = HashMap::new();
|
||||
let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice()));
|
||||
for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) {
|
||||
this_photo.clear();
|
||||
for (photo, (counts, keywords)) in std::iter::once((false, listed.as_slice())).chain(photos).enumerate() {
|
||||
for k in keywords {
|
||||
let mut path = String::new();
|
||||
let mut lower = String::new();
|
||||
let mut parent: Option<String> = None;
|
||||
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if !path.is_empty() {
|
||||
path.push(SEP);
|
||||
lower.push(SEP);
|
||||
let chain = chains.entry(k.as_str()).or_insert_with(|| {
|
||||
let mut chain: Vec<usize> = Vec::new();
|
||||
let mut path = String::new();
|
||||
let mut lower = String::new();
|
||||
for part in k.split(SEP).map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if !path.is_empty() {
|
||||
path.push(SEP);
|
||||
lower.push(SEP);
|
||||
}
|
||||
path.push_str(part);
|
||||
let sort = part.to_lowercase();
|
||||
lower.push_str(&sort);
|
||||
let i = match by_lower.get(&lower) {
|
||||
Some(&i) => i,
|
||||
None => {
|
||||
let i = levels.len();
|
||||
match chain.last().and_then(|&p| kids.get_mut(p)) {
|
||||
Some(k) => k.push(i),
|
||||
None => roots.push(i),
|
||||
}
|
||||
levels.push(Level { name: part.to_string(), sort, path: path.clone(), count: 0, seen: 0 });
|
||||
kids.push(Vec::new());
|
||||
by_lower.insert(lower.clone(), i);
|
||||
i
|
||||
}
|
||||
};
|
||||
chain.push(i);
|
||||
}
|
||||
path.push_str(part);
|
||||
lower.push_str(&part.to_lowercase());
|
||||
let l = levels.entry(lower.clone()).or_insert_with(|| Level {
|
||||
name: part.to_string(),
|
||||
path: path.clone(),
|
||||
count: 0,
|
||||
parent: parent.clone(),
|
||||
});
|
||||
if counts && this_photo.insert(lower.clone()) {
|
||||
l.count += 1;
|
||||
chain
|
||||
});
|
||||
if counts {
|
||||
for &i in chain.iter() {
|
||||
let Some(l) = levels.get_mut(i) else { continue };
|
||||
// photo 0 is the keyword list, which counts nothing
|
||||
if l.seen != photo {
|
||||
l.seen = photo;
|
||||
l.count += 1;
|
||||
}
|
||||
}
|
||||
parent = Some(lower.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut kids: HashMap<Option<String>, Vec<String>> = HashMap::new();
|
||||
for (lower, l) in &levels {
|
||||
kids.entry(l.parent.clone()).or_default().push(lower.clone());
|
||||
}
|
||||
fn build(at: Option<String>, levels: &HashMap<String, Level>, kids: &mut HashMap<Option<String>, Vec<String>>) -> Vec<KeywordNode> {
|
||||
let Some(mut list) = kids.remove(&at) else { return Vec::new() };
|
||||
list.sort_by_key(|l| levels[l].name.to_lowercase());
|
||||
fn build(mut list: Vec<usize>, levels: &[Level], kids: &mut [Vec<usize>]) -> Vec<KeywordNode> {
|
||||
list.sort_by(|&a, &b| levels.get(a).map(|l| &l.sort).cmp(&levels.get(b).map(|l| &l.sort)));
|
||||
list.into_iter()
|
||||
.map(|l| {
|
||||
let lv = &levels[&l];
|
||||
let children = build(Some(l.clone()), levels, kids);
|
||||
KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children }
|
||||
.filter_map(|i| {
|
||||
let lv = levels.get(i)?;
|
||||
let below = kids.get_mut(i).map(std::mem::take).unwrap_or_default();
|
||||
let children = build(below, levels, kids);
|
||||
Some(KeywordNode { name: lv.name.clone(), path: lv.path.clone(), count: lv.count, children })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
build(None, &levels, &mut kids)
|
||||
build(roots, &levels, &mut kids)
|
||||
}
|
||||
|
||||
/// `SetMeta` ops for every photo whose keywords `f` changes.
|
||||
@@ -804,6 +825,15 @@ mod tests {
|
||||
assert_eq!(c.query(&f, &Default::default()).len(), 3);
|
||||
}
|
||||
|
||||
/// A keyword written twice on a photo, or in two spellings, counts the photo once; the next
|
||||
/// photo with it counts again.
|
||||
#[test]
|
||||
fn tree_counts_a_repeated_keyword_once_per_photo() {
|
||||
let (c, _) = lib(&[&["sea|Baltic", "sea|Baltic", "Sea|baltic"], &["sea|baltic"], &[]]);
|
||||
let t = c.keyword_tree();
|
||||
assert_eq!(tree_paths(&t), [("sea".to_string(), 2), ("sea|Baltic".to_string(), 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_delete_merge_are_single_undoable_batches() {
|
||||
let (mut c, ids) = lib(&[&["travel|italy|rome", "beach"], &["Travel|Italy"], &["italia", "travel|italy"], &["sea"]]);
|
||||
|
||||
@@ -80,6 +80,8 @@ mod tests_keyword_list;
|
||||
#[cfg(test)]
|
||||
mod tests_keyword_set;
|
||||
#[cfg(test)]
|
||||
mod tests_keyword_sidebar;
|
||||
#[cfg(test)]
|
||||
mod tests_keywording;
|
||||
#[cfg(test)]
|
||||
mod tests_labels;
|
||||
|
||||
@@ -21,6 +21,13 @@ pub(crate) fn content_width(ctx: &egui::Context) -> f32 {
|
||||
/// The most a row asks for its name: longer ones are cut when there is no more room.
|
||||
const MAX_NAME_NEED: f32 = 140.0;
|
||||
|
||||
/// Height of a sidebar row.
|
||||
const ROW_HEIGHT: f32 = 29.0;
|
||||
|
||||
/// Temp-data key of the row (its widget id after `source:`, lower-case) the sidebar scrolls to
|
||||
/// this frame: set before the sections are drawn, cleared after.
|
||||
const SCROLL_TO_ROW: &str = "left-scroll-to-row";
|
||||
|
||||
/// A row says how wide it needs to be.
|
||||
fn note_width(ui: &egui::Ui, w: f32) {
|
||||
ui.data_mut(|d| {
|
||||
@@ -75,8 +82,12 @@ fn row_sensed(
|
||||
) -> egui::Response {
|
||||
let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), sense);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense);
|
||||
if scroll_asked(ui, id) {
|
||||
ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
register(ui.ctx(), format!("source:{id}"), r);
|
||||
let mark_w = if mark.is_some() { MARK_W } else { 0.0 };
|
||||
let said = spoken.unwrap_or(label);
|
||||
let name = match count {
|
||||
Some(n) => crate::i18n::tr_format!("{label}, {n} photos", label = said, n = n),
|
||||
@@ -108,7 +119,6 @@ fn row_sensed(
|
||||
let label_left = r.left() + 42.0 + indent;
|
||||
let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x);
|
||||
// a label's dot sits just before the count
|
||||
let mark_w = if mark.is_some() { MARK_W } else { 0.0 };
|
||||
if let Some(c) = mark {
|
||||
let dot = Rect::from_center_size(pos2(count_left - MARK_W / 2.0, r.center().y), vec2(8.0, 8.0));
|
||||
ui.painter().circle_filled(dot.center(), 4.0, c);
|
||||
@@ -122,20 +132,47 @@ fn row_sensed(
|
||||
let label_rect = ui.painter().text(pos2(label_left, r.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color);
|
||||
register(ui.ctx(), format!("label:{id}"), label_rect);
|
||||
let resp = if shown != label { resp.on_hover_text(label) } else { resp };
|
||||
// a row asks for room for its name up to a share of a panel, so one very long name does not
|
||||
// make everything scroll: depth does that
|
||||
let mut needed = 42.0 + indent + full_w.min(MAX_NAME_NEED) + 18.0 + mark_w;
|
||||
note_width(ui, row_need(indent, full_w, mark_w, count_galley.as_ref().map(|g| g.size().x)));
|
||||
if let Some(galley) = count_galley {
|
||||
let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size());
|
||||
needed += rect.width() + 16.0;
|
||||
register(ui.ctx(), format!("count:{id}"), rect);
|
||||
ui.painter().galley(rect.min, galley, t.text_dim);
|
||||
}
|
||||
note_width(ui, needed);
|
||||
let _ = app;
|
||||
resp
|
||||
}
|
||||
|
||||
/// Whether the row `id` about to be drawn is out of sight: then it only takes its room, is listed
|
||||
/// for the control channel and asks for its width (so the content does not widen and narrow as
|
||||
/// rows scroll by), and the caller skips it. A library can have thousands of keywords or folders,
|
||||
/// and drawing them all every frame slows the whole app down (issue #1011). A row the sidebar is
|
||||
/// asked to scroll to is drawn. `label`, `count` and `mark` as for [`row_sensed`].
|
||||
fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, count: Option<usize>, indent: f32, mark: bool) -> bool {
|
||||
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_HEIGHT));
|
||||
if ui.is_rect_visible(r) || scroll_asked(ui, id) {
|
||||
return false;
|
||||
}
|
||||
ui.allocate_space(r.size());
|
||||
register(ui.ctx(), format!("source:{id}"), r);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.painter();
|
||||
let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x;
|
||||
let count_w = count.filter(|_| app.ui.show_counts).map(|n| painter.layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim).size().x);
|
||||
note_width(ui, row_need(indent, name_w, if mark { MARK_W } else { 0.0 }, count_w));
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the sidebar is asked to scroll to the row `id` this frame ([`SCROLL_TO_ROW`]).
|
||||
fn scroll_asked(ui: &egui::Ui, id: &str) -> bool {
|
||||
ui.data(|d| d.get_temp::<String>(egui::Id::new(SCROLL_TO_ROW))).is_some_and(|want| want == id.to_lowercase())
|
||||
}
|
||||
|
||||
/// How wide a row needs to be, from its indent, name, colour dot and count widths. It asks for
|
||||
/// room for its name up to a share of a panel, so one very long name does not make everything
|
||||
/// scroll: depth does that.
|
||||
fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option<f32>) -> f32 {
|
||||
42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0)
|
||||
}
|
||||
|
||||
/// The disclosure triangle at the left of a tree row (`indent` is the row's): right-pointing
|
||||
/// when closed, down when open, registered as widget `widget` for the control channel. Returns
|
||||
/// its response: the caller flips the open state on `clicked()`.
|
||||
@@ -266,7 +303,7 @@ fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSec
|
||||
/// `sidebarEmpty:<id>`), so the section stays where it is instead of disappearing.
|
||||
fn empty_hint(ui: &mut egui::Ui, section: SidebarSection) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::hover());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::hover());
|
||||
register(ui.ctx(), format!("sidebarEmpty:{}", section.id()), r);
|
||||
let text = crate::i18n::tr(section.empty_hint());
|
||||
let galley = ui.painter().layout_no_wrap(text.to_string(), t.font(12.5), t.text_dim);
|
||||
@@ -638,6 +675,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
above.push_str(name);
|
||||
set_tree_open(ui, egui::Id::new(("kw-open", above.to_lowercase())), true);
|
||||
}
|
||||
// …and the sidebar scrolls to it
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("keyword:{path}").to_lowercase()));
|
||||
}
|
||||
// Local keeps track of the folder being browsed whether or not it is drawn
|
||||
let local = local_now(app, ui);
|
||||
@@ -671,6 +710,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
};
|
||||
placed.push(at);
|
||||
}
|
||||
ui.data_mut(|d| d.remove::<String>(egui::Id::new(SCROLL_TO_ROW)));
|
||||
// room below the last section (a section dragged to the end is dropped here)
|
||||
ui.add_space(SECTION_GAP * 2.0);
|
||||
pinned_header(app, ui, &placed, view_top);
|
||||
@@ -1697,44 +1737,51 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode],
|
||||
let leads_on = n.own == 0 && n.children.len() == 1;
|
||||
let mut open = tree_open(ui, open_id, n.volume || leads_on);
|
||||
// a row whose path would cover other disks' photos too only opens and closes
|
||||
let selectable = n.selectable;
|
||||
let sel = selectable
|
||||
&& app.session.source == LibrarySource::LibraryFolder
|
||||
&& app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path));
|
||||
let name = if selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() };
|
||||
// the label is said with the name, as it is shown beside it
|
||||
let spoken = match n.label {
|
||||
Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)),
|
||||
None => name.clone(),
|
||||
};
|
||||
let mark = n.label.map(crate::theme::label_color);
|
||||
let name = if n.selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() };
|
||||
let id = format!("libfolder:{}", n.path);
|
||||
let resp = row_sensed(app, ui, &id, Icon::Folder, &name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark);
|
||||
let mut toggled = false;
|
||||
if !n.children.is_empty() {
|
||||
let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("libraryFolderToggle:{}", n.path));
|
||||
toggled = tr.clicked();
|
||||
// the triangle sits on the row and takes its clicks: the menu opens from it too
|
||||
row_menu(app, &tr, n);
|
||||
if !out_of_sight(app, ui, &id, &name, Some(n.count), indent, n.label.is_some()) {
|
||||
folder_row(app, ui, n, &id, &name, indent, open_id, &mut open);
|
||||
}
|
||||
let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) };
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if resp.clicked() && !toggled {
|
||||
if selectable {
|
||||
// a source like an album or a Local folder: it replaces what the grid showed
|
||||
let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path}));
|
||||
} else {
|
||||
open = !open;
|
||||
set_tree_open(ui, open_id, open);
|
||||
}
|
||||
}
|
||||
row_menu(app, &resp, n);
|
||||
if open && !n.children.is_empty() {
|
||||
folder_rows(app, ui, &n.children, indent + 16.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One drawn row of [`folder_rows`], shown as `name`; `open_id` and `open` are its triangle's.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &str, name: &str, indent: f32, open_id: egui::Id, open: &mut bool) {
|
||||
let sel = n.selectable
|
||||
&& app.session.source == LibrarySource::LibraryFolder
|
||||
&& app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path));
|
||||
// the label is said with the name, as it is shown beside it
|
||||
let spoken = match n.label {
|
||||
Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)),
|
||||
None => name.to_string(),
|
||||
};
|
||||
let mark = n.label.map(crate::theme::label_color);
|
||||
let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark);
|
||||
let mut toggled = false;
|
||||
if !n.children.is_empty() {
|
||||
let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path));
|
||||
toggled = tr.clicked();
|
||||
// the triangle sits on the row and takes its clicks: the menu opens from it too
|
||||
row_menu(app, &tr, n);
|
||||
}
|
||||
let tip = if n.children.is_empty() { n.path.clone() } else { format!("{} ({})", n.path, crate::i18n::tr("includes the folders inside it")) };
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if resp.clicked() && !toggled {
|
||||
if n.selectable {
|
||||
// a source like an album or a Local folder: it replaces what the grid showed
|
||||
let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path}));
|
||||
} else {
|
||||
*open = !*open;
|
||||
set_tree_open(ui, open_id, *open);
|
||||
}
|
||||
}
|
||||
row_menu(app, &resp, n);
|
||||
}
|
||||
|
||||
/// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the
|
||||
/// startup disk.
|
||||
fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
|
||||
@@ -1881,44 +1928,53 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F
|
||||
/// context menu renames, merges or deletes the keyword across the library.
|
||||
fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) {
|
||||
for n in nodes {
|
||||
let open_id = egui::Id::new(("kw-open", n.path.to_lowercase()));
|
||||
let mut open = tree_open(ui, open_id, false);
|
||||
let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path));
|
||||
let resp = row(app, ui, &format!("keyword:{}", n.path), Icon::Tag, &n.name, Some(n.count), sel, indent);
|
||||
if !n.children.is_empty() {
|
||||
tree_toggle(ui, &resp, indent, open_id, &mut open, format!("keywordToggle:{}", n.path));
|
||||
// leaves (most keywords) have no open state to look up
|
||||
let open_id = (!n.children.is_empty()).then(|| egui::Id::new(("kw-open", n.path.to_lowercase())));
|
||||
let mut open = open_id.is_some_and(|k| tree_open(ui, k, false));
|
||||
let id = format!("keyword:{}", n.path);
|
||||
if !out_of_sight(app, ui, &id, &n.name, Some(n.count), indent, false) {
|
||||
keyword_row(app, ui, n, &id, indent, open_id, &mut open);
|
||||
}
|
||||
let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) });
|
||||
if resp.clicked() {
|
||||
let v = if sel { serde_json::Value::Null } else { json!(n.path) };
|
||||
browse_all_photos(app, !sel);
|
||||
let _ = app.run("library.filter", json!({"keyword": v}));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
let has_sel = app.session.active().is_some();
|
||||
if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]}));
|
||||
}
|
||||
if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Merge into…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Delete Keyword")).clicked() {
|
||||
let _ = app.run("keyword.delete", json!({"keyword": n.path}));
|
||||
}
|
||||
});
|
||||
if open && !n.children.is_empty() {
|
||||
if open {
|
||||
keyword_rows(app, ui, &n.children, indent + 16.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One drawn row of [`keyword_rows`]; `open_id` and `open` are its triangle's when it has rows inside it.
|
||||
fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: &str, indent: f32, open_id: Option<egui::Id>, open: &mut bool) {
|
||||
let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path));
|
||||
let resp = row(app, ui, id, Icon::Tag, &n.name, Some(n.count), sel, indent);
|
||||
if let Some(k) = open_id {
|
||||
tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path));
|
||||
}
|
||||
let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) });
|
||||
if resp.clicked() {
|
||||
let v = if sel { serde_json::Value::Null } else { json!(n.path) };
|
||||
browse_all_photos(app, !sel);
|
||||
let _ = app.run("library.filter", json!({"keyword": v}));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
let has_sel = app.session.active().is_some();
|
||||
if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]}));
|
||||
}
|
||||
if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Remove from Selected Photos"))).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [n.path]}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: n.path.clone(), to: n.path.clone() });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Merge into…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::MergeKeywords { from: vec![n.path.clone()], into: String::new() });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Delete Keyword")).clicked() {
|
||||
let _ = app.run("keyword.delete", json!({"keyword": n.path}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::local_places;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Headless tests of the left sidebar's Keywords section on a large library (issue #1011): with
|
||||
//! thousands of keywords listed, a frame draws only the rows near the screen, and the rows out
|
||||
//! of sight still take their room, so scrolling reaches the last keyword.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use lightcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(30);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// `photos` photos with six keywords each out of `keywords` top-level ones (each with a child).
|
||||
fn library(photos: u64, keywords: u64) -> Session {
|
||||
let mut s = Session::new();
|
||||
for i in 0..photos {
|
||||
let path = format!("/lightcraft-kw-test/IMG_{i:05}.jpg");
|
||||
let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:05}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
|
||||
p.meta.keywords = (0..6).map(|k| format!("Keyword {:05}|Child", (i * 6 + k) % keywords)).collect();
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn app(photos: u64, keywords: u64) -> Headless {
|
||||
let app = LightcraftApp::new(library(photos, keywords), Services { png: None, ..Default::default() });
|
||||
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
h
|
||||
}
|
||||
|
||||
/// The keyword rows drawn this frame (every row is listed as `source:…`; only a drawn one has its
|
||||
/// `label:…`).
|
||||
fn keyword_rows(h: &Headless) -> Vec<String> {
|
||||
h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("label:keyword:")).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// Given a library with 2,000 keywords, when the sidebar's Keywords section is open, then a frame
|
||||
/// draws only the few dozen rows that fit on screen (not all 2,000), and scrolling to the end
|
||||
/// draws the last keyword: the rows out of sight keep their height.
|
||||
#[test]
|
||||
fn keywords_section_draws_only_the_rows_near_the_screen() {
|
||||
const KEYWORDS: u64 = 2000;
|
||||
let mut h = app(KEYWORDS / 6 + 1, KEYWORDS);
|
||||
assert_eq!(h.app.caches.keyword_tree(&h.app.session.catalog).len(), KEYWORDS as usize);
|
||||
let rows = keyword_rows(&h);
|
||||
assert!(!rows.is_empty() && rows.len() < 100, "{} of {KEYWORDS} keyword rows drawn", rows.len());
|
||||
assert!(rows.contains(&"Keyword 00000".to_string()), "{rows:?}");
|
||||
// all the way down
|
||||
crate::tests_panels::scroll_sidebar(&mut h, -(KEYWORDS as f32) * 40.0);
|
||||
let rows = keyword_rows(&h);
|
||||
assert!(!rows.is_empty() && rows.len() < 100, "{} of {KEYWORDS} keyword rows drawn", rows.len());
|
||||
assert!(rows.contains(&"Keyword 01999".to_string()), "the last keyword is reached: {rows:?}");
|
||||
assert!(!rows.contains(&"Keyword 00000".to_string()), "{rows:?}");
|
||||
}
|
||||
|
||||
/// Timings on a library the size of the report's (58,000 photos): `cargo test --release -p
|
||||
/// lightcraft-ui-egui --lib probe_keyword_sidebar_cost -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "timing probe"]
|
||||
fn probe_keyword_sidebar_cost() {
|
||||
let mut h = app(58_000, 4000);
|
||||
let t = Instant::now();
|
||||
let tree = h.app.session.catalog.keyword_tree();
|
||||
eprintln!("keyword_tree: {:?} ({} top level)", t.elapsed(), tree.len());
|
||||
eprintln!("keyword rows drawn: {}", keyword_rows(&h).len());
|
||||
let t = Instant::now();
|
||||
for _ in 0..10 {
|
||||
h.step();
|
||||
}
|
||||
eprintln!("unchanged frame: {:?}", t.elapsed() / 10);
|
||||
let t = Instant::now();
|
||||
for i in 0..10u64 {
|
||||
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [i + 1]}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let r = h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": (i % 5) + 1}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
}
|
||||
eprintln!("frame after a rating: {:?}", t.elapsed() / 10);
|
||||
}
|
||||
@@ -729,7 +729,7 @@ fn the_header_of_a_long_section_stays_in_sight() {
|
||||
}
|
||||
|
||||
/// Scroll the sidebar (the pointer over it) by `dy`.
|
||||
fn scroll_sidebar(h: &mut Headless, dy: f32) {
|
||||
pub(crate) fn scroll_sidebar(h: &mut Headless, dy: f32) {
|
||||
h.request("ui.move", json!({"x": 120.0, "y": 400.0}), T);
|
||||
let r = h.request("ui.scroll", json!({"dy": dy}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
@@ -889,6 +889,10 @@ fn the_keywords_header_creates_a_keyword() {
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(has(&h, "source:keyword:zebra crossing"), "the new keyword is listed");
|
||||
// the sidebar scrolls to it: it is listed last, below what the panel shows
|
||||
h.settle(SETTLE);
|
||||
let (row, panel) = (widget(&h, "source:keyword:zebra crossing"), widget(&h, "panel:left_panel"));
|
||||
assert!(panel.contains_rect(row), "the new keyword is in sight: {row:?} in {panel:?}");
|
||||
}
|
||||
|
||||
/// A folded section's row count sits to the left of its header button; a section without a button
|
||||
|
||||
Reference in new issue
Block a user