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() %}
+
+
+
+{%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);