root/src/application.rs

// application.rs
//
// Copyright 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 adw::prelude::*;
use adw::subclass::prelude::*;
use anyhow::Result;
use gettextrs::gettext;
use gtk::glib::clone;
use gtk::{gio, glib};
use std::cell::RefCell;
use std::convert::TryInto;
use tokio::sync::mpsc::Sender;
use tracing::{debug, info};

use crate::booru::http::http_client;
use crate::booru::{Localbooru, Posts, load_tag_db};
use crate::browse_page::*;
use crate::config::{APP_ID, PKGDATADIR, PROFILE, VERSION};
use crate::data::*;
use crate::image_page::*;
use crate::settings::*;
use crate::thumb::*;
use crate::window::ExampleApplicationWindow;
use crate::{add_action, add_action_value, send, send_async};

mod imp {
    use super::*;
    use glib::WeakRef;
    use std::cell::OnceCell;

    #[derive(Debug, Default)]
    pub struct ExampleApplication {
        pub window: OnceCell<WeakRef<ExampleApplicationWindow>>,
    }

    #[glib::object_subclass]
    impl ObjectSubclass for ExampleApplication {
        const NAME: &'static str = "ExampleApplication";
        type Type = super::ExampleApplication;
        type ParentType = adw::Application;
    }

    impl ObjectImpl for ExampleApplication {}

    impl ApplicationImpl for ExampleApplication {
        fn activate(&self) {
            debug!("GtkApplication<ExampleApplication>::activate");
            self.parent_activate();
            let app = self.obj();

            if let Some(window) = self.window.get() {
                let window = window.upgrade().unwrap();
                window.present();
                return;
            }

            let window = ExampleApplicationWindow::new(&app);
            self.window
                .set(window.downgrade())
                .expect("Window already set.");

            app.setup_receiver();
            app.main_window().present();
        }

        fn startup(&self) {
            debug!("GtkApplication<ExampleApplication>::startup");
            self.parent_startup();
            let app = self.obj();

            // Set icons for shell
            gtk::Window::set_default_icon_name(APP_ID);

            // app.setup_gactions();
            app.setup_accels();
        }
    }

    impl GtkApplicationImpl for ExampleApplication {}
    impl AdwApplicationImpl for ExampleApplication {}
}

glib::wrapper! {
    pub struct ExampleApplication(ObjectSubclass<imp::ExampleApplication>)
        @extends gio::Application, gtk::Application, adw::Application,
        @implements gio::ActionMap, gio::ActionGroup;
}

impl ExampleApplication {
    fn main_window(&self) -> ExampleApplicationWindow {
        self.imp().window.get().unwrap().upgrade().unwrap()
    }

    fn setup_gactions(&self, state: &State) {
        add_action!(state, "quit", Action::Quit);
        add_action!(state, "open-browse", Action::OpenBrowse);
        add_action!(state, "open-image", Action::GotoImage);
        add_action!(state, "open-wiki", Action::GotoWiki);
        add_action!(state, "preferences", Action::OpenSettings);
        add_action!(state, "load-next-page", Action::LoadNextPage);
        add_action!(state, "reload-index", Action::ReloadIndex);
        add_action!(state, "save-search", Action::SaveSearch);
        add_action_value!(
            state,
            "open-browse-search",
            Action::OpenBrowseSearch,
            glib::VariantTy::STRING
        );
        add_action_value!(
            state,
            "open-wiki-page",
            Action::OpenWikiPage,
            glib::VariantTy::STRING
        );
        add_action_value!(
            state,
            "open-artist-page",
            Action::OpenArtistPage,
            glib::VariantTy::STRING
        );

        // About
        let action_about = gio::ActionEntry::builder("about")
            .activate(|app: &Self, _, _| {
                app.show_about_dialog();
            })
            .build();
        self.add_action_entries([action_about]);
    }

    // Sets up keyboard shortcuts
    fn setup_accels(&self) {
        self.set_accels_for_action("app.quit", &["<Control>q"]);
        self.set_accels_for_action("app.open-browse", &["F1"]);
        self.set_accels_for_action("app.open-image", &["F2"]);
        self.set_accels_for_action("app.open-wiki", &["F3"]);
        self.set_accels_for_action("app.preferences", &["<Control>comma"]);
        self.set_accels_for_action("app.load-next-page", &["F4"]);
        self.set_accels_for_action("app.reload-index", &["F5"]);
        // Remember to add new hotkeys to shortcuts-dialog.ui
    }

