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.rs
new 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.rs
new 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"));
+}
