1use std::cell::RefCell;
2use std::rc::Rc;
3
4use gtk::prelude::*;
5use libadwaita::prelude::*;
6
7use crate::api_client::Tag;
8
9use super::LibraryWindowUi;
10use super::asset_tags::color_dot;
11use super::context_menu::show_alert_dialog;
12use super::dialogs::{TextPrompt, confirm, popover_menu, prompt_text};
13use super::state::{LibraryPage, LibrarySource, Location};
14use super::tree_page::{
15 TreePage, build_tree_page, parent_path, render_breadcrumbs, show_empty_state,
16};
17
18const PRESET_COLORS: [&str; 10] = [
19 "#e11d48", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#0ea5e9", "#6366f1", "#a855f7",
20 "#ec4899", "#64748b",
21];
22
23pub(super) struct TagsView {
24 pub page: TreePage,
25 new_tag: gtk::Button,
26 tags: RefCell<Vec<Tag>>,
27 path: RefCell<Option<String>>,
28}
29
30pub(super) fn build_tags_view() -> TagsView {
31 let page = build_tree_page("mimick-tag-symbolic");
32 let new_tag = gtk::Button::builder()
33 .icon_name("list-add-symbolic")
34 .tooltip_text("New tag")
35 .build();
36 page.toolbar.append(&new_tag);
37 TagsView {
38 page,
39 new_tag,
40 tags: RefCell::default(),
41 path: RefCell::default(),
42 }
43}
44
45pub(super) fn connect_tags_view(ui: Rc<LibraryWindowUi>) {
46 let ui_new = ui.clone();
47 ui.tags
48 .new_tag
49 .connect_clicked(move |_| create_tag(&ui_new));
50}
51
52pub(super) fn show(ui: &Rc<LibraryWindowUi>, path: Option<String>) {
53 *ui.tags.path.borrow_mut() = path;
54 ui.content_stack.set_visible_child_name("tags");
55 render(ui);
56 reload(ui.clone());
57}
58
59fn reload(ui: Rc<LibraryWindowUi>) {
60 glib::MainContext::default().spawn_local(async move {
61 match ui.ctx.api_client.fetch_tags().await {
62 Ok(tags) => {
63 *ui.tags.tags.borrow_mut() = tags;
64 render(&ui);
65 }
66 Err(err) => show_alert_dialog(
67 &ui,
68 "Could Not Load Tags",
69 &format!("{err}\n\nYour API key needs the 'tag.read' permission."),
70 ),
71 }
72 });
73}
74
75fn open_path(ui: &Rc<LibraryWindowUi>, path: Option<String>) {
76 super::navigation::navigate(ui, Location::Page(LibraryPage::Tags { path }));
77}
78
79fn render(ui: &Rc<LibraryWindowUi>) {
80 let view = &ui.tags;
81 let tags = view.tags.borrow().clone();
82 let path = view
83 .path
84 .borrow()
85 .clone()
86 .filter(|path| tags.iter().any(|tag| &tag.value == path));
87 *view.path.borrow_mut() = path.clone();
88 let ui_crumb = ui.clone();
89 render_breadcrumbs(
90 &view.page,
91 "All Tags",
92 path.as_deref(),
93 Rc::new(move |target| open_path(&ui_crumb, target)),
94 );
95
96 view.page.list.remove_all();
97 let children = children_of(&tags, path.as_deref());
98 for tag in &children {
99 view.page
100 .list
101 .append(&tag_row(ui, tag, child_count(&tags, &tag.value)));
102 }
103 let empty = match &path {
104 None => (
105 "No Tags",
106 "Create a tag with the + button, or tag photos from the lightbox details pane.",
107 ),
108 Some(_) => ("No Tags Inside", "Use the + button to add one here."),
109 };
110 show_empty_state(&view.page, children.is_empty().then_some(empty));
111}
112
113fn tag_row(ui: &Rc<LibraryWindowUi>, tag: &Tag, children: usize) -> libadwaita::ActionRow {
114 let row = libadwaita::ActionRow::builder()
115 .title(&tag.name)
116 .use_markup(false)
117 .title_lines(1)
118 .activatable(true)
119 .tooltip_text(format!("Show photos tagged {}", tag.value))
120 .build();
121 if children > 0 {
122 row.set_subtitle(&match children {
123 1 => "1 tag inside".to_string(),
124 n => format!("{n} tags inside"),
125 });
126 }
127 row.add_prefix(&color_dot(tag.color.as_deref(), 12));
128 let menu = gtk::MenuButton::builder()
129 .icon_name("view-more-symbolic")
130 .tooltip_text("More")
131 .valign(gtk::Align::Center)
132 .css_classes(["flat"])
133 .build();
134 popover_menu(&menu, tag_menu_items(ui, tag));
135 row.add_suffix(&menu);
136 if children > 0 {
137 let browse = gtk::Button::builder()
138 .icon_name("go-next-symbolic")
139 .tooltip_text(format!("Open {}", tag.name))
140 .valign(gtk::Align::Center)
141 .css_classes(["flat"])
142 .build();
143 let (ui_browse, value) = (ui.clone(), tag.value.clone());
144 browse.connect_clicked(move |_| open_path(&ui_browse, Some(value.clone())));
145 row.add_suffix(&browse);
146 }
147 let (ui_open, id, value) = (ui.clone(), tag.id.clone(), tag.value.clone());
148 row.connect_activated(move |_| {
149 super::navigation::navigate_source(
150 &ui_open,
151 LibrarySource::Tag {
152 id: id.clone(),
153 name: value.clone(),
154 },
155 );
156 });
157 row
158}
159
160pub(super) fn tag_menu_items(ui: &Rc<LibraryWindowUi>, tag: &Tag) -> Vec<(String, Box<dyn Fn()>)> {
161 let (ui_rename, t_rename) = (ui.clone(), tag.clone());
162 let (ui_color, t_color) = (ui.clone(), tag.clone());
163 let (ui_delete, t_delete) = (ui.clone(), tag.clone());
164 vec![
165 (
166 "Rename…".to_string(),
167 Box::new(move || rename_tag(&ui_rename, &t_rename)),
168 ),
169 (
170 "Change Color…".to_string(),
171 Box::new(move || pick_color(&ui_color, &t_color)),
172 ),
173 (
174 "Delete…".to_string(),
175 Box::new(move || delete_tag(&ui_delete, &t_delete)),
176 ),
177 ]
178}
179
180fn run(
181 ui: &Rc<LibraryWindowUi>,
182 heading: &'static str,
183 task: impl Future<Output = Result<(), String>> + 'static,
184) {
185 let ui = ui.clone();
186 glib::MainContext::default().spawn_local(async move {
187 match task.await {
188 Ok(()) => {
189 super::source_header::refresh(&ui);
190 reload(ui);
191 }
192 Err(err) => show_alert_dialog(&ui, heading, &err),
193 }
194 });
195}
196
197fn create_tag(ui: &Rc<LibraryWindowUi>) {
198 let parent = ui.tags.path.borrow().clone();
199 let body = match &parent {
200 Some(parent) => format!("The tag is created inside {parent}."),
201 None => "Use “Parent/Child” to create a tag inside another.".to_string(),
202 };
203 let ui_create = ui.clone();
204 prompt_text(
205 ui,
206 TextPrompt {
207 heading: "New Tag",
208 body: &body,
209 initial: "",
210 placeholder: "Tag name",
211 accept: "Create",
212 },
213 move |name| {
214 let value = match &parent {
215 Some(parent) => format!("{parent}/{name}"),
216 None => name,
217 };
218 let api = ui_create.ctx.api_client.clone();
219 run(&ui_create, "Could Not Create Tag", async move {
220 api.create_tag(&value).await.map(|_| ())
221 });
222 },
223 );
224}
225
226fn rename_tag(ui: &Rc<LibraryWindowUi>, tag: &Tag) {
227 let (ui_rename, tag_id) = (ui.clone(), tag.id.clone());
228 prompt_text(
229 ui,
230 TextPrompt {
231 heading: "Rename Tag",
232 body: "Photos keep the tag under its new name.",
233 initial: &tag.name,
234 placeholder: "Tag name",
235 accept: "Rename",
236 },
237 move |name| {
238 if name.contains('/') {
239 show_alert_dialog(
240 &ui_rename,
241 "Could Not Rename Tag",
242 "Tag names can't contain “/”.",
243 );
244 return;
245 }
246 let (api, tag_id) = (ui_rename.ctx.api_client.clone(), tag_id.clone());
247 run(&ui_rename, "Could Not Rename Tag", async move {
248 api.rename_tag(&tag_id, &name).await
249 });
250 },
251 );
252}
253
254fn delete_tag(ui: &Rc<LibraryWindowUi>, tag: &Tag) {
255 let (ui_delete, tag_id) = (ui.clone(), tag.id.clone());
256 confirm(
257 ui,
258 &format!("Delete “{}”?", tag.name),
259 "The tag is removed from every photo. This can't be undone.",
260 "Delete",
261 move || {
262 let (api, tag_id) = (ui_delete.ctx.api_client.clone(), tag_id.clone());
263 let ui_after = ui_delete.clone();
264 run(&ui_delete, "Could Not Delete Tag", async move {
265 api.delete_tag(&tag_id).await?;
266 let viewing = matches!(
267 super::navigation::current(&ui_after),
268 Location::Source(LibrarySource::Tag { id, .. }) if id == tag_id
269 );
270 if viewing {
271 super::navigation::go_back(&ui_after);
272 }
273 Ok(())
274 });
275 },
276 );
277}
278
279type ChosenColor = Rc<RefCell<String>>;
280
281fn pick_color(ui: &Rc<LibraryWindowUi>, tag: &Tag) {
282 let chosen: ChosenColor = Rc::new(RefCell::new(tag.color.clone().unwrap_or_default()));
283 let (swatches, buttons) = color_swatches(&chosen);
284 let body = gtk::Box::builder()
285 .orientation(gtk::Orientation::Vertical)
286 .spacing(12)
287 .build();
288 body.append(&swatches);
289 body.append(&custom_color_row(tag, &chosen, buttons));
290 let dialog = libadwaita::AlertDialog::builder()
291 .heading(format!("Color for “{}”", tag.name))
292 .extra_child(&body)
293 .build();
294 dialog.add_response("cancel", "Cancel");
295 dialog.add_response("save", "Save");
296 dialog.set_response_appearance("save", libadwaita::ResponseAppearance::Suggested);
297 dialog.set_default_response(Some("save"));
298 dialog.set_close_response("cancel");
299 let (ui_save, tag_id) = (ui.clone(), tag.id.clone());
300 dialog.connect_response(None, move |_, response| {
301 let color = chosen.borrow().clone();
302 if response != "save" || color.is_empty() {
303 return;
304 }
305 let (api, tag_id) = (ui_save.ctx.api_client.clone(), tag_id.clone());
306 run(&ui_save, "Could Not Change Color", async move {
307 api.set_tag_color(&tag_id, &color).await
308 });
309 });
310 dialog.present(Some(&ui.window));
311}
312
313fn color_swatches(chosen: &ChosenColor) -> (gtk::FlowBox, Rc<Vec<gtk::ToggleButton>>) {
314 let swatches = gtk::FlowBox::builder()
315 .selection_mode(gtk::SelectionMode::None)
316 .homogeneous(true)
317 .min_children_per_line(5)
318 .max_children_per_line(5)
319 .halign(gtk::Align::Center)
320 .build();
321 let buttons: Rc<Vec<gtk::ToggleButton>> = Rc::new(
322 PRESET_COLORS
323 .iter()
324 .map(|color| {
325 gtk::ToggleButton::builder()
326 .child(&color_dot(Some(color), 22))
327 .tooltip_text(*color)
328 .css_classes(["flat", "circular"])
329 .active(chosen.borrow().eq_ignore_ascii_case(color))
330 .build()
331 })
332 .collect(),
333 );
334 for (swatch, color) in buttons.iter().zip(PRESET_COLORS) {
335 let (chosen, buttons_for_toggle) = (chosen.clone(), Rc::downgrade(&buttons));
336 swatch.connect_toggled(move |swatch| {
337 if !swatch.is_active() {
338 return;
339 }
340 *chosen.borrow_mut() = color.to_string();
341 if let Some(buttons) = buttons_for_toggle.upgrade() {
342 for other in buttons.iter().filter(|other| *other != swatch) {
343 other.set_active(false);
344 }
345 }
346 });
347 swatches.append(swatch);
348 }
349 (swatches, buttons)
350}
351
352fn custom_color_row(
353 tag: &Tag,
354 chosen: &ChosenColor,
355 swatches: Rc<Vec<gtk::ToggleButton>>,
356) -> gtk::Box {
357 let custom = gtk::ColorDialogButton::builder()
358 .dialog(>k::ColorDialog::builder().with_alpha(false).build())
359 .halign(gtk::Align::Center)
360 .tooltip_text("Custom color")
361 .build();
362 if let Some(rgba) = tag
363 .color
364 .as_deref()
365 .and_then(|c| gtk::gdk::RGBA::parse(c).ok())
366 {
367 custom.set_rgba(&rgba);
368 }
369 let chosen = chosen.clone();
370 custom.connect_rgba_notify(move |button| {
371 for swatch in swatches.iter() {
372 swatch.set_active(false);
373 }
374 *chosen.borrow_mut() = rgba_hex(&button.rgba());
375 });
376 let row = gtk::Box::builder()
377 .orientation(gtk::Orientation::Horizontal)
378 .spacing(8)
379 .halign(gtk::Align::Center)
380 .build();
381 row.append(>k::Label::new(Some("Custom")));
382 row.append(&custom);
383 row
384}
385
386fn rgba_hex(rgba: >k::gdk::RGBA) -> String {
387 let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
388 format!(
389 "#{:02x}{:02x}{:02x}",
390 channel(rgba.red()),
391 channel(rgba.green()),
392 channel(rgba.blue())
393 )
394}
395
396fn children_of<'a>(tags: &'a [Tag], parent: Option<&str>) -> Vec<&'a Tag> {
397 tags.iter()
398 .filter(|tag| parent_path(&tag.value) == parent)
399 .collect()
400}
401
402fn child_count(tags: &[Tag], value: &str) -> usize {
403 tags.iter()
404 .filter(|tag| parent_path(&tag.value) == Some(value))
405 .count()
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 fn tag(value: &str) -> Tag {
413 Tag {
414 id: value.into(),
415 name: value.rsplit('/').next().unwrap().into(),
416 value: value.into(),
417 color: None,
418 }
419 }
420
421 #[test]
422 fn children_of_lists_one_level() {
423 let tags = vec![
424 tag("Trips"),
425 tag("Trips/Japan"),
426 tag("Trips/Japan/Kyoto"),
427 tag("Food"),
428 ];
429 let values = |found: Vec<&Tag>| found.iter().map(|t| t.value.clone()).collect::<Vec<_>>();
430 assert_eq!(values(children_of(&tags, None)), ["Trips", "Food"]);
431 assert_eq!(values(children_of(&tags, Some("Trips"))), ["Trips/Japan"]);
432 assert_eq!(child_count(&tags, "Trips"), 1);
433 assert_eq!(child_count(&tags, "Food"), 0);
434 }
435
436 #[test]
437 fn rgba_hex_formats_lowercase_hex() {
438 let rgba = gtk::gdk::RGBA::new(1.0, 0.0, 0.5, 1.0);
439 assert_eq!(rgba_hex(&rgba), "#ff0080");
440 }
441}