    fn setup_receiver(&self) {
        let root = self.main_window().stack();

        let (sender, r) = tokio::sync::mpsc::channel(256);
        let init_sender = sender.clone();
        let receiver = RefCell::new(Some(r));
        let mut state = State {
            app: self.clone(),
            stack: root.clone(),
            sender,
            search: "".to_string(),
            page: 0,
            last_page_size: 0,
            active_booru: None,
            boorus: vec![],
            saved_searches: vec![],
            image_page: None,
            settings_page: None,
            loading: Loading::Done,
            last_load: None,
            tagdb: crate::booru::tag_db_empty(),
            preferences: Preferences::default(),
        };
        self.setup_gactions(&state);
        let mut receiver2 = receiver.borrow_mut().take().unwrap();
        gtk::glib::MainContext::default().spawn_local(async move {
            while let Some(action) = receiver2.recv().await {
                // println!("BUFFER {}", receiver2.len());
                do_action(&mut state, action);
            }
        });
        let db_sender = init_sender.clone();
        let now = std::time::Instant::now();
        crate::RUNTIME.spawn_blocking(move || {
            let tagdb = load_tag_db();
            let new_now = std::time::Instant::now();
            info!("Tag db parsed in: {:#?}", new_now.duration_since(now));
            send!(db_sender, Action::GotTagdb { tagdb });
        });
        send!(init_sender, Action::Init);
    }

    fn show_about_dialog(&self) {
        let dialog = adw::AboutDialog::builder()
            .application_icon(APP_ID)
            .application_name("Boorus")
            .copyright("© 2020-2025")
            .developer_name("nee")
            // Insert your license of choice here
            .license_type(gtk::License::Gpl30)
            // Insert your website here
            .website("https://hidamari.blue/boorus/")
            .version(VERSION)
            .translator_credits(gettext("translator-credits"))
            .build();

        dialog.present(Some(&self.main_window()));
    }

    pub fn run(&self) -> glib::ExitCode {
        info!("Boorus ({})", APP_ID);
        info!("Version: {} ({})", VERSION, PROFILE);
        info!("Datadir: {}", PKGDATADIR);

        unsafe {
            std::env::set_var("PULSE_PROP_application.icon_name", APP_ID);
        }

        ApplicationExtManual::run(self)
    }
}

impl Default for ExampleApplication {
    fn default() -> Self {
        glib::Object::builder()
            .property("application-id", APP_ID)
            .property("resource-base-path", "/blue/hidamari/boorus/")
            .build()
    }
}

// --------------------------------------------------------------------------------
// --------------------------------------------------------------------------------
// --------------------------------------------------------------------------------
// --------------------------------------------------------------------------------
// --------------------------------------------------------------------------------

async fn get_thumb(is_local: bool, url: &str) -> Result<bytes::Bytes> {
    println!("getting {:#?}", url);
    if is_local {
        // local thumbs only pass their filename
        let url = crate::download::thumb_read_path(url);
        let contents = tokio::fs::read(url).await?;
        Ok(bytes::Bytes::from(contents))
    } else {
        let request = http_client().get(url).send().await;
        // println!("got_req {:#?}", url);
        let binary = request?.bytes().await?;
        println!("got {:#?}", url);
        Ok(binary)
    }
}

