From c40a5675bb5a8297c5e2ba19b0a2f3fe9c3193cc Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 17:33:31 -0400 Subject: [PATCH] UI: Set Color Label on Folders rows, with the label's dot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click a folder or disk row in the sidebar's Folders section ▸ Set Color Label ▸ a colour or None (folder.label). A labelled row shows the label's dot before its count, and its spoken name includes the label. The colour items are shared with the photo menu (grid::label_items): each is now a control-channel widget (photoLabel:/folderLabel:), and choosing None closes the photo menu like choosing a colour does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/grid.rs | 29 ++++++++++++++--- crates/ui-egui/src/panels/left.rs | 51 ++++++++++++++++++++++++++---- crates/ui-egui/src/tests_panels.rs | 23 ++++++++++++++ 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 1b6644e0a..6668be860 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -777,9 +777,20 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { pub use super::filterbar::label_color; -/// "Set Color Label" items (coloured dot + the label's name), shared by context menus. +/// "Set Color Label" items for the active photo, shared by context menus. pub fn label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { let current = app.session.active().and_then(|id| app.session.catalog.photo(id)).and_then(|p| p.label); + if let Some(l) = label_items(app, ui, current, "photoLabel") { + let _ = app.run("photo.label", json!({"label": label_param(l)})); + ui.close(); + } +} + +/// The colour label items of a menu (coloured dot + the label's name, then None and Edit Label +/// Names…), `current` checked; each colour is widget `:` (`:none`) for +/// the control channel. Returns the choice (`Some(None)`: None). +pub fn label_items(app: &mut LightcraftApp, ui: &mut egui::Ui, current: Option, widget: &str) -> Option> { + let mut chosen = None; for l in ColorLabel::ALL { let name = crate::i18n::color_label(&app.session.catalog, l); let resp = ui.horizontal(|ui| { @@ -787,18 +798,26 @@ pub fn label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().circle_filled(r.center(), 5.0, label_color(l)); ui.selectable_label(current == Some(l), name) }); + register(ui.ctx(), format!("{widget}:{}", label_param(Some(l))), resp.inner.rect); if resp.inner.clicked() { - let _ = app.run("photo.label", json!({"label": format!("{l:?}").to_lowercase()})); - ui.close(); + chosen = Some(Some(l)); } } - if ui.selectable_label(current.is_none(), crate::i18n::tr("None")).clicked() { - let _ = app.run("photo.label", json!({"label": "none"})); + let none = ui.selectable_label(current.is_none(), crate::i18n::tr("None")); + register(ui.ctx(), format!("{widget}:none"), none.rect); + if none.clicked() { + chosen = Some(None); } ui.separator(); if ui.button(crate::i18n::tr("Edit Label Names…")).clicked() { let _ = app.run("dialog.labelNames", json!({})); } + chosen +} + +/// A label as the `label` parameter of `photo.label` / `folder.label` names it. +pub fn label_param(l: Option) -> String { + l.map_or_else(|| "none".to_string(), |l| format!("{l:?}").to_lowercase()) } /// Stack badge at the cell's top-left: the photo count on a collapsed stack's top, `i/n` on the diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 2676f59ca..7312bfc1a 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -53,10 +53,11 @@ fn row_named( selected: bool, indent: f32, ) -> egui::Response { - row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click()) + row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click(), None) } -/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`). +/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`) and a colour +/// label's dot (`mark`) before the count. #[allow(clippy::too_many_arguments)] fn row_sensed( app: &mut LightcraftApp, @@ -69,6 +70,7 @@ fn row_sensed( selected: bool, indent: f32, sense: Sense, + mark: Option, ) -> egui::Response { let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); @@ -102,8 +104,15 @@ fn row_sensed( 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)); 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 + let mark_w = if mark.is_some() { MARK_W } else { 0.0 }; + if let Some(c) = mark { + let dot = Rect::from_center_size(pos2(count_left - MARK_W / 2.0, r.center().y), vec2(8.0, 8.0)); + ui.painter().circle_filled(dot.center(), 4.0, c); + register(ui.ctx(), format!("labelMark:{id}"), dot); + } // the name gives way to the count: cut with an ellipsis, in full on hover - let room = count_left - 8.0 - label_left; + let room = count_left - mark_w - 8.0 - label_left; let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x; let full_w = measure(label); let shown = if full_w <= room { label.to_string() } else { crate::widgets::elide_head(label, room.max(0.0), measure) }; @@ -112,7 +121,7 @@ fn row_sensed( let resp = if shown != label { resp.on_hover_text(label) } else { resp }; // a row asks for room for its name up to a share of a panel, so one very long name does not // make everything scroll: depth does that - let mut needed = 42.0 + indent + full_w.min(MAX_NAME_NEED) + 18.0; + let mut needed = 42.0 + indent + full_w.min(MAX_NAME_NEED) + 18.0 + mark_w; if let Some(galley) = count_galley { let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size()); needed += rect.width() + 16.0; @@ -694,7 +703,8 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare open = true; ui.data_mut(|d| d.insert_temp(open_id, true)); } - let resp = row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag()); + let resp = + row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None); // a folder is no source, so its row folds it too; the triangle is the same click, aimed let has_children = all.get(&Some(a.id)).is_some_and(|v| !v.is_empty()); if resp.drag_started() { @@ -727,7 +737,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare let target = app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection()); let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() }; - let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag()); + let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag(), None); if resp.drag_started() { app.ui.dragging_album = Some(a.id.0); } @@ -782,6 +792,9 @@ fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) { } } +/// Room a colour label's dot takes before a row's count. +const MARK_W: f32 = 12.0; + /// How long a dragged album must rest on a closed folder before it opens. const HOVER_OPEN_SECS: f64 = 0.6; @@ -1165,7 +1178,14 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], && app.session.source == LibrarySource::LibraryFolder && app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path)); let name = if selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() }; - let resp = row_named(app, ui, &format!("libfolder:{}", n.path), Icon::Folder, &name, Some(&name), Some(n.count), sel, indent); + // the label is said with the name, as it is shown beside it + let spoken = match n.label { + Some(l) => format!("{name}, {}", crate::i18n::color_label(&app.session.catalog, l)), + None => name.clone(), + }; + let mark = n.label.map(crate::theme::label_color); + let id = format!("libfolder:{}", n.path); + let resp = row_sensed(app, ui, &id, Icon::Folder, &name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); let mut toggled = false; if !n.children.is_empty() { let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("libfolder-tri", key)), format!("libraryFolderToggle:{}", n.path)); @@ -1204,6 +1224,8 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { folder_menu_for_library(app, resp, n); } else if n.path != "/" { resp.context_menu(|ui| { + folder_label_menu(app, ui, n); + ui.separator(); if ui .button(crate::i18n::tr("Remove Disk from Library…")) .on_hover_text(crate::i18n::tr("Moves every photo imported from this disk to Recently Deleted; no file is touched")) @@ -1216,6 +1238,20 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { } } +/// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`, +/// its colours `folderLabel:`). +fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { + let menu = ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| { + if let Some(l) = crate::panels::grid::label_items(app, ui, n.label, "folderLabel") { + if let Err(e) = app.run("folder.label", json!({"path": n.path, "label": crate::panels::grid::label_param(l)})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }); + register(ui.ctx(), "folderLabelMenu", menu.response.rect); +} + /// The context menu of a folder row: the folder's disk actions (the same as Local's, photos /// follow) and taking its photos out of the library. fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { @@ -1252,6 +1288,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F let _ = f(path); ui.close(); } + folder_label_menu(app, ui, n); ui.separator(); if ui .button(crate::i18n::tr("Remove from Library…")) diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 66eb360e1..9700597bd 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -924,6 +924,29 @@ fn right_click_opens_a_folders_menu_from_the_triangle_too() { assert!(!popup_open(&h), "the startup disk offers nothing"); } +/// Right-click a folder ▸ Set Color Label ▸ a colour: the folder's row shows the label's dot (a +/// row without a label shows none), and None takes it off again. +#[test] +fn a_folder_takes_a_colour_label_from_its_menu() { + let mut h = folders_app(&["/pics/trip/a.jpg", "/pics/home/b.jpg"]); + click(&mut h, "libraryFolderToggle:/pics"); + assert!(!has(&h, "labelMark:libfolder:/pics/trip"), "no label, no dot"); + right_click(&mut h, "source:libfolder:/pics/trip"); + click(&mut h, "folderLabelMenu"); + click(&mut h, "folderLabel:red"); + assert_eq!(h.app.session.catalog.folder_color_label("/pics/trip"), Some(lightcraft_catalog::ColorLabel::Red)); + assert!(!popup_open(&h), "choosing closes the menu"); + h.step(); + assert!(has(&h, "labelMark:libfolder:/pics/trip"), "the row shows its label"); + assert!(!has(&h, "labelMark:libfolder:/pics/home"), "its neighbour does not"); + right_click(&mut h, "source:libfolder:/pics/trip"); + click(&mut h, "folderLabelMenu"); + click(&mut h, "folderLabel:none"); + assert_eq!(h.app.session.catalog.folder_color_label("/pics/trip"), None); + h.step(); + assert!(!has(&h, "labelMark:libfolder:/pics/trip")); +} + #[test] fn removing_a_disk_from_the_library_asks_first() { use crate::state::Dialog;