Skip to main content

mimick/library/
search_view.rs

1//! Dedicated search view for the library sidebar.
2//!
3//! The search form is a persistent `Revealer` that sits above the
4//! content_stack.  When search is active the form stays visible and
5//! results render in the normal masonry grid below it.  This avoids
6//! the "search bar disappears" problem while keeping a single grid
7//! renderer.
8
9use 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
22/// UI widgets for the dedicated search form.
23///
24/// `root` is a `gtk::Revealer` intended to sit between the controls
25/// bar and the content_stack in the main layout.  When revealed, it
26/// shows the search entry, mode selector, collapsible filters, and
27/// action buttons.  Search results go into the existing grid.
28pub struct SearchViewParts {
29    /// Revealer wrapping the entire search form.  Inserted into the
30    /// content pane above the content_stack.
31    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                &gtk::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    // Spacer pushes buttons to the right.
103    action_row.append(&gtk::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/// Assemble all filter groups into a revealer.
117#[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
170/// Wire the toggle button to show/hide the filters revealer.
171fn connect_filter_toggle(
172    toggle_button: &gtk::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
189/// Build the search form widget tree.
190///
191/// Returns a `SearchViewParts` whose `root` is a `Revealer`.  The
192/// caller should insert `root` into the content pane above the
193/// `content_stack` so the form stays visible while results load into
194/// the grid.
195pub 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: &gtk::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
257// ---------------------------------------------------------------------------
258// Filter extraction helpers
259// ---------------------------------------------------------------------------
260
261/// Collect all filter widget values into a `MetadataSearchFilters`.
262pub 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
306/// Reset all filter widgets to their default (empty) state.
307pub 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
332/// Convert a non-empty trimmed string to `Some`, otherwise `None`.
333pub fn opt_string(text: &gtk::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
346/// Normalise a user-entered date to ISO 8601 UTC. Accepts bare YYYY-MM-DD
347/// or full RFC 3339.
348pub fn normalise_iso_date(text: &gtk::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}