fn thumb_texture_from_binary(
    binary: bytes::Bytes,
    post: &Posts,
    thumb_size: u32,
) -> Result<gtk::gdk::Texture> {
    let cancellable: Option<&gtk::gio::Cancellable> = None;
    // let pixbuf = gtk::gdk_pixbuf::Pixbuf::from_stream(&strm, cancellable)?;
    let width_api = f64::from(post.post().width());
    let height_api = f64::from(post.post().height());
    let width;
    let height;
    if width_api == 0.0 || height_api == 0.0 {
        // get the real size from the image stream
        // this is an additional pixbuf-load without the proper scale and should be avoided,
        // so api dimensions are used by default
        let preview_stream: gtk::gio::MemoryInputStream =
            gtk::gio::MemoryInputStream::from_bytes(&gtk::glib::Bytes::from(&binary));
        let size_preview = gtk::gdk_pixbuf::Pixbuf::from_stream(&preview_stream, cancellable)?;
        width = f64::from(size_preview.width());
        height = f64::from(size_preview.height());
    } else {
        width = width_api;
        height = height_api;
    }
    let thumb_size_f = thumb_size as f64;
    let factor: f64 = if width < height {
        width / thumb_size_f
    } else {
        height / thumb_size_f
    };
    let gtk_scale: i32 = (if width < height {
        height / factor
    } else {
        width / factor
    }) as i32;
    let strm: gtk::gio::MemoryInputStream =
        gtk::gio::MemoryInputStream::from_bytes(&gtk::glib::Bytes::from(&binary));
    let pixbuf = gtk::gdk_pixbuf::Pixbuf::from_stream_at_scale(
        &strm,
        gtk_scale,
        gtk_scale,
        true,
        cancellable,
    )?;
    // Select a subsection of the non-square image to be displayed in the square thumbnail
    let overhang = if width < height {
        (height / factor) - thumb_size_f
    } else {
        (width / factor) - thumb_size_f
    }
    .max(0.0);
    let overhang_offset = (overhang / 2.5).floor() as i32;
    let overhang_width = std::cmp::min(pixbuf.width() - overhang_offset, thumb_size as i32);
    let overhang_height = std::cmp::min(pixbuf.height() - overhang_offset, thumb_size as i32);
    let pixbuf2 = if width < height {
        pixbuf.new_subpixbuf(0, overhang_offset, pixbuf.width(), overhang_height)
    } else {
        pixbuf.new_subpixbuf(overhang_offset, 0, overhang_width, pixbuf.height())
    };
    // println!("overhang: {}, o {}, gtk_scale {}, w {}, h {} w2 {} h2 {}", overhang, ((overhang/2.0)-1.0).floor() as i32, gtk_scale, width, height, pixbuf.get_width(), pixbuf.get_height());

    Ok(gtk::gdk::Texture::for_pixbuf(&pixbuf2))
}

fn fetch_index(state: &State) -> Option<()> {
    let pid = state.page;
    let search = state.search.clone();
    match &state.active_booru_data() {
        Some(active_booru) => {
            let booru = active_booru.booru.clone_booru();
            let booru = booru.imp();
            let sender = state.sender.clone();
            crate::RUNTIME.spawn(async move {
                let posts = booru.fetch_index(&search, pid).await;
                let cmd = Action::GotIndex {
                    page: pid,
                    posts,
                    skip_viewed: false,
                    is_local: booru.is_local(),
                };
                send_async!(sender, cmd);
            });
            Some(())
        }
        None => {
            let sender = state.sender.clone();
            send!(sender, Action::CancelLoading);
            None
        }
    }
}

fn fetch_booru_index(
    sender: Sender<Action>,
    booru: BooruData,
    pid: u32,
    search: String,
    skip_viewed: bool,
) {
    let booru = booru.booru.clone_booru();
    let booru = booru.imp();

    crate::RUNTIME.spawn(async move {
        let posts = booru.fetch_index(&search, pid).await;
        let cmd = Action::GotIndex {
            page: pid,
            posts,
            skip_viewed,
            is_local: booru.is_local(),
        };
        send_async!(sender, cmd);
    });
}

