From 6bb52f09f6b76751e36d67567f37f558d406709a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sun, 11 Oct 2026 20:57:14 -0400 Subject: [PATCH] LRC-LIB-KEYWORDS: what the check of the review fixes found - An offer taken asked the box to start a new edit on the next frame; a box not drawn then (its panel switched away at once) took the cursor whenever it was next drawn. The request holds for one frame. - Return on the frame the box scrolled out of sight still took the highlighted offer, which was no longer drawn: it gives nothing. - In a window with room for the offers on neither side of the box they covered it: they go to the side with more room and show as many lines as fit there, the highlighted one among them. - Ukrainian says "suggestions" as the panel does; four clearer wordings (uk, ru, de, ja). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/ja.json | 2 +- crates/ui-egui/locales/ru.json | 2 +- crates/ui-egui/locales/uk.json | 4 +- crates/ui-egui/src/panels/keywording.rs | 24 ++++++--- crates/ui-egui/src/panels/right.rs | 10 +++- crates/ui-egui/src/tests_keywording.rs | 72 +++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 63b377da0..a98ed75c2 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1976,7 +1976,7 @@ "Filter Keywords": "Stichwörter filtern", "No keywords yet": "Noch keine Stichwörter", "Show fewer": "Weniger anzeigen", - "Whole names": "Ganze Namen", + "Whole names": "Vollständige Namen", "Grouped by parent": "Nach übergeordnetem Stichwort gruppiert", "Keywords of the selection": "Stichwörter der Auswahl", "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Gruppiert: Stichwörter im selben Stichwort teilen sich eine Zeile mit dessen Namen; lange Listen und die Vorschläge werden gekürzt.", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 2b71d047e..824abe24f 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1822,7 +1822,7 @@ "Filter Keywords": "キーワードを絞り込む", "No keywords yet": "キーワードはまだありません", "Show fewer": "表示を減らす", - "Whole names": "完全な名前", + "Whole names": "パス全体", "Grouped by parent": "親キーワードでグループ化", "Keywords of the selection": "選択した写真のキーワード", "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "グループ化:同じキーワードの中にあるキーワードは、その名前を示す行にまとめられ、長いリストと候補は短く表示されます。", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a3cd09849..575b63cd0 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1530,7 +1530,7 @@ "No keywords yet": "Ключевых слов пока нет", "Show fewer": "Показать меньше", "Whole names": "Полные имена", - "Grouped by parent": "Сгруппированы по родительскому слову", + "Grouped by parent": "По родительскому ключевому слову", "Keywords of the selection": "Ключевые слова выбранных фото", "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Сгруппированы: ключевые слова внутри одного ключевого слова стоят под строкой с его именем; длинные списки и подсказки сокращаются.", "Show every keyword of the selected photos": "Показать все ключевые слова выбранных фото", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 1407c8259..50bcd4a88 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2005,9 +2005,9 @@ "No keywords yet": "Ключових слів ще немає", "Show fewer": "Показати менше", "Whole names": "Повні назви", - "Grouped by parent": "Згруповано за батьківським словом", + "Grouped by parent": "За батьківським ключовим словом", "Keywords of the selection": "Ключові слова вибраних фото", - "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Згруповано: ключові слова всередині одного ключового слова стоять під рядком із його назвою; довгі списки та підказки скорочуються.", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Згруповано: ключові слова всередині одного ключового слова стоять під рядком із його назвою; довгі списки та пропозиції скорочуються.", "Show every keyword of the selected photos": "Показати всі ключові слова вибраних фото", "new keyword": "нове ключове слово", "Return creates this keyword and gives it to the selected photos": "Клавіша Enter створить це ключове слово й додасть його вибраним фото", diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 6e66aafbf..b1f1e919e 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -417,11 +417,23 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at let t = Tokens::get(ui.ctx()); let mut picked = None; let width = (below.width() - 10.0).max(60.0); - // as tall as its lines: under the box, or over it where that is the only side with room - let lines = offers.keywords.len().saturating_add(usize::from(offers.create.is_some())); - let height = lines as f32 * 24.0 + if offers.create.is_some() && !offers.keywords.is_empty() { 17.0 } else { 10.0 }; + // under the box, or over it where the window has more room there; as many lines as fit that + // side, so that the offers never cover the box (the highlighted one is always among them) let window = ui.ctx().content_rect(); - let over = below.bottom() + 2.0 + height > window.bottom() && below.top() - window.top() > window.bottom() - below.bottom(); + let create = usize::from(offers.create.is_some()); + let fits = |room: f32| { + let lines = (room - 2.0 - if create > 0 && !offers.keywords.is_empty() { 17.0 } else { 10.0 }) / 24.0; + if lines.is_finite() && lines >= 1.0 { lines as usize } else { 0 } + }; + let wanted = offers.keywords.len().saturating_add(create); + let (under, above) = (fits(window.bottom() - below.bottom()), fits(below.top() - window.top())); + let over = under < wanted && above > under; + let room = (if over { above } else { under }).clamp(1, wanted.max(1)); + // the keywords shown: those up to the highlighted one when not all fit + let count = room.saturating_sub(create).min(offers.keywords.len()); + let lit = at.and_then(|k| offers.keywords.iter().position(|x| x == k)); + let first = lit.map_or(0, |a| a.saturating_add(1).saturating_sub(count)).min(offers.keywords.len().saturating_sub(count)); + let listed = offers.keywords.iter().skip(first).take(count); let area = egui::Area::new(egui::Id::new("keyword-offers")).order(egui::Order::Foreground).constrain(true); let area = if over { area.pivot(Align2::LEFT_BOTTOM).fixed_pos(below.left_top() - vec2(0.0, 2.0)) @@ -453,7 +465,7 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at } resp }; - for k in &offers.keywords { + for k in listed { let (place, name) = k.rsplit_once(SEP).unwrap_or(("", k)); let whole = k.replace(SEP, " › "); let lit = at == Some(k.as_str()); @@ -467,7 +479,7 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at } } if let Some(new) = &offers.create { - if !offers.keywords.is_empty() { + if count > 0 { ui.add_space(3.0); let (r, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover()); ui.painter().rect_filled(r, 0.0, t.field_border); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 23dadfb9e..aae2e3f5b 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -914,8 +914,10 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } // (after an offer was taken the box starts a new edit, empty: Esc has nothing to bring back) + // (asked for on the frame before this one only: a box that wasn't drawn then, its panel + // switched away, doesn't claim the cursor whenever it is next drawn) let bid = egui::Id::new("kw-begin"); - let begin = ui.data_mut(|d| d.remove_temp::(bid)).unwrap_or(false); + let begin = ui.data_mut(|d| d.remove_temp::(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass)); let r = crate::text_field::TextField::singleline("field:keyword", &mut text) .hint(crate::i18n::tr("Add keyword")) .width(f32::INFINITY) @@ -923,6 +925,10 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { .show(ui); let in_sight = ui.clip_rect().contains(r.response.rect.center()); let mut drawn = Rect::NOTHING; + // (what isn't drawn can't be taken: Return on the frame the box scrolls out gives nothing) + if !in_sight { + picked = None; + } if offers.is_none() { // (not over the offers) r.response.clone().on_hover_text(crate::i18n::tr("Separate several keywords with commas. Return gives them to the selected photos.")); @@ -958,7 +964,7 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { text.clear(); // an offer taken leaves the cursor in the box, for the next keyword if !returned { - ui.data_mut(|d| d.insert_temp(bid, true)); + ui.data_mut(|d| d.insert_temp(bid, pass)); } } Err(e) => app.toast_error(ui.ctx(), e), diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index ece0ffc6c..e47ba879a 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -720,3 +720,75 @@ fn wrong_values_for_the_new_settings_keep_the_rest() { let ui: crate::UiState = serde_json::from_str(r#"{"settings": {"keywordChips": "grouped"}}"#).unwrap(); assert_eq!(ui.settings.keyword_chips, crate::state::KeywordChips::Grouped); } + +// Given an offer taken with ↓ and Return, +// when the panel is switched away at once and comes back much later, +// then the box does not take the cursor unasked: keys typed then are not the box's. +#[test] +fn an_offer_taken_does_not_claim_the_cursor_later() { + let (mut h, ids) = with_places(); + typed(&mut h, "lis"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + // (Return and the switch on consecutive frames, as a script does them) + let r = h.request("ui.key", json!({"key": "Enter"}), T); + assert_eq!(r["ok"], true, "{r}"); + ask(&mut h, "ui.set", json!({"right": "info"})); + assert!(all_have(&h, &ids, "Places|Portugal|Lisbon")); + ask(&mut h, "ui.set", json!({"right": "keywords"})); + ask(&mut h, "ui.text", json!({"text": "zz"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(none_has(&h, &ids, "zz"), "typed with the cursor nowhere: {:?}", keywords_of(&h, ids[0])); +} + +// Given a highlighted offer and the box scrolled out of sight, Return gives nothing: what isn't +// drawn can't be taken, not even on the frame the box leaves. +#[test] +fn an_offer_out_of_sight_is_not_taken() { + let (mut h, ids) = with_places(); + give(&mut h, &ids, &sixteen()); + typed(&mut h, "lis"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + let panel = rect(&h, "panel:right_panel"); + ask(&mut h, "ui.move", json!({"x": panel.center().x, "y": panel.center().y})); + for _ in 0..8 { + // the wheel and Return together, frame after frame + let r = h.request("ui.scroll", json!({"dy": -400.0}), T); + assert_eq!(r["ok"], true, "{r}"); + if rect(&h, "field:keyword").bottom() < panel.top() { + let r = h.request("ui.key", json!({"key": "Enter"}), T); + assert_eq!(r["ok"], true, "{r}"); + } + } + h.settle(SETTLE); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(none_has(&h, &ids, "Places|Portugal|Lisbon"), "{:?}", keywords_of(&h, ids[0])); +} + +// Given a window too short for the offers under the box, they never cover the box being typed +// in: they go where there is more room and show as many lines as fit there. +#[test] +fn offers_never_cover_the_box() { + for height in [1000.0, 420.0, 300.0, 230.0, 180.0] { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, height], 1.0); + let ids: Vec = h.app.session.visible_cloned().iter().take(12).map(|p| p.0).collect(); + ask(&mut h, "ui.set", json!({"view": "photoGrid", "right": "keywords"})); + // nine keywords to offer for "pa", on photos that are not selected + let many: Vec = (1..=9).map(|i| format!("Parks|park {i}")).collect(); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": &ids[2..], "keywords": many}})); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": &ids[..2]}})); + typed(&mut h, "pa"); + let field = rect(&h, "field:keyword"); + let offers: Vec = h.app.widgets.iter().filter(|(w, _)| w.starts_with("kwComplete")).map(|(_, r)| *r).collect(); + assert!(!offers.is_empty(), "{height}: something is offered"); + for r in &offers { + assert!(!r.intersects(field.shrink(0.5)), "{height}: an offer at {r:?} covers the box at {field:?}"); + assert!(r.top() >= -0.5 && r.bottom() <= height + 0.5, "{height}: an offer at {r:?} is off the window"); + } + // ↓ still reaches every offer drawn, and the highlighted one is among them + for _ in 0..12 { + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + assert_eq!(lit(&h).len(), 1, "{height}"); + } + } +}