image_sieve/controller/
items_controller.rs

1use std::{
2    rc::Rc,
3    sync::{Arc, Mutex},
4};
5
6use slint::Model;
7
8use crate::{
9    item_sort_list::{timestamp_to_string, FileItem, Format, ItemList},
10    main_window,
11    misc::image_cache,
12};
13
14use super::helper;
15
16pub struct ItemsController {
17    item_list: Arc<Mutex<ItemList>>,
18    list_model: Rc<slint::VecModel<main_window::ListItem>>,
19    similar_items_model: Rc<slint::VecModel<main_window::SortItem>>,
20    image_cache: image_cache::ImageCache,
21}
22
23impl ItemsController {
24    /// Create a new items controller instance
25    pub fn new(item_list: Arc<Mutex<ItemList>>) -> Self {
26        let mut image_cache = image_cache::ImageCache::new();
27        image_cache.restrict_size(1600, 1000);
28
29        Self {
30            item_list,
31            list_model: Rc::new(slint::VecModel::<main_window::ListItem>::default()),
32            similar_items_model: Rc::new(slint::VecModel::<main_window::SortItem>::default()),
33            image_cache,
34        }
35    }
36
37    /// Gets the slint vec model for the item list
38    pub fn get_list_model(&self) -> Rc<slint::VecModel<main_window::ListItem>> {
39        self.list_model.clone()
40    }
41
42    /// Gets the slint vec model for the similar items
43    pub fn get_similar_items_model(&self) -> Rc<slint::VecModel<main_window::SortItem>> {
44        self.similar_items_model.clone()
45    }
46
47    /// Clear the list model
48    pub fn clear_list(&mut self) {
49        helper::clear_model(self.list_model.clone());
50    }
51
52    /// Clear the similar items model
53    pub fn clear_similar_items(&mut self) {
54        helper::clear_model(self.similar_items_model.clone());
55    }
56
57    /// Gets the index of an item by its path
58    pub fn find_index_by_path(&self, path: &str) -> Option<usize> {
59        let item_list = self.item_list.lock().unwrap();
60        item_list.index_of_path(path)
61    }
62
63    /// Notifies that a model from the list was selected and performs all necessary actions
64    /// to fill the similar items model and the current image
65    pub fn selected_list_item(
66        &mut self,
67        list_model_index: usize,
68        window: slint::Weak<main_window::ImageSieve>,
69    ) {
70        if list_model_index >= self.list_model.row_count() {
71            return;
72        }
73        {
74            // Clear images model
75            self.clear_similar_items();
76
77            let items_index = self
78                .list_model
79                .row_data(list_model_index)
80                .unwrap()
81                .local_index as usize;
82            let item_list = self.item_list.lock().unwrap();
83            let similars = item_list.items[items_index].get_similars();
84
85            // Clear pending commands in the image cache
86            self.image_cache.purge();
87
88            // Add the current image
89            let item = &item_list.items[items_index];
90            let image = self.get_item_image(
91                item,
92                0,
93                items_index as i32,
94                true,
95                !similars.is_empty(),
96                window.clone(),
97            );
98            let sort_image = sort_item_from_file_item(item, &item_list, image);
99            self.similar_items_model.push(sort_image);
100
101            // Now add all similar images
102            let mut model_index = 1;
103            for image_index in similars {
104                let item = &item_list.items[*image_index];
105                let image = self.get_item_image(
106                    item,
107                    model_index,
108                    items_index as i32,
109                    false,
110                    !similars.is_empty(),
111                    window.clone(),
112                );
113                let sort_image = sort_item_from_file_item(item, &item_list, image);
114                self.similar_items_model.push(sort_image);
115                model_index += 1;
116            }
117        }
118
119        // Set the data of the current image
120        window
121            .unwrap()
122            .set_current_image(self.similar_items_model.row_data(0).unwrap());
123        window.unwrap().invoke_current_image_changed();
124
125        // And prefetch the next images
126        self.prefetch_images(list_model_index);
127    }
128
129    /// Sets the take over state of an item
130    pub fn set_take_over(&mut self, local_index: i32, take_over: bool) -> slint::SharedString {
131        let description = {
132            // Change the item_list state
133            let mut item_list = self.item_list.lock().unwrap();
134            item_list.items[local_index as usize].set_take_over(take_over);
135            sort_item_description(&item_list.items[local_index as usize], &item_list)
136        };
137        // Update item list model to reflect change in icons in list
138        self.update_list_model();
139        // And update the take over state in the similar items model
140        for count in 0..self.similar_items_model.row_count() {
141            let mut item: main_window::SortItem = self.similar_items_model.row_data(count).unwrap();
142            if item.local_index == local_index {
143                item.take_over = take_over;
144                item.text = description.clone();
145                self.similar_items_model.set_row_data(count, item);
146                break;
147            }
148        }
149        description
150    }
151
152    /// Update the texts for all entries in the list model and returns true if the list contains more than one item
153    /// Should be called when the underlying data (i.e. the item list) has changed
154    pub fn update_list_model(&mut self) -> bool {
155        let item_list = self.item_list.lock().unwrap();
156        for count in 0..self.list_model.row_count() {
157            let mut list_item = self.list_model.row_data(count).unwrap();
158            let file_item = &item_list.items[list_item.local_index as usize];
159            list_item.text = list_item_title(file_item, &item_list);
160            self.list_model.set_row_data(count, list_item);
161        }
162        !item_list.items.is_empty()
163    }
164
165    /// Fills the list of found items from the internal data structure to the slint VecModel
166    pub fn populate_list_model(&mut self, filters: &main_window::Filters) -> usize {
167        self.clear_list();
168
169        let item_list = self.item_list.lock().unwrap();
170        let mut filtered_list: Vec<&FileItem> = item_list
171            .items
172            .iter()
173            .filter(|item| filter_file_items(item, filters))
174            .collect();
175        filtered_list.sort_unstable_by(|a, b| compare_file_items(a, b, filters));
176        if filters.direction == "Desc" {
177            filtered_list.reverse();
178        }
179        let list_len = filtered_list.len();
180        for image in filtered_list {
181            let list_item = list_item_from_file_item(image, &item_list);
182            self.list_model.push(list_item);
183        }
184        list_len
185    }
186
187    /// Gets the date string for an image
188    pub fn get_date_string(&self, local_index: i32) -> slint::SharedString {
189        let item_list = self.item_list.lock().unwrap();
190        let item = &item_list.items[local_index as usize];
191        slint::SharedString::from(timestamp_to_string(item.get_timestamp(), Format::Date))
192    }
193
194    /// Gets the image for an item
195    /// This function returns either a cached image or a loading image while the real image is being loaded
196    /// in the background. As soon as the process finishes, the image is displayed.
197    fn get_item_image(
198        &self,
199        item: &FileItem,
200        model_index: usize,
201        current_item_local_index: i32,
202        is_current_image: bool,
203        has_similars: bool,
204        window_weak: slint::Weak<main_window::ImageSieve>,
205    ) -> slint::Image {
206        let image = self.image_cache.get(item);
207        if let Some(image) = image {
208            image
209        } else {
210            let f: image_cache::DoneCallback = Box::new(move |image_buffer| {
211                window_weak
212                    .clone()
213                    .upgrade_in_event_loop(move |handle| {
214                        // Check if still the image is visible that caused the image loads
215                        if handle.get_current_image().local_index == current_item_local_index {
216                            let mut row_data = handle
217                                .get_similar_images_model()
218                                .row_data(model_index)
219                                .unwrap();
220                            if has_similars {
221                                row_data.image =
222                                    crate::misc::images::get_slint_image(&image_buffer);
223                                handle
224                                    .get_similar_images_model()
225                                    .set_row_data(model_index, row_data);
226                            }
227                            // If the image is the current image, then we need to also update the current image SortImage
228                            if is_current_image {
229                                let mut current_image = handle.get_current_image();
230                                current_image.image =
231                                    crate::misc::images::get_slint_image(&image_buffer);
232                                handle.set_current_image(current_image);
233                                handle.invoke_current_image_changed();
234                            }
235                        }
236                    })
237                    .unwrap()
238            });
239            self.image_cache.load(
240                item,
241                if is_current_image {
242                    image_cache::Purpose::CurrentImage
243                } else {
244                    image_cache::Purpose::SimilarImage
245                },
246                Some(f),
247            );
248            self.image_cache.get_waiting()
249        }
250    }
251
252    /// Prefetch the next images in the model list
253    fn prefetch_images(&self, list_model_index: usize) {
254        // Prefetch next two images
255        for i in list_model_index + 1..list_model_index + 3 {
256            if i < self.list_model.row_count() {
257                let item_list = self.item_list.lock().unwrap();
258                let list_item = &self.list_model.row_data(i).unwrap();
259                let file_item = &item_list.items[list_item.local_index as usize];
260                if file_item.is_image() {
261                    self.image_cache
262                        .load(file_item, image_cache::Purpose::Prefetch, None);
263                }
264            }
265        }
266    }
267}
268
269/// Filter file items to display in the item list
270fn filter_file_items(file_item: &FileItem, filters: &main_window::Filters) -> bool {
271    let mut visible = true;
272    if !filters.images && (file_item.is_image() || file_item.is_raw_image()) {
273        visible = false;
274    }
275    if !filters.videos && file_item.is_video() {
276        visible = false;
277    }
278    if !filters.sorted_out && !file_item.get_take_over() {
279        visible = false;
280    }
281    if filters.only_similars && file_item.get_similars().is_empty() {
282        visible = false;
283    }
284    visible
285}
286
287/// Compare two file items taking the current sort settings into account
288fn compare_file_items(
289    a: &FileItem,
290    b: &FileItem,
291    filters: &main_window::Filters,
292) -> std::cmp::Ordering {
293    match filters.sort_by.as_str() {
294        "Date" => a.cmp(b),
295        "Name" => a.path.cmp(&b.path),
296        "Type" => {
297            if a.is_image() && b.is_image() {
298                a.cmp(b)
299            } else if a.is_image() && b.is_video() {
300                std::cmp::Ordering::Less
301            } else {
302                std::cmp::Ordering::Greater
303            }
304        }
305        "Size" => a.get_size().cmp(&b.get_size()),
306        _ => panic!("Unknown sort by type"),
307    }
308}
309
310/// Create a sort item for the GUI from a file item
311fn sort_item_from_file_item(
312    file_item: &FileItem,
313    item_list: &ItemList,
314    image: slint::Image,
315) -> main_window::SortItem {
316    main_window::SortItem {
317        text: sort_item_description(file_item, item_list),
318        image,
319        take_over: file_item.get_take_over(),
320        local_index: item_list.index_of_item(file_item).unwrap() as i32,
321    }
322}
323
324/// Gets the description of a sort item from a file item
325fn sort_item_description(file_item: &FileItem, item_list: &ItemList) -> slint::SharedString {
326    let mut description = format!("{}", file_item);
327    if let Some(event) = item_list.get_event(file_item) {
328        description = description + ", 📅 " + &event.name;
329    }
330    slint::SharedString::from(description)
331}
332
333/// Get the list item title for the GUI from a file item
334fn list_item_title(file_item: &FileItem, item_list: &ItemList) -> slint::SharedString {
335    let mut title = file_item.get_item_string(&item_list.path);
336    if item_list.get_event(file_item).is_some() {
337        title = String::from("📅 ") + &title;
338    }
339    slint::SharedString::from(title)
340}
341
342/// Create a list item for the GUI from a file item
343fn list_item_from_file_item(file_item: &FileItem, item_list: &ItemList) -> main_window::ListItem {
344    main_window::ListItem {
345        text: list_item_title(file_item, item_list),
346        local_index: item_list.index_of_item(file_item).unwrap() as i32,
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use crate::main_window::ImageSieve;
353    use rusty_fork::rusty_fork_test;
354    use slint::{ComponentHandle, SharedString};
355
356    use super::*;
357
358    fn build_filters() -> main_window::Filters {
359        main_window::Filters {
360            images: true,
361            videos: true,
362            sorted_out: true,
363            only_similars: false,
364            sort_by: SharedString::from("Date"),
365            direction: SharedString::from("Asc"),
366        }
367    }
368
369    #[test]
370    fn test_populate() {
371        let item_list = Arc::new(Mutex::new(ItemList::new()));
372        let mut items_controller = ItemsController::new(item_list.clone());
373        let mut filters = build_filters();
374        {
375            let mut item_list = item_list.lock().unwrap();
376            item_list.items.push(FileItem::dummy("test2.mov", 1, true));
377            let mut file_item = FileItem::dummy("test1.jpg", 0, false);
378            file_item.add_similar_range(&(1..2));
379            item_list.items.push(file_item);
380        }
381        items_controller.populate_list_model(&filters);
382        let list_model = items_controller.get_list_model();
383        assert_eq!(list_model.row_count(), 2);
384        assert_eq!(list_model.row_data(0).unwrap().local_index, 1);
385        assert_eq!(list_model.row_data(0).unwrap().text, "🔀 📷 🗑 test1.jpg");
386        assert_eq!(list_model.row_data(1).unwrap().local_index, 0);
387        assert_eq!(list_model.row_data(1).unwrap().text, "📹 test2.mov");
388        assert_eq!(Some(1), items_controller.find_index_by_path("test1.jpg"));
389
390        filters.direction = SharedString::from("Desc");
391        items_controller.populate_list_model(&filters);
392        assert_eq!(list_model.row_count(), 2);
393        assert_eq!(list_model.row_data(1).unwrap().local_index, 1);
394        assert_eq!(list_model.row_data(1).unwrap().text, "🔀 📷 🗑 test1.jpg");
395        assert_eq!(list_model.row_data(0).unwrap().local_index, 0);
396        assert_eq!(list_model.row_data(0).unwrap().text, "📹 test2.mov");
397
398        filters.images = false;
399        items_controller.populate_list_model(&filters);
400        assert_eq!(list_model.row_count(), 1);
401        assert_eq!(list_model.row_data(0).unwrap().local_index, 0);
402        assert_eq!(list_model.row_data(0).unwrap().text, "📹 test2.mov");
403
404        filters.images = true;
405        filters.videos = false;
406        items_controller.populate_list_model(&filters);
407        assert_eq!(list_model.row_count(), 1);
408        assert_eq!(list_model.row_data(0).unwrap().local_index, 1);
409        assert_eq!(list_model.row_data(0).unwrap().text, "🔀 📷 🗑 test1.jpg");
410
411        filters.videos = true;
412        filters.sorted_out = false;
413        items_controller.populate_list_model(&filters);
414        assert_eq!(list_model.row_count(), 1);
415        assert_eq!(list_model.row_data(0).unwrap().local_index, 0);
416        assert_eq!(list_model.row_data(0).unwrap().text, "📹 test2.mov");
417
418        filters.sorted_out = true;
419        filters.only_similars = true;
420        items_controller.populate_list_model(&filters);
421        assert_eq!(list_model.row_count(), 1);
422        assert_eq!(list_model.row_data(0).unwrap().local_index, 1);
423        assert_eq!(list_model.row_data(0).unwrap().text, "🔀 📷 🗑 test1.jpg");
424
425        items_controller.clear_list();
426        assert_eq!(items_controller.get_list_model().row_count(), 0);
427    }
428
429    rusty_fork_test! {
430        #[test]
431        fn test_take_over() {
432            let item_list = Arc::new(Mutex::new(ItemList::new()));
433            let mut items_controller = ItemsController::new(item_list.clone());
434            let window = ImageSieve::new().unwrap();
435            let window_weak = window.as_weak();
436            let filters = build_filters();
437            {
438                let mut item_list = item_list.lock().unwrap();
439                item_list.items.push(FileItem::dummy("test2.mov", 1, true));
440                let mut file_item = FileItem::dummy("test1.jpg", 0, false);
441                file_item.add_similar_range(&(0..1));
442                item_list.items.push(file_item);
443            }
444            items_controller.populate_list_model(&filters);
445            items_controller.selected_list_item(1, window_weak);
446
447            items_controller.set_take_over(0, false);
448            {
449                let item_list = item_list.lock().unwrap();
450                assert!(!item_list.items[0].get_take_over());
451            }
452            let list_model = items_controller.get_list_model();
453            let similar_items_model = items_controller.get_similar_items_model();
454            assert_eq!(list_model.row_data(1).unwrap().text, "📹 🗑 test2.mov");
455            assert!(!similar_items_model.row_data(0).unwrap().take_over);
456
457            items_controller.set_take_over(0, true);
458            {
459                let item_list = item_list.lock().unwrap();
460                assert!(item_list.items[0].get_take_over());
461            }
462            assert_eq!(list_model.row_data(1).unwrap().text, "📹 test2.mov");
463            assert!(window.get_current_image().take_over);
464            assert!(similar_items_model.row_data(0).unwrap().take_over);
465        }
466    }
467
468    #[test]
469    fn test_select_item() {
470        let item_list = Arc::new(Mutex::new(ItemList::new()));
471        let mut items_controller = ItemsController::new(item_list.clone());
472        let window = ImageSieve::new().unwrap();
473        let window_weak = window.as_weak();
474        let filters = build_filters();
475        {
476            let mut item_list = item_list.lock().unwrap();
477            item_list.items.push(FileItem::dummy("test2.mov", 1, true));
478            let mut file_item = FileItem::dummy("test1.jpg", 0, false);
479            file_item.add_similar_range(&(0..1));
480            item_list.items.push(file_item);
481        }
482        items_controller.populate_list_model(&filters);
483
484        let similar_items_model = items_controller.get_similar_items_model();
485
486        items_controller.selected_list_item(0, window_weak.clone());
487        assert_eq!(similar_items_model.row_count(), 2);
488        assert_eq!(
489            similar_items_model.row_data(0).unwrap().text,
490            "🔀 📷 🗑 test1.jpg - 1970-01-01 00:00:00, 0 KB"
491        );
492        assert_eq!(
493            similar_items_model.row_data(1).unwrap().text,
494            "📹 test2.mov - 1970-01-01 00:00:01, 0 KB"
495        );
496        assert_eq!(
497            similar_items_model.row_data(0).unwrap().image.size().width as i32,
498            16
499        );
500        assert_eq!(window.get_current_image().image.size().height as i32, 16);
501        assert_eq!(window.get_current_image().local_index, 1);
502
503        items_controller.selected_list_item(1, window_weak);
504        assert_eq!(similar_items_model.row_count(), 1);
505        assert_eq!(window.get_current_image().local_index, 0);
506    }
507
508    #[test]
509    fn test_update_list() {
510        let item_list = Arc::new(Mutex::new(ItemList::new()));
511        let mut items_controller = ItemsController::new(item_list.clone());
512        assert!(!items_controller.update_list_model());
513        let filters = build_filters();
514        {
515            let mut item_list = item_list.lock().unwrap();
516            item_list.items.push(FileItem::dummy("test2.mov", 1, true));
517            let mut file_item = FileItem::dummy("test1.jpg", 0, false);
518            file_item.add_similar_range(&(1..2));
519            item_list.items.push(file_item);
520        }
521        items_controller.populate_list_model(&filters);
522
523        {
524            let mut item_list = item_list.lock().unwrap();
525            item_list.events.push(crate::item_sort_list::Event::new(
526                "Test",
527                "1970-01-01",
528                "1970-01-01",
529            ));
530        }
531        assert!(items_controller.update_list_model());
532        let list_model = items_controller.get_list_model();
533        assert_eq!(list_model.row_count(), 2);
534        assert_eq!(list_model.row_data(0).unwrap().text, "📅 🔀 📷 🗑 test1.jpg");
535        assert_eq!(list_model.row_data(1).unwrap().text, "📅 📹 test2.mov");
536    }
537}