1use std::cell::Cell;
10use std::rc::Rc;
11
12use glib::clone;
13use gtk::prelude::*;
14use libadwaita::prelude::*;
15
16use crate::api_client::MetadataSearchFilters;
17use crate::library::search_filters::{
18 FilterWidgets, build_camera_group, build_date_group, build_flags_group, build_location_group,
19 build_text_group,
20};
21
22pub struct SearchViewParts {
29 pub root: gtk::Revealer,
32 pub search_entry: gtk::SearchEntry,
33 pub search_mode: gtk::DropDown,
34 pub search_button: gtk::Button,
35 pub clear_button: gtk::Button,
36 pub filters: FilterWidgets,
37}
38
39fn build_search_bar() -> (gtk::Box, gtk::DropDown, gtk::SearchEntry) {
40 let mode_model = gtk::StringList::new(&["Smart Search", "Filename", "OCR"]);
41 let search_mode = gtk::DropDown::builder()
42 .model(&mode_model)
43 .selected(0)
44 .tooltip_text(
45 "Smart: CLIP-based semantic search.\n\
46 Filename: matches against file names.\n\
47 OCR: matches text inside images.",
48 )
49 .build();
50
51 let search_entry = gtk::SearchEntry::builder()
52 .placeholder_text("Describe what you're looking for\u{2026}")
53 .hexpand(true)
54 .build();
55
56 let search_bar = gtk::Box::builder()
57 .orientation(gtk::Orientation::Vertical)
58 .spacing(4)
59 .build();
60 search_bar.append(&search_mode);
61 search_bar.append(&search_entry);
62
63 (search_bar, search_mode, search_entry)
64}
65
66fn build_action_row() -> (gtk::Box, gtk::Image, gtk::Button, gtk::Button, gtk::Button) {
67 let action_row = gtk::Box::builder()
68 .orientation(gtk::Orientation::Horizontal)
69 .spacing(6)
70 .margin_top(2)
71 .build();
72
73 let toggle_icon = gtk::Image::from_icon_name("pan-end-symbolic");
74 let toggle_button = gtk::Button::builder()
75 .child(&{
76 let b = gtk::Box::builder()
77 .orientation(gtk::Orientation::Horizontal)
78 .spacing(4)
79 .build();
80 b.append(&toggle_icon);
81 b.append(
82 >k::Label::builder()
83 .label("Filters")
84 .css_classes(vec!["caption".to_string()])
85 .build(),
86 );
87 b
88 })
89 .css_classes(vec!["flat".to_string()])
90 .build();
91
92 let clear_button = gtk::Button::builder()
93 .label("Clear")
94 .css_classes(vec!["flat".to_string()])
95 .build();
96 let search_button = gtk::Button::builder()
97 .label("Search")
98 .css_classes(vec!["suggested-action".to_string()])
99 .build();
100
101 action_row.append(&toggle_button);
102 action_row.append(>k::Box::builder().hexpand(true).build());
104 action_row.append(&clear_button);
105 action_row.append(&search_button);
106
107 (
108 action_row,
109 toggle_icon,
110 toggle_button,
111 clear_button,
112 search_button,
113 )
114}
115
116#[allow(clippy::type_complexity)]
118fn build_filter_panel() -> FilterWidgets {
119 let (text_group, filename_row, description_row, ocr_row) = build_text_group();
120 let (flags_group, type_row, fav, archived, motion, not_album, deleted, vis, rating) =
121 build_flags_group();
122 let (date_group, taken_after, taken_before, created_after, created_before) = build_date_group();
123 let (camera_group, make_row, model_row, lens_row) = build_camera_group();
124 let (loc_group, country, state, city) = build_location_group();
125
126 let filter_box = gtk::Box::builder()
127 .orientation(gtk::Orientation::Vertical)
128 .spacing(6)
129 .margin_top(4)
130 .build();
131 filter_box.append(&text_group);
132 filter_box.append(&flags_group);
133 filter_box.append(&date_group);
134 filter_box.append(&camera_group);
135 filter_box.append(&loc_group);
136
137 let revealer = gtk::Revealer::builder()
138 .transition_type(gtk::RevealerTransitionType::SlideDown)
139 .transition_duration(200)
140 .reveal_child(false)
141 .child(&filter_box)
142 .build();
143
144 FilterWidgets {
145 revealer,
146 filename_row,
147 description_row,
148 ocr_row,
149 type_row,
150 favorite_row: fav,
151 archived_row: archived,
152 motion_row: motion,
153 not_in_album_row: not_album,
154 with_deleted_row: deleted,
155 visibility_row: vis,
156 rating_row: rating,
157 taken_after_row: taken_after,
158 taken_before_row: taken_before,
159 created_after_row: created_after,
160 created_before_row: created_before,
161 make_row,
162 model_row,
163 lens_row,
164 country_row: country,
165 state_row: state,
166 city_row: city,
167 }
168}
169
170fn connect_filter_toggle(
172 toggle_button: >k::Button,
173 toggle_icon: gtk::Image,
174 revealer: gtk::Revealer,
175) {
176 let expanded = Rc::new(Cell::new(false));
177 toggle_button.connect_clicked(move |_| {
178 let now = !expanded.get();
179 expanded.set(now);
180 revealer.set_reveal_child(now);
181 toggle_icon.set_icon_name(Some(if now {
182 "pan-down-symbolic"
183 } else {
184 "pan-end-symbolic"
185 }));
186 });
187}
188
189pub fn build_search_view() -> SearchViewParts {
196 let inner = gtk::Box::builder()
197 .orientation(gtk::Orientation::Vertical)
198 .spacing(6)
199 .margin_top(8)
200 .margin_bottom(4)
201 .margin_start(8)
202 .margin_end(8)
203 .build();
204
205 let (search_bar, search_mode, search_entry) = build_search_bar();
206 inner.append(&search_bar);
207
208 let (action_row, toggle_icon, toggle_button, clear_button, search_button) = build_action_row();
209 inner.append(&action_row);
210
211 let filters = build_filter_panel();
212 inner.append(&filters.revealer);
213
214 let root = wrap_in_scrolled_revealer(&inner);
215 connect_filter_toggle(&toggle_button, toggle_icon, filters.revealer.clone());
216
217 search_mode.connect_selected_notify(clone!(
218 #[weak]
219 search_entry,
220 move |dd| {
221 let ph = match dd.selected() {
222 1 => "Search filenames",
223 2 => "Find words shown inside images",
224 _ => "Describe what you're looking for\u{2026}",
225 };
226 search_entry.set_placeholder_text(Some(ph));
227 }
228 ));
229
230 SearchViewParts {
231 root,
232 search_entry,
233 search_mode,
234 search_button,
235 clear_button,
236 filters,
237 }
238}
239
240fn wrap_in_scrolled_revealer(inner: >k::Box) -> gtk::Revealer {
241 let scrolled = gtk::ScrolledWindow::builder()
242 .hscrollbar_policy(gtk::PolicyType::Automatic)
243 .propagate_natural_height(true)
244 .max_content_height(320)
245 .css_classes(["mimick-search-scroll"])
246 .child(inner)
247 .build();
248
249 gtk::Revealer::builder()
250 .transition_type(gtk::RevealerTransitionType::SlideDown)
251 .transition_duration(200)
252 .reveal_child(false)
253 .child(&scrolled)
254 .build()
255}
256
257pub fn collect_filters(view: &SearchViewParts) -> MetadataSearchFilters {
263 MetadataSearchFilters {
264 original_file_name: opt_string(&view.filters.filename_row.text()),
265 description: opt_string(&view.filters.description_row.text()),
266 ocr: opt_string(&view.filters.ocr_row.text()),
267 asset_type: match view.filters.type_row.selected() {
268 1 => Some("IMAGE".into()),
269 2 => Some("VIDEO".into()),
270 _ => None,
271 },
272 taken_after: normalise_iso_date(&view.filters.taken_after_row.text()),
273 taken_before: normalise_iso_date(&view.filters.taken_before_row.text()),
274 created_after: normalise_iso_date(&view.filters.created_after_row.text()),
275 created_before: normalise_iso_date(&view.filters.created_before_row.text()),
276 make: opt_string(&view.filters.make_row.text()),
277 model: opt_string(&view.filters.model_row.text()),
278 lens_model: opt_string(&view.filters.lens_row.text()),
279 country: opt_string(&view.filters.country_row.text()),
280 state: opt_string(&view.filters.state_row.text()),
281 city: opt_string(&view.filters.city_row.text()),
282 is_favorite: opt_true(view.filters.favorite_row.is_active()),
283 is_archived: opt_true(view.filters.archived_row.is_active()),
284 is_motion: opt_true(view.filters.motion_row.is_active()),
285 is_not_in_album: opt_true(view.filters.not_in_album_row.is_active()),
286 with_deleted: opt_true(view.filters.with_deleted_row.is_active()),
287 visibility: match view.filters.visibility_row.selected() {
288 1 => Some("timeline".into()),
289 2 => Some("archive".into()),
290 3 => Some("hidden".into()),
291 4 => Some("locked".into()),
292 _ => None,
293 },
294 rating: match view.filters.rating_row.selected() {
295 1 => Some(1),
296 2 => Some(2),
297 3 => Some(3),
298 4 => Some(4),
299 5 => Some(5),
300 _ => None,
301 },
302 ..Default::default()
303 }
304}
305
306pub fn clear_all_filters(view: &SearchViewParts) {
308 view.search_entry.set_text("");
309 view.filters.filename_row.set_text("");
310 view.filters.description_row.set_text("");
311 view.filters.ocr_row.set_text("");
312 view.filters.type_row.set_selected(0);
313 view.filters.favorite_row.set_active(false);
314 view.filters.archived_row.set_active(false);
315 view.filters.motion_row.set_active(false);
316 view.filters.not_in_album_row.set_active(false);
317 view.filters.with_deleted_row.set_active(false);
318 view.filters.visibility_row.set_selected(0);
319 view.filters.rating_row.set_selected(0);
320 view.filters.taken_after_row.set_text("");
321 view.filters.taken_before_row.set_text("");
322 view.filters.created_after_row.set_text("");
323 view.filters.created_before_row.set_text("");
324 view.filters.make_row.set_text("");
325 view.filters.model_row.set_text("");
326 view.filters.lens_row.set_text("");
327 view.filters.country_row.set_text("");
328 view.filters.state_row.set_text("");
329 view.filters.city_row.set_text("");
330}
331
332pub fn opt_string(text: >k::glib::GString) -> Option<String> {
334 let trimmed = text.trim();
335 if trimmed.is_empty() {
336 None
337 } else {
338 Some(trimmed.to_string())
339 }
340}
341
342fn opt_true(active: bool) -> Option<bool> {
343 if active { Some(true) } else { None }
344}
345
346pub fn normalise_iso_date(text: >k::glib::GString) -> Option<String> {
349 let trimmed = text.trim();
350 if trimmed.is_empty() {
351 return None;
352 }
353 if chrono::DateTime::parse_from_rfc3339(trimmed).is_ok() {
354 return Some(trimmed.to_string());
355 }
356 if chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d").is_ok() {
357 return Some(format!("{trimmed}T00:00:00.000Z"));
358 }
359 None
360}