root/src/image_page.rs

// image_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, TagType};
use crate::data::*;
use crate::send;

use adw::prelude::*;
use gtk::glib;
use gtk::glib::Properties;
use gtk::glib::clone;
use gtk::subclass::prelude::*;
use std::cell::Cell;
use std::cell::RefCell;
use tokio::sync::mpsc::Sender;

mod imp {
    use super::*;

    #[derive(Debug, Default, gtk::CompositeTemplate, Properties)]
    #[properties(wrapper_type = super::ImagePage)]
    #[template(resource = "/blue/hidamari/boorus/ui/image_page.ui")]
    pub struct ImagePage {
        #[template_child]
        pub split: TemplateChild<adw::OverlaySplitView>,
        #[template_child]
        pub window: TemplateChild<gtk::ScrolledWindow>,
        #[template_child]
        pub download: TemplateChild<gtk::Button>,
        #[template_child]
        pub tags_window: TemplateChild<adw::Bin>,
        #[template_child]
        pub create_date: TemplateChild<gtk::Label>,
        #[template_child]
        pub web_button: TemplateChild<gtk::Button>,

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

        pub video: RefCell<Option<gtk::Video>>,
    }

    #[glib::object_subclass]
    impl ObjectSubclass for ImagePage {
        const NAME: &'static str = "ImagePage";
        type Type = super::ImagePage;
        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 ImagePage {
        fn constructed(&self) {
            self.parent_constructed();
        }
    }

    impl WidgetImpl for ImagePage {}
    impl BoxImpl for ImagePage {}
}

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

impl ImagePage {
    pub fn new(state: &State, post: &dyn Post) -> ImagePage {
        let widget: Self = glib::Object::new();
        let tags_window = make_tags_sidebar(state, post);
        widget.imp().tags_window.set_child(Some(&tags_window));

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

        let post = post.clone_post();
        widget.imp().web_button.connect_clicked(move |_| {
            post.open_web();
        });

        widget
    }

    pub fn got_image(&self, sender: &Sender<Action>, post: &dyn Post, bytes: bytes::Bytes) {
        // TODO support gif
        if let Ok(texture) = texture_from_bytes(&bytes) {
            let picture = gtk::Picture::for_paintable(&texture);
            self.imp().window.set_child(Some(&picture));
        } else if let Ok(video) = make_video(&bytes) {
            self.imp().window.set_child(Some(&video));
            self.imp().video.replace(Some(video));
        } else {
            self.error_image();
        }

        self.setup_download_button(sender.clone(), post.clone_post(), bytes);
    }

    pub fn clear_video(&self) {
        self.stop_playback();
        self.imp().video.replace(None);
        self.imp().window.set_child(gtk::Widget::NONE);
    }

    fn stop_playback(&self) {
        let video = self.imp().video.borrow();
        if let Some(video) = video.as_ref()
            && let Some(stream) = video.media_stream()
        {
            stream.set_playing(false);
        }
    }

    fn setup_download_button(
        &self,
        sender: Sender<Action>,
        post: std::boxed::Box<dyn Post>,
        bytes: bytes::Bytes,
    ) {
        if post.is_local() {
            self.imp().download.set_visible(false);
            return;
        }

        let filename = crate::download::download_read_path(post.filename());
        if !filename.exists() {
            let download_button_img = gtk::Image::from_icon_name("document-save-symbolic");
            self.imp().download.set_child(Some(&download_button_img));
            let download_button = self.imp().download.clone();
            // download_button.add_css_class("suggested-action");
            self.imp().download.connect_clicked(move |_| {
                // crate::download::save_bytes(&filename, &bytes);
                // TODO FIXME STOP CLONING THE BYTES THIS IS BIG, maybe arc them???
                send!(
                    sender,
                    Action::DownloadDone {
                        bytes: bytes.clone(),
                        post: post.clone_post()
                    }
                );

                download_button.set_icon_name("emblem-ok-symbolic");
                // download_button.remove_css_class("suggested-action");
                download_button.add_css_class("success");
            });
        } else {
            // already downloaded
            self.imp().download.set_icon_name("emblem-ok-symbolic");
            self.imp().download.add_css_class("success");
        }
    }

