2023-01-29 17:46:02 +00:00
|
|
|
use crate::desktop_file::get_desktop_icon_name;
|
2023-02-01 20:42:05 +00:00
|
|
|
use cfg_if::cfg_if;
|
|
|
|
use color_eyre::{Help, Report, Result};
|
2023-05-08 15:57:14 +01:00
|
|
|
use gtk::cairo::Surface;
|
|
|
|
use gtk::gdk::ffi::gdk_cairo_surface_create_from_pixbuf;
|
2023-01-29 17:46:02 +00:00
|
|
|
use gtk::gdk_pixbuf::Pixbuf;
|
|
|
|
use gtk::prelude::*;
|
|
|
|
use gtk::{IconLookupFlags, IconTheme};
|
|
|
|
use std::path::{Path, PathBuf};
|
2023-05-20 14:36:04 +01:00
|
|
|
use tracing::warn;
|
2023-02-01 20:42:05 +00:00
|
|
|
|
|
|
|
cfg_if!(
|
|
|
|
if #[cfg(feature = "http")] {
|
|
|
|
use crate::send;
|
|
|
|
use gtk::gio::{Cancellable, MemoryInputStream};
|
|
|
|
use tokio::spawn;
|
|
|
|
use tracing::error;
|
|
|
|
}
|
|
|
|
);
|
2023-01-29 17:46:02 +00:00
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
enum ImageLocation<'a> {
|
2023-02-01 20:42:05 +00:00
|
|
|
Icon {
|
|
|
|
name: String,
|
|
|
|
theme: &'a IconTheme,
|
|
|
|
},
|
2023-01-29 17:46:02 +00:00
|
|
|
Local(PathBuf),
|
|
|
|
Steam(String),
|
2023-02-01 20:42:05 +00:00
|
|
|
#[cfg(feature = "http")]
|
|
|
|
Remote(reqwest::Url),
|
2023-01-29 17:46:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
pub struct ImageProvider<'a> {
|
|
|
|
location: ImageLocation<'a>,
|
|
|
|
size: i32,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl<'a> ImageProvider<'a> {
|
|
|
|
/// Attempts to parse the image input to find its location.
|
|
|
|
/// Errors if no valid location type can be found.
|
|
|
|
///
|
|
|
|
/// Note this checks that icons exist in theme, or files exist on disk
|
|
|
|
/// but no other check is performed.
|
2023-05-20 14:36:04 +01:00
|
|
|
pub fn parse(input: &str, theme: &'a IconTheme, size: i32) -> Option<Self> {
|
2023-01-29 17:46:02 +00:00
|
|
|
let location = Self::get_location(input, theme, size)?;
|
2023-05-20 14:36:04 +01:00
|
|
|
Some(Self { location, size })
|
2023-01-29 17:46:02 +00:00
|
|
|
}
|
|
|
|
|
2023-01-29 18:38:57 +00:00
|
|
|
/// Returns true if the input starts with a prefix
|
|
|
|
/// that is supported by the parser
|
|
|
|
/// (ie the parser would not fallback to checking the input).
|
2023-02-01 20:42:05 +00:00
|
|
|
#[cfg(any(feature = "music", feature = "workspaces"))]
|
2023-01-29 18:38:57 +00:00
|
|
|
pub fn is_definitely_image_input(input: &str) -> bool {
|
|
|
|
input.starts_with("icon:")
|
|
|
|
|| input.starts_with("file://")
|
|
|
|
|| input.starts_with("http://")
|
|
|
|
|| input.starts_with("https://")
|
|
|
|
}
|
|
|
|
|
2023-05-20 14:36:04 +01:00
|
|
|
fn get_location(input: &str, theme: &'a IconTheme, size: i32) -> Option<ImageLocation<'a>> {
|
2023-01-29 17:46:02 +00:00
|
|
|
let (input_type, input_name) = input
|
|
|
|
.split_once(':')
|
2023-01-29 22:48:42 +00:00
|
|
|
.map_or((None, input), |(t, n)| (Some(t), n));
|
2023-01-29 17:46:02 +00:00
|
|
|
|
|
|
|
match input_type {
|
2023-05-20 14:36:04 +01:00
|
|
|
Some(input_type) if input_type == "icon" => Some(ImageLocation::Icon {
|
2023-01-29 17:46:02 +00:00
|
|
|
name: input_name.to_string(),
|
|
|
|
theme,
|
|
|
|
}),
|
2023-05-20 14:36:04 +01:00
|
|
|
Some(input_type) if input_type == "file" => Some(ImageLocation::Local(PathBuf::from(
|
2023-01-29 17:46:02 +00:00
|
|
|
input_name[2..].to_string(),
|
|
|
|
))),
|
2023-02-01 20:42:05 +00:00
|
|
|
#[cfg(feature = "http")]
|
2023-01-29 17:46:02 +00:00
|
|
|
Some(input_type) if input_type == "http" || input_type == "https" => {
|
2023-05-20 14:36:04 +01:00
|
|
|
input.parse().ok().map(ImageLocation::Remote)
|
2023-01-29 17:46:02 +00:00
|
|
|
}
|
2023-05-20 14:36:04 +01:00
|
|
|
None if input.starts_with("steam_app_") => Some(ImageLocation::Steam(
|
2023-01-29 17:46:02 +00:00
|
|
|
input_name.chars().skip("steam_app_".len()).collect(),
|
|
|
|
)),
|
|
|
|
None if theme
|
2023-01-29 22:48:42 +00:00
|
|
|
.lookup_icon(input, size, IconLookupFlags::empty())
|
2023-01-29 17:46:02 +00:00
|
|
|
.is_some() =>
|
|
|
|
{
|
2023-05-20 14:36:04 +01:00
|
|
|
Some(ImageLocation::Icon {
|
2023-01-29 17:46:02 +00:00
|
|
|
name: input_name.to_string(),
|
|
|
|
theme,
|
|
|
|
})
|
|
|
|
}
|
2023-05-20 14:36:04 +01:00
|
|
|
Some(input_type) => {
|
|
|
|
warn!(
|
|
|
|
"{:?}",
|
|
|
|
Report::msg(format!("Unsupported image type: {input_type}"))
|
|
|
|
.note("You may need to recompile with support if available")
|
|
|
|
);
|
|
|
|
None
|
|
|
|
}
|
2023-02-01 21:05:58 +00:00
|
|
|
None if PathBuf::from(input_name).is_file() => {
|
2023-05-20 14:36:04 +01:00
|
|
|
Some(ImageLocation::Local(PathBuf::from(input_name)))
|
|
|
|
}
|
|
|
|
None => {
|
|
|
|
if let Some(location) = get_desktop_icon_name(input_name)
|
|
|
|
.map(|input| Self::get_location(&input, theme, size))
|
|
|
|
{
|
|
|
|
location
|
|
|
|
} else {
|
|
|
|
warn!("Failed to find image: {input}");
|
|
|
|
None
|
|
|
|
}
|
2023-01-29 17:46:02 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Attempts to fetch the image from the location
|
|
|
|
/// and load it into the provided `GTK::Image` widget.
|
|
|
|
pub fn load_into_image(&self, image: gtk::Image) -> Result<()> {
|
|
|
|
// handle remote locations async to avoid blocking UI thread while downloading
|
2023-02-01 20:42:05 +00:00
|
|
|
#[cfg(feature = "http")]
|
2023-01-29 17:46:02 +00:00
|
|
|
if let ImageLocation::Remote(url) = &self.location {
|
|
|
|
let url = url.clone();
|
|
|
|
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
|
|
|
|
|
|
|
spawn(async move {
|
|
|
|
let bytes = Self::get_bytes_from_http(url).await;
|
|
|
|
if let Ok(bytes) = bytes {
|
|
|
|
send!(tx, bytes);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
{
|
|
|
|
let size = self.size;
|
|
|
|
rx.attach(None, move |bytes| {
|
|
|
|
let stream = MemoryInputStream::from_bytes(&bytes);
|
2023-05-08 15:57:14 +01:00
|
|
|
|
|
|
|
let scale = image.scale_factor();
|
|
|
|
let scaled_size = size * scale;
|
|
|
|
|
2023-01-29 17:46:02 +00:00
|
|
|
let pixbuf = Pixbuf::from_stream_at_scale(
|
|
|
|
&stream,
|
2023-05-08 15:57:14 +01:00
|
|
|
scaled_size,
|
|
|
|
scaled_size,
|
2023-01-29 17:46:02 +00:00
|
|
|
true,
|
|
|
|
Some(&Cancellable::new()),
|
|
|
|
);
|
|
|
|
|
2023-05-08 15:57:14 +01:00
|
|
|
// Different error types makes this a bit awkward
|
|
|
|
match pixbuf.map(|pixbuf| Self::create_and_load_surface(&pixbuf, &image, scale))
|
|
|
|
{
|
|
|
|
Ok(Err(err)) => error!("{err:?}"),
|
2023-01-29 17:46:02 +00:00
|
|
|
Err(err) => error!("{err:?}"),
|
2023-05-08 15:57:14 +01:00
|
|
|
_ => {}
|
2023-01-29 17:46:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
Continue(false)
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} else {
|
2023-02-25 14:24:21 +00:00
|
|
|
self.load_into_image_sync(&image)?;
|
2023-01-29 22:48:42 +00:00
|
|
|
};
|
|
|
|
|
2023-02-01 20:42:05 +00:00
|
|
|
#[cfg(not(feature = "http"))]
|
2023-02-25 14:24:21 +00:00
|
|
|
self.load_into_image_sync(&image)?;
|
2023-02-01 20:42:05 +00:00
|
|
|
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
|
2023-05-08 15:57:14 +01:00
|
|
|
/// Attempts to synchronously fetch an image from location
|
|
|
|
/// and load into into the image.
|
2023-02-25 14:24:21 +00:00
|
|
|
fn load_into_image_sync(&self, image: >k::Image) -> Result<()> {
|
2023-05-08 15:57:14 +01:00
|
|
|
let scale = image.scale_factor();
|
|
|
|
|
2023-02-01 20:42:05 +00:00
|
|
|
let pixbuf = match &self.location {
|
2023-05-08 15:57:14 +01:00
|
|
|
ImageLocation::Icon { name, theme } => self.get_from_icon(name, theme, scale),
|
|
|
|
ImageLocation::Local(path) => self.get_from_file(path, scale),
|
|
|
|
ImageLocation::Steam(steam_id) => self.get_from_steam_id(steam_id, scale),
|
2023-02-01 20:42:05 +00:00
|
|
|
#[cfg(feature = "http")]
|
|
|
|
_ => unreachable!(), // handled above
|
|
|
|
}?;
|
|
|
|
|
2023-05-08 15:57:14 +01:00
|
|
|
Self::create_and_load_surface(&pixbuf, image, scale)
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Attempts to create a Cairo surface from the provided `Pixbuf`,
|
|
|
|
/// using the provided scaling factor.
|
|
|
|
/// The surface is then loaded into the provided image.
|
|
|
|
///
|
|
|
|
/// This is necessary for HiDPI since `Pixbuf`s are always treated as scale factor 1.
|
|
|
|
fn create_and_load_surface(pixbuf: &Pixbuf, image: >k::Image, scale: i32) -> Result<()> {
|
|
|
|
let surface = unsafe {
|
|
|
|
let ptr =
|
|
|
|
gdk_cairo_surface_create_from_pixbuf(pixbuf.as_ptr(), scale, std::ptr::null_mut());
|
|
|
|
Surface::from_raw_full(ptr)
|
|
|
|
}?;
|
|
|
|
|
|
|
|
image.set_from_surface(Some(&surface));
|
2023-02-01 20:42:05 +00:00
|
|
|
|
2023-01-29 22:48:42 +00:00
|
|
|
Ok(())
|
2023-01-29 17:46:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/// Attempts to get a `Pixbuf` from the GTK icon theme.
|
2023-04-22 23:21:12 +01:00
|
|
|
fn get_from_icon(&self, name: &str, theme: &IconTheme, scale: i32) -> Result<Pixbuf> {
|
|
|
|
let pixbuf =
|
|
|
|
match theme.lookup_icon_for_scale(name, self.size, scale, IconLookupFlags::empty()) {
|
2023-05-08 15:57:14 +01:00
|
|
|
Some(_) => theme.load_icon(name, self.size * scale, IconLookupFlags::FORCE_SIZE),
|
2023-04-22 23:21:12 +01:00
|
|
|
None => Ok(None),
|
|
|
|
}?;
|
2023-01-29 17:46:02 +00:00
|
|
|
|
2023-01-29 22:48:42 +00:00
|
|
|
pixbuf.map_or_else(
|
|
|
|
|| Err(Report::msg("Icon theme does not contain icon '{name}'")),
|
|
|
|
Ok,
|
|
|
|
)
|
2023-01-29 17:46:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/// Attempts to get a `Pixbuf` from a local file.
|
2023-05-08 15:57:14 +01:00
|
|
|
fn get_from_file(&self, path: &Path, scale: i32) -> Result<Pixbuf> {
|
|
|
|
let scaled_size = self.size * scale;
|
|
|
|
let pixbuf = Pixbuf::from_file_at_scale(path, scaled_size, scaled_size, true)?;
|
2023-01-29 17:46:02 +00:00
|
|
|
Ok(pixbuf)
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Attempts to get a `Pixbuf` from a local file,
|
|
|
|
/// using the Steam game ID to look it up.
|
2023-05-08 15:57:14 +01:00
|
|
|
fn get_from_steam_id(&self, steam_id: &str, scale: i32) -> Result<Pixbuf> {
|
2023-01-29 17:46:02 +00:00
|
|
|
// TODO: Can we load this from icon theme with app id `steam_icon_{}`?
|
2023-01-29 22:48:42 +00:00
|
|
|
let path = dirs::data_dir().map_or_else(
|
|
|
|
|| Err(Report::msg("Missing XDG data dir")),
|
|
|
|
|dir| {
|
|
|
|
Ok(dir.join(format!(
|
|
|
|
"icons/hicolor/32x32/apps/steam_icon_{steam_id}.png"
|
|
|
|
)))
|
|
|
|
},
|
|
|
|
)?;
|
2023-01-29 17:46:02 +00:00
|
|
|
|
2023-05-08 15:57:14 +01:00
|
|
|
self.get_from_file(&path, scale)
|
2023-01-29 17:46:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/// Attempts to get `Bytes` from an HTTP resource asynchronously.
|
2023-02-01 20:42:05 +00:00
|
|
|
#[cfg(feature = "http")]
|
|
|
|
async fn get_bytes_from_http(url: reqwest::Url) -> Result<glib::Bytes> {
|
2023-04-10 20:05:13 +01:00
|
|
|
let res = reqwest::get(url).await?;
|
|
|
|
|
|
|
|
let status = res.status();
|
|
|
|
if status.is_success() {
|
|
|
|
let bytes = res.bytes().await?;
|
|
|
|
Ok(glib::Bytes::from_owned(bytes))
|
|
|
|
} else {
|
|
|
|
Err(Report::msg(format!(
|
|
|
|
"Received non-success HTTP code ({status})"
|
|
|
|
)))
|
|
|
|
}
|
2023-01-29 17:46:02 +00:00
|
|
|
}
|
|
|
|
}
|