mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 21:18:11 -06:00
UI: Set Color Label on Folders rows, with the label's dot
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:<colour>), and choosing None closes the photo menu like choosing a colour does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
034991a081
commit
c40a5675bb
@@ -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 `<widget>:<colour>` (`<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<ColorLabel>, widget: &str) -> Option<Option<ColorLabel>> {
|
||||
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<ColorLabel>) -> 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
|
||||
|
||||
@@ -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::Color32>,
|
||||
) -> 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:<colour>`).
|
||||
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…"))
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user