Skip to main content

mimick/library/
tags_view.rs

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(&gtk::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(&gtk::Label::new(Some("Custom")));
382    row.append(&custom);
383    row
384}
385
386fn rgba_hex(rgba: &gtk::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}