root/src/browse_page.rs

// browse_page.rs
//
// Copyright 2020-2024 nee <nee-git@hidamari.blue>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
use crate::booru::{Post, TagDB, TagType};
use crate::data::*;
use crate::saved_searches_sidebar::*;
use crate::send;
use crate::settings_data::SavedSearch;
use crate::sidebar_booru_select::*;
use crate::thumb::*;

use adw::prelude::*;
use gtk::glib::Properties;
use gtk::glib::clone;
use gtk::subclass::prelude::*;
use gtk::{NoSelection, SignalListItemFactory};
use gtk::{gio, glib};
use std::cell::{Cell, OnceCell, RefCell};
use tokio::sync::mpsc::Sender;

mod imp {
    use super::*;

    #[derive(Debug, Default, gtk::CompositeTemplate, Properties)]
    #[properties(wrapper_type = super::BrowsePage)]
    #[template(resource = "/blue/hidamari/boorus/ui/browse.ui")]
    pub struct BrowsePage {
        #[template_child]
        pub split: TemplateChild<adw::OverlaySplitView>,
        #[template_child]
        pub scroll: TemplateChild<gtk::ScrolledWindow>,
        #[template_child]
        pub booru_list: TemplateChild<adw::Bin>,
        #[template_child]
        pub saved_searches: TemplateChild<adw::Bin>,
        #[template_child]
        pub stack: TemplateChild<gtk::Stack>,
        #[template_child]
        pub grid_view: TemplateChild<gtk::GridView>,
        #[template_child]
        pub load_index: TemplateChild<gtk::Button>,
        #[template_child]
        pub search: TemplateChild<gtk::SearchEntry>,
        #[template_child]
        pub autocomplete: TemplateChild<gtk::Popover>,
        #[template_child]
        pub current_page_label: TemplateChild<gtk::Label>,

        #[template_child]
        pub save_search: TemplateChild<gtk::Button>,
        #[template_child]
        pub submit_search: TemplateChild<gtk::Button>,
        #[template_child]
        pub page_info_box: TemplateChild<gtk::Box>,
        #[template_child]
        pub next_page: TemplateChild<gtk::Button>,

        #[property(get, set)]
        pub narrow: Cell<bool>,

        #[property(get, set)]
        pub saved_searches_page: Cell<bool>,

        pub saved_searches_sidebar: RefCell<SavedSearchesSidebar>,
        pub model: OnceCell<gio::ListStore>,
    }

    #[glib::object_subclass]
    impl ObjectSubclass for BrowsePage {
        const NAME: &'static str = "BrowsePage";
        type Type = super::BrowsePage;
        type ParentType = gtk::Box;

        fn class_init(klass: &mut Self::Class) {
            klass.bind_template();
        }

        // You must call `Widget`'s `init_template()` within `instance_init()`.
        fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
            obj.init_template();
        }
    }

    #[glib::derived_properties]
    impl ObjectImpl for BrowsePage {
        fn constructed(&self) {
            self.parent_constructed();
        }
    }

    impl WidgetImpl for BrowsePage {}
    impl BoxImpl for BrowsePage {}
}

glib::wrapper! {
    pub struct BrowsePage(ObjectSubclass<imp::BrowsePage>)
        @extends gtk::Widget, gtk::Box,
        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
}

#[derive(Debug)]
pub enum BrowseAction {
    BooruListChanged,
    SavedSearchesChanged,
}

