From 7c3c3cf48600020a521fb61704e4827b9ee022d7 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Wed, 13 Dec 2023 22:40:26 -0700 Subject: [PATCH] feat: start of rework (again lol) using webview --- Cargo.toml | 1 - plugin-network/Cargo.toml | 23 - plugin-network/public/default.css | 96 ---- plugin-network/public/spinner.css | 78 --- plugin-network/public/spinner.html | 14 - plugin-network/src/gui.rs | 211 -------- plugin-network/src/main.rs | 105 ---- plugin-network/src/network.rs | 324 ------------- rmenu/Cargo.toml | 34 +- rmenu/src/cli.rs | 10 +- rmenu/src/config.rs | 47 +- rmenu/src/gui.rs | 510 ++++++++++---------- rmenu/src/image.rs | 82 ---- rmenu/src/main.rs | 20 +- rmenu/src/search.rs | 2 +- rmenu/src/state.rs | 314 ------------ rmenu/templates/index.html | 28 ++ rmenu/templates/results.html | 35 ++ rmenu/{public/default.css => web/index.css} | 5 +- rmenu/web/index.js | 81 ++++ 20 files changed, 448 insertions(+), 1572 deletions(-) delete mode 100644 plugin-network/Cargo.toml delete mode 100644 plugin-network/public/default.css delete mode 100644 plugin-network/public/spinner.css delete mode 100644 plugin-network/public/spinner.html delete mode 100644 plugin-network/src/gui.rs delete mode 100644 plugin-network/src/main.rs delete mode 100644 plugin-network/src/network.rs delete mode 100644 rmenu/src/image.rs delete mode 100644 rmenu/src/state.rs create mode 100644 rmenu/templates/index.html create mode 100644 rmenu/templates/results.html rename rmenu/{public/default.css => web/index.css} (95%) create mode 100644 rmenu/web/index.js diff --git a/Cargo.toml b/Cargo.toml index f0654d9..9551aee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,5 @@ members = [ "rmenu-plugin", "plugin-run", "plugin-desktop", - "plugin-network", "plugin-window", ] diff --git a/plugin-network/Cargo.toml b/plugin-network/Cargo.toml deleted file mode 100644 index 601be15..0000000 --- a/plugin-network/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "network" -version = "0.0.1" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -anyhow = "1.0.72" -async-std = "1.12.0" -clap = { version = "4.3.21", features = ["derive"] } -dioxus = "0.4.0" -dioxus-desktop = "0.4.0" -dioxus-free-icons = { version = "0.7.0", features = ["font-awesome-regular"] } -env_logger = "0.10.0" -futures-channel = "0.3.28" -glib = { git = "https://github.com/gtk-rs/gtk-rs-core", version = "0.19.0" } -keyboard-types = "0.6.2" -log = "0.4.20" -nm = { git = "https://github.com/balena-io-modules/libnm-rs.git", version = "0.4.0" } -once_cell = "1.18.0" -rmenu-plugin = { version = "0.0.1", path = "../rmenu-plugin" } -serde_json = "1.0.104" diff --git a/plugin-network/public/default.css b/plugin-network/public/default.css deleted file mode 100644 index abbf203..0000000 --- a/plugin-network/public/default.css +++ /dev/null @@ -1,96 +0,0 @@ - -html, body { - overflow: hidden; - font-size: 14px; - background-color: #e8edee; -} - -#header { - padding: 0.15rem; -} - -#controls { - display: flex; - justify-content: center; - align-items: center; - margin-top: 0.5rem; -} - -#secret { - width: -webkit-fill-available; - height: 2rem; - - border: none; - outline: none; - padding: 0 0.5rem; - background-color: #ccd2df; -} - -#icon { - float: right; - position: relative; - z-index: 2; - margin-left: -2.5rem; - - display: flex; - align-items: center; - height: 2rem; - border: none; - background: none; -} - -@keyframes movein { - from { margin-top: 10rem; } - to { margin-top: 1rem; } -} - -@keyframes moveout { - from { margin-top: 1rem; } - to { bottom: 10rem; } -} - -#message { - display: flex; - justify-content: center; - margin-top: 1rem; - - font-size: 15px; - font-style: italic; - animation: movein 0.5s ease-in-out; -} - -#message.error { - color: white; - background-color: #de5a5a; -} - -#message.success { - color: white; - background-color: #53c351; -} - -#blackout { - display: none; - width: 100%; - height: 100%; - position: absolute; - top: 0; - left: 0; - z-index: 100; - - background-color: black; - filter: alpha(opacity=30); - opacity: 0.3; -} - -#blackout > #spinner { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -} - -#blackout.active { - display: block; -} diff --git a/plugin-network/public/spinner.css b/plugin-network/public/spinner.css deleted file mode 100644 index c19270e..0000000 --- a/plugin-network/public/spinner.css +++ /dev/null @@ -1,78 +0,0 @@ -.lds-spinner { - color: official; - display: inline-block; - position: relative; - width: 80px; - height: 80px; -} -.lds-spinner div { - transform-origin: 40px 40px; - animation: lds-spinner 1.2s linear infinite; -} -.lds-spinner div:after { - content: " "; - display: block; - position: absolute; - top: 3px; - left: 37px; - width: 6px; - height: 18px; - border-radius: 20%; - background: #fff; -} -.lds-spinner div:nth-child(1) { - transform: rotate(0deg); - animation-delay: -1.1s; -} -.lds-spinner div:nth-child(2) { - transform: rotate(30deg); - animation-delay: -1s; -} -.lds-spinner div:nth-child(3) { - transform: rotate(60deg); - animation-delay: -0.9s; -} -.lds-spinner div:nth-child(4) { - transform: rotate(90deg); - animation-delay: -0.8s; -} -.lds-spinner div:nth-child(5) { - transform: rotate(120deg); - animation-delay: -0.7s; -} -.lds-spinner div:nth-child(6) { - transform: rotate(150deg); - animation-delay: -0.6s; -} -.lds-spinner div:nth-child(7) { - transform: rotate(180deg); - animation-delay: -0.5s; -} -.lds-spinner div:nth-child(8) { - transform: rotate(210deg); - animation-delay: -0.4s; -} -.lds-spinner div:nth-child(9) { - transform: rotate(240deg); - animation-delay: -0.3s; -} -.lds-spinner div:nth-child(10) { - transform: rotate(270deg); - animation-delay: -0.2s; -} -.lds-spinner div:nth-child(11) { - transform: rotate(300deg); - animation-delay: -0.1s; -} -.lds-spinner div:nth-child(12) { - transform: rotate(330deg); - animation-delay: 0s; -} -@keyframes lds-spinner { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} diff --git a/plugin-network/public/spinner.html b/plugin-network/public/spinner.html deleted file mode 100644 index 92980e7..0000000 --- a/plugin-network/public/spinner.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/plugin-network/src/gui.rs b/plugin-network/src/gui.rs deleted file mode 100644 index 307a7c0..0000000 --- a/plugin-network/src/gui.rs +++ /dev/null @@ -1,211 +0,0 @@ -#![allow(non_snake_case)] - -///! NetworkManager Authenticator GUI -use anyhow::{anyhow, Result}; -use dioxus::prelude::*; -use dioxus_desktop::LogicalSize; -use dioxus_free_icons::icons::fa_regular_icons::{FaEye, FaEyeSlash}; -use dioxus_free_icons::Icon; -use keyboard_types::Code; - -use crate::network::Manager; - -static SPINNER_CSS: &'static str = include_str!("../public/spinner.css"); -static SPINNER_HTML: &'static str = include_str!("../public/spinner.html"); -static DEFAULT_CSS_CONTENT: &'static str = include_str!("../public/default.css"); - -/// Run GUI Application -pub fn run_app(ssid: &str, timeout: u32) { - let builder = dioxus_desktop::WindowBuilder::new() - .with_title("RMenu - Network Login") - .with_inner_size(LogicalSize { - width: 400, - height: 150, - }) - .with_focused(true) - .with_decorations(false) - .with_always_on_top(true); - dioxus_desktop::launch_with_props( - App, - AppProp { - ssid: ssid.to_owned(), - timeout, - }, - dioxus_desktop::Config::new().with_window(builder), - ); -} - -/// Message to send to GUI -#[derive(Debug, Clone, PartialEq)] -pub enum UserMessage { - Nothing, - Error(String), - Success(String), -} - -impl UserMessage { - /// Retrieve CSS-Class to use on UserMessage - fn css_class(&self) -> &str { - match self { - Self::Error(_) => "error", - Self::Success(_) => "success", - Self::Nothing => "none", - } - } - /// Retrieve Message Value - fn message(&self) -> Option { - match self { - Self::Nothing => None, - Self::Error(msg) => Some(msg.to_owned()), - Self::Success(msg) => Some(msg.to_owned()), - } - } -} - -#[derive(Debug, PartialEq, Props)] -struct AppProp { - ssid: String, - timeout: u32, -} - -//TODO: add auth timeout - -/// Set Cursor/Keyboard Focus onto Input -#[inline] -fn focus(cx: Scope) { - let eval = use_eval(cx); - let js = "document.getElementById(`secret`).focus()"; - let _ = eval(&js); -} - -/// Complete Network Connection/Authentication Attempt -async fn connect_async(ssid: String, timeout: u32, secret: &str) -> Result<()> { - // retrieve access-point from manager - let manager = Manager::new().await?.with_timeout(timeout); - let access_point = manager - .access_points() - .into_iter() - .find(|a| a.ssid == ssid) - .ok_or_else(|| anyhow!("Unable to find Access Point: {ssid:?}"))?; - // attempt to establish connection w/ secret - log::debug!("Found AccessPoint: {ssid:?} | {:?}", access_point.security); - manager.connect(&access_point, Some(secret)).await -} - -/// Simple Login GUI Application -fn App(cx: Scope) -> Element { - let secret = use_state(cx, || String::new()); - let message = use_state(cx, || UserMessage::Nothing); - let show_secret = use_state(cx, || false); - let loading = use_state(cx, || false); - - // always ensure focus - focus(cx); - - // build keyboard actions event handler - let keyboard_controls = move |e: KeyboardEvent| match e.code() { - Code::Escape => std::process::exit(0), - Code::Enter => { - let ssid = cx.props.ssid.to_owned(); - let timeout = cx.props.timeout.to_owned(); - let secret = secret.get().to_owned(); - let message = message.to_owned(); - let loading = loading.to_owned(); - loading.set(true); - message.set(UserMessage::Nothing); - - cx.spawn(async move { - log::info!("connecting to ssid: {ssid:?}"); - let result = connect_async(ssid, timeout, &secret).await; - log::info!("connection result: {result:?}"); - match result { - Ok(_) => { - // update message and unlock gui - message.set(UserMessage::Success("Connection Complete!".to_owned())); - loading.set(false); - // exit program after timeout - let wait = std::time::Duration::from_secs(1); - async_std::task::sleep(wait).await; - std::process::exit(0); - } - Err(err) => { - message.set(UserMessage::Error(format!("Connection Failed: {err:?}"))); - loading.set(false); - } - } - }); - } - _ => {} - }; - - // retrieve message details / set input-type / get loading css-class - let msg_css = message.css_class(); - let msg_str = message.message(); - let input_type = match show_secret.get() { - true => "text", - false => "password", - }; - let blackout_css = match loading.get() { - true => "active", - false => "", - }; - - // complete final rendering - cx.render(rsx! { - style { DEFAULT_CSS_CONTENT } - style { SPINNER_CSS } - div { - onkeydown: keyboard_controls, - label { - id: "header", - "for": "secret", - "Wi-Fi Network {cx.props.ssid:?} Requires a Password" - } - div { - id: "controls", - input { - id: "secret", - value: "{secret}", - placeholder: "Password", - oninput: move |e| secret.set(e.value.clone()), - "type": "{input_type}" - } - button { - id: "icon", - onclick: |_| show_secret.modify(|v| !v), - match show_secret.get() { - true => cx.render(rsx! { - Icon { - fill: "black", - icon: FaEye, - } - }), - false => cx.render(rsx! { - Icon { - fill: "black", - icon: FaEyeSlash, - } - }) - } - } - } - if let Some(msg) = msg_str { - cx.render(rsx! { - div { - id: "message", - class: "{msg_css}", - "{msg}" - } - }) - } - div { - id: "blackout", - class: "{blackout_css}", - div { - id: "spinner", - dangerous_inner_html: "{SPINNER_HTML}" - } - } - } - }) -} diff --git a/plugin-network/src/main.rs b/plugin-network/src/main.rs deleted file mode 100644 index 8ec59ae..0000000 --- a/plugin-network/src/main.rs +++ /dev/null @@ -1,105 +0,0 @@ -use anyhow::{anyhow, Result}; - -use clap::{Parser, Subcommand}; -use rmenu_plugin::Entry; - -mod gui; -mod network; - -#[derive(Debug, Subcommand)] -pub enum Commands { - ListAccessPoints, - Connect { ssid: String, timeout: Option }, -} - -#[derive(Parser)] -#[command(author, version, about, long_about = None)] -#[command(propagate_version = true)] -pub struct Cli { - #[clap(subcommand)] - command: Option, -} - -/// Get Signal Bars based on a signal-value -fn get_bars(signal: u8) -> String { - if signal >= 80 { - return "▂▄▆█".to_owned(); - } - if signal > 50 { - return "▂▄▆_".to_owned(); - } - if signal > 30 { - return "▂▄__".to_owned(); - } - "▂___".to_owned() -} - -/// List AccessPoints using NetworkManager -async fn list_aps() -> Result<()> { - let exe = std::env::current_exe()?.to_str().unwrap().to_string(); - // spawn manager and complete scan (if nessesary) - let manager = network::Manager::new().await?; - if !manager.scanned_recently() { - log::info!("Scanning for Access-Points..."); - manager.scan_wifi().await?; - } - // retrive access-points and print as entries - for ap in manager.access_points() { - let star = ap.is_active.then(|| " *").unwrap_or(""); - let bars = get_bars(ap.signal); - let desc = format!("{bars} {}{star}", ap.ssid); - let exec = format!("{exe} connect {:?}", ap.ssid); - let entry = Entry::new(&desc, &exec, None); - println!("{}", serde_json::to_string(&entry).unwrap()); - } - Ok(()) -} - -/// Attempt to Connect to the Specified SSID (If Connection Already Exists) -async fn try_connect_ap(ssid: String, timeout: Option) -> Result { - // spawn manager and complete scan (if nessesary) - let mut manager = network::Manager::new().await?; - if let Some(timeout) = timeout { - manager = manager.with_timeout(timeout) - } - if !manager.scanned_recently() { - log::info!("Scanning for Access-Points..."); - manager.scan_wifi().await?; - } - // attempt to find access-point - let access_point = manager - .access_points() - .into_iter() - .find(|ap| ap.ssid == ssid) - .ok_or_else(|| anyhow!("Unable to find Access-Point: {ssid:?}"))?; - // if connection already exists, try to connect - if access_point.connection.is_some() { - log::info!("Attempting Connection to {ssid:?} w/o Password"); - match manager.connect(&access_point, None).await { - Err(err) => log::info!("Connection Failed: {err:?}"), - Ok(_) => { - log::info!("Connection Successful!"); - return Ok(true); - } - }; - } - Ok(false) -} - -fn main() -> Result<()> { - env_logger::init(); - let cli = Cli::parse(); - let context = glib::MainContext::default(); - let command = cli.command.unwrap_or(Commands::ListAccessPoints); - match command { - Commands::ListAccessPoints => context.block_on(list_aps())?, - Commands::Connect { ssid, timeout } => { - let connected = context.block_on(try_connect_ap(ssid.clone(), timeout))?; - if !connected { - log::info!("Spawning GUI to complete AP Login"); - gui::run_app(&ssid, timeout.unwrap_or(30)); - } - } - } - Ok(()) -} diff --git a/plugin-network/src/network.rs b/plugin-network/src/network.rs deleted file mode 100644 index 12a8ccf..0000000 --- a/plugin-network/src/network.rs +++ /dev/null @@ -1,324 +0,0 @@ -use async_std::task; -use futures_channel::oneshot; -use std::cell::RefCell; -use std::collections::BTreeMap; -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; -static SCAN_TOTAL_WAIT: u64 = 3; -static SCAN_BETWEEN: i64 = 30; - -/// NetworkManager Simplified API -#[derive(Debug)] -pub struct Manager { - client: Client, - wifi: DeviceWifi, - timeout: Duration, -} - -/// AccessPoint Information -#[derive(Debug)] -pub struct AccessPoint { - pub in_use: bool, - pub ssid: String, - pub rate: u32, - pub signal: u8, - pub security: String, - pub is_active: bool, - pub connection: Option, - pub dbus_path: Option, -} - -// SETTING_WIRELESS_MODE -// SETTING_IP4_CONFIG_METHOD_AUTO - -/// Generate a NEW Connection to Use AccessPoint -fn new_conn(ap: &AccessPoint, password: Option<&str>) -> Result { - let connection = SimpleConnection::new(); - - // configure generate connection settings - let s_connection = SettingConnection::new(); - s_connection.set_type(Some(&SETTING_WIRELESS_SETTING_NAME)); - s_connection.set_id(Some(&ap.ssid)); - s_connection.set_autoconnect(false); - // s_connection.set_interface_name(interface); - connection.add_setting(s_connection); - - // configure wireless settings - let s_wireless = SettingWireless::new(); - s_wireless.set_ssid(Some(&(ap.ssid.as_bytes().into()))); - // s_wireless.set_band(Some("bg")); - // s_wireless.set_hidden(false); - // s_wireless.set_mode(Some(&SETTING_WIRELESS_MODE)); - connection.add_setting(s_wireless); - - // configure login settings - if let Some(password) = password { - //TODO: potentially determine key-mgmt based on ap-security - let s_wireless_security = SettingWirelessSecurity::new(); - s_wireless_security.set_key_mgmt(Some("wpa-psk")); - s_wireless_security.set_psk(Some(password)); - connection.add_setting(s_wireless_security); - } - - // assume DHCP Assignment - let s_ip4 = SettingIP4Config::new(); - s_ip4.set_method(Some(&SETTING_IP4_CONFIG_METHOD_AUTO)); - connection.add_setting(s_ip4); - - Ok(connection) -} - -/// Exit Security Section to Specify New Password -fn edit_conn(conn: &Connection, password: Option<&str>) -> Result<()> { - let s_wireless_security = conn - .setting_wireless_security() - .unwrap_or_else(|| SettingWirelessSecurity::new()); - s_wireless_security.set_key_mgmt(Some("wpa-psk")); - s_wireless_security.set_psk(password); - conn.add_setting(s_wireless_security); - Ok(()) -} - -/// Wait for Connection to Fail or Complete -async fn wait_conn(active: &ActiveConnection, timeout: Duration) -> Result<()> { - // spawn communication channels - let (sender, receiver) = oneshot::channel::>(); - let sender = Rc::new(RefCell::new(Some(sender))); - // spawn state-callback - active.connect_state_changed(move |active_connection, state, _| { - let sender = sender.clone(); - let active_connection = active_connection.clone(); - glib::MainContext::ref_thread_default().spawn_local(async move { - let state = unsafe { ActiveConnectionState::from_glib(state as _) }; - log::debug!("[Connect] Active connection state: {:?}", state); - // generate send function - let send = move |result| { - let sender = sender.borrow_mut().take(); - if let Some(sender) = sender { - sender.send(result).expect("Sender Dropped"); - } - }; - // handle connection state-changes - match state { - ActiveConnectionState::Activated => { - log::debug!("[Connect] Successfully activated"); - return send(Ok(())); - } - ActiveConnectionState::Deactivated => { - log::debug!("[Connect] Connection deactivated"); - match active_connection.connection() { - Some(remote_connection) => { - let result = remote_connection - .delete_future() - .await - .context("Failed to delete connection"); - if result.is_err() { - return send(result); - } - return send(Err(anyhow!("Connection Failed (Deactivated)"))); - } - None => { - return send(Err(anyhow!( - "Failed to get remote connection from active connection" - ))) - } - } - } - _ => {} - }; - }); - }); - // wait until state notification is done - let result = async_std::future::timeout(timeout, receiver).await; - match result { - Ok(result) => match result { - Ok(res) => res, - Err(err) => Err(anyhow!("Connection Cancelled: {err:?}")), - }, - Err(err) => Err(anyhow!("Timeout Reached: {err:?}")), - } -} - -impl Manager { - /// Spawn new Wifi Manager Instance - pub async fn new() -> Result { - // get network-manager client - let client = Client::new_future() - .await - .context("Failed to create NM Client")?; - // get wifi device if any are available - let device = client - .devices() - .into_iter() - .filter(|d| d.device_type() == DeviceType::Wifi) - .next() - .ok_or_else(|| anyhow!("Cannot find a Wi-Fi device"))?; - // access inner wifi-device object - let wifi: DeviceWifi = device - .downcast() - .map_err(|_| anyhow!("Failed to Access Wi-Fi Device"))?; - log::debug!("NetworkManager Connection Established"); - Ok(Self { - client, - wifi, - timeout: Duration::from_secs(30), - }) - } - - /// Update Manager Timeout and Return Self - pub fn with_timeout(mut self, secs: u32) -> Self { - self.timeout = Duration::from_secs(secs.into()); - self - } - - /// Check if NetworkManager already scanned recently - pub fn scanned_recently(&self) -> bool { - let last_ms = self.wifi.last_scan(); - let now_ms = utils_get_timestamp_msec(); - let elapsed = (now_ms - last_ms) / 1000; - last_ms > 0 && elapsed < SCAN_BETWEEN - } - - /// Complete General Wifi-Scan - pub async fn scan_wifi(&self) -> Result<()> { - // request wifi-scan - self.wifi - .request_scan_future() - .await - .context("Failed to Request Wi-Fi Scan")?; - // wait until access-points are collected - let mut then = SystemTime::now(); - let mut current = self.wifi.access_points().len(); - loop { - // wait interval for more access-points - task::sleep(Duration::from_millis(SCAN_INTERVAL_MS)).await; - // check if time has elapsed - let now = SystemTime::now(); - let elapsed = now.duration_since(then)?; - if elapsed.as_secs() > SCAN_TOTAL_WAIT { - break; - } - // check if more access-points were discovered - let found = self.wifi.access_points().len(); - if found > current { - then = now; - current = found; - } - } - Ok(()) - } - - /// Retrieve Access-Point Information - pub fn access_points(&self) -> Vec { - let conns: Vec = self - .client - .connections() - .into_iter() - .map(|c| c.upcast()) - .collect(); - let mut access: BTreeMap = BTreeMap::new(); - 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 - .ssid() - .map(|b| b.escape_ascii().to_string()) - .unwrap_or_else(|| "--".to_owned()); - // determine if connection-map should be updated - let is_active = active - .as_ref() - .map(|b| a.bssid() == b.bssid()) - .unwrap_or(false); - if !is_active { - if let Some(point) = access.get(&ssid) { - if point.rate > rate { - continue; - } - } - } - // build security-string - let mut security = vec![]; - let wpa_flags = a.wpa_flags(); - let wpa2_flags = a.rsn_flags(); - if !wpa_flags.is_empty() { - security.push("WPA1"); - } - if !wpa2_flags.is_empty() { - security.push("WPA2"); - } - if wpa2_flags.intersects(_80211ApSecurityFlags::KEY_MGMT_802_1X) { - security.push("802.1X"); - } - if security.is_empty() { - security.push("--"); - } - // insert access-point - access.insert( - ssid.to_owned(), - AccessPoint { - in_use: is_active, - ssid, - rate, - signal, - is_active, - security: security.join(" ").to_owned(), - connection: a.filter_connections(&conns).get(0).cloned(), - dbus_path: path.map(|s| s.to_string()), - }, - ); - } - // move map values into vector and sort by signal-strength - let mut points: Vec = access.into_values().collect(); - points.sort_by_key(|a| a.signal); - points.reverse(); - points - } - - /// Attempt to Authenticate and Activate Access-Point Connection - pub async fn connect(&self, ap: &AccessPoint, password: Option<&str>) -> Result<()> { - let device = self.wifi.clone().upcast::(); - match &ap.connection { - Some(conn) => { - edit_conn(conn, password)?; - let active_conn = self - .client - .activate_connection_future(Some(conn), Some(&device), None) - .await - .context("Failed to activate existing connection")?; - 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 - .client - .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?; - } - } - Ok(()) - } -} diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 28c3e48..69b3023 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -1,31 +1,27 @@ [package] -name = "rmenu" -version = "0.0.1" +name = "rmenu" +version = "0.0.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -cached = "0.44.0" -clap = { version = "4.3.15", features = ["derive"] } -dioxus = "0.4.0" -dioxus-desktop = "0.4.0" -env_logger = "0.10.0" +askama = "0.12.1" +clap = { version = "4.4.11", features = ["derive"] } +env_logger = "0.10.1" heck = "0.4.1" -keyboard-types = "0.6.2" +keyboard-types = "0.7.0" lastlog = { version = "0.2.3", features = ["libc"] } -log = "0.4.19" -once_cell = "1.18.0" -png = "0.17.9" -quick-xml = "0.30.0" -regex = { version = "1.9.1" } -resvg = "0.35.0" +log = "0.4.20" +once_cell = "1.19.0" +regex = "1.10.2" rmenu-plugin = { version = "0.0.1", path = "../rmenu-plugin" } -serde = { version = "1.0.171", features = ["derive"] } -serde_json = "1.0.103" -serde_yaml = "0.9.24" +serde = { version = "1.0.193", features = ["derive"] } +serde_json = "1.0.108" +serde_yaml = "0.9.27" shell-words = "1.1.0" shellexpand = "3.1.0" strfmt = "0.2.4" -thiserror = "1.0.43" -which = "4.4.0" +thiserror = "1.0.50" +web-view = "0.7.3" +which = "5.0.0" diff --git a/rmenu/src/cli.rs b/rmenu/src/cli.rs index 8318adf..068c01a 100644 --- a/rmenu/src/cli.rs +++ b/rmenu/src/cli.rs @@ -1,3 +1,4 @@ +///! RMenu CLI Implementation use std::fs::File; use std::io::{BufRead, BufReader, Read}; use std::process::{Command, ExitStatus, Stdio}; @@ -134,12 +135,6 @@ pub struct Args { /// 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, @@ -225,8 +220,6 @@ impl Args { 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); @@ -301,7 +294,6 @@ impl Args { 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) diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index d36a9b3..1501fcf 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -1,15 +1,11 @@ -//! RMENU Configuration Implementations +/// RMenu Configuration Management +use std::collections::BTreeMap; +use std::str::FromStr; + 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}, - window::Fullscreen, -}; // parse supported modifiers from string fn mod_from_str(s: &str) -> Option { @@ -97,8 +93,8 @@ impl Default for KeyConfig { return Self { exec: vec![Keybind::new(Code::Enter)], exit: vec![Keybind::new(Code::Escape)], - move_next: vec![Keybind::new(Code::ArrowUp)], - move_prev: vec![Keybind::new(Code::ArrowDown)], + move_next: vec![Keybind::new(Code::ArrowDown)], + move_prev: vec![Keybind::new(Code::ArrowUp)], open_menu: vec![], close_menu: vec![], jump_next: vec![Keybind::new(Code::PageDown)], @@ -107,12 +103,23 @@ impl Default for KeyConfig { } } +#[derive(Debug, PartialEq, Deserialize)] +pub struct LogicalSize { + pub width: T, + pub height: T, +} + +impl LogicalSize { + pub fn new(width: T, height: T) -> Self { + Self { width, height } + } +} + /// GUI Desktop Window Configuration Settings #[derive(Debug, PartialEq, Deserialize)] pub struct WindowConfig { pub title: String, pub size: LogicalSize, - pub position: LogicalPosition, #[serde(default = "_true")] pub focus: bool, pub decorate: bool, @@ -123,25 +130,11 @@ pub struct WindowConfig { 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 { title: "RMenu - App Launcher".to_owned(), - size: LogicalSize { - width: 700.0, - height: 400.0, - }, - position: LogicalPosition { x: 100.0, y: 100.0 }, + size: LogicalSize::new(700.0, 400.0), focus: true, decorate: false, transparent: false, @@ -259,7 +252,7 @@ pub struct Config { impl Default for Config { fn default() -> Self { Self { - page_size: 50, + page_size: 200, page_load: 0.8, jump_dist: 5, use_icons: true, diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 02a9e9a..fbc1427 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -1,168 +1,89 @@ -//! RMENU GUI Implementation using Dioxus -#![allow(non_snake_case)] -use std::fmt::Display; +/// Gui Implementation +use std::str::FromStr; -use dioxus::prelude::*; +use askama::Template; use keyboard_types::{Code, Modifiers}; use rmenu_plugin::Entry; +use serde::Deserialize; +use web_view::*; -use crate::config::Keybind; -use crate::state::{AppState, KeyEvent}; -use crate::{App, DEFAULT_CSS_CONTENT}; +use crate::config::{Config, Keybind}; +use crate::exec::execute; +use crate::search::build_searchfn; +use crate::AppData; -/// spawn and run the app on the configured platform -pub fn run(app: App) { - let theme = match app.config.window.dark_mode { - Some(dark) => match dark { - true => Some(dioxus_desktop::tao::window::Theme::Dark), - false => Some(dioxus_desktop::tao::window::Theme::Light), - }, - None => None, - }; - let builder = dioxus_desktop::WindowBuilder::new() - .with_title(app.config.window.title.clone()) - .with_inner_size(app.config.window.size) - .with_position(app.config.window.position) - .with_focused(app.config.window.focus) - .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); +static INDEX_JS: &'static str = include_str!("../web/index.js"); +static INDEX_CSS: &'static str = include_str!("../web/index.css"); + +#[derive(Debug, Deserialize)] +struct KeyEvent { + key: String, + ctrl: bool, + shift: bool, } -#[derive(PartialEq, Props)] -struct GEntry<'a> { +impl KeyEvent { + /// Convert Message into Keyboard Modifiers Object + fn modifiers(&self) -> Modifiers { + let mut modifiers = Modifiers::default(); + if self.ctrl { + modifiers |= Modifiers::CONTROL; + } + if self.shift { + modifiers |= Modifiers::SHIFT; + } + modifiers + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "click_type")] +#[serde(rename_all = "lowercase")] +enum ClickEvent { + Single { id: String }, + Double { id: String }, +} + +#[derive(Debug, Deserialize)] +struct ScrollEvent { + y: usize, + maxy: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +#[serde(rename_all = "lowercase")] +enum Message { + Search { value: String }, + KeyDown(KeyEvent), + Click(ClickEvent), + Scroll(ScrollEvent), +} + +#[derive(Template)] +#[template(path = "index.html")] +struct IndexTemplate<'a> { + css: &'a str, + search: &'a str, + results: &'a str, + config: &'a Config, + script: &'a str, +} + +#[derive(Template)] +#[template(path = "results.html")] +struct ResultsTemplate<'a> { + results: &'a Vec<&'a Entry>, + config: &'a Config, +} + +#[derive(Debug)] +struct AppState<'a> { pos: usize, subpos: usize, - index: usize, - entry: &'a Entry, - state: AppState<'a>, -} - -#[inline] -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>, - 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}" } }); - } - } - if crate::image::image_exists(img.to_owned()) { - return cx.render(rsx! { img { class: "image", src: "{img}" } }); - } - } - 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 -fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { - // build css classes for result and actions (if nessesary) - let main_select = cx.props.index == cx.props.pos; - let action_select = main_select && cx.props.subpos > 0; - let action_classes = match action_select { - true => "active", - false => "", - }; - let multi_classes = match cx.props.entry.actions.len() > 1 { - true => "submenu", - false => "", - }; - let result_classes = match main_select && !action_select { - true => "selected", - false => "", - }; - // build sub-actions if present - let actions = cx - .props - .entry - .actions - .iter() - .skip(1) - .enumerate() - .map(|(idx, action)| { - let act_class = match action_select && idx + 1 == cx.props.subpos { - true => "selected", - false => "", - }; - cx.render(rsx! { - div { - class: "action {act_class}", - onclick: move |_| cx.props.state.set_position(cx.props.index, idx + 1), - ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec), - div { - class: "action-name", - dangerous_inner_html: "{action.name}" - } - div { - class: "action-comment", - render_comment(action.comment.as_ref()) - } - } - }) - }); - cx.render(rsx! { - div { - class: "result-entry", - div { - id: "result-{cx.props.index}", - class: "result {result_classes} {multi_classes}", - // onmouseenter: |_| cx.props.state.set_position(cx.props.index, 0), - onclick: |_| cx.props.state.set_position(cx.props.index, 0), - ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec), - if cx.props.state.config().use_icons { - cx.render(rsx! { - div { - class: "icon", - render_image(cx, cx.props.entry.icon.as_ref(), cx.props.entry.icon_alt.as_ref()) - } - }) - } - match cx.props.state.config().use_comments { - true => cx.render(rsx! { - div { - class: "name", - dangerous_inner_html: "{cx.props.entry.name}" - } - div { - class: "comment", - dangerous_inner_html: render_comment(cx.props.entry.comment.as_ref()) - } - }), - false => cx.render(rsx! { - div { - class: "entry", - dangerous_inner_html: "{cx.props.entry.name}" - } - }) - } - } - div { - id: "result-{cx.props.index}-actions", - class: "actions {action_classes}", - actions.into_iter() - } - } - }) -} - -#[inline] -fn focus(cx: Scope) { - let eval = use_eval(cx); - let js = "document.getElementById(`search`).focus()"; - let _ = eval(js); + search: String, + results: Vec<&'a Entry>, + data: &'a AppData, } /// check if the current inputs match any of the given keybindings @@ -171,117 +92,186 @@ 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); - - // always ensure focus - focus(cx); - - // log current position - let search = state.search(); - let (pos, subpos) = state.position(); - log::debug!("search: {search:?}, pos: {pos}, {subpos}"); - - // generate state tracker instances - let results = state.results(&cx.props.entries); - let k_updater = state.partial_copy(); - let s_updater = state.partial_copy(); - - // build keyboard actions event handler - let keybinds = &cx.props.config.keybinds; - let keyboard_controls = move |e: KeyboardEvent| { - let code = e.code(); - let mods = e.modifiers(); - if matches(&keybinds.exec, &mods, &code) { - k_updater.set_event(KeyEvent::Exec); - } else if matches(&keybinds.exit, &mods, &code) { - k_updater.set_event(KeyEvent::Exit); - } 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) +impl<'a> AppState<'a> { + fn new(data: &'a AppData) -> Self { + Self { + pos: 0, + subpos: 0, + search: "".to_owned(), + results: vec![], + data, } - }; + } - // handle keyboard events - state.handle_events(cx); + /// Update AppState w/ new Search and Render HTML Results + fn search(&mut self, search: String) -> String { + // update search and calculate matching results + let sfn = build_searchfn(&self.data.config, &search); + self.pos = 0; + self.search = search; + self.results = self.data.entries.iter().filter(|e| sfn(e)).collect(); + // generate results html from template + let template = ResultsTemplate { + config: &self.data.config, + results: &self.results, + }; + template.render().unwrap() + } - // render results objects - let rendered_results = results.iter().enumerate().map(|(i, e)| { - let state = state.partial_copy(); - cx.render(rsx! { - TableEntry{ - pos: pos, - subpos: subpos, - index: i, - entry: e, - state: state, - } - }) - }); + /// Execute Action associated w/ Current Position/Subposition + fn execute(&self) { + log::debug!("execute {} {}", self.pos, self.subpos); + let Some(result) = self.results.get(self.pos) else { + return; + }; + log::debug!("result: {result:?}"); + let Some(action) = result.actions.get(self.subpos) else { + return; + }; + log::debug!("action: {action:?}"); + execute(action, self.data.config.terminal.clone()); + } - // 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()); + #[inline] + fn move_up(&mut self, up: usize) { + self.pos = std::cmp::max(self.pos, up) - up; + } - // complete final rendering - cx.render(rsx! { - style { DEFAULT_CSS_CONTENT } - style { "{cx.props.theme}" } - style { "{cx.props.css}" } - div { - id: "content", - class: "content", - div { - id: "navbar", - class: "navbar", - 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, - } - }) + #[inline] + fn move_down(&mut self, down: usize) { + self.pos = std::cmp::min(self.pos + down, self.results.len() - 1); + } + + /// Handle Search Event sent by UI + fn search_event(&mut self, search: String) -> Option { + let results = self.search(search); + Some(format!("update({results:?})")) + } + + //TODO: need to increase page-size as cursor moves down + //TODO: add loading on scroll as well + //TODO: add submenu access and selection + //TODO: put back main to reference actual config + //TODO: update sway config to make borderless + + /// Handle Keyboard Events sent by UI + fn key_event(&mut self, event: KeyEvent) -> Option { + let code = Code::from_str(&event.key).ok()?; + let mods = event.modifiers(); + let keybinds = &self.data.config.keybinds; + if matches(&keybinds.exec, &mods, &code) { + self.execute(); + None + } else if matches(&keybinds.exit, &mods, &code) { + std::process::exit(0); + } else if matches(&keybinds.move_next, &mods, &code) { + self.move_down(1); + Some(format!("setpos({})", self.pos)) + } else if matches(&keybinds.move_prev, &mods, &code) { + self.move_up(1); + Some(format!("setpos({})", self.pos)) + } else if matches(&keybinds.open_menu, &mods, &code) { + // k_updater.set_event(KeyEvent::OpenMenu); + None + } else if matches(&keybinds.close_menu, &mods, &code) { + // k_updater.set_event(KeyEvent::CloseMenu); + None + } else if matches(&keybinds.jump_next, &mods, &code) { + self.move_down(self.data.config.jump_dist); + Some(format!("setpos({})", self.pos)) + } else if matches(&keybinds.jump_prev, &mods, &code) { + self.move_up(self.data.config.jump_dist); + Some(format!("setpos({})", self.pos)) + } else { + None + } + } + + /// Parse Position/Subposition from HTML Element Id + fn parse_id_pos(&self, id: &str) -> Option<(usize, usize)> { + let mut chunks = id.split("-"); + chunks.next()?; + let pos = chunks.next()?.parse().ok()?; + let mut subpos = 0; + if chunks.next().is_some() { + subpos = chunks.next()?.parse().ok()?; + } + Some((pos, subpos)) + } + + /// Handle Single/Doubleclicks Events sent by UI + fn click_event(&mut self, event: ClickEvent) -> Option { + match event { + ClickEvent::Single { id } => { + if let Some((pos, subpos)) = self.parse_id_pos(&id) { + self.pos = pos; + self.subpos = subpos; + return Some(format!("setpos({pos}, true)")); } } - div { - id: "results", - class: "results", - rendered_results.into_iter() + ClickEvent::Double { id } => { + if let Some((pos, subpos)) = self.parse_id_pos(&id) { + self.pos = pos; + self.subpos = subpos; + self.execute(); + } } + }; + None + } + + /// Handle Scrolling Events sent by UI + fn scroll_event(&mut self, event: ScrollEvent) -> Option { + println!("scroll: {event:?}"); + None + } + + /// Parse and Process Raw UI Messages + fn handle_event(&mut self, event: &str) -> Option { + let message: Message = serde_json::from_str(&event).unwrap(); + match message { + Message::Search { value } => self.search_event(value), + Message::KeyDown(event) => self.key_event(event), + Message::Click(event) => self.click_event(event), + Message::Scroll(event) => self.scroll_event(event), } - }) + } + + /// Render Initial Index HTML w/ AppState + fn render_index(&mut self) -> String { + let results = self.search("".to_owned()); + let index = IndexTemplate { + css: &format!("{INDEX_CSS}\n{}\n{}", self.data.css, self.data.theme), + search: &self.search, + results: &results, + config: &self.data.config, + script: &INDEX_JS, + }; + index.render().unwrap() + } +} + +/// Run GUI Applcation via WebView +pub fn run(data: AppData) { + // build app-state + let mut state = AppState::new(&data); + let html = state.render_index(); + // spawn webview instance + let size = &state.data.config.window.size; + web_view::builder() + .title(&state.data.config.window.title) + .content(Content::Html(html)) + .frameless(!state.data.config.window.decorate) + .size(size.width as i32, size.height as i32) + .resizable(false) + .debug(true) + .user_data(()) + .invoke_handler(|webview, msg| { + if let Some(js) = state.handle_event(msg) { + webview.eval(&js)?; + }; + Ok(()) + }) + .run() + .unwrap(); } diff --git a/rmenu/src/image.rs b/rmenu/src/image.rs deleted file mode 100644 index b77f0a6..0000000 --- a/rmenu/src/image.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! GUI Image Processing -use std::fs::{create_dir_all, write}; -use std::io; -use std::path::PathBuf; -use std::sync::Mutex; - -use cached::proc_macro::cached; -use once_cell::sync::Lazy; -use resvg::usvg::TreeParsing; -use thiserror::Error; - -static TEMP_EXISTS: Lazy>> = Lazy::new(|| Mutex::new(vec![])); -static TEMP_DIR: Lazy = Lazy::new(|| PathBuf::from("/tmp/rmenu")); - -#[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(u32, u32, u32), - #[error("Failed to Convert SVG to PNG")] - PngError(#[from] png::EncodingError), -} - -/// Make Temporary Directory for Generated PNGs -fn make_temp() -> Result<(), io::Error> { - let mut temp = TEMP_EXISTS.lock().expect("Failed to Access Global Mutex"); - if temp.len() == 0 { - create_dir_all(TEMP_DIR.to_owned())?; - temp.push(true); - } - Ok(()) -} - -/// 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 = 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 - Ok(write(dest, png)?) -} - -#[cached] -pub fn convert_svg(path: String) -> Option { - // ensure temporary directory exists - let _ = make_temp(); - // 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 = TEMP_DIR.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:?}"), - _ => {} - } - } - 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 2b1d962..f3c7596 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -3,21 +3,19 @@ mod cli; mod config; mod exec; mod gui; -mod image; mod search; -mod state; use clap::Parser; +use config::{CacheSetting, PluginConfig}; 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"); /// Application State for GUI #[derive(Debug, PartialEq)] -pub struct App { +pub struct AppData { css: String, name: String, theme: String, @@ -43,7 +41,17 @@ fn main() -> cli::Result<()> { // parse cli and retrieve values for app let mut cli = cli::Args::parse(); - let mut config = cli.get_config()?; + // let mut config = cli.get_config()?; + let mut config = crate::config::Config::default(); + config.plugins.insert( + "run".to_owned(), + PluginConfig { + exec: vec!["/home/andrew/.config/rmenu/rmenu-run".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(); @@ -63,7 +71,7 @@ fn main() -> cli::Result<()> { } // genrate app context and run gui - gui::run(App { + gui::run(AppData { name: "rmenu".to_owned(), css, theme, diff --git a/rmenu/src/search.rs b/rmenu/src/search.rs index d9b7191..6238d21 100644 --- a/rmenu/src/search.rs +++ b/rmenu/src/search.rs @@ -6,7 +6,7 @@ use crate::config::Config; /// Generate a new dynamic Search Function based on /// Configurtaion Settings and Search-String -pub fn new_searchfn(cfg: &Config, search: &str) -> Box bool> { +pub fn build_searchfn(cfg: &Config, search: &str) -> Box bool> { // build regex search expression if cfg.search.use_regex { let rgx = RegexBuilder::new(search) diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs deleted file mode 100644 index 2faa4f2..0000000 --- a/rmenu/src/state.rs +++ /dev/null @@ -1,314 +0,0 @@ -use dioxus::prelude::{use_eval, use_ref, Scope, UseRef}; -use regex::Regex; -use rmenu_plugin::Entry; - -use crate::config::Config; -use crate::exec::execute; -use crate::search::new_searchfn; -use crate::App; - -#[inline] -fn scroll(cx: Scope, pos: usize) { - let eval = use_eval(cx); - let js = format!("document.getElementById(`result-{pos}`).scrollIntoView(false)"); - let _ = eval(&js); -} - -#[derive(Debug, PartialEq, Clone)] -pub enum KeyEvent { - Exec, - Exit, - MovePrev, - MoveNext, - OpenMenu, - CloseMenu, - JumpNext, - JumpPrev, -} - -pub struct InnerState { - pos: usize, - subpos: usize, - page: usize, - search: String, - event: Option, - search_regex: Option, -} - -impl InnerState { - /// Move X Primary Results Upwards - pub fn move_up(&mut self, x: usize) { - self.subpos = 0; - self.pos = std::cmp::max(self.pos, x) - x; - } - - /// Move X Primary Results Downwards - pub fn move_down(&mut self, x: usize, max: usize) { - self.subpos = 0; - 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 move_prev(&mut self) { - if self.subpos > 0 { - self.subpos -= 1; - return; - } - self.move_up(1); - } - - /// Move Down Once With Context of SubMenu - 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; - } - } - self.jump_down(1, results) - } -} - -#[derive(PartialEq)] -pub struct AppState<'a> { - state: &'a UseRef, - app: &'a App, - results: Vec<&'a Entry>, -} - -impl<'a> AppState<'a> { - /// Spawn new Application State Tracker - pub fn new(cx: Scope<'a, T>, app: &'a App) -> Self { - Self { - state: use_ref(cx, || InnerState { - pos: 0, - subpos: 0, - 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![], - } - } - - /// Create Partial Copy of Self (Not Including Results) - pub fn partial_copy(&self) -> Self { - Self { - state: self.state, - app: self.app, - results: vec![], - } - } - - /// Retrieve Configuration - #[inline] - pub fn config(&self) -> &Config { - &self.app.config - } - - /// Retrieve Current Position State - #[inline] - pub fn position(&self) -> (usize, usize) { - self.state.with(|s| (s.pos, s.subpos)) - } - - /// Retrieve Current Search String - #[inline] - pub fn search(&self) -> String { - self.state.with(|s| s.search.clone()) - } - - /// Execute the Current Action - pub fn execute(&self) { - let (pos, subpos) = self.position(); - log::debug!("execute {pos} {subpos}"); - let Some(result) = self.results.get(pos) else { - return; - }; - log::debug!("result: {result:?}"); - let Some(action) = result.actions.get(subpos) else { - return; - }; - log::debug!("action: {action:?}"); - execute(action, self.app.config.terminal.clone()); - } - - /// Set Current Key/Action for Later Evaluation - #[inline] - pub fn set_event(&self, event: KeyEvent) { - self.state.with_mut(|s| s.event = Some(event)); - } - - /// React to Previously Activated KeyEvents - pub fn handle_events(&self, cx: Scope<'a, App>) { - match self.state.with(|s| s.event.clone()) { - None => {} - Some(event) => { - match event { - KeyEvent::Exit => std::process::exit(0), - KeyEvent::Exec => self.execute(), - KeyEvent::OpenMenu => self.open_menu(), - KeyEvent::CloseMenu => self.close_menu(), - KeyEvent::MovePrev => { - self.move_prev(); - let pos = self.position().0; - scroll(cx, if pos <= 3 { pos } else { pos + 3 }) - } - 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) - } - }; - self.state.with_mut(|s| s.event = None); - } - } - } - - /// Generate and return Results PTR - pub fn results(&mut self, entries: &'a Vec) -> Vec<&'a Entry> { - let ratio = self.app.config.page_load; - let page_size = self.app.config.page_size; - let (pos, page, search) = self.state.with(|s| (s.pos, s.page, s.search.clone())); - // determine current page based on position and configuration - let next = (pos % page_size) as f64 / page_size as f64 > ratio; - let pos_page = (pos + 1) / page_size + 1 + next as usize; - let new_page = std::cmp::max(pos_page, page); - let index = page_size * new_page; - // update page counter if higher than before - if new_page > page { - self.state.with_mut(|s| s.page = new_page); - } - // render results and stop at page-limit - let sfn = new_searchfn(&self.app.config, &search); - self.results = entries.iter().filter(|e| sfn(e)).take(index).collect(); - self.results.clone() - } - - /// 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; - s.search = search; - }); - scroll(cx, 0); - } - - /// Manually Set Position/SubPosition (with Click) - pub fn set_position(&self, pos: usize, subpos: usize) { - self.state.with_mut(|s| { - s.pos = pos; - s.subpos = subpos; - }) - } - - /// Automatically Increase PageCount When Nearing Bottom - // pub fn scroll_down(&self) { - // self.state.with_mut(|s| { - // if self.app.config.page_size * s.page < self.app.entries.len() { - // s.page += 1; - // } - // }); - // } - - /// Move Position To SubMenu if it Exists - pub fn open_menu(&self) { - let pos = self.state.with(|s| s.pos); - if let Some(result) = self.results.get(pos) { - if result.actions.len() > 1 { - self.state.with_mut(|s| s.subpos += 1); - } - } - } - - // Reset and Close SubMenu Position - #[inline] - pub fn close_menu(&self) { - self.state.with_mut(|s| s.subpos = 0); - } - - /// Move Up Once With Context of SubMenu - #[inline] - pub fn move_prev(&self) { - self.state.with_mut(|s| s.move_prev()); - } - - /// Move Down Once With Context of SubMenu - #[inline] - 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/rmenu/templates/index.html b/rmenu/templates/index.html new file mode 100644 index 0000000..ffb86e6 --- /dev/null +++ b/rmenu/templates/index.html @@ -0,0 +1,28 @@ + + + + RMenu Application Launcher + + + + + +
+ +
+ {{ results|safe }} +
+
+ + + diff --git a/rmenu/templates/results.html b/rmenu/templates/results.html new file mode 100644 index 0000000..9387433 --- /dev/null +++ b/rmenu/templates/results.html @@ -0,0 +1,35 @@ +{%- for (i, entry) in results.iter().enumerate() %} +
+
+ {%-if config.use_icons %} +
+ {%endif%} + {%-if config.use_comments %} +
{{ entry.name|safe }}
+
+ {%- if let Some(comment) = entry.comment %} + {{ comment|safe }} + {%endif%} +
+ {%else%} +
{{ entry.name|safe }}
+ {%endif%} +
+ {%for action in entry.actions%} +
+
{{ action.name|safe }}
+
+ {%- if let Some(comment) = action.comment %} + {{ comment|safe }} + {%endif%} +
+
+ {%endfor%} +
+
+
+{%endfor%} diff --git a/rmenu/public/default.css b/rmenu/web/index.css similarity index 95% rename from rmenu/public/default.css rename to rmenu/web/index.css index 2031940..65c8bc0 100644 --- a/rmenu/public/default.css +++ b/rmenu/web/index.css @@ -19,12 +19,13 @@ body, left: 0; position: fixed; overflow: hidden; + height: 10vh; width: -webkit-fill-available; } .results { - height: 100vh; - margin-top: 50px; + height: 90vh; + margin-top: 10vh; overflow-y: auto; } diff --git a/rmenu/web/index.js b/rmenu/web/index.js new file mode 100644 index 0000000..ee8f6ec --- /dev/null +++ b/rmenu/web/index.js @@ -0,0 +1,81 @@ +/// Javasript for index.html + +/* Variables */ + +const results = document.getElementById("results"); + +/* Functions */ + +/// send message back to rust +function _send(type, msg) { + const message = JSON.stringify({ type, ...msg }); + window.webkit.messageHandlers.external.postMessage(message); +} + +/// focus on search element always +function focus() { + const search = document.getElementById("search"); + search.focus(); +} + +/// send search event back to rust +function search(value) { + _send("search", { "value": value }); +} + +/// send keydown event back to rust +function keydown({ key, ctrlKey, shiftKey }) { + _send("keydown", { key, "ctrl": ctrlKey, "shift": shiftKey }); +} + +/// send click event back to rust +function sclick(id) { + _send("click", { click_type: "single", id }); +} + +/// send double-click event back to rust +function dclick(id) { + _send("click", { click_type: "double", id }); +} + +/// send scroll event back to rust +function scroll() { + const height = results.scrollHeight - results.clientHeight; + _send("scroll", { "y": results.scrollTop, "maxy": height }); +} + +/// set selected-result position +function setpos(pos, smooth = false) { + // remove selected class from all current objects + const selected = document.getElementsByClassName("selected"); + const elems = Array.from(selected); + elems.forEach((e) => e.classList.remove("selected")); + // add selected to current position + let current = document.getElementById(`result-${pos}`); + if (!current) { + return; + } + current.classList.add("selected"); + // ensure selected always within view + current.scrollIntoView({ + behavior: smooth ? "smooth" : "auto", + block: "center", + inline: "center", + }); +} + +/// Update Results HTML +function update(html) { + results.innerHTML = html; + setpos(0); +} + +/* Init */ + +// start position at zero +setpos(0); + +// capture relevant events +results.onscroll = scroll; +document.onkeydown = keydown; +document.addEventListener("DOMContentLoaded", focus);