azalea_shell/component/
image.rs1use 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 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}