impl BrowsePage {
    pub fn init(&self, state: &State) {
        let sender = &state.sender;

        let action_group = gio::SimpleActionGroup::new();
        let add_to_search = gio::SimpleAction::new("add-to-search", Some(glib::VariantTy::STRING));
        add_to_search.connect_activate(clone!(
            #[strong]
            sender,
            move |_, text| {
                send!(
                    sender,
                    Action::AddToSearch {
                        tag: text.unwrap().get::<String>().unwrap()
                    }
                );
            }
        ));
        action_group.add_action(&add_to_search);
        self.insert_action_group("browse", Some(&action_group));

        self.bind_property("narrow", &self.imp().split.get(), "collapsed")
            .flags(glib::BindingFlags::SYNC_CREATE)
            .build();

        self.bind_property(
            "saved_searches_page",
            &self.imp().page_info_box.get(),
            "visible",
        )
        .flags(glib::BindingFlags::SYNC_CREATE | glib::BindingFlags::INVERT_BOOLEAN)
        .build();

        self.imp().scroll.connect_edge_reached(clone!(
            #[weak(rename_to = this)]
            self,
            #[strong]
            sender,
            move |_, edge| {
                if this.saved_searches_page() {
                    return;
                }
                if edge == gtk::PositionType::Bottom {
                    send!(sender, Action::LoadNextPage);
                }
            }
        ));

        self.imp().scroll.connect_edge_overshot(clone!(
            #[weak(rename_to = this)]
            self,
            #[strong]
            sender,
            move |_, edge| {
                if this.saved_searches_page() {
                    return;
                }
                if edge == gtk::PositionType::Bottom {
                    send!(sender, Action::LoadNextPage);
                }
            }
        ));

        self.imp().search.connect_changed(clone!(
            #[strong]
            sender,
            move |e| {
                let text = e.text();
                send!(
                    sender,
                    Action::Search {
                        search: text.to_string()
                    }
                );
            }
        ));

        self.imp().search.connect_search_changed(clone!(
            #[strong]
            sender,
            move |e| {
                let text = e.text();
                send!(
                    sender,
                    Action::Autocomplete {
                        search: text.to_string()
                    }
                );
            }
        ));

        self.imp().search.connect_changed(clone!(
            #[weak(rename_to=this)]
            self,
            move |_| {
                this.imp().autocomplete.popdown();
            }
        ));
        self.imp().search.connect_activate(clone!(
            #[strong]
            sender,
            move |_e| {
                send!(sender, Action::ReloadIndex);
            }
        ));

        // let booru_box = gtk::Box::new(gtk::Orientation::Vertical, 3);
        self.imp()
            .booru_list
            .set_child(Some(&make_booru_selection_list(state, false)));

        self.imp().next_page.connect_clicked(clone!(
            #[weak(rename_to=this)]
            self,
            #[strong]
            sender,
            move |_| {
                if this.saved_searches_page() {
                    return;
                }
                send!(sender, Action::LoadNextPage);
            }
        ));

        let saved_searches_sidebar = SavedSearchesSidebar::default();
        saved_searches_sidebar.init(sender.clone());
        saved_searches_sidebar.fill_search_list(
            &sender.clone(),
            &state.boorus,
            &state.saved_searches,
        );
        self.imp()
            .saved_searches
            .set_child(Some(&saved_searches_sidebar.root));
        *self.imp().saved_searches_sidebar.borrow_mut() = saved_searches_sidebar;

        let model = gtk::gio::ListStore::new::<ThumbObject>();
        let selection_model = NoSelection::new(Some(model.clone()));
        let factory = setup_factory(sender, state.preferences.thumb_size);
        self.imp().grid_view.set_model(Some(&selection_model));
        self.imp().grid_view.set_factory(Some(&factory));
        if let Err(err) = self.imp().model.set(model) {
            error!("Already initalized browse gridview {err:#?}");
        }
    }

    pub fn do_action(&self, action: BrowseAction, state: &State) {
        match action {
            BrowseAction::BooruListChanged => {
                self.imp()
                    .booru_list
                    .set_child(Some(&make_booru_selection_list(state, false)));
            }
            BrowseAction::SavedSearchesChanged => {
                self.imp().saved_searches_sidebar.borrow().fill_search_list(
                    &state.sender.clone(),
                    &state.boorus,
                    &state.saved_searches,
                );
            }
        }
    }

    pub fn set_viewed(&self, post: &dyn Post) {
        for list_item in self.model().into_iter() {
            let thumb_object = list_item
                .expect("set_viewed: The item has to exist.")
                .downcast::<ThumbObject>()
                .expect("set_viewed: The item has to be an `ThumbObject`.");
            let data = thumb_object.object_data();
            if data.borrow().post.as_ref().map(|p| p.id()) == Some(post.id()) {
                thumb_object.set_viewed(true);
                return;
            }
        }
    }