fn do_action(state: &mut State, action: Action) -> gtk::glib::ControlFlow {
    // match action {
    //     Action::GotThumb {
    //         texture: _,
    //         post: _,
    //     } => println!("Action::GotThumb"),
    //     Action::GotImage { bytes: _, post: _ } => println!("Action::GotImage"),
    //     Action::GotTagdb { tagdb: _ } => println!("Action::GotTagDB"),
    //     Action::DownloadDone { bytes: _, post: _ } => println!("Action::DownloadDone"),
    //     _ => println!("{action:#?}"),
    // }
    match action {
        Action::Init => {
            if let Some(settings) = read_settings() {
                let mut boorus: Vec<_> = settings
                    .boorus
                    .iter()
                    .filter_map(|(url, settings)| {
                        crate::booru::make_booru(url).map(|driver| BooruData {
                            booru: driver,
                            settings: settings.clone(),
                        })
                    })
                    .collect();
                boorus.push(
                    BooruData {
                            booru: std::boxed::Box::new(Localbooru::default()),
                            settings: BooruSettings::default(),
                        }
                );
                state.boorus = boorus;

                state.saved_searches = settings.saved_searches.clone();
                state.preferences = settings.preferences.clone();
                state.active_booru = settings.active_booru;
                // state.active_booru = settings
                //     .active_booru
                //     .as_ref()
                //     .and_then(|url| crate::booru::make_booru(url));
            }
            state.app.main_window().init(state);
            do_action(state, Action::OpenBrowse);
            do_action(state, Action::LoadSavedSearchesUntilViewed { only_auto_load: true });
            // do_action(state, Action::OpenWikiPage("patchouli_knowledge".to_string()));
            // do_action(state, Action::OpenArtistPage("yuuji_(and)".to_string()));
        }
        Action::OpenBrowse => match &state.active_booru_data_any() {
            Some(_b) => {
                // save settings when moving away from it
                if state.stack.visible_child_name().as_ref().map(|s| s.as_str()) == Some("settings") {
                    write_settings(state);
                }

                state
                    .stack
                    .set_visible_child_full("browse", gtk::StackTransitionType::SlideLeftRight);

                if let Some(image_page) = &state.image_page {
                    image_page.clear_video();
                }
                // match state.stack.child_by_name("browse") {
                //     Some(_browse) => {

                //     }
                //     None => ()
                //     // _ => {
                //     //     let browse_page = BrowsePage::new(state);
                //     //     state.stack.add_named(&browse_page, Some("browse"));
                //     //     state.browse_page = Some(browse_page);
                //     //     state.loading = Loading::Index;
                //     //     fetch_index(state);
                //     //     do_action(state, Action::OpenBrowse);
                //     // }
                // },
            }
            None => {
                do_action(state, Action::OpenSettings);
            }
        },
        Action::OpenBrowseSearch(search) => {
            do_action(state, Action::ReplaceSearch {tag: search});
            do_action(state, Action::OpenBrowse);
            do_action(state, Action::ReloadIndex);
        }
        Action::OpenWikiPage(search) => {
            do_action(state, Action::OpenWikiOrArtist(search, false));
        }
        Action::OpenArtistPage(search) => {
            do_action(state, Action::OpenWikiOrArtist(search, true));
        }
        Action::OpenWikiOrArtist(search, artist) => match &state.active_booru_data_any() {
            Some(b) => {
                state
                    .stack
                    .set_visible_child_full("wiki", gtk::StackTransitionType::SlideLeftRight);

                    let booru = b.booru.clone_booru();
                    let booru = booru.imp();
                    let sender = state.sender.clone();
                crate::RUNTIME.spawn(async move {
                    let result = if artist { booru.fetch_artist(&search).await } else { booru.fetch_wiki(&search).await };
                            if let Ok(entry) = result {
                                send_async!(sender, Action::GotWiki(entry));
                            } else {
                                send_async!(sender, Action::MakeToast("Couldn't get wiki page".to_string()));

                            }
                    });
            }
            None => {
            //
            }
        }
        Action::GotWiki(entry) => {
            state
                .app
                .main_window()
                .wiki()
                .set_content(&entry);
        }
        Action::GotoWiki => {
                state
                    .stack
                    .set_visible_child_full("wiki", gtk::StackTransitionType::SlideLeftRight);
        }
        Action::GotTagdb { tagdb } => {
            state.tagdb = tagdb
        }
        Action::GotIndex {
            page,
            posts,
            skip_viewed,
            is_local
        } => {
            state.last_page_size = posts.len().try_into().unwrap();
            state
                .app
                .main_window()
                .browse()
                .current_page_label()
                .set_text(&format!("Page {}", page + 1));
            let mut any_viewed = false;
            let posts: Vec<_> = posts
                .into_iter()
                .filter_map(|p| {
                    if skip_viewed && (any_viewed || state.viewed(p.as_ref())) {
                        any_viewed = true;
                        None
                    } else {
                        Some(p.imp())
                    }
                })
                .collect();
            let empty = posts.is_empty();
            let sender = state.sender.clone();
            // let size = state.preferences.thumb_size;
            crate::RUNTIME.spawn(async move {
                for post in posts {
                    let sender = sender.clone();
                    if let Err(err) = (async {
                        let bytes = get_thumb(is_local, &post.post().thumb_url()).await?;
                        let post2 = post.post().imp();
                        let texture = tokio::task::spawn_blocking(move || {
                            thumb_texture_from_binary(bytes, &post2, DEFAULT_THUMB_SIZE_IMAGE as u32)
                        })
                        .await?
                        .ok();
                        let cmd = Action::GotThumb {
                            texture,
                            post: post.post().clone_post(),
                        };
                        send_async!(sender, cmd);
                        Ok::<(), anyhow::Error>(())
                    })
                    .await
                    {
                        println!("failed to get thumb {err}");
                        let cmd = Action::GotThumb {
                            texture: None,
                            post: post.post().clone_post(),
                        };
                        send_async!(sender, cmd);
                    }
                }
            });

            state.loading = match state.loading {
                Loading::SavedSearch(1, any_results) | Loading::SavedSearch(0, any_results) => {
                    // possibility to show the no-new-results page
                    state
                        .app
                        .main_window()
                        .browse()
                        .saved_searches_done(any_results || !empty);
                    Loading::Done
                },
                Loading::SavedSearch(n, any_results) => {
                    if !empty {
                        // show results as they roll in
                        state
                            .app
                            .main_window()
                            .browse()
                            .show_gridview()
                    }
                    Loading::SavedSearch(n - 1, any_results || !empty)
                },
                _ => {
                    state
                        .app
                        .main_window()
                        .browse()
                        .search_done(&state.sender, !empty, page > 0);
                    Loading::Done
                },
            };
        }
        Action::GotThumb { texture, post } => {
            let viewed = match state.booru_settings(post.domain()) {
                Some(settings) => settings.viewed.contains(&post.id()),
                None => false,
            };

            let item = ThumbObject::new(texture, post, viewed);
            state
                .app
                .main_window()
                .browse()
                .model()
                .insert_sorted(&item, browse_page_sort);
        }
        Action::GotImage { bytes, post } => match &state.image_page {
            Some(image_page) => {
                image_page.got_image(&state.sender, &*post, bytes);
                state.loading = Loading::Done;
            }
            _ => {
                do_action(state, Action::CancelLoading);
                println!("got image, before image was initialized!");
            }
        }
        Action::OpenImage { post } => {
            // state.stack.set_transition_type();
            info!("OPENING IMG");
            // set active booru
            if state.active_booru.as_ref() != Some(post.domain()) {
                state.active_booru = Some(post.domain().clone());
                state
                    .app
                    .main_window()
                    .browse()
                    .do_action(BrowseAction::BooruListChanged, state);
            }

            if state.loading == Loading::Done {
                state.loading = Loading::Post;

                if let Some(settings) = state.booru_settings_mut(post.domain()) {
                    settings.viewed.insert(post.id());
                    state.app.main_window().browse().set_viewed(&*post);
                    write_settings(state);
                }

                let image_page = ImagePage::new(state, &*post);
                state
                    .app
                    .main_window()
                    .browse()
                    .bind_property("narrow", &image_page, "narrow")
                    .flags(glib::BindingFlags::SYNC_CREATE)
                    .build();
                if let Some(old_image) = state.stack.child_by_name("image") {
                    state.stack.remove(&old_image);
                }
                state.stack.add_named(&image_page, Some("image"));
                state.image_page = Some(image_page);
                state
                    .stack
                    .set_visible_child_full("image", gtk::StackTransitionType::Crossfade);

                let sender = state.sender.clone();
                crate::RUNTIME.spawn(async move {
                    println!("OPENING");
                    match post.imp().fetch_full().await {
                        Ok((bytes, new_post)) => {
                            let post = new_post.unwrap_or(post);
                            let cmd = Action::GotImage {
                                bytes,
                                post,
                            };
                            send_async!(sender, cmd);
                        }
                        Err(e) => {
                            error!("failed to get full-img {:#?} {:#?}", post, e);
                            drop(e);
                            send_async!(sender, Action::CancelLoading);
                        }
                    }
                });
            }
        },
        Action::GotoImage => {
            if None == state.stack.child_by_name("image") {
               do_action(state, Action::OpenBrowse);
            } else {
                state
                .stack
                    .set_visible_child_full("image", gtk::StackTransitionType::Crossfade);
            }
        }
        Action::DownloadImage { post } => {
            let sender = state.sender.clone();

            if let Some(settings) = state.booru_settings_mut(post.domain()) {
                settings.viewed.insert(post.id());
                state.app.main_window().browse().set_viewed(&*post);
                write_settings(state);
            }

            crate::RUNTIME.spawn(async move {
                let imp = post.imp();
                match imp.fetch_full().await {
                    Ok((bytes, new_post)) => {
                        let post = new_post.unwrap_or(post);
                        let cmd = Action::DownloadDone { bytes, post };
                        send_async!(sender, cmd);
                    }
                    _ => {
                        println!("failed to get image {:#?}", post);
                        send_async!(sender, Action::CancelLoading);
                   }
                }
            });
        }
        Action::DownloadDone { bytes, post } => {
            let filename = &post.filename();
            let filename = std::path::Path::new(filename);
            let file_path = crate::download::save_image(filename, &bytes);
            let thumb = state.app.main_window().browse().texture(&*post);
            let thumb_path = crate::download::save_thumb(filename, thumb);

            if let Some(file_path) = file_path {
                // must be done out of async, because of tagdb eww
                use crate::booru::local::TagsForPost;
                let tags = TagsForPost {
                    tags_character: post.tags_character(&state.tagdb).join(" "),
                    tags_copyright: post.tags_copyright(&state.tagdb).join(" "),
                    tags_artist: post.tags_artist(&state.tagdb).join(" "),
                    tags_general: post.tags_general(&state.tagdb).join(" "),
                    tags_meta: post.tags_meta(&state.tagdb).join(" "),
                    tags_unknown: post.tags_unknown(&state.tagdb).join(" "),
                };
                crate::RUNTIME.spawn(async move {
                    if !Localbooru::post_already_saved(&*post).await.unwrap_or(false) {
                        let _ = Localbooru::post_saved(post, &file_path, &thumb_path, tags).await;
                    }
                });
            }
        }
        Action::LoadNextPage => if let Some(b) = &state.active_booru_data_any() {
            // delay to avoid spamming
            let can_load = state.last_load.map(|o| o.elapsed().as_secs() >= 3).unwrap_or(true);
            if state.loading == Loading::Done && can_load {
                state.last_load = Some(std::time::Instant::now());
                state.page = b.booru.next_page(state.page, state.last_page_size);
                state.loading = Loading::IndexPage;
                fetch_index(state);
                state
                    .app
                    .main_window()
                    .browse()
                    .set_saved_searches_page(false);
            }
        },
        Action::LoadSavedSearchesUntilViewed { only_auto_load } => {
            let mut load_counter: u32 = 0;
            state.app.main_window().browse().model().remove_all();
            for saved_search in state.saved_searches.iter() {
                if only_auto_load && !saved_search.auto_load {
                    continue;
                }
                if let Some(first) = saved_search.booru_allowlist.first()
                    && let Some(booru) = state.booru_data(first) {
                        fetch_booru_index(
                            state.sender.clone(),
                            booru,
                            0,
                            saved_search.search.clone(),
                            true,
                        );
                        load_counter += 1;
                    }
            }
            if load_counter > 0 {
                state.loading = Loading::SavedSearch(load_counter, false);
                state
                    .app
                    .main_window()
                    .browse()
                    .set_saved_searches_page(true);
            }
        }
        Action::Autocomplete { .. /* search */ } => {
            // TODO enable later when UI is better we have merged multi-source TagDBs
            // state
            //     .app
            //     .main_window()
            //     .browse()
            //     .update_autocomplete(&state.tagdb, &search);
        }
        Action::Search { search } => {
            state.search = search;
        }
        Action::AddToSearch { tag } => {
            let new_search = if state.search.is_empty() {
                tag.to_string()
            } else {
                [state.search.to_owned(), tag.to_string()].join(" ")
            };
            state.search = new_search;
            state
                .app
                .main_window()
                .browse()
                .search()
                .set_text(&state.search);

            do_action(state, Action::MakeToast("Added to search".to_string()));
        }
        Action::ReplaceSearch { tag } => {
            state.search = "".to_string();
            do_action(state, Action::AddToSearch { tag });
        }
        Action::ReloadIndex => {
            if state.loading == Loading::Done {
                state.page = 0;
                state.app.main_window().browse().model().remove_all();
                state.loading = Loading::IndexPage;
                fetch_index(state);
                state
                    .app
                    .main_window()
                    .browse()
                    .set_saved_searches_page(false);
            } else {
                info!("Skipping index load, already loading");
            }
        }
        Action::AddBooru { booru } => {
            state.boorus.push(BooruData {
                booru: booru.clone_booru(),
                settings: BooruSettings::default(),
            });
            if state.active_booru.is_none() {
                state.active_booru = Some(booru.get_domain().clone());
            }
            state
                .app
                .main_window()
                .browse()
                .do_action(BrowseAction::BooruListChanged, state);
        }
        Action::SetActiveBooru { booru } => {
            state.active_booru = Some(booru.get_domain().clone());
            state
                .app
                .main_window()
                .browse()
                .do_action(BrowseAction::BooruListChanged, state);
            state
                .app
                .main_window()
                .wiki()
                .update_booru_selection(state);
            if state.stack.visible_child_name().as_ref().map(|s| s.as_str()) == Some("browse") {
                do_action(state, Action::ReloadIndex);
            }
        }
        Action::RemoveBooru { booru } => {
            let index = state
                .boorus
                .iter()
                .position(|b| b.booru.get_domain() == &booru);
            if let Some(index) = index {
                let b = state.boorus.remove(index);
                println!("{:#?}", b);

                let matches = state.active_booru.as_ref().is_some_and(|ab| {
                    println!("comp {}", ab == b.booru.get_domain());
                    ab == b.booru.get_domain()
                });

                if matches {
                    state.active_booru = None;
                    if let Some(sp) = state.settings_page.as_ref() {
                        sp.do_action(SettingsAction::AllBoorusRemoved)
                    }
                }
                state
                    .app
                    .main_window()
                    .browse()
                    .do_action(BrowseAction::BooruListChanged, state);
            }
        }
        Action::OpenSettings => match &state.settings_page {
            Some(_sp) => state
                .stack
                .set_visible_child_full("settings", gtk::StackTransitionType::SlideLeft),
            None => {
                let settings_page = SettingsPage::new(state);
                state.settings_page = Some(settings_page.clone());
                state.stack.add_named(&settings_page, Some("settings"));
                state
                    .stack
                    .set_visible_child_full("settings", gtk::StackTransitionType::SlideLeft)
            }
        },
        Action::CancelLoading => {
            state.loading = Loading::Done;
        }
        Action::BrowseAction { action } => {
            state
                .app
                .main_window()
                .browse()
                .do_action(action, state);
        }
        Action::SaveSearch => {
            state.saved_searches.push(SavedSearch {
                search: state.search.clone(),
                booru_allowlist: state
                    .active_booru
                    .as_ref()
                    .map(|id| vec![id.clone()])
                    .unwrap_or_default(),
                auto_load: true,
            });
            write_settings(state);

            if let Some(search) = state.saved_searches.last() {
                state
                    .app
                    .main_window()
                    .browse()
                    .saved_search_added(state, search);
            }
        }
        Action::RemoveSavedSearch { search } => {
            state.saved_searches.retain_mut(|s| s.search != search);
            write_settings(state);
            state
                .app
                .main_window()
                .browse()
                .do_action(BrowseAction::SavedSearchesChanged, state);
        }
        Action::SetSavedSearchBooru { search, booru } => {
            for s in state.saved_searches.iter_mut() {
                if s.search == search {
                    s.booru_allowlist = vec![booru];
                    break;
                }
            }
            write_settings(state);
        }
        Action::SetSavedSearchAutoLoad { search, auto } => {
            for s in state.saved_searches.iter_mut() {
                if s.search == search {
                    s.auto_load = auto;
                    break;
                }
            }
            write_settings(state);
        }
        Action::OpenSavedSearch { search, booru } => {
            state.active_booru = Some(booru);
            do_action(state, Action::ReplaceSearch { tag: search });
            do_action(state, Action::ReloadIndex);
            state
                .app
                .main_window()
                .browse()
                .do_action(BrowseAction::BooruListChanged, state);
        } // Action::RefreshSavedSearch { search } => {

        Action::Quit => {
            // state.window.close();
            state.app.quit();
        } // }
        Action::MakeToast(message) => {
            state
                .app
                .main_window()
                .make_toast(adw::Toast::builder().title(&message).build());
        }
    }

    gtk::glib::ControlFlow::Continue
}