diff --git a/Cargo.toml b/Cargo.toml index 317cfd6..f0654d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,6 @@ members = [ "rmenu-plugin", "plugin-run", "plugin-desktop", - "plugin-audio", "plugin-network", "plugin-window", ] diff --git a/Makefile b/Makefile index a7f188c..63bbcb6 100644 --- a/Makefile +++ b/Makefile @@ -17,22 +17,25 @@ install: build deploy deploy: mkdir -p ${DEST} + cp -vfr themes ${DEST}/themes + cp -vfr other-plugins/* ${DEST}/. cp -vf ./target/release/rmenu ${INSTALL}/rmenu + cp -vf ./target/release/rmenu-build ${INSTALL}/rmenu-build cp -vf ./target/release/desktop ${DEST}/rmenu-desktop cp -vf ./target/release/run ${DEST}/rmenu-run - cp -vf ./target/release/audio ${DEST}/rmenu-audio cp -vf ./target/release/network ${DEST}/rmenu-network cp -vf ./target/release/window ${DEST}/rmenu-window cp -vf ./rmenu/public/config.yaml ${DEST}/config.yaml + ln -sf ${DEST}/themes/dark.css ${DEST}/style.css build: build-rmenu build-plugins build-rmenu: ${CARGO} build -p rmenu ${FLAGS} + ${CARGO} build -p rmenu-plugin --bin rmenu-build ${FLAGS} build-plugins: ${CARGO} build -p run ${FLAGS} ${CARGO} build -p desktop ${FLAGS} - ${CARGO} build -p audio ${FLAGS} ${CARGO} build -p network ${FLAGS} ${CARGO} build -p window ${FLAGS} diff --git a/README.md b/README.md index ddbcb73..d086184 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ RMenu ------ + + Another customizable Application-Launcher written in Rust ### Features @@ -19,7 +25,7 @@ $ make install ### Usage -RMenu Comes with Two Bultin Plugins: "Desktop Run" aka `drun`. +RMenu Comes with Two Builtin Plugins: "Desktop Run" aka `drun`. ```bash $ rmenu -r run @@ -55,4 +61,16 @@ Customize the entire app's appearance with CSS. A few [Example Themes](./themes/ are available as reference. To try them out use: `rmenu --css ` or move the css file to `$HOME/.config/rmenu/style.css` +### Example Screenshots +#### Launchpad +![launchpad](./screenshots/launchpad.png) + +#### Nord +![nord](./screenshots/nord.png) + +#### Dark +![dark](./screenshots/dark.png) + +#### Solarized +![solzarized](./screenshots/solarized.png) diff --git a/other-plugins/css/powermenu.css b/other-plugins/css/powermenu.css new file mode 100644 index 0000000..c56af5a --- /dev/null +++ b/other-plugins/css/powermenu.css @@ -0,0 +1,28 @@ + +.navbar { + width: 0; + height: 0; +} + +.results { + margin: 0; + display: flex; + justify-content: center; + align-items: center; +} + +.result { + display: flex; + flex-direction: column; + font-size: 30px; + width: 10rem; +} + +.icon { + width: 100%; + font-size: 6rem; +} + +.entry { + font-size: 1rem; +} diff --git a/other-plugins/pactl-audio.sh b/other-plugins/pactl-audio.sh new file mode 100755 index 0000000..bb61f04 --- /dev/null +++ b/other-plugins/pactl-audio.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +get_sinks() { + sinks=`pactl list sinks | grep -e 'Sink' -e 'Name' -e 'Description' | nl -s '>'` + default=`pactl get-default-sink` + for i in `seq 1 3 $(echo "$sinks" | wc -l)`; do + sink=`echo "$sinks" | grep "$i>" | cut -d '#' -f2` + name=`echo "$sinks" | grep "$(expr $i + 1)>" | cut -d ':' -f2 | xargs echo -n` + desc=`echo "$sinks" | grep "$(expr $i + 2)>" | cut -d ':' -f2 | xargs echo -n` + if [ "$name" = "$default" ]; then + desc="* $desc" + fi + rmenu-build entry -n "$desc" -a "`rmenu-build action "pactl set-default-sink $sink"`" + done +} + +get_sinks diff --git a/other-plugins/powermenu.sh b/other-plugins/powermenu.sh new file mode 100755 index 0000000..fdc723d --- /dev/null +++ b/other-plugins/powermenu.sh @@ -0,0 +1,74 @@ +#!/bin/sh + +CSS=`realpath "$(dirname $0)/css/powermenu.css"` +SELF=`realpath $0` +RMENU=${RMENU:-"rmenu"} + +#: desc => generate options for basic operation +main_options() { + rmenu-build options \ + -C $CSS \ + -n ArrowRight -p ArrowLeft \ + -w 550 -h 150 -M 0 +} + +#: desc => generate options for confirm operation +confirm_options() { + rmenu-build options \ + -C $CSS \ + -n ArrowRight -p ArrowLeft \ + -w 300 -h 150 -M 0 +} + +#: desc => generate confirmation entries +#: usage => $cmd $name? +confirm() { + cmd=$1 + name="${2:-"Confirm"}" + confirm_options + rmenu-build entry -n "Cancel" -I "" -a "`rmenu-build action -m echo "$name Cancelled"`" + rmenu-build entry -n "$name" -I "" -a "`rmenu-build action "$cmd"`" +} + +#: desc => generate non-confirm entry +#: usage => $icon $name $cmd +gen_direct() { + rmenu-build entry -n "$2" -I "$1" -a "`rmenu-build action "$3"`" +} + +#: desc => generate confirmation entry +#: usage => $icon $name $cmd +gen_confirm() { + rmenu-build entry -n "$2" -I "$1" -a "`rmenu-build action "$SELF confirm '$2:$3'"`" +} + +#: desc => generate action-entry +#: usage => $icon $name $command $do-confirm +action() { + icon="$1" + name="$2" + cmd="$3" + confirm="$4" + [ -z "$confirm" ] \ + && gen_direct "$icon" "$name" "$cmd" \ + || gen_confirm "$icon" "$name" "$cmd" +} + +case "$1" in + "help") + echo "usage: $0 " && exit 1 + ;; + "confirm") + name=`echo $2 | cut -d ':' -f1` + action=`echo $2 | cut -d ':' -f2` + confirm "$action" "$name" | $RMENU + ;; + *) + [ "$1" != "--no-confirm" ] && confirm="Y" + main_options + action "⏻" "Shutdown" "systemctl poweroff" "$confirm" + action "" "Reboot" "systemctl reboot" "$confirm" + action "⏾" "Suspend" "systemctl suspend" "$confirm" + action "" "Log Out" "sway exit" "$confirm" + ;; +esac diff --git a/plugin-audio/Cargo.toml b/plugin-audio/Cargo.toml deleted file mode 100644 index 0ac0d4e..0000000 --- a/plugin-audio/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "audio" -version = "0.0.1" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -clap = { version = "4.3.21", features = ["derive"] } -env_logger = "0.10.0" -log = "0.4.19" -rmenu-plugin = { version = "0.0.1", path = "../rmenu-plugin" } -serde_json = "1.0.104" -thiserror = "1.0.44" diff --git a/plugin-audio/src/main.rs b/plugin-audio/src/main.rs deleted file mode 100644 index 74fcd82..0000000 --- a/plugin-audio/src/main.rs +++ /dev/null @@ -1,43 +0,0 @@ -use clap::{Parser, Subcommand}; -use rmenu_plugin::Entry; - -mod pulse; - -#[derive(Debug, Subcommand)] -pub enum Commands { - ListSinks, - SetDefaultSink { sink: u32 }, -} - -#[derive(Parser)] -#[command(author, version, about, long_about = None)] -#[command(propagate_version = true)] -pub struct Cli { - #[clap(subcommand)] - command: Option, -} - -fn main() -> Result<(), pulse::PulseError> { - let cli = Cli::parse(); - let exe = std::env::current_exe()?.to_str().unwrap().to_string(); - - let command = cli.command.unwrap_or(Commands::ListSinks); - match command { - Commands::ListSinks => { - let sinks = pulse::get_sinks()?; - for sink in sinks { - let star = sink.default.then(|| "* ").unwrap_or(""); - let desc = format!("{star}{}", sink.description); - let exec = format!("{exe} set-default-sink {}", sink.index); - let entry = Entry::new(&desc, &exec, None); - println!("{}", serde_json::to_string(&entry).unwrap()); - } - } - Commands::SetDefaultSink { sink } => { - let sinkstr = format!("{sink}"); - pulse::set_default_sink(&sinkstr)?; - } - } - - Ok(()) -} diff --git a/plugin-audio/src/pulse.rs b/plugin-audio/src/pulse.rs deleted file mode 100644 index eb1eed5..0000000 --- a/plugin-audio/src/pulse.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::io::{BufRead, BufReader}; -use std::process::{Command, ExitStatus, Stdio}; - -use thiserror::Error; - -static PACTL: &'static str = "pactl"; - -#[derive(Debug, Error)] -pub enum PulseError { - #[error("Invalid Status")] - InvalidStatus(ExitStatus), - #[error("Cannot Read Command Output")] - InvalidStdout, - #[error("Invalid Output Encoding")] - InvalidEncoding(#[from] std::string::FromUtf8Error), - #[error("Unexepcted Command Output")] - UnexepctedOutput(String), - #[error("Command Error")] - CommandError(#[from] std::io::Error), -} - -#[derive(Debug)] -pub struct Sink { - pub index: u32, - pub name: String, - pub description: String, - pub default: bool, -} - -/// Retrieve Default Sink -pub fn get_default_sink() -> Result { - let output = Command::new(PACTL).arg("get-default-sink").output()?; - if !output.status.success() { - return Err(PulseError::InvalidStatus(output.status)); - } - Ok(String::from_utf8(output.stdout)?.trim().to_string()) -} - -/// Set Default PACTL Sink -pub fn set_default_sink(name: &str) -> Result<(), PulseError> { - let status = Command::new(PACTL) - .args(["set-default-sink", name]) - .status()?; - if !status.success() { - return Err(PulseError::InvalidStatus(status)); - } - Ok(()) -} - -/// Retrieve PulseAudio Sinks From PACTL -pub fn get_sinks() -> Result, PulseError> { - // retrieve default-sink - let default = get_default_sink()?; - // spawn command and begin reading - let mut command = Command::new(PACTL) - .args(["list", "sinks"]) - .stdout(Stdio::piped()) - .spawn()?; - let stdout = command.stdout.as_mut().ok_or(PulseError::InvalidStdout)?; - // collect output into only important lines - let mut lines = vec![]; - for line in BufReader::new(stdout).lines().filter_map(|l| l.ok()) { - let line = line.trim_start(); - if line.starts_with("Sink ") - || line.starts_with("Name: ") - || line.starts_with("Description: ") - { - lines.push(line.to_owned()); - } - } - // ensure status after command completion - let status = command.wait()?; - if !status.success() { - return Err(PulseError::InvalidStatus(status)); - } - // ensure number of lines matches expected - if lines.len() == 0 || lines.len() % 3 != 0 { - return Err(PulseError::UnexepctedOutput(lines.join("\n"))); - } - // parse details into chunks and generate sinks - let mut sinks = vec![]; - for chunk in lines.chunks(3) { - let (_, idx) = chunk[0] - .split_once("#") - .ok_or_else(|| PulseError::UnexepctedOutput(chunk[0].to_owned()))?; - let (_, name) = chunk[1] - .split_once(" ") - .ok_or_else(|| PulseError::UnexepctedOutput(chunk[1].to_owned()))?; - let (_, desc) = chunk[2] - .split_once(" ") - .ok_or_else(|| PulseError::UnexepctedOutput(chunk[2].to_owned()))?; - sinks.push(Sink { - index: idx.parse().unwrap(), - name: name.to_owned(), - description: desc.to_owned(), - default: name == default, - }); - } - Ok(sinks) -} diff --git a/plugin-desktop/src/main.rs b/plugin-desktop/src/main.rs index 57497fc..bf0167a 100644 --- a/plugin-desktop/src/main.rs +++ b/plugin-desktop/src/main.rs @@ -79,6 +79,7 @@ fn parse_desktop(path: &PathBuf, locale: Option<&str>) -> Option { actions, comment, icon, + icon_alt: None, }) } diff --git a/plugin-network/src/network.rs b/plugin-network/src/network.rs index 2b127c4..12a8ccf 100644 --- a/plugin-network/src/network.rs +++ b/plugin-network/src/network.rs @@ -1,5 +1,3 @@ -use glib::translate::FromGlib; - use async_std::task; use futures_channel::oneshot; use std::cell::RefCell; @@ -8,6 +6,9 @@ use std::rc::Rc; use std::time::{Duration, SystemTime}; use anyhow::{anyhow, Context, Result}; +use glib::translate::FromGlib; +use glib::Variant; +use nm::traits::ObjectExt; use nm::*; static SCAN_INTERVAL_MS: u64 = 500; @@ -32,6 +33,7 @@ pub struct AccessPoint { pub security: String, pub is_active: bool, pub connection: Option, + pub dbus_path: Option, } // SETTING_WIRELESS_MODE @@ -226,6 +228,7 @@ impl Manager { let active = self.wifi.active_access_point(); for a in self.wifi.access_points() { // retrieve access-point information + let path = a.path(); let rate = a.max_bitrate() / 1000; let signal = a.strength(); let ssid = a @@ -271,6 +274,7 @@ impl Manager { is_active, security: security.join(" ").to_owned(), connection: a.filter_connections(&conns).get(0).cloned(), + dbus_path: path.map(|s| s.to_string()), }, ); } @@ -295,10 +299,21 @@ impl Manager { wait_conn(&active_conn, self.timeout).await?; } None => { + // generate options + let mut options: BTreeMap = BTreeMap::new(); + options.insert("persist".to_string(), "disk".to_variant()); + options.insert("bind-activation".to_string(), "none".to_variant()); + // complete connection + let glib_opts = options.to_variant(); let conn = new_conn(ap, password)?; - let active_conn = self + let (active_conn, _) = self .client - .add_and_activate_connection_future(Some(&conn), Some(&device), None) + .add_and_activate_connection2_future( + Some(&conn), + Some(&device), + ap.dbus_path.as_deref(), + &glib_opts, + ) .await .context("Failed to add and activate connection")?; wait_conn(&active_conn, self.timeout).await?; diff --git a/rmenu-plugin/Cargo.toml b/rmenu-plugin/Cargo.toml index af7ddb1..aca54e7 100644 --- a/rmenu-plugin/Cargo.toml +++ b/rmenu-plugin/Cargo.toml @@ -5,5 +5,16 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "rmenu_plugin" +path = "src/lib.rs" + +[[bin]] +name = "rmenu-build" +path = "src/bin/main.rs" + [dependencies] +bincode = "1.3.3" +clap = { version = "4.3.22", features = ["derive"] } serde = { version = "1.0.171", features = ["derive"] } +serde_json = "1.0.105" diff --git a/rmenu-plugin/src/bin/main.rs b/rmenu-plugin/src/bin/main.rs new file mode 100644 index 0000000..8ca2e9b --- /dev/null +++ b/rmenu-plugin/src/bin/main.rs @@ -0,0 +1,257 @@ +use std::{fmt::Display, str::FromStr}; + +use rmenu_plugin::*; + +use clap::{Args, Parser, Subcommand}; + +/// Parse Action from JSON +fn parse_action(action: &str) -> Result { + serde_json::from_str(action) +} + +//TODO: add options struct object that allows for further +// dynamic customization of the cli settings. +// last instance overwrites previous entries +// properties do not override cli-specified values +// +// priority order: +// 1. cli flags +// 2. plugin/source latest merged options +// 3. configuration settings + +//TODO: add python library to build entries as well + +/// Valid Action Modes +#[derive(Debug, Clone)] +enum ActionMode { + Run, + Terminal, + Echo, +} + +impl Display for ActionMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Run => write!(f, "run"), + Self::Terminal => write!(f, "terminal"), + Self::Echo => write!(f, "echo"), + } + } +} + +impl FromStr for ActionMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "run" => Ok(Self::Run), + "terminal" => Ok(Self::Terminal), + "echo" => Ok(Self::Echo), + _ => Err(format!("Invalid Method: {s:?}")), + } + } +} + +/// Arguents for Action CLI Command +#[derive(Debug, Args)] +struct ActionArgs { + /// Set Name of Action + #[arg(short, long, default_value_t=String::from("main"))] + name: String, + /// Set Comment of Action + #[arg(short, long)] + comment: Option, + /// Arguments to run As Action Command + #[clap(required = true, value_delimiter = ' ')] + args: Vec, + /// Action Mode + #[arg(short, long, default_value_t=ActionMode::Run)] + mode: ActionMode, +} + +impl Into for ActionArgs { + fn into(self) -> Action { + let exec = self.args.join(" "); + Action { + name: self.name, + comment: self.comment, + exec: match self.mode { + ActionMode::Run => Method::Run(exec), + ActionMode::Terminal => Method::Terminal(exec), + ActionMode::Echo => Method::Echo(exec), + }, + } + } +} + +/// Arguments for Entry CLI Command +#[derive(Debug, Args)] +struct EntryArgs { + /// Set Name of Entry + #[arg(short, long, default_value_t=String::from("main"))] + name: String, + /// Set Comment of Entry + #[arg(short, long)] + comment: Option, + /// Precomposed Action JSON Objects + #[arg(short, long, value_parser=parse_action)] + #[clap(required = true)] + actions: Vec, + /// Icon Image Path + #[arg(short = 'i', long)] + icon: Option, + /// Alternative Image Text/HTML + #[arg(short = 'I', long)] + icon_alt: Option, +} + +impl Into for EntryArgs { + fn into(self) -> Entry { + Entry { + name: self.name, + comment: self.comment, + actions: self.actions, + icon: self.icon, + icon_alt: self.icon_alt, + } + } +} + +/// Arguments for Options CLI Command +#[derive(Debug, Args)] +struct OptionArgs { + /// Override Applicaiton Theme + #[arg(short = 'C', long)] + pub css: Option, + #[arg(short = 's', long)] + pub page_size: Option, + #[arg(short = 'l', long)] + pub page_load: Option, + #[arg(short = 'd', long)] + pub jump_dist: Option, + // search settings + /// Override Default Placeholder + #[arg(short = 'P', long)] + pub placeholder: Option, + /// Override Search Restriction + #[arg(short = 'r', long)] + pub search_restrict: Option, + /// Override Minimum Search Length + #[arg(short = 'm', long)] + pub search_min_length: Option, + /// Override Maximum Search Length + #[arg(short = 'M', long)] + pub search_max_length: Option, + // key settings + /// Override Execution Keybinds + #[arg(short = 'e', long)] + pub key_exec: Option>, + /// Override Program-Exit Keybinds + #[arg(short = 'E', long)] + pub key_exit: Option>, + /// Override Move-Next Keybinds + #[arg(short = 'n', long)] + pub key_move_next: Option>, + /// Override Move-Previous Keybinds + #[arg(short = 'p', long)] + pub key_move_prev: Option>, + /// Override Open-Menu Keybinds + #[arg(short = 'o', long)] + pub key_open_menu: Option>, + /// Override Close-Menu Keybinds + #[arg(short = 'c', long)] + pub key_close_menu: Option>, + /// Override Jump-Next Keybinds + #[arg(short = 'j', long)] + pub key_jump_next: Option>, + /// Override Jump-Previous Keybinds + #[arg(short = 'J', long)] + pub key_jump_prev: Option>, + // window settings + /// Override Window Title + #[arg(short, long)] + pub title: Option, + /// Override Window Deocration Settings + #[arg(short, long)] + pub deocorate: Option, + /// Override Window Fullscreen Settings + #[arg(short, long)] + pub fullscreen: Option, + /// Override Window Tranparent Settings + #[arg(short, long)] + pub transparent: Option, + /// Override Window Width + #[arg(short = 'w', long)] + pub window_width: Option, + /// Override Window Height + #[arg(short = 'h', long)] + pub window_height: Option, +} + +impl Into for OptionArgs { + fn into(self) -> Options { + Options { + css: self.css, + page_size: self.page_size, + page_load: self.page_load, + jump_dist: self.jump_dist, + placeholder: self.placeholder, + search_restrict: self.search_restrict, + search_min_length: self.search_min_length, + search_max_length: self.search_max_length, + key_exec: self.key_exec, + key_exit: self.key_exit, + key_move_next: self.key_move_next, + key_move_prev: self.key_move_prev, + key_open_menu: self.key_open_menu, + key_close_menu: self.key_close_menu, + key_jump_next: self.key_jump_next, + key_jump_prev: self.key_jump_prev, + title: self.title, + decorate: self.deocorate, + fullscreen: self.fullscreen, + transparent: self.transparent, + window_width: self.window_width, + window_height: self.window_height, + } + } +} + +/// Valid CLI Commands and their Arguments +#[derive(Debug, Subcommand)] +enum Command { + /// Generate Complete RMenu Entry + Entry(EntryArgs), + /// Generate RMenu Entry Action Object + Action(ActionArgs), + /// Generate RMenu Options Settings + Options(OptionArgs), +} + +#[derive(Debug, Parser)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +struct Cli { + /// Generate an Entry/Action Object + #[clap(subcommand)] + command: Command, +} + +fn main() { + let cli = Cli::parse(); + let result = match cli.command { + Command::Entry(args) => { + let entry: Entry = args.into(); + serde_json::to_string(&entry) + } + Command::Action(args) => { + let action: Action = args.into(); + serde_json::to_string(&action) + } + Command::Options(args) => { + let options: Options = args.into(); + serde_json::to_string(&options) + } + }; + println!("{}", result.expect("Serialization Failed")); +} diff --git a/rmenu-plugin/src/lib.rs b/rmenu-plugin/src/lib.rs index afa8b77..f554198 100644 --- a/rmenu-plugin/src/lib.rs +++ b/rmenu-plugin/src/lib.rs @@ -1,6 +1,8 @@ +//! RMenu-Plugin Object Implementations use serde::{Deserialize, Serialize}; -#[derive(Debug, PartialEq, Serialize, Deserialize)] +/// Methods allowed to Execute Actions on Selection +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Method { Terminal(String), @@ -9,6 +11,7 @@ pub enum Method { } impl Method { + /// Generate the Required Method from a Function pub fn new(exec: String, terminal: bool) -> Self { match terminal { true => Self::Terminal(exec), @@ -17,7 +20,8 @@ impl Method { } } -#[derive(Debug, PartialEq, Serialize, Deserialize)] +/// RMenu Entry Action Definition +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct Action { pub name: String, pub exec: Method, @@ -25,13 +29,15 @@ pub struct Action { } impl Action { - pub fn new(exec: &str) -> Self { + /// Generate a simple Execution Action + pub fn exec(exec: &str) -> Self { Self { name: "main".to_string(), exec: Method::Run(exec.to_string()), comment: None, } } + /// Generate a simple Echo Action pub fn echo(echo: &str) -> Self { Self { name: "main".to_string(), @@ -41,30 +47,108 @@ impl Action { } } -#[derive(Debug, PartialEq, Serialize, Deserialize)] +/// RMenu Menu-Entry Implementation +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename = "entry")] pub struct Entry { pub name: String, pub actions: Vec, pub comment: Option, pub icon: Option, + pub icon_alt: Option, } impl Entry { + /// Generate a simplified Exec Action Entry pub fn new(name: &str, action: &str, comment: Option<&str>) -> Self { Self { name: name.to_owned(), - actions: vec![Action::new(action)], + actions: vec![Action::exec(action)], comment: comment.map(|c| c.to_owned()), icon: Default::default(), + icon_alt: Default::default(), } } - + /// Generate a simplified Echo Action Entry pub fn echo(echo: &str, comment: Option<&str>) -> Self { Self { name: echo.to_owned(), actions: vec![Action::echo(echo)], comment: comment.map(|c| c.to_owned()), icon: Default::default(), + icon_alt: Default::default(), } } } + +/// Additional Plugin Option Overrides +#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] +#[serde(default, tag = "type", rename = "options")] +pub struct Options { + // base settings + #[serde(skip_serializing_if = "Option::is_none")] + pub css: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_load: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jump_dist: Option, + // search settings + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub search_restrict: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub search_min_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub search_max_length: Option, + // key settings + #[serde(skip_serializing_if = "Option::is_none")] + pub key_exec: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_exit: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_move_next: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_move_prev: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_open_menu: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_close_menu: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_jump_next: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_jump_prev: Option>, + // window settings + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub decorate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transparent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fullscreen: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub window_width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub window_height: Option, +} + +/// Valid RMenu Plugin Messages +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Message { + Entry(Entry), + Options(Options), +} + +/// Retrieve EXE of Self +#[inline] +pub fn self_exe() -> String { + std::env::current_exe() + .expect("Cannot Find EXE of Self") + .to_str() + .unwrap() + .to_string() +} diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 19a6f5c..28c3e48 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -6,7 +6,6 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -bincode = "1.3.3" cached = "0.44.0" clap = { version = "4.3.15", features = ["derive"] } dioxus = "0.4.0" diff --git a/rmenu/public/config.yaml b/rmenu/public/config.yaml index 5a74bef..64fd8bf 100644 --- a/rmenu/public/config.yaml +++ b/rmenu/public/config.yaml @@ -25,10 +25,16 @@ plugins: drun: exec: ["~/.config/rmenu/rmenu-desktop"] cache: onlogin - audio: - exec: ["~/.config/rmenu/rmenu-audio"] - cache: false - placeholder: "Select an Audio Sink" + options: + css: ~/.config/rmenu/themes/launchpad.css + page_size: 500 + transparent: true + window_width: 1200 + window_height: 800 + key_move_prev: ["Arrow-Left"] + key_move_next: ["Arrow-Right"] + key_jump_prev: ["Arrow-Up"] + key_jump_next: ["Arrow-Down"] network: exec: ["~/.config/rmenu/rmenu-network"] cache: false @@ -37,12 +43,21 @@ plugins: exec: ["~/.config/rmenu/rmenu-window"] cache: false placeholder: "Jump to the Specified Window" + audio: + exec: ["~/.config/rmenu/pactl-audio.sh"] + cache: false + placeholder: "Select an Audio Sink" + powermenu: + exec: ["~/.config/rmenu/powermenu.sh"] + cache: false # custom keybindings keybinds: exec: ["Enter"] exit: ["Escape"] - move_up: ["Arrow-Up", "Shift+Tab"] - move_down: ["Arrow-Down", "Tab"] + move_next: ["Arrow-Down", "Tab"] + move_prev: ["Arrow-Up", "Shift+Tab"] + jump_next: ["Page-Down"] + jump_prev: ["Page-Up"] open_menu: ["Arrow-Right"] close_menu: ["Arrow-Left"] diff --git a/rmenu/public/default.css b/rmenu/public/default.css index 4c72c8f..2031940 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -8,6 +8,12 @@ body > div { overflow: hidden; } +html, +body, +.content { + margin: 0; +} + .navbar { top: 0; left: 0; @@ -28,8 +34,12 @@ body > div { /* Navigation */ +#search:invalid { + border: 1px solid red; +} + input { - width: 100%; + width: -webkit-fill-available; height: 5vw; border: none; outline: none; @@ -49,24 +59,24 @@ input { margin: 2px 5px; } -.result > .icon { +.icon { width: 4%; overflow: hidden; display: flex; justify-content: center; } -.result > .icon > img { +img { width: 100%; height: 100%; object-fit: cover; } -.result > .name { +.name { width: 30%; } -.result > .comment { +.comment { flex: 1; } diff --git a/rmenu/src/cache.rs b/rmenu/src/cache.rs index f9522e4..b33707a 100644 --- a/rmenu/src/cache.rs +++ b/rmenu/src/cache.rs @@ -1,6 +1,5 @@ //! RMenu Plugin Result Cache use std::fs; -use std::io::Write; use std::path::PathBuf; use std::time::{Duration, SystemTime}; @@ -25,7 +24,7 @@ pub enum CacheError { #[error("Cache File Error")] FileError(#[from] std::io::Error), #[error("Encoding Error")] - EncodingError(#[from] bincode::Error), + EncodingError(#[from] serde_json::Error), } #[inline] @@ -69,7 +68,7 @@ pub fn read_cache(name: &str, cfg: &PluginConfig) -> Result, CacheErr } // attempt to read content let data = fs::read(path)?; - let results: Vec = bincode::deserialize(&data)?; + let results: Vec = serde_json::from_slice(&data)?; Ok(results) } @@ -79,10 +78,10 @@ pub fn write_cache(name: &str, cfg: &PluginConfig, entries: &Vec) -> Resu match cfg.cache { CacheSetting::NoCache => {} _ => { + log::debug!("{name:?} writing {} entries", entries.len()); let path = cache_file(name); - let data = bincode::serialize(entries)?; - let mut f = fs::File::create(path)?; - f.write_all(&data)?; + let f = fs::File::create(path)?; + serde_json::to_writer(f, entries)?; } } Ok(()) diff --git a/rmenu/src/cli.rs b/rmenu/src/cli.rs new file mode 100644 index 0000000..8318adf --- /dev/null +++ b/rmenu/src/cli.rs @@ -0,0 +1,379 @@ +use std::fs::File; +use std::io::{BufRead, BufReader, Read}; +use std::process::{Command, ExitStatus, Stdio}; +use std::str::FromStr; +use std::{fmt::Display, fs::read_to_string}; + +use clap::Parser; +use rmenu_plugin::{Entry, Message}; +use thiserror::Error; + +use crate::config::{cfg_replace, Config, Keybind}; +use crate::{DEFAULT_CONFIG, DEFAULT_CSS}; + +/// Allowed Formats for Entry Ingestion +#[derive(Debug, Clone)] +pub enum Format { + Json, + DMenu, +} + +impl Display for Format { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!("{self:?}").to_lowercase()) + } +} + +impl FromStr for Format { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + match s.to_ascii_lowercase().as_str() { + "json" => Ok(Format::Json), + "dmenu" => Ok(Format::DMenu), + _ => Err("No Such Format".to_owned()), + } + } +} + +/// Dynamic Applicaiton-Menu Tool (Built with Rust) +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +pub struct Args { + // simple configuration arguments + /// Filepath for entry input + #[arg(short, long)] + input: Option, + /// Format to accept entries + #[arg(short, long, default_value_t=Format::Json)] + format: Format, + /// Plugins to run + #[arg(short, long)] + run: Vec, + /// Override default configuration path + #[arg(short, long)] + config: Option, + /// Override base css theme styling + #[arg(long, default_value_t=String::from(DEFAULT_CSS))] + theme: String, + /// Include additional css settings + #[arg(long)] + css: Option, + + // root config settings + /// Override terminal command + #[arg(long)] + terminal: Option, + /// Number of results to include for each page + #[arg(long)] + page_size: Option, + /// Control ratio on when to load next page + #[arg(long)] + page_load: Option, + /// Force enable/disable comments + #[arg(long)] + use_icons: Option, + /// Force enable/disable comments + #[arg(long)] + use_comments: Option, + + // search settings + /// Enforce Regex Pattern on Search + #[arg(long)] + search_restrict: Option, + /// Enforce Minimum Length on Search + #[arg(long)] + search_min_length: Option, + /// Enforce Maximum Length on Search + #[arg(long)] + search_max_length: Option, + /// Force enable/disable regex in search + #[arg(long)] + search_regex: Option, + /// Force enable/disable ignore-case in search + #[arg(long)] + ignore_case: Option, + /// Override placeholder in searchbar + #[arg(short, long)] + placeholder: Option, + + // keybinding settings + /// Override exec keybind + #[arg(long)] + key_exec: Option>, + /// Override exit keybind + #[arg(long)] + key_exit: Option>, + /// Override move-next keybind + #[arg(long)] + key_move_next: Option>, + /// Override move-previous keybind + #[arg(long)] + key_move_prev: Option>, + /// Override open-menu keybind + #[arg(long)] + key_open_menu: Option>, + /// Override close-menu keybind + #[arg(long)] + key_close_menu: Option>, + /// Override jump-next keybind + #[arg(long)] + key_jump_next: Option>, + /// Override jump-previous keybind + #[arg(long)] + key_jump_prev: Option>, + + //window settings + /// Override Window Title + #[arg(long)] + title: Option, + /// Override Window Width + #[arg(long)] + width: Option, + /// Override Window Height + #[arg(long)] + height: Option, + /// Override Window X Position + #[arg(long)] + xpos: Option, + /// Override Window Y Position + #[arg(long)] + ypos: Option, + /// Override Window Focus on Startup + #[arg(long)] + focus: Option, + /// Override Window Decoration + #[arg(long)] + decorate: Option, + /// Override Window Transparent + #[arg(long)] + transparent: Option, + /// Override Window Always-On-Top + #[arg(long)] + always_top: Option, + /// Override Fullscreen Settings + #[arg(long)] + fullscreen: Option, +} + +#[derive(Error, Debug)] +pub enum RMenuError { + #[error("Invalid Config")] + InvalidConfig(#[from] serde_yaml::Error), + #[error("File Error")] + FileError(#[from] std::io::Error), + #[error("No Such Plugin")] + NoSuchPlugin(String), + #[error("Invalid Plugin Specified")] + InvalidPlugin(String), + #[error("Invalid Keybind Definition")] + InvalidKeybind(String), + #[error("Command Runtime Exception")] + CommandError(Option), + #[error("Invalid JSON Entry Object")] + InvalidJson(#[from] serde_json::Error), +} + +pub type Result = std::result::Result; + +impl Args { + /// Load Configuration File + pub fn get_config(&self) -> Result { + // read configuration + let path = self + .config + .as_ref() + .map(|v| v.as_str()) + .unwrap_or(DEFAULT_CONFIG); + let path = shellexpand::tilde(path).to_string(); + let config: Config = match read_to_string(path) { + Ok(content) => serde_yaml::from_str(&content), + Err(err) => { + log::error!("Failed to Load Config: {err:?}"); + Ok(Config::default()) + } + }?; + Ok(config) + } + + /// Update Configuration w/ CLI Specified Settings + pub fn update_config(&self, mut config: Config) -> Config { + // override basic settings + config.terminal = self.terminal.clone().or_else(|| config.terminal); + config.page_size = self.page_size.unwrap_or(config.page_size); + config.page_load = self.page_load.unwrap_or(config.page_load); + config.use_icons = self.use_icons.unwrap_or(config.use_icons); + config.use_comments = self.use_icons.unwrap_or(config.use_comments); + // override search settings + cfg_replace!(config.search.restrict, self.search_restrict); + cfg_replace!(config.search.min_length, self.search_min_length); + cfg_replace!(config.search.max_length, self.search_max_length); + cfg_replace!(config.search.use_regex, self.search_regex, true); + cfg_replace!(config.search.ignore_case, self.ignore_case, true); + cfg_replace!(config.search.placeholder, self.placeholder); + // override keybind settings + cfg_replace!(config.keybinds.exec, self.key_exec, true); + cfg_replace!(config.keybinds.exit, self.key_exit, true); + cfg_replace!(config.keybinds.move_next, self.key_move_next, true); + cfg_replace!(config.keybinds.move_prev, self.key_move_prev, true); + cfg_replace!(config.keybinds.open_menu, self.key_open_menu, true); + cfg_replace!(config.keybinds.close_menu, self.key_close_menu, true); + cfg_replace!(config.keybinds.jump_next, self.key_jump_next, true); + cfg_replace!(config.keybinds.jump_prev, self.key_jump_prev, true); + // override window settings + cfg_replace!(config.window.title, self.title, true); + cfg_replace!(config.window.size.width, self.width, true); + cfg_replace!(config.window.size.height, self.height, true); + cfg_replace!(config.window.position.x, self.xpos, true); + cfg_replace!(config.window.position.y, self.ypos, true); + cfg_replace!(config.window.focus, self.focus, true); + cfg_replace!(config.window.decorate, self.decorate, true); + cfg_replace!(config.window.transparent, self.transparent, true); + cfg_replace!(config.window.always_top, self.always_top, true); + cfg_replace!(config.window.fullscreen, self.fullscreen); + config + } + + /// Load CSS Theme or Default + pub fn get_theme(&self) -> String { + let path = shellexpand::tilde(&self.theme).to_string(); + match read_to_string(&path) { + Ok(css) => css, + Err(err) => { + log::error!("Failed to load CSS: {err:?}"); + String::new() + } + } + } + + /// Load Additional CSS or Default + pub fn get_css(&self, c: &Config) -> String { + if let Some(css) = self.css.as_ref().or(c.css.as_ref()) { + let path = shellexpand::tilde(&css).to_string(); + match read_to_string(&path) { + Ok(theme) => return theme, + Err(err) => log::error!("Failed to load Theme: {err:?}"), + } + } + String::new() + } + + fn read_entries( + &mut self, + r: BufReader, + v: &mut Vec, + c: &mut Config, + ) -> Result<()> { + for line in r.lines().filter_map(|l| l.ok()) { + match &self.format { + Format::DMenu => v.push(Entry::echo(line.trim(), None)), + Format::Json => { + let msg: Message = serde_json::from_str(&line)?; + match msg { + Message::Entry(entry) => v.push(entry), + Message::Options(options) => c + .update(&options) + .map_err(|s| RMenuError::InvalidKeybind(s))?, + } + } + } + } + Ok(()) + } + + /// Read Entries from a Configured Input + fn load_input(&mut self, input: &str, config: &mut Config) -> Result> { + // retrieve input file + let input = if input == "-" { "/dev/stdin" } else { input }; + let fpath = shellexpand::tilde(input).to_string(); + // read entries into iterator and collect + log::info!("reading from: {fpath:?}"); + let file = File::open(fpath)?; + let reader = BufReader::new(file); + let mut entries = vec![]; + self.read_entries(reader, &mut entries, config)?; + Ok(entries) + } + + /// Read Entries from a Plugin Source + fn load_plugins(&mut self, config: &mut Config) -> Result> { + let mut entries = vec![]; + for name in self.run.clone().into_iter() { + // retrieve plugin configuration + log::info!("running plugin: {name:?}"); + let plugin = config + .plugins + .get(&name) + .cloned() + .ok_or_else(|| RMenuError::NoSuchPlugin(name.to_owned()))?; + // update config w/ plugin options when available + if let Some(options) = plugin.options.as_ref() { + config + .update(options) + .map_err(|e| RMenuError::InvalidKeybind(e))?; + } + // read cache when available + match crate::cache::read_cache(&name, &plugin) { + Err(err) => log::error!("cache read failed: {err:?}"), + Ok(cached) => { + entries.extend(cached); + continue; + } + } + // build command arguments + let args: Vec = plugin + .exec + .iter() + .map(|s| shellexpand::tilde(s).to_string()) + .collect(); + let main = args + .get(0) + .ok_or_else(|| RMenuError::InvalidPlugin(name.to_owned()))?; + // spawn command + let mut command = Command::new(main) + .args(&args[1..]) + .stdout(Stdio::piped()) + .spawn()?; + let stdout = command + .stdout + .as_mut() + .ok_or_else(|| RMenuError::CommandError(None))?; + // parse and read entries into vector of results + let reader = BufReader::new(stdout); + let mut entry = vec![]; + self.read_entries(reader, &mut entry, config)?; + let status = command.wait()?; + if !status.success() { + return Err(RMenuError::CommandError(Some(status))); + } + // finalize settings and save to cache + if config.search.placeholder.is_none() { + config.search.placeholder = plugin.placeholder.clone(); + } + match crate::cache::write_cache(&name, &plugin, &entry) { + Ok(_) => {} + Err(err) => log::error!("cache write error: {err:?}"), + } + // write collected entries to main output + entries.append(&mut entry); + } + Ok(entries) + } + + /// Load Entries from Enabled/Configured Entry-Sources + pub fn get_entries(&mut self, config: &mut Config) -> Result> { + // configure default source if none are given + let mut input = self.input.clone(); + let mut entries = vec![]; + if input.is_none() && self.run.is_empty() { + input = Some("-".to_owned()); + } + // load entries + if let Some(input) = input { + entries.extend(self.load_input(&input, config)?); + } + entries.extend(self.load_plugins(config)?); + Ok(entries) + } +} diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index 001c3bd..d36a9b3 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -1,11 +1,15 @@ //! RMENU Configuration Implementations use heck::AsPascalCase; use keyboard_types::{Code, Modifiers}; +use rmenu_plugin::Options; use serde::{de::Error, Deserialize}; use std::collections::BTreeMap; use std::str::FromStr; -use dioxus_desktop::tao::dpi::{LogicalPosition, LogicalSize}; +use dioxus_desktop::tao::{ + dpi::{LogicalPosition, LogicalSize}, + window::Fullscreen, +}; // parse supported modifiers from string fn mod_from_str(s: &str) -> Option { @@ -19,7 +23,7 @@ fn mod_from_str(s: &str) -> Option { } /// Single GUI Keybind for Configuration -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct Keybind { pub mods: Modifiers, pub key: Code, @@ -80,10 +84,12 @@ impl<'de> Deserialize<'de> for Keybind { pub struct KeyConfig { pub exec: Vec, pub exit: Vec, - pub move_up: Vec, - pub move_down: Vec, + pub move_next: Vec, + pub move_prev: Vec, pub open_menu: Vec, pub close_menu: Vec, + pub jump_next: Vec, + pub jump_prev: Vec, } impl Default for KeyConfig { @@ -91,10 +97,12 @@ impl Default for KeyConfig { return Self { exec: vec![Keybind::new(Code::Enter)], exit: vec![Keybind::new(Code::Escape)], - move_up: vec![Keybind::new(Code::ArrowUp)], - move_down: vec![Keybind::new(Code::ArrowDown)], + move_next: vec![Keybind::new(Code::ArrowUp)], + move_prev: vec![Keybind::new(Code::ArrowDown)], open_menu: vec![], close_menu: vec![], + jump_next: vec![Keybind::new(Code::PageDown)], + jump_prev: vec![Keybind::new(Code::PageUp)], }; } } @@ -105,13 +113,26 @@ pub struct WindowConfig { pub title: String, pub size: LogicalSize, pub position: LogicalPosition, + #[serde(default = "_true")] pub focus: bool, pub decorate: bool, pub transparent: bool, + #[serde(default = "_true")] pub always_top: bool, + pub fullscreen: Option, pub dark_mode: Option, } +impl WindowConfig { + /// Retrieve Desktop Compatabible Fullscreen Settings + pub fn get_fullscreen(&self) -> Option { + self.fullscreen.and_then(|fs| match fs { + true => Some(Fullscreen::Borderless(None)), + false => None, + }) + } +} + impl Default for WindowConfig { fn default() -> Self { Self { @@ -125,13 +146,14 @@ impl Default for WindowConfig { decorate: false, transparent: false, always_top: true, + fullscreen: None, dark_mode: None, } } } /// Cache Settings for Configured RMenu Plugins -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub enum CacheSetting { NoCache, Never, @@ -173,13 +195,15 @@ impl Default for CacheSetting { } /// RMenu Data-Source Plugin Configuration -#[derive(Debug, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize)] pub struct PluginConfig { pub exec: Vec, #[serde(default)] pub cache: CacheSetting, #[serde(default)] pub placeholder: Option, + #[serde(default)] + pub options: Option, } #[inline] @@ -187,24 +211,48 @@ fn _true() -> bool { true } +#[derive(Debug, PartialEq, Deserialize)] +#[serde(default)] +pub struct SearchConfig { + pub restrict: Option, + pub min_length: Option, + pub max_length: Option, + pub placeholder: Option, + #[serde(default = "_true")] + pub use_regex: bool, + #[serde(default = "_true")] + pub ignore_case: bool, +} + +impl Default for SearchConfig { + fn default() -> Self { + Self { + restrict: Default::default(), + min_length: Default::default(), + max_length: Default::default(), + placeholder: Default::default(), + use_regex: true, + ignore_case: true, + } + } +} + /// Global RMenu Complete Configuration #[derive(Debug, PartialEq, Deserialize)] #[serde(default)] pub struct Config { pub page_size: usize, pub page_load: f64, + pub jump_dist: usize, #[serde(default = "_true")] pub use_icons: bool, #[serde(default = "_true")] pub use_comments: bool, - #[serde(default = "_true")] - pub search_regex: bool, - #[serde(default = "_true")] - pub ignore_case: bool, - pub placeholder: Option, + pub search: SearchConfig, pub plugins: BTreeMap, pub keybinds: KeyConfig, pub window: WindowConfig, + pub css: Option, pub terminal: Option, } @@ -213,15 +261,77 @@ impl Default for Config { Self { page_size: 50, page_load: 0.8, + jump_dist: 5, use_icons: true, use_comments: true, - search_regex: false, - ignore_case: true, - placeholder: Default::default(), + search: Default::default(), plugins: Default::default(), keybinds: Default::default(), window: Default::default(), - terminal: Default::default(), + css: None, + terminal: None, } } } + +macro_rules! cfg_replace { + ($key:expr, $repl:expr) => { + if $repl.is_some() { + $key = $repl.clone(); + } + }; + ($key:expr, $repl:expr, true) => { + if let Some(value) = $repl.as_ref() { + $key = value.to_owned(); + } + }; +} + +macro_rules! cfg_keybind { + ($key:expr, $repl:expr) => { + if let Some(bind_strings) = $repl.as_ref() { + let mut keybinds = vec![]; + for bind_str in bind_strings.iter() { + let bind = Keybind::from_str(bind_str)?; + keybinds.push(bind); + } + $key = keybinds; + } + }; +} + +pub(crate) use cfg_keybind; +pub(crate) use cfg_replace; + +impl Config { + /// Update Configuration from Options Object + pub fn update(&mut self, options: &Options) -> Result<(), String> { + cfg_replace!(self.css, options.css); + cfg_replace!(self.page_size, options.page_size, true); + cfg_replace!(self.page_load, options.page_load, true); + cfg_replace!(self.jump_dist, options.jump_dist, true); + // search settings + cfg_replace!(self.search.placeholder, options.placeholder); + cfg_replace!(self.search.restrict, options.search_restrict); + cfg_replace!(self.search.min_length, options.search_min_length); + cfg_replace!(self.search.max_length, options.search_max_length); + // keybind settings + cfg_keybind!(self.keybinds.exec, options.key_exec); + cfg_keybind!(self.keybinds.exec, options.key_exec); + cfg_keybind!(self.keybinds.exit, options.key_exit); + cfg_keybind!(self.keybinds.move_next, options.key_move_next); + cfg_keybind!(self.keybinds.move_prev, options.key_move_prev); + cfg_keybind!(self.keybinds.open_menu, options.key_open_menu); + cfg_keybind!(self.keybinds.close_menu, options.key_close_menu); + cfg_keybind!(self.keybinds.jump_next, options.key_jump_next); + cfg_keybind!(self.keybinds.jump_prev, options.key_jump_prev); + // window settings + cfg_replace!(self.window.title, options.title, true); + cfg_replace!(self.window.decorate, options.decorate, true); + cfg_replace!(self.window.fullscreen, options.fullscreen); + cfg_replace!(self.window.transparent, options.transparent, true); + cfg_replace!(self.window.size.width, options.window_width, true); + cfg_replace!(self.window.size.height, options.window_height, true); + Ok(()) + } +} diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index a47e45a..02a9e9a 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::fmt::Display; + use dioxus::prelude::*; use keyboard_types::{Code, Modifiers}; use rmenu_plugin::Entry; @@ -10,7 +12,6 @@ use crate::{App, DEFAULT_CSS_CONTENT}; /// spawn and run the app on the configured platform pub fn run(app: App) { - // customize window let theme = match app.config.window.dark_mode { Some(dark) => match dark { true => Some(dioxus_desktop::tao::window::Theme::Dark), @@ -26,6 +27,7 @@ pub fn run(app: App) { .with_decorations(app.config.window.decorate) .with_transparent(app.config.window.transparent) .with_always_on_top(app.config.window.always_top) + .with_fullscreen(app.config.window.get_fullscreen()) .with_theme(theme); let config = dioxus_desktop::Config::new().with_window(builder); dioxus_desktop::launch_with_props(App, app, config); @@ -41,21 +43,28 @@ struct GEntry<'a> { } #[inline] -fn render_comment(comment: Option<&String>) -> String { - return comment.map(|s| s.as_str()).unwrap_or("").to_string(); +fn render_comment(comment: Option<&String>) -> &str { + comment.map(|s| s.as_str()).unwrap_or("") } #[inline] -fn render_image<'a, T>(cx: Scope<'a, T>, image: Option<&String>) -> Element<'a> { +fn render_image<'a, T>( + cx: Scope<'a, T>, + image: Option<&String>, + alt: 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}" } }); + if crate::image::image_exists(img.to_owned()) { + return cx.render(rsx! { img { class: "image", src: "{img}" } }); + } } - None + let alt = alt.map(|s| s.as_str()).unwrap_or_else(|| "?"); + return cx.render(rsx! { div { class: "icon_alt", dangerous_inner_html: "{alt}" } }); } /// render a single result entry w/ the given information @@ -95,7 +104,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec), div { class: "action-name", - "{action.name}" + dangerous_inner_html: "{action.name}" } div { class: "action-comment", @@ -117,7 +126,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { cx.render(rsx! { div { class: "icon", - render_image(cx, cx.props.entry.icon.as_ref()) + render_image(cx, cx.props.entry.icon.as_ref(), cx.props.entry.icon_alt.as_ref()) } }) } @@ -125,17 +134,17 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { true => cx.render(rsx! { div { class: "name", - "{cx.props.entry.name}" + dangerous_inner_html: "{cx.props.entry.name}" } div { class: "comment", - render_comment(cx.props.entry.comment.as_ref()) + dangerous_inner_html: render_comment(cx.props.entry.comment.as_ref()) } }), false => cx.render(rsx! { div { class: "entry", - "{cx.props.entry.name}" + dangerous_inner_html: "{cx.props.entry.name}" } }) } @@ -162,6 +171,12 @@ fn matches(bind: &Vec, mods: &Modifiers, key: &Code) -> bool { bind.iter().any(|b| mods.contains(b.mods) && &b.key == key) } +/// retrieve string value for display-capable enum +#[inline] +fn get_str(item: Option) -> String { + item.map(|i| i.to_string()).unwrap_or_else(String::new) +} + /// main application function/loop fn App<'a>(cx: Scope) -> Element { let mut state = AppState::new(cx, cx.props); @@ -176,11 +191,8 @@ fn App<'a>(cx: Scope) -> Element { // generate state tracker instances let results = state.results(&cx.props.entries); - let s_updater = state.partial_copy(); let k_updater = state.partial_copy(); - - //TODO: consider implementing some sort of - // action channel reference to pass to keboard events + let s_updater = state.partial_copy(); // build keyboard actions event handler let keybinds = &cx.props.config.keybinds; @@ -191,14 +203,18 @@ fn App<'a>(cx: Scope) -> Element { k_updater.set_event(KeyEvent::Exec); } else if matches(&keybinds.exit, &mods, &code) { k_updater.set_event(KeyEvent::Exit); - } else if matches(&keybinds.move_up, &mods, &code) { - k_updater.set_event(KeyEvent::ShiftUp); - } else if matches(&keybinds.move_down, &mods, &code) { - k_updater.set_event(KeyEvent::ShiftDown); + } else if matches(&keybinds.move_next, &mods, &code) { + k_updater.set_event(KeyEvent::MoveNext); + } else if matches(&keybinds.move_prev, &mods, &code) { + k_updater.set_event(KeyEvent::MovePrev); } else if matches(&keybinds.open_menu, &mods, &code) { k_updater.set_event(KeyEvent::OpenMenu); } else if matches(&keybinds.close_menu, &mods, &code) { k_updater.set_event(KeyEvent::CloseMenu); + } else if matches(&keybinds.jump_next, &mods, &code) { + k_updater.set_event(KeyEvent::JumpNext) + } else if matches(&keybinds.jump_prev, &mods, &code) { + k_updater.set_event(KeyEvent::JumpPrev) } }; @@ -219,29 +235,46 @@ fn App<'a>(cx: Scope) -> Element { }) }); - // retreive placeholder - let placeholder = cx - .props - .config - .placeholder - .as_ref() - .map(|s| s.to_string()) - .unwrap_or_else(|| "".to_owned()); + // get input settings + let minlen = get_str(cx.props.config.search.min_length.as_ref()); + let maxlen = get_str(cx.props.config.search.max_length.as_ref()); + let placeholder = get_str(cx.props.config.search.placeholder.as_ref()); // complete final rendering cx.render(rsx! { style { DEFAULT_CSS_CONTENT } + style { "{cx.props.theme}" } style { "{cx.props.css}" } div { - // onclick: |_| focus(cx), - onkeydown: keyboard_controls, + id: "content", + class: "content", div { + id: "navbar", class: "navbar", - input { - id: "search", - value: "{search}", - placeholder: "{placeholder}", - oninput: move |evt| s_updater.set_search(cx, evt.value.clone()), + match cx.props.config.search.restrict.as_ref() { + Some(pattern) => cx.render(rsx! { + input { + id: "search", + value: "{search}", + pattern: "{pattern}", + minlength: "{minlen}", + maxlength: "{maxlen}", + placeholder: "{placeholder}", + oninput: move |e| s_updater.set_search(cx, e.value.clone()), + onkeydown: keyboard_controls, + } + }), + None => cx.render(rsx! { + input { + id: "search", + value: "{search}", + minlength: "{minlen}", + maxlength: "{maxlen}", + placeholder: "{placeholder}", + oninput: move |e| s_updater.set_search(cx, e.value.clone()), + onkeydown: keyboard_controls, + } + }) } } div { diff --git a/rmenu/src/image.rs b/rmenu/src/image.rs index ff87319..b77f0a6 100644 --- a/rmenu/src/image.rs +++ b/rmenu/src/image.rs @@ -1,5 +1,5 @@ //! GUI Image Processing -use std::fs::{create_dir_all, read_to_string, write}; +use std::fs::{create_dir_all, write}; use std::io; use std::path::PathBuf; use std::sync::Mutex; @@ -37,9 +37,9 @@ fn make_temp() -> Result<(), io::Error> { /// Convert SVG to PNG Image fn svg_to_png(path: &str, dest: &PathBuf, pixels: u32) -> Result<(), SvgError> { // read and convert to resvg document tree - let xml = read_to_string(path)?; + let xml = std::fs::read(path)?; let opt = resvg::usvg::Options::default(); - let tree = resvg::usvg::Tree::from_str(&xml, &opt)?; + 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(); @@ -75,3 +75,8 @@ pub fn convert_svg(path: String) -> Option { } Some(new_path.to_str()?.to_string()) } + +#[cached] +pub fn image_exists(path: String) -> bool { + PathBuf::from(path).exists() +} diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index 50708b5..2b1d962 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -1,11 +1,5 @@ -use std::collections::VecDeque; -use std::fmt::Display; -use std::fs::{read_to_string, File}; -use std::io::{self, prelude::*, BufReader}; -use std::process::{Command, ExitStatus, Stdio}; -use std::str::FromStr; - mod cache; +mod cli; mod config; mod exec; mod gui; @@ -14,252 +8,68 @@ mod search; mod state; use clap::Parser; -use rmenu_plugin::Entry; -use thiserror::Error; +use rmenu_plugin::{self_exe, Entry}; static CONFIG_DIR: &'static str = "~/.config/rmenu/"; static DEFAULT_CSS: &'static str = "~/.config/rmenu/style.css"; static DEFAULT_CONFIG: &'static str = "~/.config/rmenu/config.yaml"; static DEFAULT_CSS_CONTENT: &'static str = include_str!("../public/default.css"); -#[derive(Debug, Clone)] -pub enum Format { - Json, - DMenu, -} - -impl Display for Format { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!("{self:?}").to_lowercase()) - } -} - -impl FromStr for Format { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_ascii_lowercase().as_str() { - "json" => Ok(Format::Json), - "dmenu" => Ok(Format::DMenu), - _ => Err("No Such Format".to_owned()), - } - } -} - -#[derive(Error, Debug)] -pub enum RMenuError { - #[error("$HOME not found")] - HomeNotFound, - #[error("Invalid Config")] - InvalidConfig(#[from] serde_yaml::Error), - #[error("File Error")] - FileError(#[from] io::Error), - #[error("No Such Plugin")] - NoSuchPlugin(String), - #[error("Invalid Plugin Specified")] - InvalidPlugin(String), - #[error("Command Runtime Exception")] - CommandError(Vec, Option), - #[error("Invalid JSON Entry Object")] - InvalidJson(#[from] serde_json::Error), -} - /// Application State for GUI #[derive(Debug, PartialEq)] pub struct App { css: String, name: String, + theme: String, entries: Vec, config: config::Config, } -/// Rofi Clone (Built with Rust) -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -#[command(propagate_version = true)] -pub struct Args { - #[arg(short, long, default_value_t=String::from("-"))] - input: String, - #[arg(short, long, default_value_t=Format::Json)] - format: Format, - #[arg(short, long)] - run: Vec, - #[arg(long)] - regex: Option, - #[arg(short, long)] - config: Option, - #[arg(long)] - css: Option, - #[arg(short, long)] - placehold: Option, -} +//TODO: how should scripting work? +// - need a better mechanism for rmenu and another executable to go back and forth +// - need some way to preserve settings between executions of rmenu +// - need some way for plugins to customize configuration according to preference -impl Args { - /// Load Config based on CLI Settings - fn config(&self) -> Result { - let path = match &self.config { - Some(path) => path.to_owned(), - None => shellexpand::tilde(DEFAULT_CONFIG).to_string(), - }; - log::debug!("loading config from {path:?}"); - let cfg = match read_to_string(path) { - Ok(cfg) => cfg, - Err(err) => { - log::error!("failed to load config: {err:?}"); - return Ok(config::Config::default()); - } - }; - serde_yaml::from_str(&cfg).map_err(|e| RMenuError::InvalidConfig(e)) - } +fn main() -> cli::Result<()> { + // export self to environment for other scripts + let exe = self_exe(); + std::env::set_var("RMENU", exe); - /// Read single entry from incoming line object - fn readentry(&self, cfg: &config::Config, line: &str) -> Result { - let mut entry = match self.format { - Format::Json => serde_json::from_str::(line)?, - Format::DMenu => Entry::echo(line.trim(), None), - }; - if !cfg.use_icons { - entry.icon = None; - } - Ok(entry) - } - - /// Load Entries From Input (Stdin by Default) - fn load_default(&self, cfg: &config::Config) -> Result, RMenuError> { - let fpath = match self.input.as_str() { - "-" => "/dev/stdin", - _ => &self.input, - }; - log::info!("reading from {fpath:?}"); - let file = File::open(fpath).map_err(|e| RMenuError::FileError(e))?; - let reader = BufReader::new(file); - let mut entries = vec![]; - for line in reader.lines() { - let entry = self.readentry(cfg, &line?)?; - entries.push(entry); - } - Ok(entries) - } - - /// Load Entries From Specified Sources - fn load_sources(&self, cfg: &mut config::Config) -> Result, RMenuError> { - log::debug!("config: {cfg:?}"); - // execute commands to get a list of entries - let mut entries = vec![]; - for name in self.run.iter() { - log::debug!("running plugin: {name}"); - // retrieve plugin command arguments - let plugin = cfg - .plugins - .get(name) - .ok_or_else(|| RMenuError::NoSuchPlugin(name.to_owned()))?; - // attempt to read cache rather than run command - match cache::read_cache(name, plugin) { - Ok(cached) => { - entries.extend(cached); - continue; - } - Err(err) => log::error!("cache read error: {err:?}"), - } - // build command - let mut cmdargs: VecDeque = plugin - .exec - .iter() - .map(|arg| shellexpand::tilde(arg).to_string()) - .collect(); - let main = cmdargs - .pop_front() - .ok_or_else(|| RMenuError::InvalidPlugin(name.to_owned()))?; - let mut cmd = Command::new(main); - for arg in cmdargs.iter() { - cmd.arg(arg); - } - // spawn command - let mut proc = cmd.stdout(Stdio::piped()).spawn()?; - let stdout = proc - .stdout - .as_mut() - .ok_or_else(|| RMenuError::CommandError(plugin.exec.clone().into(), None))?; - let reader = BufReader::new(stdout); - // read output line by line and parse content - for line in reader.lines() { - let entry = self.readentry(cfg, &line?)?; - entries.push(entry); - } - // check status of command on exit - let status = proc.wait()?; - if !status.success() { - return Err(RMenuError::CommandError( - plugin.exec.clone().into(), - Some(status.clone()), - )); - } - // update placeholder if empty - if cfg.placeholder.is_none() { - cfg.placeholder = plugin.placeholder.clone(); - } - // write cache for entries collected - match cache::write_cache(name, plugin, &entries) { - Ok(_) => {} - Err(err) => log::error!("cache write error: {err:?}"), - }; - } - Ok(entries) - } - - /// Load Application - pub fn parse_app() -> Result { - let args = Self::parse(); - let mut config = args.config()?; - // load css files from settings - let csspath = args.css.clone().unwrap_or_else(|| DEFAULT_CSS.to_owned()); - let csspath = shellexpand::tilde(&csspath).to_string(); - let css = match read_to_string(csspath) { - Ok(css) => css, - Err(err) => { - log::error!("failed to load css: {err:?}"); - "".to_owned() - } - }; - // load entries from configured sources - let entries = match args.run.len() > 0 { - true => args.load_sources(&mut config)?, - false => args.load_default(&config)?, - }; - // update configuration based on cli - config.use_icons = config.use_icons && entries.iter().any(|e| e.icon.is_some()); - config.use_comments = config.use_icons && entries.iter().any(|e| e.comment.is_some()); - config.search_regex = args.regex.unwrap_or(config.search_regex); - if args.placehold.is_some() { - config.placeholder = args.placehold.clone(); - }; - // generate app object - return Ok(App { - css, - name: "rmenu".to_owned(), - entries, - config, - }); - } -} - -//TODO: improve search w/ modes? -//TODO: improve looks and css - -fn main() -> Result<(), RMenuError> { // enable log and set default level if std::env::var("RUST_LOG").is_err() { std::env::set_var("RUST_LOG", "info"); } env_logger::init(); - // parse cli / config / application-settings - let app = Args::parse_app()?; - // change directory to configuration dir + + // parse cli and retrieve values for app + let mut cli = cli::Args::parse(); + let mut config = cli.get_config()?; + let entries = cli.get_entries(&mut config)?; + let css = cli.get_css(&config); + let theme = cli.get_theme(); + + // update config based on cli-settings and entries + config = cli.update_config(config); + config.use_icons = config.use_icons + && entries + .iter() + .any(|e| e.icon.is_some() || e.icon_alt.is_some()); + config.use_comments = config.use_comments && entries.iter().any(|e| e.comment.is_some()); + + // change directory to config folder let cfgdir = shellexpand::tilde(CONFIG_DIR).to_string(); if let Err(err) = std::env::set_current_dir(&cfgdir) { log::error!("failed to change directory: {err:?}"); } - // run gui - gui::run(app); + + // genrate app context and run gui + gui::run(App { + name: "rmenu".to_owned(), + css, + theme, + entries, + config, + }); + Ok(()) } diff --git a/rmenu/src/search.rs b/rmenu/src/search.rs index 1ec0cef..d9b7191 100644 --- a/rmenu/src/search.rs +++ b/rmenu/src/search.rs @@ -8,9 +8,9 @@ use crate::config::Config; /// Configurtaion Settings and Search-String pub fn new_searchfn(cfg: &Config, search: &str) -> Box bool> { // build regex search expression - if cfg.search_regex { + if cfg.search.use_regex { let rgx = RegexBuilder::new(search) - .case_insensitive(cfg.ignore_case) + .case_insensitive(cfg.search.ignore_case) .build(); let Ok(regex) = rgx else { return Box::new(|_| false); @@ -26,7 +26,7 @@ pub fn new_searchfn(cfg: &Config, search: &str) -> Box bool> { }); } // build case-insensitive search expression - if cfg.ignore_case { + if cfg.search.ignore_case { let matchstr = search.to_lowercase(); return Box::new(move |entry: &Entry| { if entry.name.to_lowercase().contains(&matchstr) { diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index 62e6253..2faa4f2 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -1,4 +1,5 @@ use dioxus::prelude::{use_eval, use_ref, Scope, UseRef}; +use regex::Regex; use rmenu_plugin::Entry; use crate::config::Config; @@ -17,10 +18,12 @@ fn scroll(cx: Scope, pos: usize) { pub enum KeyEvent { Exec, Exit, - ShiftUp, - ShiftDown, + MovePrev, + MoveNext, OpenMenu, CloseMenu, + JumpNext, + JumpPrev, } pub struct InnerState { @@ -29,6 +32,7 @@ pub struct InnerState { page: usize, search: String, event: Option, + search_regex: Option, } impl InnerState { @@ -44,8 +48,20 @@ impl InnerState { self.pos = std::cmp::min(self.pos + x, max - 1) } + /// Jump a spefified number of results upwards + #[inline] + pub fn jump_up(&mut self, jump: usize) { + self.move_up(jump) + } + + /// Jump a specified number of results downwards + pub fn jump_down(&mut self, jump: usize, results: &Vec<&Entry>) { + let max = std::cmp::max(results.len(), 1); + self.move_down(jump, max); + } + /// Move Up Once With Context of SubMenu - pub fn shift_up(&mut self) { + pub fn move_prev(&mut self) { if self.subpos > 0 { self.subpos -= 1; return; @@ -54,15 +70,14 @@ impl InnerState { } /// Move Down Once With Context of SubMenu - pub fn shift_down(&mut self, results: &Vec<&Entry>) { + pub fn move_next(&mut self, results: &Vec<&Entry>) { if let Some(result) = results.get(self.pos) { if self.subpos > 0 && self.subpos < result.actions.len() - 1 { self.subpos += 1; return; } } - let max = std::cmp::max(results.len(), 1); - self.move_down(1, max); + self.jump_down(1, results) } } @@ -83,6 +98,21 @@ impl<'a> AppState<'a> { page: 0, search: "".to_string(), event: None, + search_regex: app.config.search.restrict.clone().and_then(|mut r| { + if !r.starts_with('^') { + r = format!("^{r}") + }; + if !r.ends_with('$') { + r = format!("{r}$") + }; + match Regex::new(&r) { + Ok(regex) => Some(regex), + Err(err) => { + log::error!("Invalid Regex Expression: {:?}", err); + None + } + } + }), }), app, results: vec![], @@ -147,13 +177,22 @@ impl<'a> AppState<'a> { KeyEvent::Exec => self.execute(), KeyEvent::OpenMenu => self.open_menu(), KeyEvent::CloseMenu => self.close_menu(), - KeyEvent::ShiftUp => { - self.shift_up(); + KeyEvent::MovePrev => { + self.move_prev(); let pos = self.position().0; scroll(cx, if pos <= 3 { pos } else { pos + 3 }) } - KeyEvent::ShiftDown => { - self.shift_down(); + KeyEvent::MoveNext => { + self.move_next(); + scroll(cx, self.position().0 + 3) + } + KeyEvent::JumpPrev => { + self.jump_prev(); + let pos = self.position().0; + scroll(cx, if pos <= 3 { pos } else { pos + 3 }) + } + KeyEvent::JumpNext => { + self.jump_next(); scroll(cx, self.position().0 + 3) } }; @@ -184,6 +223,27 @@ impl<'a> AppState<'a> { /// Update Search and Reset Position pub fn set_search(&self, cx: Scope<'_, App>, search: String) { + // confirm search meets required criteria + if let Some(min) = self.app.config.search.min_length.as_ref() { + if search.len() < *min { + return; + } + } + if let Some(min) = self.app.config.search.min_length.as_ref() { + if search.len() < *min { + return; + } + } + let is_match = self.state.with(|s| { + s.search_regex + .as_ref() + .map(|r| r.is_match(&search)) + .unwrap_or(true) + }); + if !is_match { + return; + } + // update search w/ new content self.state.with_mut(|s| { s.pos = 0; s.subpos = 0; @@ -227,13 +287,28 @@ impl<'a> AppState<'a> { /// Move Up Once With Context of SubMenu #[inline] - pub fn shift_up(&self) { - self.state.with_mut(|s| s.shift_up()); + pub fn move_prev(&self) { + self.state.with_mut(|s| s.move_prev()); } /// Move Down Once With Context of SubMenu #[inline] - pub fn shift_down(&self) { - self.state.with_mut(|s| s.shift_down(&self.results)) + pub fn move_next(&self) { + self.state.with_mut(|s| s.move_next(&self.results)) + } + + /// Jump a Configured Distance Up the Results + #[inline] + pub fn jump_prev(&self) { + let distance = self.app.config.jump_dist; + self.state.with_mut(|s| s.jump_up(distance)) + } + + /// Jump a Configured Distance Down the Results + #[inline] + pub fn jump_next(&self) { + let distance = self.app.config.jump_dist; + self.state + .with_mut(|s| s.jump_down(distance, &self.results)) } } diff --git a/screenshots/dark.png b/screenshots/dark.png new file mode 100644 index 0000000..2af2f41 Binary files /dev/null and b/screenshots/dark.png differ diff --git a/screenshots/default.png b/screenshots/default.png new file mode 100644 index 0000000..381d0f9 Binary files /dev/null and b/screenshots/default.png differ diff --git a/screenshots/launchpad.png b/screenshots/launchpad.png new file mode 100644 index 0000000..b9d2018 Binary files /dev/null and b/screenshots/launchpad.png differ diff --git a/screenshots/nord.png b/screenshots/nord.png new file mode 100644 index 0000000..50ad313 Binary files /dev/null and b/screenshots/nord.png differ diff --git a/screenshots/solarized.png b/screenshots/solarized.png new file mode 100644 index 0000000..50d8ef7 Binary files /dev/null and b/screenshots/solarized.png differ diff --git a/themes/launchpad.css b/themes/launchpad.css new file mode 100644 index 0000000..d7d4774 --- /dev/null +++ b/themes/launchpad.css @@ -0,0 +1,86 @@ +* { + font-family: monospace; + color: #f5f5f5; + background-color: transparent; +} + +:root { + --bg1: #363636; + --bg2: #f5f5f520; + --bg3: #f5f5f540; + --bg4: #0860f2E6; + + --fg0: #f5f5f5; + --fg1: #f5f5f580; +} + +html, body { + background-color: #24242480; +} + +input { + width: 33.333%; + height: 1.5rem; + margin-left: 33.333%; + + border: 1px; + border-color: #f5f5f540; + border-radius: 2px; + background-color: #363636; +} + +input::placeholder { + color: #f5f5f520; +} + +.navbar { + top: 0.5rem; +} + +.results { + margin: 5vh; + height: 90vh; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; +} + +.result-entry { + display: flex; + flex-basis: 20%; + align-items: center; + justify-content: center; + background-color: transparent !important; +} + +.result { + display: flex; + flex-direction: column; + font-size: 30px; + width: 10rem; + padding: 0.5rem; + + border-radius: 16px; + border-color: transparent; +} + +.icon { + width: 100%; + font-size: 6rem; +} + +.name, +.entry { + width: 100%; + font-size: 1rem; + text-align: center; +} + +.comment { + display: none; +} + +.selected { + background-color: #0860f2E6; +}