Skip to main content

mimick/library/
tree_page.rs

1use std::rc::Rc;
2
3use gtk::prelude::*;
4
5pub(super) struct TreePage {
6    pub root: gtk::Box,
7    pub toolbar: gtk::Box,
8    pub list: gtk::ListBox,
9    crumbs: gtk::Box,
10    crumb_scroller: gtk::ScrolledWindow,
11    empty: libadwaita::StatusPage,
12}
13
14pub(super) fn build_tree_page(empty_icon: &str) -> TreePage {
15    let crumbs = gtk::Box::builder()
16        .orientation(gtk::Orientation::Horizontal)
17        .spacing(2)
18        .build();
19    let crumb_scroller = gtk::ScrolledWindow::builder()
20        .hscrollbar_policy(gtk::PolicyType::External)
21        .vscrollbar_policy(gtk::PolicyType::Never)
22        .hexpand(true)
23        .child(&crumbs)
24        .build();
25    let toolbar = gtk::Box::builder()
26        .orientation(gtk::Orientation::Horizontal)
27        .spacing(8)
28        .margin_top(12)
29        .margin_start(12)
30        .margin_end(12)
31        .build();
32    toolbar.append(&crumb_scroller);
33    let (scroller, list, empty) = build_list_area(empty_icon);
34    let root = gtk::Box::builder()
35        .orientation(gtk::Orientation::Vertical)
36        .build();
37    root.append(&toolbar);
38    root.append(&scroller);
39    TreePage {
40        root,
41        toolbar,
42        list,
43        crumbs,
44        crumb_scroller,
45        empty,
46    }
47}
48
49fn build_list_area(
50    empty_icon: &str,
51) -> (gtk::ScrolledWindow, gtk::ListBox, libadwaita::StatusPage) {
52    let list = gtk::ListBox::builder()
53        .selection_mode(gtk::SelectionMode::None)
54        .css_classes(["boxed-list"])
55        .valign(gtk::Align::Start)
56        .build();
57    let empty = libadwaita::StatusPage::builder()
58        .icon_name(empty_icon)
59        .vexpand(true)
60        .visible(false)
61        .build();
62    let content = gtk::Box::builder()
63        .orientation(gtk::Orientation::Vertical)
64        .margin_top(12)
65        .margin_bottom(12)
66        .margin_start(12)
67        .margin_end(12)
68        .build();
69    content.append(&list);
70    content.append(&empty);
71    let clamp = libadwaita::Clamp::builder()
72        .maximum_size(820)
73        .child(&content)
74        .build();
75    let scroller = gtk::ScrolledWindow::builder()
76        .hscrollbar_policy(gtk::PolicyType::Never)
77        .vexpand(true)
78        .child(&clamp)
79        .build();
80    (scroller, list, empty)
81}
82
83pub(super) fn show_empty_state(page: &TreePage, empty: Option<(&str, &str)>) {
84    if let Some((title, description)) = empty {
85        page.empty.set_title(title);
86        page.empty.set_description(Some(description));
87    }
88    page.empty.set_visible(empty.is_some());
89    page.list.set_visible(empty.is_none());
90}
91
92pub(super) fn render_breadcrumbs(
93    page: &TreePage,
94    root_label: &str,
95    path: Option<&str>,
96    on_click: Rc<dyn Fn(Option<String>)>,
97) {
98    let crumbs = &page.crumbs;
99    while let Some(child) = crumbs.first_child() {
100        crumbs.remove(&child);
101    }
102    let segments = segments(root_label, path);
103    let last = segments.len() - 1;
104    for (index, (label, target)) in segments.into_iter().enumerate() {
105        if index > 0 {
106            let separator = gtk::Label::builder()
107                .label("/")
108                .css_classes(["dim-label"])
109                .build();
110            crumbs.append(&separator);
111        }
112        let button = gtk::Button::builder()
113            .label(&label)
114            .css_classes(["flat"])
115            .build();
116        if index == last {
117            button.add_css_class("heading");
118        }
119        let on_click = on_click.clone();
120        button.connect_clicked(move |_| on_click(target.clone()));
121        crumbs.append(&button);
122    }
123    let adjustment = page.crumb_scroller.hadjustment();
124    glib::idle_add_local_once(move || adjustment.set_value(adjustment.upper()));
125}
126
127pub(super) fn parent_path(path: &str) -> Option<&str> {
128    path.rsplit_once('/')
129        .map(|(parent, _)| parent)
130        .filter(|parent| !parent.is_empty())
131}
132
133pub(super) fn leaf_name(path: &str) -> &str {
134    path.rsplit('/').next().unwrap_or(path)
135}
136
137fn segments(root_label: &str, path: Option<&str>) -> Vec<(String, Option<String>)> {
138    let mut crumbs = vec![(root_label.to_string(), None)];
139    let Some(path) = path else {
140        return crumbs;
141    };
142    let ends = path
143        .match_indices('/')
144        .map(|(index, _)| index)
145        .chain(std::iter::once(path.len()));
146    for end in ends {
147        let prefix = &path[..end];
148        let name = leaf_name(prefix);
149        if !name.is_empty() {
150            crumbs.push((name.to_string(), Some(prefix.to_string())));
151        }
152    }
153    crumbs
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn crumb(label: &str, target: Option<&str>) -> (String, Option<String>) {
161        (label.to_string(), target.map(str::to_string))
162    }
163
164    #[test]
165    fn segments_build_each_ancestor() {
166        assert_eq!(segments("All Tags", None), [crumb("All Tags", None)]);
167        assert_eq!(
168            segments("All Tags", Some("Trips/Japan")),
169            [
170                crumb("All Tags", None),
171                crumb("Trips", Some("Trips")),
172                crumb("Japan", Some("Trips/Japan")),
173            ]
174        );
175    }
176
177    #[test]
178    fn segments_keep_the_leading_slash_of_absolute_paths() {
179        assert_eq!(
180            segments("Folders", Some("/photos/2024")),
181            [
182                crumb("Folders", None),
183                crumb("photos", Some("/photos")),
184                crumb("2024", Some("/photos/2024")),
185            ]
186        );
187    }
188
189    #[test]
190    fn parent_path_stops_at_the_root() {
191        assert_eq!(parent_path("Trips/Japan"), Some("Trips"));
192        assert_eq!(parent_path("Trips"), None);
193        assert_eq!(parent_path("/photos/2024"), Some("/photos"));
194        assert_eq!(parent_path("/photos"), None);
195        assert_eq!(leaf_name("/photos/2024"), "2024");
196    }
197}