    pub fn texture(&self, post: &dyn Post) -> Option<gtk::gdk::Texture> {
        for list_item in self.model().into_iter() {
            let thumb_object = list_item
                .expect("set_viewed: The item has to exist.")
                .downcast::<ThumbObject>()
                .expect("set_viewed: The item has to be an `ThumbObject`.");
            let data = thumb_object.object_data();
            let matches = data.borrow().post.as_ref().map(|p| p.id()) == Some(post.id());
            if matches {
                return data.borrow().texture.clone();
            }
        }
        None
    }

    pub fn saved_search_added(&self, state: &State, search: &SavedSearch) {
        let popover = SavedSearchesSidebar::make_saved_search_popover(
            &state.sender,
            &state.boorus,
            search.clone(),
        );
        popover.set_parent(&self.imp().save_search.get());

        self.do_action(BrowseAction::SavedSearchesChanged, state);
        popover.popup();
    }

    pub fn model(&self) -> &gio::ListStore {
        self.imp().model.get().unwrap()
    }

    pub fn current_page_label(&self) -> gtk::Label {
        self.imp().current_page_label.get()
    }

    pub fn search(&self) -> gtk::SearchEntry {
        self.imp().search.get()
    }

    pub fn update_autocomplete(&self, tagdb: &TagDB, text: &str) {
        if !self.imp().autocomplete.is_visible() {
            if text.len() > 1 {
                println!("SEARCH");

                let results = autocomplete(tagdb, text);
                if !results.is_empty() {
                    let widget = autocomplete_popover_results(results);
                    self.imp().autocomplete.set_child(Some(&widget));
                    self.imp().autocomplete.popup();
                }
            }
        } else if text.len() < 2 {
            self.imp().autocomplete.set_child(gtk::Widget::NONE);
            self.imp().autocomplete.popdown();
        } else {
            let results = autocomplete(tagdb, text);
            if results.is_empty() {
                self.imp().autocomplete.set_child(gtk::Widget::NONE);
                self.imp().autocomplete.popdown();
            } else {
                let widget = autocomplete_popover_results(results);
                self.imp().autocomplete.set_child(Some(&widget));
            }
        }
    }

    pub fn saved_searches_done(&self, any_results: bool) {
        if any_results {
            self.imp().stack.set_visible_child_name("grid");
        } else {
            self.imp().stack.set_visible_child_name("no-new-posts");
        }
    }

    pub fn search_done(&self, sender: &Sender<Action>, any_results: bool, is_last_page: bool) {
        info!(is_last_page);
        if any_results {
            self.imp().stack.set_visible_child_name("grid");
        } else if is_last_page {
            send!(sender, Action::MakeToast("Last Page Reached".to_string()));
        } else {
            self.imp().stack.set_visible_child_name("no-results");
        }
    }

    pub fn show_gridview(&self) {
        self.imp().stack.set_visible_child_name("grid");
    }
}

