mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-BEHAV-ACCESS: Tab leaves the name field of a dialog
dialog_field took the focus back every frame while the field wasn't being edited, so Tab never reached the check box, Cancel or OK of New Album, New Smart Album, Save Metadata Preset and the other one-field dialogs. It now takes the focus once, when the dialog opens (the way the keyword dialogs already did).
This commit is contained in:
@@ -81,6 +81,8 @@ mod tests_dialog_fit;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_dialog_primary;
|
mod tests_dialog_primary;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_dialog_tab;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_dialogs;
|
mod tests_dialogs;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_edit_layout;
|
mod tests_edit_layout;
|
||||||
|
|||||||
@@ -2244,13 +2244,15 @@ fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) -
|
|||||||
PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block }
|
PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A dialog's one text field: it keeps the focus (except while its own menu has it) and opens
|
/// A dialog's one text field: it takes the focus when the dialog opens, and opens with its text
|
||||||
/// with its text selected, so typing replaces it. True when Return confirms the dialog; Esc
|
/// selected, so typing replaces it. It takes the focus back only while nothing else has it: taking
|
||||||
/// cancels the dialog (`show`). Taking the focus back with a bare `TextEdit` every frame hid the
|
/// it back every frame would keep Tab from ever reaching the dialog's buttons and check boxes.
|
||||||
/// focus Return gives up, and Return did nothing.
|
/// True when Return confirms the dialog; Esc cancels the dialog (`show`).
|
||||||
fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) -> bool {
|
fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) -> bool {
|
||||||
let r = crate::text_field::TextField::singleline(widget, text).hint(crate::i18n::tr(hint)).width(f32::INFINITY).select_on_focus(true).show(ui);
|
let r = crate::text_field::TextField::singleline(widget, text).hint(crate::i18n::tr(hint)).width(f32::INFINITY).select_on_focus(true).show(ui);
|
||||||
if !r.editing {
|
// (and takes it back when nothing has it: after a click on the dialog's background or a check box,
|
||||||
|
// or when Return gave it up on a refusal; a control Tab reached keeps it)
|
||||||
|
if just_opened(ui, widget) || (!r.editing && r.ending.is_none() && ui.memory(|m| m.focused().is_none())) {
|
||||||
r.response.request_focus();
|
r.response.request_focus();
|
||||||
}
|
}
|
||||||
r.ending == Some(crate::text_field::Ending::Return)
|
r.ending == Some(crate::text_field::Ending::Return)
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//! A dialog's name field takes the keyboard focus once, when the dialog opens: Tab then carries it
|
||||||
|
//! on to the dialog's other controls (a check box, Cancel, OK) and Shift+Tab back, instead of the
|
||||||
|
//! field taking it back every frame.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::headless::Headless;
|
||||||
|
use crate::state::Dialog;
|
||||||
|
use crate::{LightcraftApp, Services};
|
||||||
|
|
||||||
|
const T: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
fn demo_with_selection() -> Headless {
|
||||||
|
let mut session = lightcraft_engine::Session::with_demo();
|
||||||
|
session.selection.ids = session.catalog.photos().take(2).map(|p| p.id).collect();
|
||||||
|
let mut h = Headless::new(LightcraftApp::new(session, Services::default()), [1400.0, 900.0], 1.0);
|
||||||
|
h.settle(T);
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The registered widget (`field:…`, `check:…`, `button:…`) that has the keyboard focus.
|
||||||
|
fn focused(h: &Headless) -> Option<String> {
|
||||||
|
let id = h.view.ctx.memory(|m| m.focused())?;
|
||||||
|
let rect = h.view.ctx.read_response(id)?.rect;
|
||||||
|
h.app.widgets.iter().find(|(_, r)| *r == rect).map(|(w, _)| w.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(h: &mut Headless, params: serde_json::Value) {
|
||||||
|
let r = h.request("ui.key", params, T);
|
||||||
|
assert_eq!(r["ok"], true, "{r}");
|
||||||
|
h.step();
|
||||||
|
h.step();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `dialog`, then Tab through it; the focus order, starting with the field it opens on.
|
||||||
|
fn tab_order(dialog: Dialog) -> Vec<String> {
|
||||||
|
let mut h = demo_with_selection();
|
||||||
|
h.app.ui.dialog = Some(dialog);
|
||||||
|
for _ in 0..4 {
|
||||||
|
h.step();
|
||||||
|
}
|
||||||
|
let mut order = vec![focused(&h).unwrap_or_default()];
|
||||||
|
for _ in 0..4 {
|
||||||
|
key(&mut h, json!({"key": "Tab"}));
|
||||||
|
order.push(focused(&h).unwrap_or_default());
|
||||||
|
}
|
||||||
|
order
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_album_opens_on_its_name_and_tab_reaches_the_check_box_cancel_and_ok() {
|
||||||
|
let order = tab_order(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true });
|
||||||
|
assert_eq!(order[..4], ["field:albumName", "check:albumIncludeSelected", "button:dialogCancel", "button:dialogOk"], "{order:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_smart_album_opens_on_its_name_and_tab_reaches_cancel_and_ok() {
|
||||||
|
let order = tab_order(Dialog::NewSmartAlbum { name: "Rejected".into(), parent: None });
|
||||||
|
let (cancel, ok) = (order.iter().position(|w| w == "button:dialogCancel"), order.iter().position(|w| w == "button:dialogOk"));
|
||||||
|
assert_eq!(order[0], "field:smartAlbumName", "{order:?}");
|
||||||
|
assert!(cancel.is_some() && ok.is_some() && cancel < ok, "Cancel then OK are reached by Tab: {order:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_metadata_preset_opens_on_its_name_and_tab_reaches_cancel_and_ok() {
|
||||||
|
let prompt = Dialog::TextPrompt {
|
||||||
|
title: "Save Metadata Preset".into(),
|
||||||
|
hint: "Preset name".into(),
|
||||||
|
value: String::new(),
|
||||||
|
command: "metadata.savePreset".into(),
|
||||||
|
params: json!({}),
|
||||||
|
key: "name".into(),
|
||||||
|
};
|
||||||
|
let order = tab_order(prompt);
|
||||||
|
assert_eq!(order[..3], ["field:prompt", "button:dialogCancel", "button:dialogOk"], "{order:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_tab_goes_back_from_ok_to_the_name() {
|
||||||
|
let mut h = demo_with_selection();
|
||||||
|
h.app.ui.dialog = Some(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true });
|
||||||
|
for _ in 0..4 {
|
||||||
|
h.step();
|
||||||
|
}
|
||||||
|
for _ in 0..3 {
|
||||||
|
key(&mut h, json!({"key": "Tab"}));
|
||||||
|
}
|
||||||
|
assert_eq!(focused(&h).as_deref(), Some("button:dialogOk"));
|
||||||
|
for _ in 0..3 {
|
||||||
|
key(&mut h, json!({"key": "Tab", "shift": true}));
|
||||||
|
}
|
||||||
|
assert_eq!(focused(&h).as_deref(), Some("field:albumName"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_name_still_takes_typing_and_enter_after_opening() {
|
||||||
|
let mut h = demo_with_selection();
|
||||||
|
h.app.ui.dialog = Some(Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: false });
|
||||||
|
for _ in 0..4 {
|
||||||
|
h.step();
|
||||||
|
}
|
||||||
|
let r = h.request("ui.text", json!({"text": "Holiday"}), T);
|
||||||
|
assert_eq!(r["ok"], true, "{r}");
|
||||||
|
h.step();
|
||||||
|
key(&mut h, json!({"key": "Enter"}));
|
||||||
|
h.settle(T);
|
||||||
|
assert!(h.app.ui.dialog.is_none(), "Enter confirmed the dialog");
|
||||||
|
assert!(h.app.session.catalog.albums().any(|a| a.name == "Holiday"), "the typed name made the album");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_still_cancels_after_tabbing_away_from_the_name() {
|
||||||
|
let mut h = demo_with_selection();
|
||||||
|
h.app.ui.dialog = Some(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true });
|
||||||
|
for _ in 0..4 {
|
||||||
|
h.step();
|
||||||
|
}
|
||||||
|
key(&mut h, json!({"key": "Tab"}));
|
||||||
|
key(&mut h, json!({"key": "Escape"}));
|
||||||
|
assert!(h.app.ui.dialog.is_none(), "Esc closed the dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_click_on_a_check_box_leaves_return_confirming_the_name() {
|
||||||
|
let mut h = demo_with_selection();
|
||||||
|
h.app.ui.dialog = Some(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true });
|
||||||
|
for _ in 0..4 {
|
||||||
|
h.step();
|
||||||
|
}
|
||||||
|
let r = h.request("ui.clickWidget", json!({"id": "check:albumIncludeSelected"}), T);
|
||||||
|
assert_eq!(r["ok"], true, "{r}");
|
||||||
|
h.step();
|
||||||
|
h.step();
|
||||||
|
key(&mut h, json!({"key": "Enter"}));
|
||||||
|
h.settle(T);
|
||||||
|
assert!(h.app.ui.dialog.is_none(), "Enter confirmed the dialog after the click");
|
||||||
|
assert!(h.app.session.catalog.albums().any(|a| a.name == "Trip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refused_return_leaves_the_name_focused_for_the_correction() {
|
||||||
|
let mut h = demo_with_selection();
|
||||||
|
h.app.ui.dialog = Some(Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: false });
|
||||||
|
for _ in 0..4 {
|
||||||
|
h.step();
|
||||||
|
}
|
||||||
|
key(&mut h, json!({"key": "Enter"}));
|
||||||
|
h.step();
|
||||||
|
assert!(h.app.ui.dialog.is_some(), "an empty name is refused and the dialog stays");
|
||||||
|
assert_eq!(focused(&h).as_deref(), Some("field:albumName"), "the field has the focus again");
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Lightroom parity checklist
|
# Lightroom parity checklist
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (LR-BEHAV-ACCESS: the New Album, New Smart Album and name-prompt dialogs give their name field the focus once, so Tab reaches their check box, Cancel and OK; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||||
|
|
||||||
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
||||||
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
||||||
@@ -927,3 +927,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
|||||||
| 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged |
|
| 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged |
|
||||||
| 2026-10-19 | minor | LRC-LIB-LIBFOLDERS: the sidebar trees indent by a narrower step (14 / 12 / 10 / 8 pt) when the deepest open row would not fit the panel, instead of cutting its name or scrolling sideways at once (issue #2298); status unchanged (✅) |
|
| 2026-10-19 | minor | LRC-LIB-LIBFOLDERS: the sidebar trees indent by a narrower step (14 / 12 / 10 / 8 pt) when the deepest open row would not fit the panel, instead of cutting its name or scrolling sideways at once (issue #2298); status unchanged (✅) |
|
||||||
| 2026-10-15 | patch | LR-EXP-DIM: Width & Height (`fit: widthHeight`) limits the width and the height as the photo stands, beside Dimensions (W × H, either orientation), as in Lightroom (#1620); status unchanged (✅) |
|
| 2026-10-15 | patch | LR-EXP-DIM: Width & Height (`fit: widthHeight`) limits the width and the height as the photo stands, beside Dimensions (W × H, either orientation), as in Lightroom (#1620); status unchanged (✅) |
|
||||||
|
| 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) |
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# What's new in LightCraft
|
# What's new in LightCraft
|
||||||
|
|
||||||
|
## 2026-10-23
|
||||||
|
|
||||||
|
### Tab leaves the name field in New Album, New Smart Album and the name prompts
|
||||||
|
- In New Album, New Smart Album, Save Metadata Preset and the other dialogs with one name field, the field has the
|
||||||
|
keyboard focus when the dialog opens, and Tab now moves on to the check box, Cancel and OK (Shift+Tab goes back).
|
||||||
|
Before, the field took the focus back every frame, so only Return and Esc got you out of it. Return still
|
||||||
|
confirms and Esc still cancels.
|
||||||
|
|
||||||
## 2026-10-21
|
## 2026-10-21
|
||||||
|
|
||||||
### Sharper resampling in HDR and panorama merges
|
### Sharper resampling in HDR and panorama merges
|
||||||
|
|||||||
Reference in New Issue
Block a user