mimick/library/
tree_page.rs1use 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}