diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 049c08c..7c0b8cd 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -6,6 +6,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +base64 = "0.21.2" +cached = "0.44.0" clap = { version = "4.3.15", features = ["derive"] } dioxus = "0.3.2" dioxus-desktop = "0.3.0" @@ -13,7 +15,10 @@ env_logger = "0.10.0" heck = "0.4.1" keyboard-types = "0.6.2" log = "0.4.19" +png = "0.17.9" +quick-xml = "0.30.0" regex = { version = "1.9.1" } +resvg = "0.35.0" rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } serde = { version = "1.0.171", features = ["derive"] } serde_json = "1.0.103" diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 50cfe0b..2a4d821 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -1,5 +1,7 @@ //! RMENU GUI Implementation using Dioxus #![allow(non_snake_case)] +use std::fs::read_to_string; + use dioxus::prelude::*; use keyboard_types::{Code, Modifiers}; use rmenu_plugin::Entry; @@ -40,6 +42,24 @@ struct GEntry<'a> { state: AppState<'a>, } +#[inline] +fn render_comment(comment: Option<&String>) -> String { + return comment.map(|s| s.as_str()).unwrap_or("").to_string(); +} + +#[inline] +fn render_image<'a, T>(cx: Scope<'a, T>, image: Option<&String>) -> Element<'a> { + if let Some(img) = image { + if img.ends_with(".svg") { + if let Some(content) = crate::image::convert_svg(img.to_owned()) { + return cx.render(rsx! { img { class: "image", src: "{content}" } }); + } + } + return cx.render(rsx! { img { class: "image", src: "{img}" } }); + } + None +} + /// render a single result entry w/ the given information fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { // build css classes for result and actions (if nessesary) @@ -81,9 +101,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { } div { class: "action-comment", - if let Some(comment) = action.comment.as_ref() { - format!("- {comment}") - } + render_comment(action.comment.as_ref()) } } }) @@ -99,9 +117,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { cx.render(rsx! { div { class: "icon", - if let Some(icon) = cx.props.entry.icon.as_ref() { - cx.render(rsx! { img { src: "{icon}" } }) - } + render_image(cx, cx.props.entry.icon.as_ref()) } }) } @@ -111,9 +127,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { } div { class: "comment", - if let Some(comment) = cx.props.entry.comment.as_ref() { - comment.to_string() - } + render_comment(cx.props.entry.comment.as_ref()) } } div { @@ -201,7 +215,7 @@ fn App<'a>(cx: Scope) -> Element { input { id: "search", value: "{search}", - oninput: move |evt| s_updater.set_search(evt.value.clone()), + oninput: move |evt| s_updater.set_search(cx, evt.value.clone()), } } div { diff --git a/rmenu/src/image.rs b/rmenu/src/image.rs new file mode 100644 index 0000000..2d617ac --- /dev/null +++ b/rmenu/src/image.rs @@ -0,0 +1,42 @@ +//! GUI Image Processing +use std::fs::read_to_string; + +use base64::{engine::general_purpose, Engine as _}; +use cached::proc_macro::cached; +use resvg::usvg::TreeParsing; +use thiserror::Error; + +#[derive(Debug, Error)] +enum SvgError { + #[error("Invalid SVG Filepath")] + InvalidFile(#[from] std::io::Error), + #[error("Invalid Document")] + InvalidTree(#[from] resvg::usvg::Error), + #[error("Failed to Alloc PixBuf")] + NoPixBuf, + #[error("Failed to Convert SVG to PNG")] + PngError(#[from] png::EncodingError), +} + +fn svg_to_png(path: &str) -> Result { + // read and convert to resvg document tree + let xml = read_to_string(path)?; + let opt = resvg::usvg::Options::default(); + let tree = resvg::usvg::Tree::from_str(&xml, &opt)?; + let rtree = resvg::Tree::from_usvg(&tree); + // generate pixel-buffer + let size = rtree.size.to_int_size(); + let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width(), size.height()) + .ok_or_else(|| SvgError::NoPixBuf)?; + // render as png to memory + rtree.render(resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut()); + let mut png = pixmap.encode_png()?; + // base64 encode png + let encoded = general_purpose::STANDARD.encode(&mut png); + Ok(format!("data:image/png;base64, {encoded}")) +} + +#[cached] +pub fn convert_svg(path: String) -> Option { + svg_to_png(&path).ok() +} diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index 1b04c9d..0043aaf 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -8,6 +8,7 @@ use std::str::FromStr; mod config; mod exec; mod gui; +mod image; mod search; mod state; diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index 81cc092..77c0afa 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -149,11 +149,12 @@ impl<'a> AppState<'a> { KeyEvent::CloseMenu => self.close_menu(), KeyEvent::ShiftUp => { self.shift_up(); - scroll(cx, self.position().0) + let pos = self.position().0; + scroll(cx, if pos <= 3 { pos } else { pos + 3 }) } KeyEvent::ShiftDown => { self.shift_down(); - scroll(cx, self.position().0) + scroll(cx, self.position().0 + 3) } }; self.state.with_mut(|s| s.event = None); @@ -182,12 +183,13 @@ impl<'a> AppState<'a> { } /// Update Search and Reset Position - pub fn set_search(&self, search: String) { + pub fn set_search(&self, cx: Scope<'_, App>, search: String) { self.state.with_mut(|s| { s.pos = 0; s.subpos = 0; s.search = search; }); + scroll(cx, 0); } /// Manually Set Position/SubPosition (with Click)