diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index aa24e0f10..4b8fe3875 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2031,5 +2031,13 @@ "Downloading model": "Modell wird heruntergeladen", "Finding faces": "Gesichter werden gesucht", "Synchronizing folder": "Ordner wird synchronisiert", - "Looking for changes": "Änderungen werden gesucht" + "Looking for changes": "Änderungen werden gesucht", + "Sidebar Sections": "Seitenleistenbereiche", + "Reset Sidebar Sections": "Seitenleistenbereiche zurücksetzen", + "Hide This Section": "Diesen Bereich ausblenden", + "No folders to browse": "Keine Ordner zum Durchsuchen", + "No photos yet": "Noch keine Fotos", + "No imported folders yet": "Noch keine importierten Ordner", + "Show Sidebar Section": "Seitenleistenbereich anzeigen", + "Move Sidebar Section": "Seitenleistenbereich verschieben" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index c4f2e8357..5e434c565 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1876,5 +1876,13 @@ "Downloading model": "Descargando modelo", "Finding faces": "Buscando caras", "Synchronizing folder": "Sincronizando carpeta", - "Looking for changes": "Buscando cambios" + "Looking for changes": "Buscando cambios", + "Sidebar Sections": "Secciones de la barra lateral", + "Reset Sidebar Sections": "Restablecer secciones de la barra lateral", + "Hide This Section": "Ocultar esta sección", + "No folders to browse": "No hay carpetas para explorar", + "No photos yet": "Aún no hay fotos", + "No imported folders yet": "Aún no hay carpetas importadas", + "Show Sidebar Section": "Mostrar sección de la barra lateral", + "Move Sidebar Section": "Mover sección de la barra lateral" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f3422bc0a..7f61cb495 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1876,5 +1876,13 @@ "Downloading model": "Téléchargement du modèle", "Finding faces": "Recherche des visages", "Synchronizing folder": "Synchronisation du dossier", - "Looking for changes": "Recherche des modifications" + "Looking for changes": "Recherche des modifications", + "Sidebar Sections": "Sections de la barre latérale", + "Reset Sidebar Sections": "Réinitialiser les sections de la barre latérale", + "Hide This Section": "Masquer cette section", + "No folders to browse": "Aucun dossier à parcourir", + "No photos yet": "Aucune photo pour l’instant", + "No imported folders yet": "Aucun dossier importé pour l’instant", + "Show Sidebar Section": "Afficher la section de la barre latérale", + "Move Sidebar Section": "Déplacer la section de la barre latérale" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index a7a3a32ef..11f33756f 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1878,5 +1878,13 @@ "Downloading model": "モデルをダウンロード中", "Finding faces": "顔を検索中", "Synchronizing folder": "フォルダを同期中", - "Looking for changes": "変更を確認中" + "Looking for changes": "変更を確認中", + "Sidebar Sections": "サイドバーのセクション", + "Reset Sidebar Sections": "サイドバーのセクションをリセット", + "Hide This Section": "このセクションを隠す", + "No folders to browse": "参照できるフォルダがありません", + "No photos yet": "写真はまだありません", + "No imported folders yet": "読み込んだフォルダはまだありません", + "Show Sidebar Section": "サイドバーのセクションを表示", + "Move Sidebar Section": "サイドバーのセクションを移動" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index c3d9a8d78..c7a380816 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1876,5 +1876,13 @@ "Downloading model": "Baixando modelo", "Finding faces": "Procurando rostos", "Synchronizing folder": "Sincronizando pasta", - "Looking for changes": "Procurando alterações" + "Looking for changes": "Procurando alterações", + "Sidebar Sections": "Seções da barra lateral", + "Reset Sidebar Sections": "Redefinir seções da barra lateral", + "Hide This Section": "Ocultar esta seção", + "No folders to browse": "Nenhuma pasta para navegar", + "No photos yet": "Ainda não há fotos", + "No imported folders yet": "Ainda não há pastas importadas", + "Show Sidebar Section": "Mostrar seção da barra lateral", + "Move Sidebar Section": "Mover seção da barra lateral" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index bf9974740..d1f5525ad 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1590,5 +1590,15 @@ "Finding faces": "Поиск лиц", "Quit Anyway": "Всё равно выйти", "Synchronizing folder": "Синхронизация папки", - "Looking for changes": "Поиск изменений" + "Looking for changes": "Поиск изменений", + "Sidebar Sections": "Разделы боковой панели", + "Reset Sidebar Sections": "Сбросить разделы боковой панели", + "Hide This Section": "Скрыть этот раздел", + "No folders to browse": "Нет папок для просмотра", + "No photos yet": "Фотографий пока нет", + "No imported folders yet": "Импортированных папок пока нет", + "Show Sidebar Section": "Показать раздел боковой панели", + "Move Sidebar Section": "Переместить раздел боковой панели", + "Move Up": "Переместить вверх", + "Move Down": "Переместить вниз" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index c73c05b93..17a682c18 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2070,5 +2070,13 @@ "Downloading model": "Завантаження моделі", "Finding faces": "Пошук облич", "Synchronizing folder": "Синхронізація папки", - "Looking for changes": "Пошук змін" + "Looking for changes": "Пошук змін", + "Sidebar Sections": "Розділи бічної панелі", + "Reset Sidebar Sections": "Скинути розділи бічної панелі", + "Hide This Section": "Сховати цей розділ", + "No folders to browse": "Немає тек для перегляду", + "No photos yet": "Фотографій ще немає", + "No imported folders yet": "Імпортованих тек ще немає", + "Show Sidebar Section": "Показати розділ бічної панелі", + "Move Sidebar Section": "Перемістити розділ бічної панелі" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 21fa4e4e9..0cba0bbc9 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1876,5 +1876,13 @@ "Downloading model": "正在下载模型", "Finding faces": "正在查找人脸", "Synchronizing folder": "正在同步文件夹", - "Looking for changes": "正在查找更改" + "Looking for changes": "正在查找更改", + "Sidebar Sections": "边栏分区", + "Reset Sidebar Sections": "重置边栏分区", + "Hide This Section": "隐藏此分区", + "No folders to browse": "没有可浏览的文件夹", + "No photos yet": "还没有照片", + "No imported folders yet": "还没有导入的文件夹", + "Show Sidebar Section": "显示边栏分区", + "Move Sidebar Section": "移动边栏分区" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index d2e0a30c2..d99f0bbd3 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1876,5 +1876,13 @@ "Downloading model": "正在下載模型", "Finding faces": "正在尋找臉孔", "Synchronizing folder": "正在同步資料夾", - "Looking for changes": "正在尋找變更" + "Looking for changes": "正在尋找變更", + "Sidebar Sections": "側邊欄區段", + "Reset Sidebar Sections": "重設側邊欄區段", + "Hide This Section": "隱藏此區段", + "No folders to browse": "沒有可瀏覽的資料夾", + "No photos yet": "還沒有照片", + "No imported folders yet": "還沒有匯入的資料夾", + "Show Sidebar Section": "顯示側邊欄區段", + "Move Sidebar Section": "移動側邊欄區段" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 73b11a2b7..3ed20fb6b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -330,7 +330,14 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest } // `collapsedSidebar`, as scripts from before `sidebar` set it: the folded sections if let Some(ids) = p.get("collapsedSidebar").and_then(Value::as_array) { - for section in crate::sidebar::SidebarSection::ALL { + use crate::sidebar::SidebarSection; + if p.get("sidebar").is_some() { + return err("ui.set: give `sidebar` or the older `collapsedSidebar`, not both".to_string()); + } + if let Some(unknown) = ids.iter().find(|id| id.as_str().and_then(SidebarSection::from_id).is_none()) { + return err(format!("ui.set: `collapsedSidebar`: unknown section {unknown} ({})", SidebarSection::ids())); + } + for section in SidebarSection::ALL { u.sidebar.set_collapsed(section, ids.iter().any(|id| id.as_str() == Some(section.id()))); } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 0ae4afa78..1ef894b32 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -102,7 +102,9 @@ fn row_sensed( ); let font = t.font(13.5); let color = if selected { t.text } else { t.text_label }; - let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim)); + // a count of nothing is there, but fainter: the rows that hold photos stand out + let count_color = |n: usize| if n == 0 { t.text_dim.gamma_multiply(0.55) } else { t.text_dim }; + let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), count_color(n))); let label_left = r.left() + 42.0 + indent; let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x); // a label's dot sits just before the count @@ -188,22 +190,62 @@ 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. +/// `rows`: how many rows the section lists at its top level (a folded header says so). fn section( app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, + rows: usize, body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui), ) -> PlacedSection { ui.add_space(SECTION_GAP); let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_HEADER), Sense::hover()); - if section_header(app, ui, section, header) { + // a hairline in the gap above tells the sections apart + let t = Tokens::get(ui.ctx()); + let edge = visible_right(ui).min(header.right()); + let y = header.top() - SECTION_GAP / 2.0; + ui.painter().hline(header.left() + 18.0..=(edge - 18.0).max(header.left() + 18.0), y, egui::Stroke::new(1.0, t.divider)); + let show = egui::Id::new("left-show-section"); + if ui.data(|d| d.get_temp::(show)) == Some(section) { + ui.data_mut(|d| d.remove::(show)); + ui.scroll_to_rect_animation(header, Some(egui::Align::TOP), egui::style::ScrollAnimation::none()); + } + let open = section_header(app, ui, section, header, rows, false).open; + if open { let rows_from = ui.cursor().top(); body(app, ui); if ui.cursor().top() <= rows_from { empty_hint(ui, section); } } - PlacedSection { section, top: header.top(), bottom: ui.cursor().top() } + PlacedSection { section, top: header.top(), bottom: ui.cursor().top(), open, rows } +} + +/// Where what the panel shows of the sidebar's content ends (screen x): the content is wider +/// than the panel while the sidebar scrolls sideways. +fn visible_right(ui: &egui::Ui) -> f32 { + ui.data(|d| d.get_temp(egui::Id::new("left-visible-right"))).unwrap_or(f32::MAX) +} + +/// The header of the open section whose own header has scrolled out of sight stays at the top of +/// the sidebar (widget `sidebarSectionPinned:`), so it is always clear which section the rows +/// belong to, and the section can be folded, moved or hidden from anywhere in a long list. The +/// next section's header pushes it out as it arrives. `view_top`: where the visible part starts. +fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection], view_top: f32) { + let Some(p) = placed.iter().find(|p| p.open && p.top < view_top && p.bottom > view_top) else { return }; + let top = view_top.min(p.bottom - SECTION_HEADER); + let r = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + SECTION_HEADER); + let t = Tokens::get(ui.ctx()); + let view = ui.clip_rect(); + // over the rows scrolling beneath it + ui.painter().rect_filled(Rect::from_x_y_ranges(view.x_range(), r.y_range()), 0.0, t.chrome); + ui.painter().hline(view.x_range(), r.bottom(), egui::Stroke::new(1.0, t.divider)); + if section_header(app, ui, p.section, r, p.rows, true).toggled { + // folded from here: its own header comes into view, not whatever was below the section + // (next frame, when the section is laid out folded: see `section`) + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-show-section"), p.section)); + ui.ctx().request_repaint(); + } } /// What an open section with nothing to list says in place of its rows (widget @@ -224,6 +266,17 @@ pub(crate) struct PlacedSection { pub section: SidebarSection, pub top: f32, pub bottom: f32, + /// Not folded: its rows (or its note that there are none) are drawn. + pub open: bool, + /// How many rows it lists at its top level. + pub rows: usize, +} + +/// What a section's header says happened to it this frame. +struct HeaderOutcome { + open: bool, + /// A click folded or unfolded the section. + toggled: bool, } /// Where a section dragged to `y` would go among the sections drawn: above the first one whose @@ -238,6 +291,8 @@ fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSect let Some(dragged) = app.ui.dragging_section else { return }; let (released, pos, esc) = ui.input(|i| (i.pointer.primary_released(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); if esc { + // the drag's Esc, not also a dialog's or a view's + crate::widgets::take_escape(ui.ctx()); app.ui.dragging_section = None; return; } @@ -264,8 +319,7 @@ fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSect }; if let Some(y) = y { let t = Tokens::get(ui.ctx()); - let visible_right: f32 = ui.data(|d| d.get_temp(egui::Id::new("left-visible-right"))).unwrap_or(view.right()); - let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right.min(view.right()) - 8.0, y + 1.0)); + let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right(ui).min(view.right()) - 8.0, y + 1.0)); ui.painter().rect_filled(line, 1.0, t.accent); register(ui.ctx(), "sidebarSectionDrop", line); } @@ -293,17 +347,20 @@ pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { ); } -/// A section's header in `r`: the bold title with a disclosure chevron after it; a click folds or -/// unfolds the section (kept in the UI state, so it survives restarts), and what the section adds -/// to its header (Albums: the + menu) sits at the visible edge. Returns whether the section is -/// open. -fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect) -> bool { +/// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their +/// triangle), then the bold title; a click folds or unfolds the section (kept in the UI state, so +/// it survives restarts), and what the section adds to its header (Albums: the + menu) sits at +/// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo +/// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`). +fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect, rows: usize, pinned: bool) -> HeaderOutcome { let t = Tokens::get(ui.ctx()); let id = section.id(); let title = crate::i18n::tr(section.title()); - let resp = ui.interact(r, ui.id().with(("sidebar-section", id)), Sense::click_and_drag()); - register(ui.ctx(), format!("sidebarSection:{id}"), r); - if resp.clicked() { + let (interact, widget) = if pinned { ("sidebar-section-pinned", "sidebarSectionPinned") } else { ("sidebar-section", "sidebarSection") }; + let resp = ui.interact(r, ui.id().with((interact, id)), Sense::click_and_drag()); + register(ui.ctx(), format!("{widget}:{id}"), r); + let toggled = resp.clicked(); + if toggled { app.ui.sidebar.toggle_collapsed(section); } // dragged by its header, a section moves among the others (see `section_drag`) @@ -313,15 +370,30 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe resp.context_menu(|ui| sections_menu(app, ui, Some(section))); let open = !app.ui.sidebar.is_collapsed(section); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); - let text = ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); - let c = pos2(text.right() + 10.0, r.center().y); - let col = if resp.hovered() { t.text } else { t.text_dim }; + // what is visible of the header: its bar and count end at the panel's edge, like a row's + let edge = r.right().min(visible_right(ui)); + let hovered = resp.hovered() && app.ui.dragging_section.is_none(); + if hovered { + let bar = Rect::from_min_max(r.min + vec2(8.0, 2.0), pos2((edge - 8.0).max(r.left() + 8.0), r.bottom() - 2.0)); + ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6)); + } + let c = pos2(r.left() + 10.0, r.center().y); + register(ui.ctx(), format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(14.0, 14.0))); + let col = if hovered { t.text } else { t.text_dim }; ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); - if section == SidebarSection::Albums { + ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); + let has_actions = section == SidebarSection::Albums; + if !open && rows > 0 && app.ui.show_counts { + // folded: how much is in there (left of the header's own buttons) + let right = edge - 18.0 - if has_actions { 30.0 } else { 0.0 }; + let count = ui.painter().text(pos2(right, r.center().y), Align2::RIGHT_CENTER, rows.to_string(), t.font(12.5), t.text_dim); + register(ui.ctx(), format!("{widget}Count:{id}"), count); + } + if has_actions { top_level_drop_target(app, ui, r); albums_header_actions(app, ui, r); } - open + HeaderOutcome { open, toggled } } /// The menu of a section's header (`on`) and of the My Photos title (`None`): hide this section, @@ -368,8 +440,7 @@ fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option = Vec::with_capacity(shown.len()); for s in shown { let at = match s { - SidebarSection::Albums => section(app, ui, s, |app, ui| albums_rows(app, ui, reveal)), + SidebarSection::Albums => { + let rows = app.session.catalog.albums().filter(|a| a.parent.is_none()).count(); + section(app, ui, s, rows, |app, ui| albums_rows(app, ui, reveal)) + } SidebarSection::Local => { let Some(local) = &local else { continue }; - section(app, ui, s, |app, ui| local_rows(app, ui, local)) + section(app, ui, s, local.places.places.len(), |app, ui| local_rows(app, ui, local)) + } + SidebarSection::ByDate => { + let rows = app.caches.date_groups(&app.session.catalog).len(); + section(app, ui, s, rows, date_rows) } - SidebarSection::ByDate => section(app, ui, s, date_rows), SidebarSection::Folders => { let tree = app.caches.folder_tree(&app.session.catalog); - section(app, ui, s, |app, ui| { + section(app, ui, s, tree.len(), |app, ui| { reveal_chosen(app, ui, &tree); folder_rows(app, ui, &tree, 0.0); }) } SidebarSection::Keywords => { let tree = app.caches.keyword_tree(&app.session.catalog); - section(app, ui, s, |app, ui| keyword_rows(app, ui, &tree, 0.0)) + section(app, ui, s, tree.len(), |app, ui| keyword_rows(app, ui, &tree, 0.0)) } }; placed.push(at); } // room below the last section (a section dragged to the end is dropped here) ui.add_space(SECTION_GAP * 2.0); + pinned_header(app, ui, &placed, view_top); section_drag(app, ui, &placed); // what the rows asked for becomes next frame's width let next = ui.data(|d| d.get_temp::(egui::Id::new("left-content-width-next"))).unwrap_or(0.0); diff --git a/crates/ui-egui/src/sidebar.rs b/crates/ui-egui/src/sidebar.rs index 4bc49fd61..37b539595 100644 --- a/crates/ui-egui/src/sidebar.rs +++ b/crates/ui-egui/src/sidebar.rs @@ -207,18 +207,25 @@ impl SidebarLayout { self.set_collapsed(section, false); } - /// Put `section` just above `before`, or last when `before` is `None`. Returns whether the - /// order changed (moving a section above itself, or to where it already is, changes nothing). + /// Put `section` just above `before`, or last when `before` is `None`. Returns whether it + /// moved. Moving a section above itself or to where it already is changes nothing, and neither + /// does a move of a shown section that would only take it past hidden ones: the sidebar would + /// look the same, so the arrangement stays the same too. pub fn move_before(&mut self, section: SidebarSection, before: Option) -> bool { if before == Some(section) { return false; } - let was = self.0.clone(); + let was = self.clone(); let moved = self.state(section); self.0.retain(|s| s.id != section); let at = before.and_then(|b| self.0.iter().position(|s| s.id == b)).unwrap_or(self.0.len()); self.0.insert(at, moved); - self.0 != was + let seen = !moved.hidden && section.available(); + if seen && self.shown().eq(was.shown()) { + *self = was; + return false; + } + *self != was } /// Move `section` one place up or down among the sections that are shown (hidden ones are @@ -364,6 +371,20 @@ mod tests { assert_eq!(order(&layout), [Keywords, Local, ByDate, Folders, Albums]); } + /// Given a hidden section between two shown ones, when the upper one is dropped right where + /// it already appears (above the lower one), then nothing changes: no move that can't be seen. + #[test] + fn a_move_that_cannot_be_seen_is_no_move() { + let mut layout = SidebarLayout::default(); + layout.set_hidden(Local, true); + let was = layout.clone(); + assert!(!layout.move_before(Albums, Some(ByDate)), "Albums is already right above By Date, as far as anyone can see"); + assert_eq!(layout, was); + // a hidden section can still be put elsewhere (it shows there when it is shown again) + assert!(layout.move_before(Local, None)); + assert_eq!(order(&layout), [Albums, ByDate, Folders, Keywords, Local]); + } + /// Moving a section keeps whether it is hidden or folded. #[test] fn a_moved_section_stays_folded() { diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index a7cc59f00..ddb09401c 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -626,6 +626,14 @@ fn an_arrangement_set_by_an_agent_is_checked() { let r = h.request("ui.set", json!({"collapsedSidebar": []}), T); assert_eq!(r["ok"], true, "{r}"); assert!(!h.app.ui.sidebar_section_collapsed("byDate")); + // a name it doesn't know, or both ways of saying it at once, is refused and changes nothing + exec(&mut h, "view.sidebarSection", json!({"section": "albums", "collapsed": true})); + let arranged = h.app.ui.sidebar.clone(); + for bad in [json!({"collapsedSidebar": ["albms"]}), json!({"collapsedSidebar": [], "sidebar": [{"id": "albums", "collapsed": true}]})] { + let r = h.request("ui.set", bad.clone(), T); + assert_eq!(r["ok"], false, "{bad}: {r}"); + assert_eq!(h.app.ui.sidebar, arranged, "{bad}"); + } } /// Where a dragged section goes: above the first section whose middle is below the pointer. @@ -634,9 +642,9 @@ fn a_dragged_section_goes_above_the_section_it_is_over_the_top_half_of() { use crate::panels::left::{PlacedSection, section_drop_before}; use crate::sidebar::SidebarSection::{Albums, ByDate, Keywords}; let placed = [ - PlacedSection { section: Albums, top: 100.0, bottom: 300.0 }, - PlacedSection { section: ByDate, top: 310.0, bottom: 344.0 }, - PlacedSection { section: Keywords, top: 354.0, bottom: 500.0 }, + PlacedSection { section: Albums, top: 100.0, bottom: 300.0, open: true, rows: 1 }, + PlacedSection { section: ByDate, top: 310.0, bottom: 344.0, open: true, rows: 1 }, + PlacedSection { section: Keywords, top: 354.0, bottom: 500.0, open: true, rows: 1 }, ]; for (y, before) in [ (0.0, Some(Albums)), @@ -666,6 +674,80 @@ fn a_year_opens_into_its_months() { assert_eq!(rows(&h), years, "closed again"); } +/// Given a section longer than the sidebar, when its header scrolls out of sight, then a copy of +/// the header stays at the top of the sidebar for as long as the section's rows are there; a +/// click on it folds the section and brings the section's own header into view. +#[test] +fn the_header_of_a_long_section_stays_in_sight() { + let mut h = demo([1400.0, 700.0], json!({"view": "photoGrid", "leftPanel": true})); + for i in 0..40 { + h.app.session.execute("album.create", &json!({"name": format!("Album {i:02}")})).unwrap(); + } + h.step(); + h.step(); + assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("sidebarSectionPinned:")), "nothing is pinned while every header is in sight"); + let own = header(&h, "albums"); + let c = egui::pos2(own.center().x, 400.0); + let scroll = |h: &mut Headless, dy: f32| { + // the wheel scrolls what the pointer is over + h.request("ui.move", json!({"x": c.x, "y": c.y}), T); + let r = h.request("ui.scroll", json!({"dy": dy}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..60 { + h.step(); + } + }; + scroll(&mut h, -500.0); + assert!(header(&h, "albums").top() < own.top() - 300.0, "the sidebar scrolled"); + let pinned = widget(&h, "sidebarSectionPinned:albums"); + let first_row = h + .app + .widgets + .iter() + .filter(|(w, r)| w.starts_with("source:album:") && r.bottom() > pinned.bottom()) + .map(|(_, r)| r.top()) + .fold(f32::MAX, f32::min); + assert!( + pinned.top() > header(&h, "albums").bottom() && first_row < pinned.bottom() + 29.0, + "at the top of the rows in view: {pinned:?} {first_row}" + ); + assert!(has(&h, "icon:albumNew"), "with its buttons"); + // folded from the pinned header: Albums' own header is what the sidebar shows now + click(&mut h, "sidebarSectionPinned:albums"); + h.settle(SETTLE); + assert!(h.app.ui.sidebar_section_collapsed("albums")); + assert!(!has(&h, "sidebarSectionPinned:albums"), "a folded section has no rows to pin its header over"); + let panel = widget(&h, "panel:left_panel"); + let own = header(&h, "albums"); + assert!(own.top() >= panel.top() && own.bottom() <= panel.bottom(), "its own header is in view: {own:?}"); +} + +/// A folded section says how many rows it holds (unless photo counts are off); an open one +/// doesn't need to. +#[test] +fn a_folded_section_says_how_much_it_holds() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + assert!(!has(&h, "sidebarSectionCount:byDate")); + click(&mut h, "sidebarSection:byDate"); + assert!(has(&h, "sidebarSectionCount:byDate")); + exec(&mut h, "view.photoCounts", json!({"show": false})); + assert!(!has(&h, "sidebarSectionCount:byDate")); + // an empty section has nothing to count + exec(&mut h, "view.photoCounts", json!({"show": true})); + click(&mut h, "sidebarSection:folders"); + assert!(h.app.ui.sidebar_section_collapsed("folders") && !has(&h, "sidebarSectionCount:folders")); +} + +/// A section's chevron is in the same column as the triangles of the rows below it. +#[test] +fn section_chevrons_line_up_with_row_triangles() { + let h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let triangle = h.app.widgets.iter().find(|(w, _)| w.starts_with("dateToggle:")).map(|(_, r)| *r).expect("a year has a triangle"); + for id in sections_drawn(&h) { + assert_eq!(widget(&h, &format!("sidebarSectionChevron:{id}")).center().x, triangle.center().x, "{id}"); + } +} + /// Albums nest in folders like the other sidebar trees: a folder row has a disclosure triangle /// (`albumToggle:`), plain albums have none, and folding a folder hides what is inside it. #[test] diff --git a/docs/parity.md b/docs/parity.md index 9394a27c7..aca0c0561 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 21 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | +| X. Cross-cutting behaviours (BEHAV) | 22 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 83 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 33 | 15 | 35 | 9 | — | 23/25 (92%) | -| **Total** | 401 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) | +| **Total** | 402 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.4%** of 523 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 48.5% of 167. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.4%** of 524 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 48.8% of 168. ## Top gaps @@ -529,6 +529,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-SIDEBAR-SECTIONS | Left-sidebar sections share one frame and one arrangement | P2 | ✅ | `crates/ui-egui/src/sidebar.rs` (`SidebarSection`, `SidebarLayout`), `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`, `tree_toggle`), `crates/ui-egui/src/tests_panels.rs` | Albums, Local, By Date, Folders and Keywords are drawn by one `section` frame (the same gap above, header, fold and accessibility name) from one arrangement, `sidebar` in the UI state: a list of `{id, hidden, collapsed}` top to bottom, also settable with `ui.set`. Whatever is read, every section is in it exactly once (unknown and repeated entries are dropped, the rest follow in the usual order), so a damaged or newer settings file never loses the other settings. Rows that open (album folders, Local folders, dates, library folders, keywords) share `tree_toggle`: the same triangle, the same remembered open state, announced as Expand / Collapse. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a section: it stays on top | | LR-BEHAV-SIDEBAR-HIDE | Show and hide left-sidebar sections | P2 | ✅ | `cmd:view.sidebarSection`, `cmd:view.sidebarReset`, `crates/ui-egui/src/sidebar.rs`, `crates/ui-egui/src/panels/left.rs` (`sections_menu`), `crates/ui-egui/src/menubar.rs`, `crates/ui-egui/src/tests_panels.rs` | View ▸ Sidebar Sections lists Albums, Local, By Date, Folders and Keywords in the sidebar's order, each checked while shown; unchecking one takes its header and rows off the sidebar, checking it puts it back where and how it was (still folded if it was). The same list opens with a right-click on any section header (with Hide This Section first; widgets `sidebarMenu:hide`, `sidebarMenu:section:`, `sidebarMenu:reset`) and on the My Photos title (`sidebarTitle`), which is always there, so hidden sections can always come back. Reset Sidebar Sections (enabled once something changed) shows and opens every section in the usual order. `view.sidebarSection` `{section, show?, collapsed?}` flips shown / hidden when neither flag is given and refuses an unknown section or a flag that is not true / false. Hiding the section of what the grid shows (an album, a keyword filter) leaves the grid as it is; an album made while Albums is hidden or folded brings Albums back, open. Kept with the UI state (`sidebar`). My Photos itself can't be hidden | | LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before:
\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | +| LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb1fd3165..1ffe5a8c6 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,19 @@ ## October 2026 +### Sidebar sections +- Arrange the left sidebar your way: drag Albums, Local, By Date, Folders or Keywords by its header to move it (a line + shows where it will go), or right-click a header for Move Up / Move Down. +- Hide the sections you don't use: View ▸ Sidebar Sections, or right-click a header ▸ Hide This Section. Right-click + the My Photos title to bring one back, or choose Reset Sidebar Sections. The arrangement is remembered. +- In a long section, the header stays at the top of the sidebar while you scroll through its rows, so you always see + which section you are in and can fold it from there. +- Sections are easier to tell apart: a hairline between them, the fold chevron in the same column as the rows' + triangles, a highlight under the pointer. A folded section shows how many rows it holds, counts of 0 are fainter, + and a section with nothing in it says so instead of disappearing. +- Recently Deleted is listed with All Photos, Recently Added and Picks under My Photos. +- Agents: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` takes `sidebar`. + ### Activity stack - Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,