feat: icon support

This commit is contained in:
imgurbot12 2023-12-14 15:49:01 -07:00
parent 3f4fd61aa8
commit e3598ebf2e
6 changed files with 160 additions and 7 deletions

View File

@ -7,6 +7,7 @@ edition = "2021"
[dependencies]
askama = "0.12.1"
base64 = "0.21.5"
clap = { version = "4.4.11", features = ["derive"] }
env_logger = "0.10.1"
heck = "0.4.1"
@ -14,7 +15,10 @@ keyboard-types = "0.7.0"
lastlog = { version = "0.2.3", features = ["libc"] }
log = "0.4.20"
once_cell = "1.19.0"
png = "0.17.10"
rayon = "1.8.0"
regex = "1.10.2"
resvg = "0.36.0"
rmenu-plugin = { version = "0.0.1", path = "../rmenu-plugin" }
serde = { version = "1.0.193", features = ["derive"] }
serde_json = "1.0.108"

View File

@ -252,7 +252,7 @@ pub struct Config {
impl Default for Config {
fn default() -> Self {
Self {
page_size: 200,
page_size: 50,
page_load: 0.8,
jump_dist: 5,
use_icons: true,

View File

@ -9,6 +9,7 @@ use web_view::*;
use crate::config::{Config, Keybind};
use crate::exec::execute;
use crate::icons::IconCache;
use crate::search::build_searchfn;
use crate::AppData;
@ -77,6 +78,7 @@ struct ResultsTemplate<'a> {
end: usize,
results: &'a Vec<&'a Entry>,
config: &'a Config,
cache: &'a IconCache,
}
#[derive(Debug)]
@ -87,6 +89,7 @@ struct AppState<'a> {
search: String,
results: Vec<&'a Entry>,
data: &'a AppData,
icons: IconCache,
}
/// check if the current inputs match any of the given keybindings
@ -103,23 +106,30 @@ impl<'a> AppState<'a> {
page: 0,
search: "".to_owned(),
results: vec![],
icons: IconCache::new().unwrap(),
data,
}
}
/// Render Current Page of Results
fn render_results_page(&self) -> String {
fn render_results_page(&mut self) -> String {
let size = self.data.config.page_size;
let start = self.page * size;
let max = (self.page + 1) * size;
let nresults = std::cmp::max(self.results.len(), 1);
let end = std::cmp::min(max, nresults - 1);
let min = std::cmp::min(max, self.results.len());
let end = std::cmp::max(min, 1) - 1;
self.icons.prepare(&self.results[..]);
// skip generation if results are empty
if self.results.is_empty() {
return "".to_owned();
}
// generate results html from template
let template = ResultsTemplate {
start,
end,
config: &self.data.config,
results: &self.results,
cache: &self.icons,
};
template.render().unwrap()
}
@ -172,7 +182,8 @@ impl<'a> AppState<'a> {
#[inline]
fn move_down(&mut self, down: usize) -> Option<String> {
let max = (self.page + 1) * self.data.config.page_size;
let end = std::cmp::min(max, self.results.len()) - 1;
let n = std::cmp::max(self.results.len(), 1);
let end = std::cmp::min(max, n) - 1;
self.pos = std::cmp::min(self.pos + down, end);
match self.append_results(false) {
Some(operation) => Some(operation),

116
rmenu/src/icons.rs Normal file
View File

@ -0,0 +1,116 @@
//! GUI Image Processing
use std::collections::HashMap;
use std::fs::{create_dir_all, write};
use std::path::PathBuf;
use base64::{engine::general_purpose, Engine as _};
use rayon::prelude::*;
use resvg::usvg::TreeParsing;
use rmenu_plugin::Entry;
use thiserror::Error;
static TEMP_DIR: &'static str = "/tmp/rmenu";
#[derive(Debug, Error)]
pub 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(u32, u32, u32),
#[error("Failed to Convert SVG to PNG")]
PngError(#[from] png::EncodingError),
}
#[inline]
fn encode(data: Vec<u8>) -> String {
general_purpose::STANDARD_NO_PAD.encode(data)
}
/// Convert SVG to PNG Image
fn svg_to_png(path: &str, dest: &PathBuf, pixels: u32) -> Result<Vec<u8>, SvgError> {
// read and convert to resvg document tree
let xml = std::fs::read(path)?;
let opt = resvg::usvg::Options::default();
let tree = resvg::usvg::Tree::from_data(&xml, &opt)?;
let rtree = resvg::Tree::from_usvg(&tree);
// generate pixel-buffer and scale according to size preference
let size = rtree.size.to_int_size();
let scale = pixels as f32 / size.width() as f32;
let width = (size.width() as f32 * scale) as u32;
let height = (size.height() as f32 * scale) as u32;
let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)
.ok_or_else(|| SvgError::NoPixBuf(width, height, pixels))?;
let form = resvg::tiny_skia::Transform::from_scale(scale, scale);
// render as png to memory
rtree.render(form, &mut pixmap.as_mut());
let png = pixmap.encode_png()?;
// base64 encode png
write(dest, png.clone())?;
Ok(png)
}
#[derive(Debug)]
pub struct IconCache {
path: PathBuf,
rendered: HashMap<String, Option<String>>,
}
impl IconCache {
pub fn new() -> Result<Self, SvgError> {
let path = PathBuf::from(TEMP_DIR);
create_dir_all(&path)?;
Ok(Self {
path,
rendered: HashMap::new(),
})
}
fn convert_svg(&self, path: &str) -> Option<Vec<u8>> {
// convert path to new temporary png filepath
let (_, fname) = path.rsplit_once('/')?;
let (name, _) = fname.rsplit_once(".")?;
let name = format!("{name}.png");
let new_path = self.path.join(name);
// generate png if it doesnt already exist
if !new_path.exists() {
log::debug!("generating png {new_path:?}");
match svg_to_png(&path, &new_path, 64) {
Err(err) => log::error!("failed svg->png: {err:?}"),
Ok(data) => return Some(data),
}
}
std::fs::read(new_path).ok()
}
/// Prepare and PreGenerate Icon Images
pub fn prepare(&mut self, entries: &[&Entry]) {
let icons: Vec<(String, Option<String>)> = entries
.into_par_iter()
.filter_map(|e| e.icon.as_ref())
.filter(|i| !self.rendered.contains_key(i.to_owned()))
.filter_map(|path| {
if path.ends_with(".png") {
let result = std::fs::read(path).ok().map(encode);
return Some((path.clone(), result));
}
if path.ends_with(".svg") {
let result = self.convert_svg(&path).map(encode);
return Some((path.clone(), result));
}
None
})
.collect();
self.rendered.extend(icons);
}
// locate cached icon from specified path (if given)
pub fn locate(&self, icon: &Option<String>) -> &Option<String> {
let Some(path) = icon else { return &None };
if self.rendered.contains_key(path) {
return self.rendered.get(path).unwrap();
}
&None
}
}

View File

@ -3,6 +3,7 @@ mod cli;
mod config;
mod exec;
mod gui;
mod icons;
mod search;
use clap::Parser;
@ -52,6 +53,15 @@ fn main() -> cli::Result<()> {
options: None,
},
);
config.plugins.insert(
"drun".to_owned(),
PluginConfig {
exec: vec!["/home/andrew/.config/rmenu/rmenu-desktop".to_owned()],
cache: CacheSetting::OnLogin,
placeholder: None,
options: None,
},
);
let entries = cli.get_entries(&mut config)?;
let css = cli.get_css(&config);
let theme = cli.get_theme();

View File

@ -1,4 +1,4 @@
{%- for i in start..end %}
{%- for i in start..=end %}
{% let entry = results[i] %}
<div class="result-entry">
<div
@ -7,7 +7,19 @@
onclick="sclick(this.id)"
ondblclick="dclick(this.id)">
{%-if config.use_icons %}
<div class="icon"></div>
<div class="icon">
{%- if let Some(icon) = cache.locate(entry.icon) %}
<img class="image" src="data:image/png;base64,{{ icon }}" alt="?">
{%else%}
<div class="icon_alt">
{%- if let Some(alt) = entry.icon_alt %}
{{ alt|safe }}
{%else%}
?
{%endif %}
</div>
{%endif%}
</div>
{%endif%}
{%-if config.use_comments %}
<div class="name">{{ entry.name|safe }}</div>