    pub fn error_image(&self) {
        let status_page = adw::StatusPage::builder()
            .icon_name("image-missing-symbolic")
            .title("Failed to open image/video. 😿")
            .build();
        self.imp().window.set_child(Some(&status_page));
    }
}

fn make_video(bytes: &bytes::Bytes) -> Result<gtk::Video, Box<dyn std::error::Error>> {
    // memory streaming is currently not supported
    // let stream = gtk::gio::MemoryInputStream::from_bytes(&gtk::glib::Bytes::from(bytes));
    // let stream = gtk::gio::DataInputStream::new(&stream);
    // let file = gtk::MediaFile::for_input_stream(&stream);

    // tmp file workaround
    use std::fs::File;
    use std::io::Write;
    let mut path = glib::tmp_dir();
    path.push("boorus-last-video");
    let mut file = File::create(&path)?;
    file.write_all(bytes)?;
    file.sync_all()?;

    let video = gtk::Video::builder()
        .autoplay(true)
        .loop_(true)
        .file(&gtk::gio::File::for_path(path))
        .graphics_offload(gtk::GraphicsOffloadEnabled::Enabled)
        .build();
    Ok(video)
}

fn texture_from_bytes(
    bytes: &bytes::Bytes,
) -> Result<gtk::gdk::Texture, Box<dyn std::error::Error>> {
    let strm: gtk::gio::MemoryInputStream =
        gtk::gio::MemoryInputStream::from_bytes(&gtk::glib::Bytes::from(bytes));
    let cancellable: Option<&gtk::gio::Cancellable> = None;
    let pixbuf = gtk::gdk_pixbuf::Pixbuf::from_stream(&strm, cancellable)?;
    // let pixbuf = gtk::gdk_pixbuf::Pixbuf::from_stream_at_scale(&strm, THUMB_SIZE, THUMB_SIZE, true, cancellable)?;
    let texture = gtk::gdk::Texture::for_pixbuf(&pixbuf);
    Ok(texture)
}

fn make_tag_button(sender: &Sender<Action>, t: &str, tag_type: TagType) -> gtk::Box {
    let b = gtk::Box::new(gtk::Orientation::Horizontal, 3);
    let tag1 = t.to_owned();

    let add_to_search = gtk::Button::new();
    let add_to_search_img = gtk::Image::from_icon_name("list-add-symbolic");
    add_to_search.set_child(Some(&add_to_search_img));
    add_to_search.add_css_class("flat");
    add_to_search.connect_clicked(clone!(
        #[strong]
        sender,
        move |_| {
            let cmd = Action::AddToSearch {
                tag: tag1.to_string(),
            };
            send!(sender, cmd);
        }
    ));

    let tag_label = gtk::Label::new(Some(crate::booru::display_tag(t).as_str()));
    tag_label.set_halign(gtk::Align::Start);
    tag_label.set_wrap(true);
    tag_label.set_wrap_mode(gtk::pango::WrapMode::WordChar);
    tag_label.set_max_width_chars(30);

    let replace_search = gtk::Button::new();
    replace_search.add_css_class("flat");
    replace_search.set_hexpand(true);
    replace_search.set_child(Some(&tag_label));
    replace_search.set_action_name(Some("app.open-browse-search"));
    replace_search.set_action_target_value(Some(&glib::Variant::from(&t)));

    init_tag_context_menu(replace_search.clone(), t, tag_type);

    b.append(&add_to_search);
    b.append(&replace_search);
    // b.append(&fav);
    b
}

fn init_tag_context_menu(button: gtk::Button, t: &str, tag_type: TagType) {
    let tag: String = t.to_string();
    let on_rightclick = clone!(
        #[weak]
        button,
        move |(x, y)| {
            let menu = gtk::gio::Menu::new();
            let wiki_item;
            if tag_type == TagType::Artist {
                wiki_item = gtk::gio::MenuItem::new(Some("View Artist Profile"), None);
                wiki_item.set_action_and_target_value(
                    Some("app.open-artist-page"),
                    Some(&glib::Variant::from(&tag)),
                );
            } else {
                wiki_item = gtk::gio::MenuItem::new(Some("View Wiki"), None);
                wiki_item.set_action_and_target_value(
                    Some("app.open-wiki-page"),
                    Some(&glib::Variant::from(&tag)),
                );
            }
            menu.append_item(&wiki_item);
            let popover = gtk::PopoverMenu::from_model(Some(&menu));
            popover.set_parent(&button);
            popover.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
            popover.set_halign(gtk::Align::Start);
            popover.set_has_arrow(false);
            popover.popup();
        }
    );
    let on_long_press = on_rightclick.clone();
    let long_press = gtk::GestureLongPress::new();
    long_press.connect_pressed(move |_, x, y| {
        on_long_press((x, y));
    });
    let right_click = gtk::GestureClick::builder()
        .button(gtk::gdk::BUTTON_SECONDARY)
        .build();
    right_click.connect_pressed(move |_, _, x, y| {
        on_rightclick((x, y));
    });
    button.add_controller(long_press.clone());
    button.add_controller(right_click);
}

fn make_headlined_tag_list(
    sender: &Sender<Action>,
    b: &gtk::Box,
    tags: Vec<&str>,
    tag_type: TagType,
) {
    if tags.is_empty() {
        return;
    }
    let label = gtk::Label::new(Some(tag_type.css_class()));
    label.add_css_class("tag_headline");
    label.add_css_class(tag_type.css_class());
    b.append(&label);
    for t in tags {
        let button = make_tag_button(sender, t, tag_type);
        b.append(&button);
    }
}

fn make_tags_sidebar(state: &State, post: &dyn Post) -> gtk::ScrolledWindow {
    let b = gtk::Box::new(gtk::Orientation::Vertical, 3);
    let sender = &state.sender;

    make_headlined_tag_list(sender, &b, post.tags_artist(&state.tagdb), TagType::Artist);
    make_headlined_tag_list(
        sender,
        &b,
        post.tags_character(&state.tagdb),
        TagType::Character,
    );
    make_headlined_tag_list(
        sender,
        &b,
        post.tags_copyright(&state.tagdb),
        TagType::Copyright,
    );
    make_headlined_tag_list(
        sender,
        &b,
        post.tags_unknown(&state.tagdb),
        TagType::Unknown,
    );
    make_headlined_tag_list(
        sender,
        &b,
        post.tags_general(&state.tagdb),
        TagType::General,
    );
    make_headlined_tag_list(sender, &b, post.tags_meta(&state.tagdb), TagType::Meta);
    // make_headlined_tag_list(&b, post.tags_deprecated(&state.tagdb), TagType::Deprecated);

    let tag_scroll = gtk::ScrolledWindow::new();
    tag_scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
    tag_scroll.set_child(Some(&b));
    tag_scroll.set_hexpand(false);
    tag_scroll
}