fn setup_factory(sender: &Sender<Action>, thumb_size: u32) -> SignalListItemFactory {
    let sender = sender.clone();
    // Create a new factory
    let factory = SignalListItemFactory::new();

    // Create an empty `ThumbRow` during setup
    factory.connect_setup(move |_, item| {
        // Create `gtk::Overlay`
        let item = item.downcast_ref::<gtk::ListItem>().unwrap();
        let thumb_row = gtk::Overlay::new();
        item.set_child(Some(&thumb_row));
    });

    // Tell factory how to bind `gtk::Overlay` to a `ThumbObject`
    factory.connect_bind(move |_, item| {
        let item = item.downcast_ref::<gtk::ListItem>().unwrap();
        // Get `ThumbObject` from `ListItem`

        let thumb_object = item
            .item()
            .expect("The item has to exist.")
            .downcast::<ThumbObject>()
            .expect("The item has to be an `ThumbObject`.");

        // Get `gtk::Overlay` from `ListItem`
        let thumb_row = item
            .child()
            .expect("The child has to exist.")
            .downcast::<gtk::Overlay>()
            .expect("The child has to be a `gtk::Overlay`.");

        let object = thumb_object.object_data();
        let data = object.borrow();
        if let (Some(image), Some(post)) = (data.image.as_ref(), data.post.as_ref()) {
            init_thumb(
                &thumb_object,
                &thumb_row,
                &sender,
                image,
                post.clone_post(),
                thumb_size,
            );
        }
        // thumb_row.bind(&thumb_object);
    });

    // Tell factory how to unbind `gtk::Overlay` from `ThumbObject`
    factory.connect_unbind(move |_, item| {
        // Get `gtk::Overlay` from `ListItem`
        let item = item.downcast_ref::<gtk::ListItem>().unwrap();
        let thumb_row = item
            .child()
            .expect("The child has to exist.")
            .downcast::<gtk::Overlay>()
            .expect("The child has to be a `gtk::Overlay`.");

        // thumb_row.unbind();
        uninit_thumb(&thumb_row);
    });

    // Set the factory of the list view
    // self.grid_view.set_factory(Some(&factory));
    factory
}

pub fn browse_page_sort(
    a: &gtk::glib::object::Object,
    b: &gtk::glib::object::Object,
) -> core::cmp::Ordering {
    let a2 = a
        .downcast_ref::<ThumbObject>()
        .expect("The item has to be an `ThumbObject`.");
    let b2 = b
        .downcast_ref::<ThumbObject>()
        .expect("The item has to be an `ThumbObject`.");

    let a_object = a2.object_data();
    let b_object = b2.object_data();

    if let (Some(post_a), Some(post_b)) = (
        a_object.borrow().post.as_ref(),
        b_object.borrow().post.as_ref(),
    ) {
        return post_a.sort_id().cmp(&post_b.sort_id()).reverse();
    }
    core::cmp::Ordering::Equal
}

fn autocomplete(tagdb: &TagDB, text: &str) -> Vec<(String, (TagType, u32))> {
    if !text.len() > 1 {
        let mut vec: Vec<_> = tagdb
            .iter()
            .filter_map(|(tag_text, tag_type)| {
                if tag_text.starts_with(text) {
                    // return ture as sort_hint, starts_with should come first in results
                    Some((true, (tag_text.clone(), *tag_type)))
                } else if tag_text.contains(text) {
                    Some((false, (tag_text.clone(), *tag_type)))
                } else {
                    None
                }
            })
            .collect();
        vec.sort_by(
            |(start_a, (text_a, (_, count_a))), (start_b, (text_b, (_, count_b)))| {
                let start_diff = start_b.cmp(start_a);
                if start_diff != std::cmp::Ordering::Equal {
                    start_diff
                } else {
                    let count_diff = count_b.cmp(count_a);
                    if count_diff != std::cmp::Ordering::Equal {
                        count_diff
                    } else {
                        text_a.len().cmp(&text_b.len())
                    }
                }
            },
        );
        vec.into_iter().map(|(_, data)| data).collect()
    } else {
        Vec::new()
    }
}

fn autocomplete_popover_results(results: Vec<(String, (TagType, u32))>) -> gtk::Box {
    let widget = gtk::Box::new(gtk::Orientation::Vertical, 5);
    for (tag_text, (tagtype, count)) in results.into_iter().take(40) {
        let button = gtk::Button::new();
        button.set_action_name(Some("browse.add-to-search"));
        button.set_action_target_value(Some(&tag_text.to_variant()));
        let content = gtk::Box::new(gtk::Orientation::Horizontal, 5);
        let tag_string = crate::booru::display_tag(&tag_text);
        let tag_label = gtk::Label::new(Some(tag_string.as_str()));
        let count_label = gtk::Label::new(Some(format!("({count})").as_str()));
        content.append(&tag_label);
        content.append(&count_label);
        button.set_child(Some(&content));
        button.add_css_class("tag");
        button.add_css_class(tagtype.css_class());
        widget.append(&button);
    }
    // let window = gtk::ScrolledWindow::new();
    // window.set_child(Some(&widget));
    // window
    widget
}