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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-14 07:52:51 -04:00
co-authored by Claude Opus 5.5
parent c2c85f5d97
commit 6bb52f09f6
7 changed files with 103 additions and 13 deletions
+1 -1
View File
@@ -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.",
+1 -1
View File
@@ -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.": "グループ化:同じキーワードの中にあるキーワードは、その名前を示す行にまとめられ、長いリストと候補は短く表示されます。",
+1 -1
View File
@@ -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": "Показать все ключевые слова выбранных фото",
+2 -2
View File
@@ -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 створить це ключове слово й додасть його вибраним фото",
+18 -6
View File
@@ -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);
+8 -2
View File
@@ -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::<bool>(bid)).unwrap_or(false);
let begin = ui.data_mut(|d| d.remove_temp::<u64>(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),
+72
View File
@@ -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<u64> = 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<String> = (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<egui::Rect> = 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}");
}
}
}