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
@@ -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" }@@ -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" }@@ -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" }@@ -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" }@@ -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" }@@ -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" }@@ -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" }@@ -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": "この行には変更できる名前がありません" }@@ -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": "ამ მწკრივს შესაცვლელი სახელი არ აქვს" }@@ -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" }@@ -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" }@@ -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": "У этой строки нет имени, которое можно изменить" }@@ -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" }@@ -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": "Цей рядок не має назви, яку можна змінити" }@@ -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" }@@ -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": "此行没有可更改的名称" }@@ -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": "此列沒有可變更的名稱" }@@ -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;@@ -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,@@ -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.new file mode 100644@@ -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);+ }+}@@ -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,@@ -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;new file mode 100644@@ -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"));+}