azalea_shell/component/
image.rs

1use std::cell::RefCell;
2use std::collections::{HashMap, VecDeque};
3use std::fmt::Debug;
4use std::fs::File;
5use std::io::Read;
6use std::rc::Rc;
7use std::sync::OnceLock;
8
9use base64::Engine;
10use relm4::gtk::prelude::{FrameExt, WidgetExt};
11use relm4::gtk::{gdk, gdk_pixbuf};
12use relm4::{Component, ComponentParts, ComponentSender, RelmWidgetExt, component};
13
14pub struct Model {
15    fallback: Option<gdk::Texture>,
16    image: Option<gdk::Texture>,
17    width: Option<i32>,
18    height: Option<i32>,
19}
20
21pub struct Init {
22    pub fallback: Option<gdk::Texture>,
23    pub width: Option<i32>,
24    pub height: Option<i32>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Input {
29    Unload,
30    LoadImage(String),
31    LoadPixbuf(gdk::gdk_pixbuf::Pixbuf),
32    LoadTexture(gdk::Texture),
33    LoadBytes(Vec<u8>),
34}
35
36#[derive(Debug)]
37pub enum CommandOutput {
38    LoadedImage(String, Option<VecDeque<u8>>),
39}
40
41#[component(pub)]
42impl Component for Model {
43    type CommandOutput = CommandOutput;
44    type Input = Input;
45    type Output = ();
46    type Init = Init;
47
48    view! {
49        gtk::Frame {
50            inline_css: "border-radius: 6px;",
51
52            #[wrap(Some)]
53            set_child = if model.image.is_none() && model.fallback.is_none() {
54                gtk::Spinner {
55                    set_halign: gtk::Align::Center,
56                    set_valign: gtk::Align::Center,
57                    start: (),
58                }
59            } else {
60                gtk::Picture {
61                    #[watch]
62                    set_paintable: if model.image.is_some() {
63                        model.image.as_ref()
64                    } else {
65                        model.fallback.as_ref()
66                    },
67                    set_can_shrink: true,
68                }
69            },
70
71            set_width_request: model.width.unwrap_or(-1),
72            set_height_request: model.height.unwrap_or(-1),
73        },
74    }
75
76    fn init(
77        init: Self::Init,
78        _root: Self::Root,
79        _sender: ComponentSender<Self>,
80    ) -> ComponentParts<Self> {
81        let model = Self {
82            fallback: init.fallback,
83            image: None,
84            width: init.width,
85            height: init.height,
86        };
87
88        let widgets = view_output!();
89
90        ComponentParts { model, widgets }
91    }
92
93    fn update(&mut self, input: Self::Input, sender: ComponentSender<Self>, _root: &Self::Root) {
94        match input {
95            Input::LoadImage(url) => {
96                if let Some(pixbuf) = Self::cache().borrow().get(&url) {
97                    azalea_log::debug!(
98                        Self,
99                        "Loaded image (cache hit): {}...",
100                        Self::truncate(&url)
101                    );
102                    self.set_image(&pixbuf);
103                } else {
104                    sender.oneshot_command(async move {
105                        let image = Self::load_image(&url).await;
106                        azalea_log::debug!(
107                            Self,
108                            "Loaded image (cache miss): {}...",
109                            Self::truncate(&url)
110                        );
111                        CommandOutput::LoadedImage(url, image)
112                    });
113                }
114            }
115            Input::LoadPixbuf(pixbuf) => self.set_image(&self.resize_pixbuf(pixbuf)),
116            Input::LoadTexture(texture) => self.set_image_from_texture(texture),
117            Input::LoadBytes(bytes) => {
118                let bytes = gtk::glib::Bytes::from_owned(bytes);
119                let stream = gtk::gio::MemoryInputStream::from_bytes(&bytes);
120                let pixbuf =
121                    gtk::gdk_pixbuf::Pixbuf::from_stream(&stream, gtk::gio::Cancellable::NONE)
122                        .unwrap();
123                self.set_image(&self.resize_pixbuf(pixbuf))
124            }
125            Input::Unload => self.set_spinner(),
126        }
127    }
128
129    fn update_cmd(
130        &mut self,
131        message: Self::CommandOutput,
132        _sender: ComponentSender<Self>,
133        _root: &Self::Root,
134    ) {
135        match message {
136            CommandOutput::LoadedImage(url, Some(data)) => {
137                let pixbuf = self.resize_pixbuf(gdk_pixbuf::Pixbuf::from_read(data).unwrap());
138                self.set_image(&pixbuf);
139                Self::cache().borrow_mut().insert(url, pixbuf);
140            }
141            CommandOutput::LoadedImage(url, None) => {
142                azalea_log::warning!("Failed to load image: {url}")
143            }
144        }
145    }
146}
147
148impl Model {
149    fn set_spinner(&mut self) {
150        self.image = None;
151    }
152
153    fn set_image(&mut self, pixbuf: &gdk_pixbuf::Pixbuf) {
154        self.image = Some(gdk::Texture::for_pixbuf(&pixbuf));
155    }
156
157    fn set_image_from_texture(&mut self, texture: gdk::Texture) {
158        self.image = Some(texture)
159    }
160
161    fn resize_pixbuf(&self, mut pixbuf: gdk::gdk_pixbuf::Pixbuf) -> gdk::gdk_pixbuf::Pixbuf {
162        if self.height.is_some() || self.width.is_some() {
163            let width = self
164                .width
165                .unwrap_or_else(|| pixbuf.width() * self.height.unwrap() / pixbuf.height());
166
167            let height = self
168                .height
169                .unwrap_or_else(|| pixbuf.height() * self.width.unwrap() / pixbuf.width());
170
171            if let Some(new_pixbuf) =
172                pixbuf.scale_simple(width, height, gdk_pixbuf::InterpType::Hyper)
173            {
174                pixbuf = new_pixbuf
175            }
176        }
177        pixbuf
178    }
179
180    async fn load_image(url: &str) -> Option<VecDeque<u8>> {
181        Some(match url {
182            url if url.starts_with("http") => reqwest::get(url)
183                .await
184                .ok()?
185                .bytes()
186                .await
187                .ok()?
188                .into_iter()
189                .collect(),
190            base64 if base64.starts_with("data:image") => base64
191                .split("base64,")
192                .collect::<Vec<&str>>()
193                .get(1)
194                .and_then(|img| base64::engine::general_purpose::STANDARD.decode(img).ok())?
195                .into(),
196            file => {
197                let mut buffer = vec![];
198                File::open(file.strip_prefix("file://").unwrap_or(file))
199                    .ok()?
200                    .read_to_end(&mut buffer)
201                    .ok()?;
202                buffer.into()
203            }
204        })
205    }
206
207    fn truncate<'a>(url: &'a str) -> &'a str {
208        url.char_indices()
209            .nth(50)
210            .map(|(size, _)| &url[..size])
211            .unwrap_or(&url)
212    }
213
214    fn cache() -> Rc<RefCell<HashMap<String, gdk_pixbuf::Pixbuf>>> {
215        // TODO: Set max capacity, add basic timestamp (updated on every touch) and remove oldest
216        // if max capacity reached
217        thread_local! {
218            static CACHE: OnceLock<Rc<RefCell<HashMap<String, gdk_pixbuf::Pixbuf>>>> = OnceLock::new();
219        }
220
221        CACHE.with(|cache| {
222            cache
223                .get_or_init(move || Rc::new(RefCell::new(Default::default())))
224                .clone()
225        })
226    }
227}