collective

Browse / github.com/storytold/lightcraft / patch

LR-BEHAV-SIDEBAR-KEYS: the keys' model for the left sidebar: the mode, its cursor and its ring (no caller yet)

LR-BEHAV-SIDEBAR-KEYS: the keys' model for the left sidebar: the mode, its cursor and its ring (no caller yet) Off `main`. ## Problem Nothing in the left sidebar can be reached or told apart from the keys: no row takes focus, and the only key the panel reads is Esc during a drag. Keyboard in Sidebar (a mode that gives the keys to the sidebar) needs a core that knows which row the keys are on, what each key asks of it and when the mode ends, before any row is wired to it. ## Change The tested core of Keyboard in Sidebar, `crates/ui-egui/src/panels/side_keys.rs`, with no caller yet: the sidebar's wiring is the next PR (the module's doc names it). - **The mode and its cursor:** the cursor starts on t

Status
open
Score
0.00
Adopters
0 other operators
Author
nledford (imported from pull request #3179, open)
Base
07bc2008d9457603bb77f6b3de33a901db75a8c4
Size
+1685 −19 in 24 files
Review
pending
Shared
2026-10-28
Id
07ad2ad9d71e859c6d57d8c9

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/locales/cs.json crates/ui-egui/locales/de.json crates/ui-egui/locales/es.json crates/ui-egui/locales/fi.json crates/ui-egui/locales/fr.json crates/ui-egui/locales/id.json crates/ui-egui/locales/it.json crates/ui-egui/locales/ja.json crates/ui-egui/locales/ka.json crates/ui-egui/locales/pl.json crates/ui-egui/locales/pt-br.json crates/ui-egui/locales/ru.json crates/ui-egui/locales/sv.json crates/ui-egui/locales/uk.json crates/ui-egui/locales/vi.json crates/ui-egui/locales/zh-hans.json crates/ui-egui/locales/zh-hant.json crates/ui-egui/src/panels.rs crates/ui-egui/src/panels/left.rs crates/ui-egui/src/panels/left/sections.rs crates/ui-egui/src/panels/side_keys.rs crates/ui-egui/src/state.rs crates/ui-egui/src/tests.rs crates/ui-egui/src/tests/side_keys.rs

diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json--- a/crates/ui-egui/locales/cs.json+++ b/crates/ui-egui/locales/cs.json@@ -2408,5 +2408,7 @@   "Import dropped files without review": "Importovat přetažené soubory bez kontroly",   "An import is running": "Import už běží",   "An import is running. Drop photos when it has finished": "Import už běží. Fotky přetáhněte, až skončí",-  "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky"+  "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "Klávesy ovládají postranní panel: šipky přesouvají, Enter otevírá, psaním hledáte, Esc vrací k fotografiím",+  "This row has no name to change": "Tento řádek nemá název, který by šlo změnit" }diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json--- a/crates/ui-egui/locales/de.json+++ b/crates/ui-egui/locales/de.json@@ -3440,5 +3440,7 @@   "Import dropped files without review": "Abgelegte Dateien ohne Prüfung importieren",   "An import is running": "Ein Import läuft",   "An import is running. Drop photos when it has finished": "Ein Import läuft. Lege die Fotos ab, wenn er fertig ist",-  "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst"+  "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "Die Tasten gelten der Seitenleiste: Pfeile bewegen, Eingabe öffnet, Tippen sucht, Esc führt zurück zu den Fotos",+  "This row has no name to change": "Diese Zeile hat keinen Namen, der sich ändern ließe" }diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json--- a/crates/ui-egui/locales/es.json+++ b/crates/ui-egui/locales/es.json@@ -2358,5 +2358,7 @@   "Import dropped files without review": "Importar los archivos soltados sin revisión",   "An import is running": "Hay una importación en curso",   "An import is running. Drop photos when it has finished": "Hay una importación en curso. Suelta las fotos cuando termine",-  "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos"+  "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "Las teclas están en la barra lateral: las flechas mueven, Intro abre, escribe para buscar, Esc vuelve a las fotos",+  "This row has no name to change": "Esta fila no tiene un nombre que cambiar" }diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json--- a/crates/ui-egui/locales/fi.json+++ b/crates/ui-egui/locales/fi.json@@ -2327,5 +2327,7 @@   "Drop to open the face model": "Pudota avataksesi kasvomallin",   "Dropped Files": "Pudotetut tiedostot",   "Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä",-  "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta"+  "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "Näppäimet toimivat sivupalkissa: nuolet liikuttavat, Enter avaa, kirjoita etsiäksesi, Esc palaa kuviin",+  "This row has no name to change": "Tällä rivillä ei ole nimeä, jota voisi muuttaa" }diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json--- a/crates/ui-egui/locales/fr.json+++ b/crates/ui-egui/locales/fr.json@@ -2390,5 +2390,7 @@   "Checking recent libraries": "Vérification des bibliothèques récentes",   "The photos are imported once a library is open": "Les photos seront importées dès qu'une bibliothèque sera ouverte",   "Open a library first, then drop the presets again": "Ouvrez d'abord une bibliothèque, puis déposez à nouveau les préréglages",-  "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :"+  "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "Les touches sont dans la barre latérale : les flèches déplacent, Entrée ouvre, tapez pour chercher, Échap revient aux photos",+  "This row has no name to change": "Cette ligne n’a pas de nom à modifier" }diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json--- a/crates/ui-egui/locales/id.json+++ b/crates/ui-egui/locales/id.json@@ -2339,5 +2339,7 @@   "Drop to open the face model": "Jatuhkan untuk membuka model wajah",   "Dropped Files": "File yang dijatuhkan",   "Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto",-  "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan"+  "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "Tombol berlaku di bilah samping: panah untuk berpindah, Enter untuk membuka, ketik untuk mencari, Esc untuk kembali ke foto",+  "This row has no name to change": "Baris ini tidak punya nama yang bisa diubah" }diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json--- a/crates/ui-egui/locales/it.json+++ b/crates/ui-egui/locales/it.json@@ -2361,5 +2361,7 @@   "Import dropped files without review": "Importa i file rilasciati senza revisione",   "An import is running": "Un'importazione è in corso",   "An import is running. Drop photos when it has finished": "Un'importazione è in corso. Trascina le foto quando è finita",-  "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto"+  "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "I tasti agiscono sulla barra laterale: le frecce spostano, Invio apre, digita per cercare, Esc torna alle foto",+  "This row has no name to change": "Questa riga non ha un nome da cambiare" }diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json--- a/crates/ui-egui/locales/ja.json+++ b/crates/ui-egui/locales/ja.json@@ -2356,5 +2356,7 @@   "Import dropped files without review": "ドロップしたファイルを確認せずに読み込む",   "An import is running": "読み込み中です",   "An import is running. Drop photos when it has finished": "読み込み中です。完了してから写真をドロップしてください",-  "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください"+  "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "キーはサイドバーにあります。矢印で移動、Enter で開く、入力で検索、Esc で写真に戻ります",+  "This row has no name to change": "この行には変更できる名前がありません" }diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json--- a/crates/ui-egui/locales/ka.json+++ b/crates/ui-egui/locales/ka.json@@ -2734,5 +2734,7 @@   "Import dropped files without review": "ჩაგდებული ფაილების იმპორტი გადახედვის გარეშე",   "An import is running": "იმპორტი მიმდინარეობს",   "An import is running. Drop photos when it has finished": "იმპორტი მიმდინარეობს. ფოტოები ჩააგდეთ, როცა დასრულდება",-  "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი"+  "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "კლავიშები გვერდით ზოლს მართავს: ისრები გადაადგილებს, Enter ხსნის, აკრიფეთ საძებნად, Esc ფოტოებს უბრუნდება",+  "This row has no name to change": "ამ მწკრივს შესაცვლელი სახელი არ აქვს" }diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json--- a/crates/ui-egui/locales/pl.json+++ b/crates/ui-egui/locales/pl.json@@ -2283,5 +2283,7 @@   "Import dropped files without review": "Importuj upuszczone pliki bez przeglądu",   "An import is running": "Import jest w toku",   "An import is running. Drop photos when it has finished": "Import jest w toku. Upuść zdjęcia, gdy się zakończy",-  "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia"+  "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "Klawisze działają na pasku bocznym: strzałki przesuwają, Enter otwiera, pisz, aby szukać, Esc wraca do zdjęć",+  "This row has no name to change": "Ten wiersz nie ma nazwy do zmiany" }diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json--- a/crates/ui-egui/locales/pt-br.json+++ b/crates/ui-egui/locales/pt-br.json@@ -2202,5 +2202,7 @@   "Import dropped files without review": "Importar arquivos soltos sem revisão",   "An import is running": "Uma importação está em andamento",   "An import is running. Drop photos when it has finished": "Uma importação está em andamento. Solte as fotos quando ela terminar",-  "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos"+  "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "As teclas estão na barra lateral: as setas movem, Enter abre, digite para achar, Esc volta às fotos",+  "This row has no name to change": "Esta linha não tem um nome para mudar" }diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json--- a/crates/ui-egui/locales/ru.json+++ b/crates/ui-egui/locales/ru.json@@ -2349,5 +2349,7 @@   "Import dropped files without review": "Импортировать перетащенные файлы без проверки",   "An import is running": "Импорт уже выполняется",   "An import is running. Drop photos when it has finished": "Импорт уже выполняется. Перетащите фото, когда он закончится",-  "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото"+  "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "Клавиши работают в боковой панели: стрелки перемещают, Enter открывает, набор текста ищет, Esc возвращает к фотографиям",+  "This row has no name to change": "У этой строки нет имени, которое можно изменить" }diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json--- a/crates/ui-egui/locales/sv.json+++ b/crates/ui-egui/locales/sv.json@@ -2429,5 +2429,7 @@   "Drop to open the face model": "Släpp för att öppna ansiktsmodellen",   "Dropped Files": "Släppta filer",   "Finish Lightroom catalog import before adding photos": "Slutför importen av Lightroom-katalogen innan du lägger till foton",-  "Import dropped files without review": "Importera släppta filer utan granskning"+  "Import dropped files without review": "Importera släppta filer utan granskning",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "Tangenterna styr sidofältet: piltangenterna flyttar, Enter öppnar, skriv för att söka, Esc går tillbaka till fotona",+  "This row has no name to change": "Raden har inget namn att ändra" }diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json--- a/crates/ui-egui/locales/uk.json+++ b/crates/ui-egui/locales/uk.json@@ -2354,5 +2354,7 @@   "Import dropped files without review": "Імпортувати перетягнуті файли без перегляду",   "An import is running": "Імпорт уже виконується",   "An import is running. Drop photos when it has finished": "Імпорт уже виконується. Перетягніть фото, коли він завершиться",-  "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото"+  "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "Клавіші працюють у бічній панелі: стрілки переміщують, Enter відкриває, набір тексту шукає, Esc повертає до фотографій",+  "This row has no name to change": "Цей рядок не має назви, яку можна змінити" }diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json--- a/crates/ui-egui/locales/vi.json+++ b/crates/ui-egui/locales/vi.json@@ -2235,5 +2235,7 @@   "Drop to open the face model": "Thả để mở mô hình khuôn mặt",   "Dropped Files": "Tệp đã thả",   "Finish Lightroom catalog import before adding photos": "Hoàn tất nhập danh mục Lightroom trước khi thêm ảnh",-  "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại"+  "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "Phím đang điều khiển thanh bên: phím mũi tên di chuyển, Enter mở, gõ để tìm, Esc quay lại ảnh",+  "This row has no name to change": "Hàng này không có tên để đổi" }diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json--- a/crates/ui-egui/locales/zh-hans.json+++ b/crates/ui-egui/locales/zh-hans.json@@ -2200,5 +2200,7 @@   "Import dropped files without review": "不经检查直接导入拖入的文件",   "An import is running": "正在导入",   "An import is running. Drop photos when it has finished": "正在导入。导入完成后再拖放照片",-  "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入"+  "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "按键现在作用于边栏:方向键移动,回车打开,输入文字查找,Esc 返回照片",+  "This row has no name to change": "此行没有可更改的名称" }diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json--- a/crates/ui-egui/locales/zh-hant.json+++ b/crates/ui-egui/locales/zh-hant.json@@ -2157,5 +2157,7 @@   "Import dropped files without review": "不經檢查直接匯入拖放的檔案",   "An import is running": "正在匯入",   "An import is running. Drop photos when it has finished": "正在匯入。匯入完成後再拖放照片",-  "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入"+  "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入",+  "Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos": "按鍵現在作用於側邊欄:方向鍵移動,Enter 開啟,輸入文字尋找,Esc 返回照片",+  "This row has no name to change": "此列沒有可變更的名稱" }diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs--- a/crates/ui-egui/src/panels.rs+++ b/crates/ui-egui/src/panels.rs@@ -26,6 +26,7 @@ pub mod presets; pub mod profiles; pub mod right; pub mod second;+pub(crate) mod side_keys; pub mod slideshow; pub mod strip; pub mod topbar;diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs--- a/crates/ui-egui/src/panels/left.rs+++ b/crates/ui-egui/src/panels/left.rs@@ -32,10 +32,10 @@ use local::{local_now, local_rows}; pub(crate) use rows::{INDENT_STEP, MIN_INDENT_STEP}; use rows::{LIBFOLDER_REVEALED, SCROLL_TO_ROW, row, set_tree_open}; pub(crate) use rows::{content_width, indent_step, step_for};-pub(crate) use sections::PlacedSection; pub use sections::section_drag_feedback; #[cfg(test)] pub(crate) use sections::section_drop_before;+pub(crate) use sections::{PlacedSection, SECTION_HEADER}; use sections::{SECTION_GAP, missing_count, pinned_header, section, section_drag, sections_menu};  /// Draw the My Photos sidebar: title and open-library name, the scrollable sources, albums,diff --git a/crates/ui-egui/src/panels/left/sections.rs b/crates/ui-egui/src/panels/left/sections.rs--- a/crates/ui-egui/src/panels/left/sections.rs+++ b/crates/ui-egui/src/panels/left/sections.rs@@ -6,7 +6,7 @@ use super::*; pub(super) const SECTION_GAP: f32 = 10.0;  /// Height of a section's header.-const SECTION_HEADER: f32 = 34.0;+pub(crate) const SECTION_HEADER: f32 = 34.0;  /// One sidebar section: the gap above it, its header, and (while it is open) the rows `body` /// draws. Every section goes through here, so they all fold, space and announce themselves alike.diff --git a/crates/ui-egui/src/panels/side_keys.rs b/crates/ui-egui/src/panels/side_keys.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/panels/side_keys.rs@@ -0,0 +1,628 @@+//! The keyboard in the left sidebar: with Keyboard in Sidebar on (`view.sidebarKeys`, Cmd+Alt+B), the+//! arrow keys move a cursor over the rows and section headers that are drawn, Left / Right fold+//! and unfold, Enter or Space chooses the row as a click does, typing jumps to the next row that+//! starts so, F2 renames, and Esc gives the keys back to the photos.+//!+//! It is a mode asked for, never entered by a click: with it off every key is what it always+//! was (P picks a photo also right after an album was clicked). While it is on, keys without Cmd /+//! Ctrl / Alt belong to the sidebar ([`owns_keys`]); the others stay shortcuts. Tab, a click+//! elsewhere, a text field taking the keys (Find), or the sidebar going out of sight end it.+//!+//! The rows say what they are as they are drawn ([`note_row`], [`note_open`]); a key acts on what+//! the last frame drew, and what it asks of a row (choose, fold) is handed to that row when it is+//! drawn next, so a key does exactly what the click on the row does.+//!+//! Nothing calls this yet: it is the tested core of Keyboard in Sidebar (LR-BEHAV-SIDEBAR-KEYS), and+//! the next part of that feature starts it: the sidebar's rows say what they are through [`note_row`]+//! and the rest, and the `view.sidebarKeys` command, its menu item and its shortcut turn the mode on.+//! Until then the app's keys are what they were, and the module is not counted as dead code outside tests.+#![cfg_attr(not(test), allow(dead_code))]++use egui::{Key, Rect};++use crate::LightcraftApp;++/// A row or section header as the keys see it.+#[derive(Clone, Debug)]+struct NavRow {+    /// The row's widget id without `source:` (`all`, `album:3`, `libfolder:/pics`), or a+    /// header's whole id (`sidebarSection:albums`); a second Local row of the same id in one+    /// frame (a folder that is a place and also lies inside another) has [`TWIN`] and `2` after+    /// it, which no file name can hold.+    id: String,+    /// What typing is matched against.+    label: String,+    indent: f32,+    header: bool,+    /// Whether it is open, when it can be opened at all.+    open: Option<bool>,+    selected: bool,+}++/// What a key asks of a row, done when the row is drawn.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+enum Ask {+    /// As a click on the row.+    Choose,+    /// As a click on its triangle.+    Fold,+}++#[derive(Clone, Default)]+struct Nav {+    /// The rows drawn so far this frame, top to bottom.+    rows: Vec<NavRow>,+    /// The rows of the last frame: what a key press acts on.+    prev: Vec<NavRow>,+    ask: Option<(String, Ask)>,+    /// What was typed lately, and when the last letter came.+    typed: String,+    typed_at: f64,+    /// The pass the sidebar last began in: keys are the sidebar's only while it is drawn.+    seen: u64,+    /// Whether the keys were the sidebar's when it was last drawn (to say so when that begins).+    was_on: bool,+    /// Whether the keys' toast was shown this session: it explains the mode once.+    told: bool,+    /// How often each Local row's id was drawn so far this frame (only they can repeat).+    twins: std::collections::HashMap<String, u32>,+    /// Whether a dialog or a menu was open when the sidebar was last drawn: what it leaves+    /// focused as it closes is let go, where any other focus ends the mode.+    had_window: bool,+    /// Whether an album or a section was being dragged when the sidebar was last drawn: the Esc+    /// that abandons a drag ends the drag before the sidebar is drawn again, and is the drag's.+    was_dragging: bool,+}++/// What parts a Local row's id from its number when the same folder is drawn more than once: a+/// character no path contains, so a folder really called `Day #2` is never taken for a second row.+const TWIN: char = '\u{1f}';++fn nav_id() -> egui::Id {+    egui::Id::unique("left-side-keys")+}++/// What every row asks about each frame, kept apart from [`Nav`] so asking copies two words:+/// whether the keys are the sidebar's, and the row a key chose this frame (by its response's id).+#[derive(Clone, Copy, Default)]+struct Now {+    on: bool,+    chose: Option<egui::Id>,+}++fn now(ctx: &egui::Context) -> Now {+    ctx.data(|d| d.get_temp::<Now>(nav_id())).unwrap_or_default()+}++fn with<R>(ctx: &egui::Context, f: impl FnOnce(&mut Nav) -> R) -> R {+    ctx.data_mut(|d| f(d.get_temp_mut_or_default::<Nav>(nav_id())))+}++/// How long typed letters add up to one word.+const TYPE_AHEAD_SECONDS: f64 = 1.0;+/// The most letters kept: nothing typed by hand is longer, and it bounds what is compared.+const TYPE_AHEAD_MAX: usize = 64;+/// Rows a Page Up / Page Down moves by.+const PAGE: usize = 10;++/// Whether keys without Cmd / Ctrl / Alt are the sidebar's this frame (so not shortcuts; of them it reads typed+/// text, Shift+Tab and the listed keys with no Shift, and any other key with Shift is nothing): the mode is on and+/// the sidebar was drawn within the last two passes or in this one. A sidebar that is not drawn owns no key,+/// and ends the mode (after the few frames it has to appear in when the mode was just asked+/// for), so the keys can never be lost to something out of sight.+pub(crate) fn owns_keys(app: &mut LightcraftApp, ctx: &egui::Context) -> bool {+    if !app.ui.sidebar_keys {+        return false;+    }+    let seen = with(ctx, |n| n.seen);+    if ctx.cumulative_pass_nr().saturating_sub(seen) > 2 {+        if app.ui.sidebar_keys_grace == 0 {+            app.ui.sidebar_keys = false;+        } else {+            app.ui.sidebar_keys_grace -= 1;+        }+        return false;+    }+    app.ui.sidebar_keys_grace = 0;+    !ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.alt)+}++/// Forget the rows drawn so far, this frame's and the last one's: they are another library's, so the+/// next [`begin`] has no row to put the cursor on until the sidebar has been drawn again.+pub(crate) fn forget_rows(ctx: &egui::Context) {+    with(ctx, |n| {+        n.rows.clear();+        n.prev.clear();+    });+}++/// A key press or typed text, in the order it came.+enum Typed {+    Key(Key),+    Text(String),+}++/// Start of the sidebar's frame (`panel`: its rect): the rows of the last frame become what the+/// keys act on, and this frame's key presses are read.+pub(crate) fn begin(app: &mut LightcraftApp, ui: &egui::Ui, panel: Rect) {+    let ctx = ui.ctx();+    let pass = ctx.cumulative_pass_nr();+    let on = app.ui.sidebar_keys;+    let (began, tell) = with(ctx, |n| {+        // a sidebar that was not drawn for a while (hidden meanwhile) has stale rows, and the mode was not on in it+        if pass.saturating_sub(n.seen) > 2 {+            n.rows.clear();+            n.was_on = false;+        }+        n.prev = std::mem::take(&mut n.rows);+        n.twins.clear();+        n.seen = pass;+        // a row that was not drawn (folded away meanwhile) is asked nothing later+        n.ask = None;+        let was_on = std::mem::replace(&mut n.was_on, on);+        let began = on && !was_on;+        (began, began && !std::mem::replace(&mut n.told, true))+    });+    ctx.data_mut(|d| d.insert_temp(nav_id(), Now { on, chose: None }));+    if !on {+        return;+    }+    if tell {+        // a mode: say that it is on, what the keys do now and how it ends (once: after that the ring says it)+        app.toast_for(ctx, crate::i18n::tr("Keys are in the sidebar: arrows move, Enter opens, type to find, Esc goes back to the photos"), 6.0);+    }+    // a press anywhere else gives the keys back to what was pressed (a menu or a dialog of the+    // sidebar's own is not "elsewhere"). The in-window View menu is elsewhere: pressing it ends the+    // mode, and its Keyboard in Sidebar item then turns it on, so that item is a way in, not out+    let outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !panel.contains(p)));+    let own_window = app.ui.dialog.is_some() || egui::Popup::is_any_open(ctx);+    if outside && !own_window {+        app.ui.sidebar_keys = false;+        return;+    }+    // a dialog or an open menu has the keys meanwhile, and so has a drag (Esc abandons it)+    let dragging = app.ui.dragging_album.is_some() || app.ui.dragging_section.is_some();+    let dragged = with(ctx, |n| std::mem::replace(&mut n.was_dragging, dragging));+    if own_window || dragging || dragged {+        with(ctx, |n| n.had_window = own_window);+        return;+    }+    // a field that takes the keys has them: Find, a box that was clicked. Only what a dialog or menu left+    // focused as it closed is let go, so renaming a row comes back to the row+    let had_window = with(ctx, |n| std::mem::replace(&mut n.had_window, false));+    if let Some(focused) = ctx.memory(|m| m.focused()) {+        if had_window {+            ctx.memory_mut(|m| m.surrender_focus(focused));+        } else if take_tab(ui) {+            // (egui gave a control the focus for this very Tab, before the sidebar was drawn: it leaves)+            app.ui.sidebar_keys = false;+            ctx.memory_mut(|m| m.surrender_focus(focused));+        } else {+            app.ui.sidebar_keys = false;+        }+        return;+    }+    let prev = with(ctx, |n| std::mem::take(&mut n.prev));+    // nowhere yet, or its row is gone: start at what is shown, else at the top (before the keys, so a key+    // pressed in the first frame that has rows acts from there)+    if !prev.is_empty() && !app.ui.sidebar_cursor.as_ref().is_some_and(|c| prev.iter().any(|r| &r.id == c)) {+        move_to(app, &prev, prev.iter().position(|r| r.selected).unwrap_or(0));+    }+    if !began {+        keys(app, ui, &prev);+    }+    with(ctx, |n| n.prev = prev);+}++/// A Tab the sidebar took still moves egui's focus on (egui reads it before the sidebar is drawn): cancel that+/// move, so the keys go back to the photos and not to the first control.+fn no_focus_move(ctx: &egui::Context) {+    ctx.memory_mut(|m| {+        m.move_focus(egui::FocusDirection::None);+        if let Some(f) = m.focused() {+            m.surrender_focus(f);+        }+    });+}++/// Whether this frame has a Tab (or Shift+Tab) press, taken out so that nothing else reads it.+fn take_tab(ui: &egui::Ui) -> bool {+    ui.input_mut(|i| {+        let mut found = false;+        i.events.retain(|e| match e {+            egui::Event::Key { key: Key::Tab, pressed: true, modifiers, .. } if modifiers.is_none() || modifiers.shift_only() => {+                found = true;+                false+            }+            _ => true,+        });+        found+    })+}++/// This frame's key presses, one after the other, on the rows of the last frame.+fn keys(app: &mut LightcraftApp, ui: &egui::Ui, rows: &[NavRow]) {+    let ctx = ui.ctx();+    let Some(mut at) = app.ui.sidebar_cursor.as_ref().and_then(|c| rows.iter().position(|r| &r.id == c)) else {+        // no row to act on (none was drawn: an empty sidebar, or another library's just opened): Esc and Tab still leave+        let leaves = ui.input_mut(|i| {+            let mut leaves = false;+            i.events.retain(|e| match e {+                egui::Event::Key { key: key @ (Key::Escape | Key::Tab), pressed: true, modifiers, .. }+                    if modifiers.is_none() || (*key == Key::Tab && modifiers.shift_only()) =>+                {+                    leaves = true;+                    false+                }+                _ => true,+            });+            leaves+        });+        if leaves {+            app.ui.sidebar_keys = false;+            crate::widgets::take_escape(ctx);+            no_focus_move(ctx);+        }+        return;+    };+    let (typed, now) = ui.input_mut(|i| {+        const KEYS: [Key; 13] = [+            Key::ArrowDown,+            Key::ArrowUp,+            Key::ArrowLeft,+            Key::ArrowRight,+            Key::Home,+            Key::End,+            Key::PageUp,+            Key::PageDown,+            Key::Enter,+            Key::Space,+            Key::Escape,+            Key::F2,+            Key::Tab,+        ];+        let mut typed = Vec::new();+        // presses with no modifier at all are the sidebar's (Tab also with Shift: either way it+        // leaves); taken out of the events so nothing else acts on them+        i.events.retain(|e| match e {+            egui::Event::Key { key, pressed: true, modifiers, .. }+                if KEYS.contains(key) && (modifiers.is_none() || (*key == Key::Tab && modifiers.shift_only())) =>+            {+                typed.push(Typed::Key(*key));+                false+            }+            egui::Event::Text(t) if !(i.modifiers.command || i.modifiers.ctrl || i.modifiers.alt) => {+                typed.push(Typed::Text(t.clone()));+                false+            }+            _ => true,+        });+        (typed, i.time)+    });+    let last = rows.len().saturating_sub(1);+    for t in typed {+        let Some(row) = rows.get(at) else { return };+        // a key that is no letter ends the word being typed (a space inside one is a letter)+        let in_word = with(ctx, |n| !n.typed.is_empty() && now - n.typed_at <= TYPE_AHEAD_SECONDS);+        if matches!(t, Typed::Key(k) if !(k == Key::Space && in_word)) {+            with(ctx, |n| n.typed.clear());+        }+        let to = match t {+            Typed::Key(Key::ArrowDown) => Some((at + 1).min(last)),+            Typed::Key(Key::ArrowUp) => Some(at.saturating_sub(1)),+            Typed::Key(Key::PageDown) => Some(at.saturating_add(PAGE).min(last)),+            Typed::Key(Key::PageUp) => Some(at.saturating_sub(PAGE)),+            Typed::Key(Key::Home) => Some(0),+            Typed::Key(Key::End) => Some(last),+            Typed::Key(Key::ArrowRight) => match row.open {+                Some(false) => return ask(ctx, row, Ask::Fold),+                // open: on to the first row inside it, if it has one+                Some(true) => {+                    (at < last && rows.get(at + 1).is_some_and(|next| !next.header && (row.header || next.indent > row.indent))).then_some(at + 1)+                }+                None => None,+            },+            Typed::Key(Key::ArrowLeft) => match row.open {+                Some(true) => return ask(ctx, row, Ask::Fold),+                _ => parent_of(rows, at),+            },+            // (a space while a word is being typed is part of the word when a row goes on so,+            // "blue hour"; after "p" on Picks it chooses)+            Typed::Key(Key::Space)+                if in_word+                    && with(ctx, |n| rows.iter().any(|r| !r.header && name_of(&r.label).to_lowercase().starts_with(&format!("{} ", n.typed)))) =>+            {+                type_more(ctx, rows, at, " ", now)+            }+            // what the row does next is the row's: later keys of this frame wait for nothing+            Typed::Key(Key::Enter | Key::Space) => return ask(ctx, row, Ask::Choose),+            Typed::Key(Key::F2) => return rename(app, ctx, &row.id),+            Typed::Key(Key::Escape) => {+                app.ui.sidebar_keys = false;+                crate::widgets::take_escape(ctx);+                return;+            }+            // Tab leaves, as it leaves any list: the keys go back to the photos+            Typed::Key(Key::Tab) => {+                app.ui.sidebar_keys = false;+                no_focus_move(ctx);+                return;+            }+            Typed::Key(_) => None,+            Typed::Text(text) => {+                let letters: String = text.chars().filter(|c| !c.is_control() && *c != ' ').collect();+                if letters.is_empty() { None } else { type_more(ctx, rows, at, &letters, now) }+            }+        };+        if let Some(to) = to {+            move_to(app, rows, to);+            at = to;+        }+    }+}++/// `more` was typed at `now` with the cursor on row `at`: the row to go to, if any.+fn type_more(ctx: &egui::Context, rows: &[NavRow], at: usize, more: &str, now: f64) -> Option<usize> {+    let word = with(ctx, |n| {+        if now - n.typed_at > TYPE_AHEAD_SECONDS {+            n.typed.clear();+        }+        n.typed_at = now;+        let room = TYPE_AHEAD_MAX.saturating_sub(n.typed.chars().count());+        n.typed.extend(more.to_lowercase().chars().take(room));+        n.typed.clone()+    });+    type_ahead(rows, at, &word)+}++/// The row typing `word` goes to from row `at`: the next one down whose name starts so, round+/// past the end; headers are not typed to. One letter (also the same letter again and again:+/// `s`, `ss`, `sss`) moves on to the next such row each time; a longer word narrows from the+/// row the cursor is on.+fn type_ahead(rows: &[NavRow], at: usize, word: &str) -> Option<usize> {+    let first = word.chars().next()?;+    if rows.is_empty() {+        return None;+    }+    let one_letter = word.chars().all(|c| c == first);+    let (from, word) = if one_letter { (at + 1, first.to_string()) } else { (at, word.to_string()) };+    (0..rows.len())+        .map(|k| (from + k) % rows.len())+        .find(|i| rows.get(*i).is_some_and(|r| !r.header && name_of(&r.label).to_lowercase().starts_with(&word)))+}++/// What a row is called, without the mark the sidebar draws before a smart album whose rules need fixing+/// (`left::albums`): typing the album's name finds it.+fn name_of(label: &str) -> &str {+    label.strip_prefix("⚠ ").unwrap_or(label)+}++/// The row `at` is inside: the nearest one above it that is indented less, or its section's+/// header. None for a header and for the rows above every section (All Photos, Picks…).+fn parent_of(rows: &[NavRow], at: usize) -> Option<usize> {+    let row = rows.get(at)?;+    if row.header {+        return None;+    }+    (0..at).rev().find(|i| rows.get(*i).is_some_and(|r| r.header || r.indent < row.indent))+}++fn move_to(app: &mut LightcraftApp, rows: &[NavRow], to: usize) {+    if let Some(row) = rows.get(to) {+        if app.ui.sidebar_cursor.as_deref() != Some(row.id.as_str()) {+            app.ui.sidebar_cursor_moved = true;+        }+        app.ui.sidebar_cursor = Some(row.id.clone());+    }+}++fn ask(ctx: &egui::Context, row: &NavRow, what: Ask) {+    with(ctx, |n| n.ask = Some((row.id.clone(), what)));+}++/// F2 on a row that has a name of its own: the dialog its menu's Rename opens (a Local place is offered under its+/// folder's own name, where the menu offers the name the row shows, which may be translated).+fn rename(app: &mut LightcraftApp, ctx: &egui::Context, id: &str) {+    use crate::state::Dialog;+    // (a second row of one Local folder carries its number after `TWIN`: the same folder)+    let id = id.split_once(TWIN).map_or(id, |(id, _)| id);+    // a folder renamed on disk, as Rename Folder… of its menu does (Local's by its name, a library folder's by its last two)+    let folder = |path: &str, called: String| Dialog::TextPrompt {+        title: crate::i18n::tr_format!("Rename “{name}”", name = called),+        hint: "Folder name (renamed on disk; its photos follow)".into(),+        value: std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()),+        command: "folder.rename".into(),+        params: serde_json::json!({"path": path}),+        key: "name".into(),+    };+    // (an album's row is `album:<id>`, a folder of albums' `folder:<id>`)+    let dialog = if let Some(album) = id.strip_prefix("album:").or_else(|| id.strip_prefix("folder:")).and_then(|n| n.parse::<u64>().ok()) {+        let name = app.session.catalog.albums().find(|a| a.id.0 == album).map(|a| a.name.clone());+        name.map(|name| Dialog::RenameAlbum { id: album, name })+    } else if let Some(path) = id.strip_prefix("keyword:") {+        Some(Dialog::RenameKeyword { from: path.to_string(), to: path.to_string() })+    } else if let Some(path) = id.strip_prefix("libfolder:").filter(|p| !lightcraft_catalog::folders::is_disk_root(p)) {+        Some(folder(path, lightcraft_catalog::folders::folder_label(path)))+    } else if let Some(path) = id.strip_prefix("local:").filter(|p| std::path::Path::new(p).is_absolute()) {+        let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string());+        name.map(|name| folder(path, name))+    } else {+        None+    };+    match dialog {+        Some(d) => app.ui.dialog = Some(d),+        None => app.toast(ctx, crate::i18n::tr("This row has no name to change")),+    }+}++/// What the keys make of a row as it is drawn.+#[derive(Clone, Copy, Default)]+pub(crate) struct Noted {+    /// The cursor is on it: it is drawn ringed.+    pub cursor: bool,+    /// A key asked for it to be chosen (headers: folded or unfolded) this frame.+    pub chosen: bool,+}++/// A row (or, with `header`, a section's header) is being drawn: `id` as in its widget id,+/// `label` what it says, `resp` its response. With the keys elsewhere this is one comparison:+/// a library's every row passes through here each frame.+#[allow(clippy::too_many_arguments)]+pub(crate) fn note_row(+    app: &mut LightcraftApp,+    ui: &egui::Ui,+    id: &str,+    label: &str,+    indent: f32,+    header: bool,+    open: Option<bool>,+    selected: bool,+    resp: &egui::Response,+) -> Noted {+    if !app.ui.sidebar_keys {+        return Noted::default();+    }+    let (id, asked) = with(ui.ctx(), |n| {+        // a second row of one Local folder is a row of its own to the keys+        let nth = if id.starts_with("local:") {+            let seen = n.twins.entry(id.to_string()).or_insert(0);+            *seen = seen.saturating_add(1);+            *seen+        } else {+            1+        };+        let id = if nth <= 1 { id.to_string() } else { format!("{id}{TWIN}{nth}") };+        let mine = n.ask.as_ref().is_some_and(|(row, what)| *row == id && (*what == Ask::Choose || header));+        if mine {+            n.ask = None;+        }+        // (a header that was asked folds or unfolds as it is drawn: the keys' next press sees it as it is now, not as it was)+        let open = open.map(|o| o != (mine && header));+        n.rows.push(NavRow { id: id.clone(), label: label.to_string(), indent, header, open, selected });+        (id, mine)+    });+    if asked {+        ui.ctx().data_mut(|d| d.insert_temp(nav_id(), Now { on: true, chose: Some(resp.id) }));+    }+    // a click puts the cursor where the pointer went, so keys go on from there+    if resp.clicked() {+        app.ui.sidebar_cursor = Some(id.clone());+    }+    let cursor = app.ui.sidebar_cursor.as_deref() == Some(id.as_str());+    if cursor && std::mem::take(&mut app.ui.sidebar_cursor_moved) {+        // into view at once (a key held down moves row by row, and an eased scroll would trail+        // behind it), and clear of the header pinned over the top rows+        let clear = if header { 0.0 } else { super::left::SECTION_HEADER };+        let rect = Rect::from_min_max(resp.rect.min - egui::vec2(0.0, clear), resp.rect.max);+        ui.scroll_to_rect_animation(rect, None, egui::style::ScrollAnimation::none());+    }+    Noted { cursor, chosen: asked }+}++/// A row that is out of sight and not drawn (`left::out_of_sight`) is still one of the keys'+/// rows: the arrows pass over it and typing finds it. Returns whether it has to be drawn after+/// all: the cursor is on it (it is then scrolled to), or a key asked something of it. `selected`+/// is asked only for a row that is listed (the mode is on), so a sidebar without it pays nothing. A second row+/// of one Local folder is counted as [`note_row`] counts it (a row that has to be drawn after all is counted+/// there, not here).+pub(crate) fn note_unseen(app: &LightcraftApp, ui: &egui::Ui, id: &str, label: &str, indent: f32, selected: impl FnOnce() -> bool) -> bool {+    if !app.ui.sidebar_keys {+        return false;+    }+    with(ui.ctx(), |n| {+        let local = id.starts_with("local:");+        let nth = if local { n.twins.get(id).copied().unwrap_or(0).saturating_add(1) } else { 1 };+        let id_of_row = if nth <= 1 { id.to_string() } else { format!("{id}{TWIN}{nth}") };+        if app.ui.sidebar_cursor.as_deref() == Some(id_of_row.as_str()) || n.ask.as_ref().is_some_and(|(row, _)| *row == id_of_row) {+            return true;+        }+        if local {+            n.twins.insert(id.to_string(), nth);+        }+        n.rows.push(NavRow { id: id_of_row, label: label.to_string(), indent, header: false, open: None, selected: selected() });+        false+    })+}++/// The row drawn last can be opened and is `open`; returns whether a key asked to fold or+/// unfold it (the caller flips it, as for a click on its triangle).+pub(crate) fn note_open(ui: &egui::Ui, open: bool) -> bool {+    if !now(ui.ctx()).on {+        return false;+    }+    with(ui.ctx(), |n| {+        let Some(row) = n.rows.last_mut() else { return false };+        let asked = n.ask.as_ref().is_some_and(|(id, what)| *id == row.id && *what == Ask::Fold);+        if asked {+            n.ask = None;+        }+        row.open = Some(open != asked);+        asked+    })+}++/// Whether row `resp` was chosen: clicked, or chosen with Enter / Space while the keys are the+/// sidebar's.+pub(crate) fn chosen(ui: &egui::Ui, resp: &egui::Response) -> bool {+    resp.clicked() || now(ui.ctx()).chose == Some(resp.id)+}++/// The ring round the row the cursor is on (`bar`: the row's highlight bar): 2 pt in the text's+/// own color, drawn inside `bar`, so it shows on the row that is shown (a filled bar) and on one that is not.+pub(crate) fn ring(ui: &egui::Ui, bar: Rect) {+    let t = crate::theme::Tokens::get(ui.ctx());+    ui.painter().rect_stroke(bar, 4.0, egui::Stroke::new(2.0, t.text), egui::StrokeKind::Inside);+    crate::widgets::register(ui, "sidebarCursor", bar);+}++#[cfg(test)]+mod tests {+    use super::*;++    fn rows() -> Vec<NavRow> {+        let r = |id: &str, label: &str, indent: f32, header: bool| NavRow {+            id: id.into(),+            label: label.into(),+            indent,+            header,+            open: None,+            selected: false,+        };+        vec![+            r("all", "All Photos", 0.0, false),+            r("sidebarSection:albums", "Albums", 0.0, true),+            r("album:1", "Travel", 0.0, false),+            r("album:2", "Lisbon", 16.0, false),+            r("album:3", "Tokyo", 16.0, false),+            r("album:4", "Weddings", 0.0, false),+            r("sidebarSection:keywords", "Keywords", 0.0, true),+            r("keyword:travel", "travel", 0.0, false),+        ]+    }++    #[test]+    fn left_goes_to_the_row_a_row_is_inside_then_to_its_sections_header() {+        let rows = rows();+        assert_eq!(parent_of(&rows, 4), Some(2), "Tokyo is inside Travel");+        assert_eq!(parent_of(&rows, 2), Some(1), "Travel is in Albums");+        assert_eq!(parent_of(&rows, 1), None, "a header is inside nothing");+        assert_eq!(parent_of(&rows, 0), None, "All Photos is in no section");+        assert_eq!(parent_of(&rows, 99), None, "a row that is not there");+    }++    #[test]+    fn typing_goes_to_the_next_row_that_starts_so_and_wraps() {+        let rows = rows();+        assert_eq!(type_ahead(&rows, 0, "t"), Some(2), "Travel");+        assert_eq!(type_ahead(&rows, 2, "t"), Some(4), "the same letter again moves on: Tokyo");+        assert_eq!(type_ahead(&rows, 4, "t"), Some(7), "then the keyword, whatever its case");+        assert_eq!(type_ahead(&rows, 7, "t"), Some(2), "and round to the first again");+        assert_eq!(type_ahead(&rows, 2, "to"), Some(4), "a second letter narrows from where the first landed");+        assert_eq!(type_ahead(&rows, 0, "a"), Some(0), "wrapping back to the row itself when it is the only one (All Photos; never a header)");+        assert_eq!(type_ahead(&rows, 2, "tt"), Some(4), "the same letter typed again, still within the second, moves on too");+        assert_eq!(type_ahead(&rows, 4, "ttt"), Some(7));+        assert_eq!(type_ahead(&rows, 0, "zz"), None);+        assert_eq!(type_ahead(&rows, 0, ""), None);+        assert_eq!(type_ahead(&[], 0, "t"), None);+    }+}diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs--- a/crates/ui-egui/src/state.rs+++ b/crates/ui-egui/src/state.rs@@ -120,6 +120,20 @@ pub struct UiState {     pub face_boxes: bool,     /// The left sidebar's sections: their order, which are hidden and which are folded shut.     pub sidebar: crate::sidebar::SidebarLayout,+    /// Keyboard in Sidebar (`view.sidebarKeys`): the arrow keys, Enter, typing and F2 are the left+    /// sidebar's (see [`crate::panels::side_keys`]). A mode of the moment: never saved.+    #[serde(skip)]+    pub sidebar_keys: bool,+    /// The row the sidebar's keys are on (its widget id without `source:`, or a header's id).+    #[serde(skip)]+    pub sidebar_cursor: Option<String>,+    /// Frames the sidebar has left to appear in after the keys were asked into it while it was+    /// hidden, before they are given back (see [`crate::panels::side_keys::owns_keys`]).+    #[serde(skip)]+    pub sidebar_keys_grace: u8,+    /// The cursor moved by a key: its row scrolls into view when it is drawn next.+    #[serde(skip)]+    pub sidebar_cursor_moved: bool,     /// What settings files said before [`UiState::sidebar`]: the ids of the folded sections. Read     /// so they stay folded ([`UiState::sanitized`] moves them over), never written.     #[serde(skip_serializing)]@@ -405,6 +419,10 @@ impl Default for UiState {             show_counts: true,             face_boxes: true,             sidebar: Default::default(),+            sidebar_keys: false,+            sidebar_cursor: None,+            sidebar_keys_grace: 0,+            sidebar_cursor_moved: false,             collapsed_sidebar: Vec::new(),             name_edit: None,             person_page: None,diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs--- a/crates/ui-egui/src/tests.rs+++ b/crates/ui-egui/src/tests.rs@@ -68,6 +68,7 @@ mod rename_person; mod rules_editor; mod sam_setup; mod scroll;+mod side_keys; mod slider_reset; mod slideshow; mod solo_sections;diff --git a/crates/ui-egui/src/tests/side_keys.rs b/crates/ui-egui/src/tests/side_keys.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/tests/side_keys.rs@@ -0,0 +1,984 @@+//! The core of Keyboard in Sidebar (`panels::side_keys`, LR-BEHAV-SIDEBAR-KEYS), with no sidebar of the+//! app drawn: a small stand-in sidebar of rows is drawn the way the real one calls the core (`begin`,+//! then `note_row` / `note_open` / `note_unseen` / `chosen` / `ring` for each row), and the keys are+//! pressed on it frame by frame.+//!+//! - Off: no cursor, and every key is left to whoever read it before.+//! - On: a cursor starts on the row of what is shown; the arrow keys, Home, End and Page Up / Down+//!   move it, Enter and Space choose its row as a click does, Left / Right fold, unfold, and step+//!   in and out, typing goes to the next row that starts so, F2 opens the row's rename dialog.+//! - It ends on Esc and Tab, on a click outside the sidebar, when a field takes the keys, and when+//!   the sidebar is no longer drawn; keys with Cmd / Ctrl / Alt are not the sidebar's.++use std::collections::{HashMap, HashSet};++use egui::{Event, Key, Modifiers, Pos2, Rect, Sense, vec2};++use crate::panels::side_keys::{begin, chosen, forget_rows, note_open, note_row, note_unseen, owns_keys, ring};+use crate::state::Dialog;+use crate::{LightcraftApp, Services};++const ROW: f32 = 24.0;+/// What a header pinned over the top rows covers (`left::SECTION_HEADER`, which the core clears).+const PINNED: f32 = 34.0;+const FIELD: &str = "side-keys-test-field";++/// One row of the stand-in sidebar.+#[derive(Clone)]+struct Row {+    id: String,+    label: String,+    indent: f32,+    header: bool,+    /// Has a triangle (a folder of albums): Left / Right fold it. A header folds too.+    foldable: bool,+    /// Out of sight and so not drawn, as `left::out_of_sight` leaves rows (unless the keys ask for it).+    unseen: bool,+}++fn row(id: &str, label: &str) -> Row {+    Row { id: id.into(), label: label.into(), indent: 0.0, header: false, foldable: false, unseen: false }+}++impl Row {+    fn at(mut self, indent: f32) -> Self {+        self.indent = indent;+        self+    }+    fn header(mut self) -> Self {+        self.header = true;+        self.foldable = true;+        self+    }+    fn fold(mut self) -> Self {+        self.foldable = true;+        self+    }+    fn unseen(mut self) -> Self {+        self.unseen = true;+        self+    }+}++/// The rows the tests use unless they say otherwise (index: row).+/// 0 all, 1 recentlyAdded, 2 picks, 3 recentlyDeleted, 4 Albums (header), 5 Travel (a folder), 6 Lisbon and 7 Tokyo+/// inside it, 8 Weddings, 9 Keywords (header), 10 travel, 11 zebra (out of sight).+fn standard() -> Vec<Row> {+    vec![+        row("all", "All Photos"),+        row("recentlyAdded", "Recently Added"),+        row("picks", "Picks"),+        row("recentlyDeleted", "Recently Deleted"),+        row("sidebarSection:albums", "Albums").header(),+        row("album:1", "Travel").fold(),+        row("album:2", "Lisbon").at(16.0),+        row("album:3", "Tokyo").at(16.0),+        row("album:4", "Weddings"),+        row("sidebarSection:keywords", "Keywords").header(),+        row("keyword:travel", "travel"),+        row("keyword:zebra", "zebra").unseen(),+    ]+}++/// The stand-in sidebar, 200 pt wide at the left of a 600 pt window, with the app state the keys use.+struct Sidebar {+    ctx: egui::Context,+    app: LightcraftApp,+    rows: Vec<Row>,+    /// The id of the row that is shown (what the grid shows).+    shown: String,+    /// Foldable rows that are folded shut.+    closed: HashSet<String>,+    visible: bool,+    /// A text field outside the sidebar, drawn when set.+    field: bool,+    field_text: String,+    view_h: f32,+    queued: Vec<Event>,+    time: f64,+    // what the last frame showed+    owned: bool,+    drawn: Vec<String>,+    rects: HashMap<String, Rect>,+    /// The ids of the rows whose choosing was asked for.+    chosen: Vec<String>,+    /// The row ringed, by id and by where it is.+    ring: Option<(String, Rect)>,+    left_keys: Vec<Key>,+    left_text: Vec<String>,+    offset: f32,+    /// What the scroll area shows of its rows.+    inner: Rect,+}++fn panel() -> Rect {+    Rect::from_min_size(Pos2::ZERO, vec2(200.0, 600.0))+}++impl Sidebar {+    fn new(rows: Vec<Row>) -> Self {+        let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });+        let mut s = Self {+            ctx: egui::Context::default(),+            app,+            rows,+            shown: "all".into(),+            closed: HashSet::new(),+            visible: true,+            field: false,+            field_text: String::new(),+            view_h: 600.0,+            queued: Vec::new(),+            time: 1.0,+            owned: false,+            drawn: Vec::new(),+            rects: HashMap::new(),+            chosen: Vec::new(),+            ring: None,+            left_keys: Vec::new(),+            left_text: Vec::new(),+            offset: 0.0,+            inner: Rect::NOTHING,+        };+        s.frame();+        s+    }++    /// One frame, in the order the app has: the shortcut pass asks who owns the keys, then the sidebar is drawn.+    fn frame(&mut self) {+        let raw = egui::RawInput {+            events: std::mem::take(&mut self.queued),+            time: Some(self.time),+            screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 700.0))),+            ..Default::default()+        };+        self.time += 0.05;+        let Self {+            ctx,+            app,+            rows,+            shown,+            closed,+            visible,+            field,+            field_text,+            view_h,+            owned,+            drawn,+            rects,+            chosen: chose,+            ring: ringed,+            left_keys,+            left_text,+            offset,+            inner,+            ..+        } = self;+        drawn.clear();+        rects.clear();+        left_keys.clear();+        left_text.clear();+        *ringed = None;+        let mut out = ctx.run_ui(raw, |ui| {+            let ctx = ui.ctx().clone();+            ui.spacing_mut().item_spacing.y = 0.0;+            *owned = owns_keys(app, &ctx);+            if *visible {+                begin(app, ui, panel());+                let mut in_view = Rect::NOTHING;+                let out = egui::ScrollArea::vertical().id_salt("stand-in").max_height(*view_h).auto_shrink(false).show(ui, |ui| {+                    in_view = ui.clip_rect();+                    let mut skip: Option<(f32, bool)> = None;+                    for r in rows.iter() {+                        if let Some((indent, header)) = skip {+                            if !r.header && (header || r.indent > indent) {+                                continue;+                            }+                            skip = None;+                        }+                        let open = !closed.contains(&r.id);+                        let (_, rect) = ui.allocate_space(vec2(190.0, ROW));+                        if r.unseen && !note_unseen(app, ui, &r.id, &r.label, r.indent, || *shown == r.id) {+                            continue;+                        }+                        let resp = ui.interact(rect, ui.scope_id().with(&r.id), Sense::click());+                        let noted = note_row(app, ui, &r.id, &r.label, r.indent, r.header, r.header.then_some(open), *shown == r.id, &resp);+                        let flip = |closed: &mut HashSet<String>| {+                            if !closed.remove(&r.id) {+                                closed.insert(r.id.clone());+                            }+                        };+                        // (as `left::tree_toggle`, and as a header or a folder row is chosen)+                        let mut toggled = false;+                        if r.foldable && !r.header && note_open(ui, open) {+                            flip(closed);+                            toggled = true;+                        }+                        if r.header {+                            if noted.chosen || resp.clicked() {+                                flip(closed);+                            }+                        } else if chosen(ui, &resp) {+                            chose.push(r.id.clone());+                            if r.foldable && !toggled {+                                flip(closed);+                            }+                        }+                        if noted.cursor {+                            ring(ui, rect);+                            *ringed = Some((r.id.clone(), rect));+                        }+                        drawn.push(r.id.clone());+                        rects.insert(r.id.clone(), rect);+                        // (a header's rows follow its state now; a folder's, as in `albums_tree`, the state it was drawn with)+                        let hides = if r.header { closed.contains(&r.id) } else { !open };+                        if r.foldable && hides {+                            skip = Some((r.indent, r.header));+                        }+                    }+                });+                *offset = out.state.clamped_offset().y;+                *inner = in_view;+            }+            if *field {+                ui.add(egui::TextEdit::singleline(field_text).id(egui::Id::unique(FIELD)));+            }+            ui.input(|i| {+                for e in &i.events {+                    match e {+                        Event::Key { key, pressed: true, .. } => left_keys.push(*key),+                        Event::Text(t) => left_text.push(t.clone()),+                        _ => {}+                    }+                }+            });+        });+        out.textures_delta.clear();+    }++    /// What `view.sidebarKeys` (turning the mode on) does to the app's state.+    fn turn_on(&mut self) {+        self.app.ui.sidebar_keys = true;+        self.app.ui.sidebar_cursor = None;+        self.app.ui.sidebar_cursor_moved = true;+        self.app.ui.sidebar_keys_grace = 4;+    }++    /// The mode turned on, then two frames.+    fn enter(&mut self) {+        self.turn_on();+        self.frame();+        self.frame();+    }++    fn press(&mut self, key: Key) {+        self.queue_key(key, Modifiers::NONE);+        self.frame();+    }++    /// The modifier keys held from now on.+    fn hold(&mut self, mods: Modifiers) {+        self.queued.push(Event::ModifiersChanged(mods));+        self.frame();+    }++    fn queue_key(&mut self, key: Key, modifiers: Modifiers) {+        self.queued.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });+    }++    fn text(&mut self, t: &str) {+        self.queued.push(Event::Text(t.into()));+        self.frame();+    }++    /// A click at `pos`: pressed in one frame, released in the next.+    fn click(&mut self, pos: Pos2) {+        self.queued.push(Event::PointerMoved(pos));+        self.queued.push(Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });+        self.frame();+        self.queued.push(Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });+        self.frame();+    }++    fn click_row(&mut self, id: &str) {+        let at = self.rects.get(id).unwrap_or_else(|| panic!("{id} is not drawn")).center();+        self.click(at);+    }++    /// Frames for a scroll the cursor asked for to be done (the scroll area takes a request in the frame after it).+    fn settle(&mut self) {+        self.frame();+        self.frame();+    }++    /// The id of the row the ring is round.+    fn at(&self) -> Option<&str> {+        self.ring.as_ref().map(|(id, _)| id.as_str())+    }++    fn on(&self) -> bool {+        self.app.ui.sidebar_keys+    }++    fn said(&self) -> String {+        self.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default()+    }++    fn open_rename_of(&mut self, id: &str) -> Option<Dialog> {+        self.app.ui.sidebar_cursor = Some(id.into());+        self.frame();+        self.press(Key::F2);+        let dialog = self.app.ui.dialog.take();+        self.frame();+        dialog+    }+}++/// Given the mode was never asked for, then no row is ringed, no key is taken, and a click on a+/// row chooses it as ever without taking the keys.+#[test]+fn off_there_is_no_cursor_and_every_key_is_left_alone() {+    let mut s = Sidebar::new(standard());+    s.frame();+    s.press(Key::ArrowDown);+    assert_eq!(s.left_keys, [Key::ArrowDown], "the arrow was not taken");+    s.press(Key::Enter);+    assert_eq!(s.left_keys, [Key::Enter]);+    s.text("p");+    assert_eq!(s.left_text, ["p"], "the typed letter was not taken");+    for key in [Key::Escape, Key::Tab] {+        s.press(key);+        assert_eq!(s.left_keys, [key], "{key:?} was not taken: it closes a dialog, or goes on to the next control");+    }+    assert!(!s.owned, "no key is the sidebar's");+    assert_eq!((s.at(), s.app.ui.sidebar_cursor.as_deref()), (None, None));+    assert!(s.chosen.is_empty());+    s.click_row("picks");+    assert_eq!(s.chosen, ["picks"], "a click chooses");+    assert!(!s.on(), "a click on a row does not take the keys");+    assert_eq!(s.app.ui.sidebar_cursor, None);+    assert_eq!(s.said(), "", "and nothing is said");+}++/// Given the mode is turned on, then the cursor is on the row of what is shown and the mode says how it ends;+/// Down and Up move it and choose nothing; Enter and Space choose the row as a click does; Home, End+/// and the page keys go far; the top and the bottom stop it.+#[test]+fn the_cursor_starts_on_the_shown_row_and_the_keys_move_and_choose() {+    let mut s = Sidebar::new(standard());+    s.shown = "picks".into();+    s.enter();+    assert!(s.on());+    assert_eq!(s.at(), Some("picks"), "it starts where the grid is");+    assert!(s.said().contains("Esc"), "the mode says how it ends: {:?}", s.said());+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("recentlyDeleted"));+    assert!(s.chosen.is_empty(), "moving the cursor chooses nothing");+    s.press(Key::ArrowUp);+    s.press(Key::Enter);+    assert_eq!(s.chosen, ["picks"], "Enter is a click on the row");+    s.press(Key::ArrowUp);+    s.press(Key::Space);+    assert_eq!(s.chosen, ["picks", "recentlyAdded"], "so is Space");+    assert!(s.left_keys.is_empty(), "the keys were the sidebar's");+    s.press(Key::Home);+    assert_eq!(s.at(), Some("all"));+    s.press(Key::ArrowUp);+    assert_eq!(s.at(), Some("all"), "the top is the top");+    s.press(Key::PageDown);+    assert_eq!(s.at(), Some("keyword:travel"), "a page is ten rows");+    s.press(Key::PageDown);+    assert_eq!(s.at(), Some("keyword:zebra"), "and stops at the last row, which is drawn when the cursor is on it though it was out of sight");+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("keyword:zebra"), "the bottom is the bottom");+    s.press(Key::PageUp);+    assert_eq!(s.at(), Some("recentlyAdded"));+    s.press(Key::End);+    assert_eq!(s.at(), Some("keyword:zebra"));+    s.press(Key::Home);+    assert_eq!(s.at(), Some("all"));+    let (_, ring) = s.ring.clone().expect("a ring");+    assert_eq!(+        crate::widgets::take_registry(&s.ctx).iter().rfind(|(w, _)| w == "sidebarCursor").map(|(_, r)| *r),+        Some(ring),+        "the ring is registered by its widget id"+    );+}++/// Left and Right fold and unfold what the cursor is on (a section by its header, a folder of+/// albums by its row) and step into and out of it.+#[test]+fn left_and_right_fold_unfold_and_step_in_and_out() {+    let mut s = Sidebar::new(standard());+    s.shown = "album:3".into();+    s.enter();+    assert_eq!(s.at(), Some("album:3"));+    s.press(Key::ArrowLeft);+    assert_eq!(s.at(), Some("album:1"), "Left steps out to the folder it is in");+    s.press(Key::ArrowLeft);+    s.frame();+    assert!(!s.drawn.contains(&"album:2".to_string()), "Left on an open folder folds it");+    assert_eq!(s.at(), Some("album:1"));+    assert!(s.chosen.is_empty(), "folding is a click on the triangle, not on the row");+    s.press(Key::ArrowRight);+    s.frame();+    assert!(s.drawn.contains(&"album:2".to_string()), "Right unfolds it");+    s.press(Key::ArrowRight);+    assert_eq!(s.at(), Some("album:2"), "Right on an open folder steps in");+    s.press(Key::ArrowRight);+    assert_eq!(s.at(), Some("album:2"), "a row with nothing in it stays");+    s.press(Key::ArrowLeft);+    s.press(Key::ArrowLeft);+    s.press(Key::ArrowLeft);+    assert_eq!(s.at(), Some("sidebarSection:albums"), "out of the folder is the section's header");+    assert!(s.drawn.contains(&"album:4".to_string()));+    s.press(Key::ArrowLeft);+    assert!(!s.drawn.contains(&"album:4".to_string()), "Left on a header folds the section");+    s.press(Key::ArrowLeft);+    assert_eq!(s.at(), Some("sidebarSection:albums"), "a header is inside nothing");+    s.press(Key::Enter);+    assert!(s.drawn.contains(&"album:4".to_string()), "Enter on a header is a click on it");+    s.press(Key::ArrowRight);+    assert_eq!(s.at(), Some("album:1"), "Right on an open header steps in");+    s.press(Key::Enter);+    assert_eq!(s.chosen, ["album:1"], "Enter on a folder's row chooses it");+    s.frame();+    assert!(s.drawn.contains(&"album:2".to_string()), "and unfolds it (it was folded), as a click on it does");+}++/// A smart album whose rules need fixing is drawn as "⚠ name": typing its name finds it, and the mark does not.+#[test]+fn typing_finds_a_smart_album_flagged_with_a_warning_by_its_name() {+    let mut rows = standard();+    rows[8] = row("album:4", "⚠ Weddings");+    let mut s = Sidebar::new(rows);+    s.enter();+    s.text("w");+    assert_eq!(s.at(), Some("album:4"), "Weddings, whatever mark is drawn before it");+    s.time += 2.0;+    s.text("⚠");+    assert_eq!(s.at(), Some("album:4"), "the mark is not a name: the cursor stays");+    // a space typed inside a flagged name is part of the word, as in an unflagged one+    let mut rows = standard();+    rows[8] = row("album:4", "⚠ Blue hour");+    let mut s = Sidebar::new(rows);+    s.enter();+    s.text("blue");+    assert_eq!(s.at(), Some("album:4"));+    s.press(Key::Space);+    assert!(s.chosen.is_empty(), "the space went on with the word: {:?}", s.chosen);+    s.text("h");+    assert_eq!(s.at(), Some("album:4"));+}++/// A Local folder listed twice and out of sight is two rows to the keys, as when it is drawn: the arrows pass over each.+#[test]+fn a_local_folder_listed_twice_out_of_sight_is_two_rows() {+    let mut s = Sidebar::new(vec![row("all", "All Photos"), row("local:/x", "x").unseen(), row("local:/x", "x").unseen(), row("picks", "Picks")]);+    s.enter();+    assert_eq!(s.at(), Some("all"));+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("local:/x"));+    let first = s.app.ui.sidebar_cursor.clone();+    s.press(Key::ArrowDown);+    let second = s.app.ui.sidebar_cursor.clone();+    assert_ne!(second, first, "the second row is a row of its own");+    assert!(second.as_deref().is_some_and(|id| id.starts_with("local:/x")), "{second:?}");+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("picks"));+    s.press(Key::ArrowUp);+    assert_eq!(s.app.ui.sidebar_cursor, second, "and Up comes back to it");+}++/// Typing goes to the next row whose name starts so, the same letter again to the one after;+/// a pause starts a new word; the letters are the sidebar's, with a modifier they are not.+#[test]+fn typing_finds_a_row_and_the_same_letter_goes_on() {+    let mut s = Sidebar::new(standard());+    s.enter();+    s.text("t");+    assert_eq!(s.at(), Some("album:1"), "Travel");+    assert!(s.left_text.is_empty(), "the letter was the sidebar's");+    s.text("t");+    assert_eq!(s.at(), Some("album:3"), "the same letter again: Tokyo");+    s.text("t");+    assert_eq!(s.at(), Some("keyword:travel"), "then the keyword, whatever its case");+    s.text("t");+    assert_eq!(s.at(), Some("album:1"), "and round");+    s.time += 2.0;+    s.text("t");+    assert_eq!(s.at(), Some("album:3"), "after a pause a letter is a new word");+    s.text("o");+    assert_eq!(s.at(), Some("album:3"), "a second letter narrows: \"to\" is Tokyo");+    s.time += 2.0;+    s.text("w");+    assert_eq!(s.at(), Some("album:4"), "after a pause \"w\" is a new word (\"tow\" starts no row): Weddings");+    s.time += 2.0;+    s.text("z");+    assert_eq!(s.at(), Some("keyword:zebra"), "a row out of sight is found too");+    s.time += 2.0;+    s.text("q");+    assert_eq!(s.at(), Some("keyword:zebra"), "no row starts so: the cursor stays");+    assert!(s.owned, "unmodified keys are the sidebar's");+    for mods in [Modifiers::COMMAND, Modifiers::CTRL, Modifiers::ALT] {+        s.hold(mods);+        assert!(!s.owned, "{mods:?} keys stay shortcuts");+        s.text("t");+        assert_eq!(s.left_text, ["t"], "{mods:?} T was not taken");+        assert_eq!(s.at(), Some("keyword:zebra"));+    }+    s.hold(Modifiers::NONE);+    assert!(s.owned);+}++/// A word of more than one letter narrows from the row the cursor is on: after T went to Travel, R stays there ("tr"),+/// where another T would have gone on.+#[test]+fn a_longer_word_narrows_from_the_row_the_cursor_is_on() {+    let mut s = Sidebar::new(standard());+    s.enter();+    s.text("t");+    assert_eq!(s.at(), Some("album:1"), "Travel");+    s.text("r");+    assert_eq!(s.at(), Some("album:1"), "\"tr\" starts the row the cursor is on, so it stays (not the keyword below)");+    s.time += 2.0;+    s.text("t");+    assert_eq!(s.at(), Some("album:3"), "after a pause the next T is a new word and goes on: Tokyo");+}++/// Shift+Enter is not read by the sidebar (it would otherwise choose a range, which the keys+/// do not offer), nor is Cmd+Down; a space while a word is being typed belongs to the word, and after the first+/// letter of a row it chooses.+#[test]+fn shift_is_left_alone_and_a_space_inside_a_word_is_typed() {+    let mut s = Sidebar::new(standard());+    s.shown = "picks".into();+    s.enter();+    s.queue_key(Key::Enter, Modifiers::SHIFT);+    s.frame();+    assert_eq!(s.left_keys, [Key::Enter], "Shift+Enter was left alone");+    assert!(s.chosen.is_empty());+    s.queue_key(Key::ArrowDown, Modifiers::COMMAND);+    s.frame();+    assert_eq!(s.left_keys, [Key::ArrowDown], "so was Cmd+Down");+    assert_eq!(s.at(), Some("picks"));+    s.press(Key::Home);+    s.text("r");+    assert_eq!(s.at(), Some("recentlyAdded"));+    s.text("r");+    assert_eq!(s.at(), Some("recentlyDeleted"), "the same letter again moves on");+    s.press(Key::End);+    s.text("all");+    assert_eq!(s.at(), Some("all"));+    s.press(Key::Space);+    assert!(s.chosen.is_empty(), "a space inside \"all\" and the row \"All Photos\" is typed, not a choice");+    s.text("p");+    assert_eq!(s.at(), Some("all"), "\"all p\" is All Photos");+    s.press(Key::Home);+    s.text("p");+    s.press(Key::Space);+    assert_eq!(s.chosen, ["picks"], "a space after a letter no row goes on with chooses");+}++/// Keys that come in one frame act one after the other: Down, Down, Enter chooses the row the cursor+/// got to, not the one it was on.+#[test]+fn keys_of_one_frame_act_in_order() {+    let mut s = Sidebar::new(standard());+    s.enter();+    for k in [Key::ArrowDown, Key::ArrowDown, Key::Enter] {+        s.queue_key(k, Modifiers::NONE);+    }+    s.frame();+    assert_eq!(s.at(), Some("picks"));+    assert_eq!(s.chosen, ["picks"], "Enter chose where the cursor had got to");+}++/// Esc and Tab (also with Shift) give the keys back and are not passed on; the ring goes.+#[test]+fn esc_and_tab_give_the_keys_back() {+    for (key, mods) in [(Key::Escape, Modifiers::NONE), (Key::Tab, Modifiers::NONE), (Key::Tab, Modifiers::SHIFT)] {+        let mut s = Sidebar::new(standard());+        s.enter();+        s.press(Key::ArrowDown);+        s.queue_key(key, mods);+        s.frame();+        assert!(!s.on(), "{key:?} {mods:?}");+        assert!(s.left_keys.is_empty(), "{key:?} was taken");+        s.frame();+        // (egui moves the focus on a Tab by itself: it must not land on a control, or the keys are not the photos')+        assert!(s.ctx.memory(|m| m.focused()).is_none(), "{key:?} {mods:?} left the focus on a control");+        assert_eq!(s.at(), None, "the ring goes");+        assert!(!s.owned);+        s.press(Key::ArrowDown);+        assert_eq!(s.left_keys, [Key::ArrowDown], "the arrows are the photos' again");+    }+}++/// The same with no row to act on (an empty sidebar): Tab and Shift+Tab leave and hand the focus to nothing.+#[test]+fn tab_with_no_rows_gives_the_keys_back_without_moving_the_focus() {+    for mods in [Modifiers::NONE, Modifiers::SHIFT] {+        let mut s = Sidebar::new(Vec::new());+        s.enter();+        s.field = true;+        s.frame();+        s.queue_key(Key::Tab, mods);+        s.frame();+        s.frame();+        assert!(!s.on(), "{mods:?}");+        assert!(s.ctx.memory(|m| m.focused()).is_none(), "{mods:?} left the focus on a control");+    }+}++/// While an album is dragged the sidebar's keys wait, and the Esc that gives the drag up is the drag's:+/// the frame the drag ends in does not take it either, so it is not also the sidebar's.+#[test]+fn esc_during_a_drag_is_the_drags() {+    let mut s = Sidebar::new(standard());+    s.enter();+    s.app.ui.dragging_album = Some(1);+    s.press(Key::ArrowDown);+    assert_eq!(s.left_keys, [Key::ArrowDown], "no key is taken during a drag");+    assert_eq!(s.at(), Some("all"));+    s.app.ui.dragging_album = None;+    s.press(Key::Escape);+    assert!(s.on(), "the Esc that ended the drag does not end the mode");+    assert_eq!(s.left_keys, [Key::Escape], "it is left to the drag");+    s.press(Key::Escape);+    assert!(!s.on(), "the next one is the sidebar's");+}++/// The frame the mode comes on in has seen no rows and takes no key, on every entry and not only the first.+#[test]+fn the_frame_the_mode_comes_on_in_takes_no_key() {+    let mut s = Sidebar::new(standard());+    s.enter();+    s.press(Key::Escape);+    s.frame();+    s.turn_on();+    s.press(Key::Escape);+    assert!(s.on(), "the entry frame did not act on rows of nothing");+    assert_eq!(s.left_keys, [Key::Escape], "and left the key alone");+    s.press(Key::Escape);+    assert!(!s.on(), "the next frame acts on its rows");+}++/// A key pressed in the first frame that has rows acts from the shown row, not on nothing.+#[test]+fn a_key_in_the_second_frame_acts_from_the_shown_row() {+    let mut s = Sidebar::new(standard());+    s.shown = "picks".into();+    s.turn_on();+    s.frame();+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("recentlyDeleted"), "Down went on from the row that is shown");+    assert!(s.left_keys.is_empty());+}++/// A sidebar that was hidden meanwhile starts afresh: the mode asked for again puts the cursor on the row that is+/// shown now, not on the one that was shown when the sidebar went.+#[test]+fn a_sidebar_hidden_meanwhile_starts_afresh() {+    let mut s = Sidebar::new(standard());+    s.shown = "picks".into();+    s.enter();+    s.visible = false;+    for _ in 0..4 {+        s.frame();+    }+    assert!(!s.on(), "the mode ended with the sidebar");+    s.shown = "album:4".into();+    s.turn_on();+    s.visible = true;+    s.press(Key::Escape);+    assert!(s.on(), "the frame it comes on in takes no key");+    assert_eq!(s.left_keys, [Key::Escape]);+    s.frame();+    assert_eq!(s.at(), Some("album:4"), "the row shown now");+}++/// A dialog, an open menu and a dragged section have the keys meanwhile: the sidebar takes none, and has them again after.+#[test]+fn a_dialog_a_menu_and_a_drag_have_the_keys_meanwhile() {+    let mut s = Sidebar::new(standard());+    s.enter();+    s.app.ui.dialog = Some(Dialog::RenameKeyword { from: "a".into(), to: "a".into() });+    s.press(Key::ArrowDown);+    assert_eq!(s.left_keys, [Key::ArrowDown], "a dialog has the keys");+    s.app.ui.dialog = None;+    s.frame();+    egui::Popup::open_id(&s.ctx, egui::Id::unique("stand-in-menu"));+    assert!(egui::Popup::is_any_open(&s.ctx));+    s.press(Key::ArrowDown);+    assert_eq!(s.left_keys, [Key::ArrowDown], "an open menu has the keys");+    egui::Popup::close_all(&s.ctx);+    s.frame();+    s.app.ui.dragging_section = Some(crate::sidebar::SidebarSection::Albums);+    s.press(Key::ArrowDown);+    assert_eq!(s.left_keys, [Key::ArrowDown], "a dragged section has the keys");+    s.app.ui.dragging_section = None;+    s.frame();+    s.frame();+    assert_eq!(s.at(), Some("all"), "nothing moved meanwhile");+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("recentlyAdded"), "and the sidebar has them again");+    assert!(s.left_keys.is_empty());+}++/// Right on an open section with no row in it stays on its header (it does not step on to the next section's).+#[test]+fn right_on_an_open_section_with_nothing_in_it_stays() {+    let mut s = Sidebar::new(vec![+        row("all", "All Photos"),+        row("sidebarSection:albums", "Albums").header(),+        row("sidebarSection:keywords", "Keywords").header(),+        row("keyword:travel", "travel"),+    ]);+    s.enter();+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("sidebarSection:albums"));+    s.press(Key::ArrowRight);+    assert_eq!(s.at(), Some("sidebarSection:albums"), "no row inside it to step to");+}++/// With no row drawn to act on, the arrows do nothing and Esc and Tab still leave.+#[test]+fn with_no_row_to_act_on_esc_and_tab_still_leave() {+    for key in [Key::Escape, Key::Tab] {+        let mut s = Sidebar::new(Vec::new());+        s.enter();+        s.press(Key::ArrowDown);+        assert_eq!(s.left_keys, [Key::ArrowDown], "no row, no move");+        assert!(s.on());+        s.press(key);+        assert!(!s.on(), "{key:?}");+        assert!(s.left_keys.is_empty(), "{key:?} was taken");+    }+}++/// A click outside the sidebar gives the keys to what was clicked; a click on a row of it keeps them+/// and puts the cursor there; with a dialog or a menu of the sidebar open, a click elsewhere does not end the mode.+#[test]+fn a_click_elsewhere_gives_the_keys_back_and_a_click_on_a_row_moves_the_cursor() {+    let mut s = Sidebar::new(standard());+    s.enter();+    s.click_row("keyword:travel");+    assert!(s.on(), "a click in the sidebar keeps the keys there");+    assert_eq!(s.at(), Some("keyword:travel"), "and the cursor goes where the click went");+    assert_eq!(s.chosen, ["keyword:travel"]);+    s.click(Pos2::new(400.0, 300.0));+    assert!(!s.on(), "a click on the photos gives them the keys");+    s.enter();+    s.app.ui.dialog = Some(Dialog::RenameKeyword { from: "a".into(), to: "a".into() });+    s.click(Pos2::new(400.0, 300.0));+    assert!(s.on(), "a dialog has the keys meanwhile: the click is in its world");+}++/// The mode explains itself once: the first time the keys come in it says what they do; later the ring says it.+#[test]+fn the_first_time_says_what_the_keys_do() {+    let mut s = Sidebar::new(standard());+    s.enter();+    assert!(s.said().contains("Esc"), "{:?}", s.said());+    s.app.ui.toast = None;+    s.press(Key::Escape);+    s.frame();+    s.enter();+    assert!(s.on());+    assert!(s.app.ui.toast.is_none(), "the second time the ring says it");+}++/// Keys are never left with something out of sight: when the sidebar is not drawn, the mode ends after the frames+/// a hidden sidebar had to come back in; asked for while it is hidden, it has a few frames to appear.+#[test]+fn the_mode_ends_with_the_sidebar_and_a_hidden_sidebar_has_a_few_frames() {+    let mut s = Sidebar::new(standard());+    s.enter();+    s.visible = false;+    s.frame();+    s.frame();+    assert!(s.on(), "a frame or two without it is not the end");+    s.frame();+    s.frame();+    assert!(!s.on(), "no sidebar, no sidebar keys");+    assert!(!s.owned);+    // asked for while hidden+    let mut s = Sidebar::new(standard());+    s.visible = false;+    for _ in 0..8 {+        s.frame();+    }+    s.enter();+    assert!(s.on());+    s.frame();+    assert!(s.on(), "a few frames to appear in");+    assert!(!s.owned, "and no key is the sidebar's until it does");+    s.visible = true;+    s.frame();+    s.frame();+    assert!(s.on() && s.owned, "it appeared in time");+    assert_eq!(s.at(), Some("all"));+}++/// A field that takes the focus (Find, a box that was clicked) takes the keys: the mode ends and what is typed+/// goes to the field. What a dialog leaves focused as it closes is no such field: it is let go and the keys stay.+#[test]+fn a_field_that_takes_the_focus_takes_the_keys() {+    let mut s = Sidebar::new(standard());+    s.field = true;+    s.enter();+    s.ctx.memory_mut(|m| m.request_focus(egui::Id::unique(FIELD)));+    s.frame();+    s.frame();+    assert!(!s.on(), "the field has the keys");+    s.text("pets");+    assert_eq!(s.left_text, ["pets"], "what was typed was left to the field");+    assert_eq!(s.at(), None);+    // a dialog that closes leaving its field focused+    let mut s = Sidebar::new(standard());+    s.field = true;+    s.enter();+    s.app.ui.dialog = Some(Dialog::RenameKeyword { from: "a".into(), to: "a".into() });+    s.frame();+    s.ctx.memory_mut(|m| m.request_focus(egui::Id::unique(FIELD)));+    s.frame();+    s.frame();+    assert!(s.on(), "the dialog has the keys meanwhile");+    s.app.ui.dialog = None;+    s.frame();+    assert!(s.on(), "the dialog's field is not a field that took the keys");+    assert!(s.ctx.memory(|m| m.focused()).is_none(), "its focus was let go");+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("recentlyAdded"), "and the keys work");+}++/// The keys pass over rows that are out of sight and are not drawn; a row asked for is drawn after all.+#[test]+fn the_keys_reach_rows_that_are_out_of_sight() {+    let mut s = Sidebar::new(standard());+    s.enter();+    assert!(!s.drawn.contains(&"keyword:zebra".to_string()), "the last keyword is out of sight and not drawn");+    s.press(Key::End);+    assert_eq!(s.at(), Some("keyword:zebra"), "the cursor is on it, and it is drawn, ringed");+    s.press(Key::Enter);+    assert_eq!(s.chosen, ["keyword:zebra"], "and chosen, though it was out of sight");+    s.press(Key::ArrowUp);+    assert!(!s.drawn.contains(&"keyword:zebra".to_string()), "off it, it is out of sight again");+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("keyword:zebra"), "the arrows went over it");+}++/// A cursor that moves asks the scroll area to show its row (End scrolls the list), and moving up leaves the row clear+/// of the header pinned over the top rows.+#[test]+fn the_cursor_stays_in_view_and_never_under_the_pinned_header() {+    let mut s = Sidebar::new(standard());+    s.view_h = 100.0;+    s.enter();+    s.press(Key::End);+    s.settle();+    assert!(s.offset > 0.0, "End scrolled the list");+    let mut checked = 0;+    for _ in 0..8 {+        s.press(Key::ArrowUp);+        s.settle();+        let (id, ring) = s.ring.clone().expect("a cursor");+        if s.offset > 0.0 && !id.starts_with("sidebarSection:") {+            assert!(ring.top() >= s.inner.top() + PINNED - 0.5, "the ring {ring:?} is under the pinned header in {:?}", s.inner);+            checked += 1;+        }+    }+    assert!(checked > 3, "the list was scrolled while moving up: {checked}");+}++/// F2 opens the rename dialog of what the row is (an album, a folder of albums, a keyword, a library+/// folder, a Local folder) and says so for a row with no name to change.+#[test]+fn f2_renames_the_row_under_the_cursor() {+    let probe = Sidebar::new(Vec::new());+    let albums: Vec<_> = probe.app.session.catalog.albums().map(|a| (a.id.0, a.name.clone(), a.folder, a.parent.is_none())).collect();+    let (album, album_name) = albums.iter().find(|a| !a.2 && a.3).map(|a| (a.0, a.1.clone())).expect("a demo album");+    let (folder, folder_name) = albums.iter().find(|a| a.2 && a.3).map(|a| (a.0, a.1.clone())).expect("a demo album folder");+    let local = std::env::temp_dir().join("Day#2").to_string_lossy().to_string();+    let rows = vec![+        row("all", "All Photos"),+        row(&format!("album:{album}"), &album_name),+        row(&format!("folder:{folder}"), &folder_name).fold(),+        row("keyword:pets/cats", "cats"),+        row("libfolder:/photos/Trips", "Trips"),+        row("libfolder:/", "This Computer"),+        row(&format!("local:{local}"), "Day #2"),+        row(&format!("local:{local}"), "Day #2"),+        row("local:relative", "relative"),+    ];+    let mut s = Sidebar::new(rows);+    s.enter();+    s.press(Key::F2);+    assert_eq!(s.app.ui.dialog, None, "All Photos has no name to change");+    assert!(s.said().contains("no name"), "{:?}", s.said());+    assert_eq!(s.open_rename_of(&format!("album:{album}")), Some(Dialog::RenameAlbum { id: album, name: album_name }));+    assert_eq!(s.open_rename_of(&format!("folder:{folder}")), Some(Dialog::RenameAlbum { id: folder, name: folder_name }));+    assert_eq!(s.open_rename_of("keyword:pets/cats"), Some(Dialog::RenameKeyword { from: "pets/cats".into(), to: "pets/cats".into() }));+    match s.open_rename_of("libfolder:/photos/Trips") {+        Some(Dialog::TextPrompt { value, command, params, .. }) => {+            assert_eq!((value.as_str(), command.as_str()), ("Trips", "folder.rename"));+            assert_eq!(params["path"], "/photos/Trips");+        }+        other => panic!("no rename dialog: {other:?}"),+    }+    #[cfg(unix)]+    {+        s.app.ui.toast = None;+        assert_eq!(s.open_rename_of("libfolder:/"), None, "a disk's root is not renamed");+        assert!(s.said().contains("no name"), "{:?}", s.said());+    }+    // the same folder drawn twice is two rows to the keys, and F2 on either is the one folder+    s.app.ui.sidebar_cursor = Some(format!("local:{local}"));+    s.frame();+    let below = s.rects["local:relative"].top();+    let (id, ring) = s.ring.clone().expect("a cursor");+    assert_eq!(id, format!("local:{local}"));+    assert_eq!(ring.top() + 2.0 * ROW, below, "the cursor is on the first of the twins");+    s.press(Key::ArrowDown);+    let (_, ring) = s.ring.clone().expect("a cursor");+    assert_eq!(ring.top() + ROW, below, "Down goes to the second of the twins, which is a row of its own");+    s.press(Key::F2);+    match s.app.ui.dialog.take() {+        Some(Dialog::TextPrompt { value, params, .. }) => {+            assert_eq!(value, "Day#2", "the keys' own mark of a row drawn twice is no part of a name");+            assert_eq!(params["path"], local);+        }+        other => panic!("no rename dialog: {other:?}"),+    }+    s.frame();+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("local:relative"), "and Down goes on past them");+    assert_eq!(s.open_rename_of("local:relative"), None, "a Local row that is no folder has no name to change");+}++/// A cursor whose row is gone starts again at the shown row; the rows of another library are forgotten+/// ([`forget_rows`]), so a key pressed before the new sidebar is drawn acts on nothing of the old one.+#[test]+fn a_cursor_with_no_row_starts_at_the_shown_row_and_a_new_library_forgets_the_rows() {+    let mut s = Sidebar::new(standard());+    s.shown = "picks".into();+    s.enter();+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("recentlyDeleted"));+    s.rows.retain(|r| r.id != "recentlyDeleted");+    s.frame();+    s.frame();+    assert_eq!(s.at(), Some("picks"), "its row is gone: the shown row it is");+    // another library is opened: the old rows are not acted on, and the next frame starts afresh+    forget_rows(&s.ctx);+    s.app.ui.sidebar_cursor = None;+    s.rows = vec![row("all", "All Photos"), row("picks", "Picks"), row("album:9", "Other")];+    s.press(Key::ArrowDown);+    assert_eq!(s.left_keys, [Key::ArrowDown], "no row of the old library took the key");+    assert_eq!(s.app.ui.sidebar_cursor, None);+    s.frame();+    assert_eq!(s.at(), Some("picks"), "the cursor starts from the new rows");+    s.press(Key::ArrowDown);+    assert_eq!(s.at(), Some("album:9"));+}