Compare commits

...

5 Commits

Author SHA1 Message Date
imgurbot12
e21e9a2cdb fix: remove useless css 2023-12-15 01:35:08 -07:00
imgurbot12
7b2741c4c1 feat: submenu, tranparency-support, better cfgs 2023-12-15 01:34:28 -07:00
imgurbot12
e3598ebf2e feat: icon support 2023-12-14 15:49:01 -07:00
imgurbot12
3f4fd61aa8 feat: paginated results 2023-12-14 13:14:08 -07:00
imgurbot12
7c3c3cf486 feat: start of rework (again lol) using webview 2023-12-13 22:40:26 -07:00
22 changed files with 780 additions and 1582 deletions

View File

@ -5,6 +5,5 @@ members = [
"rmenu-plugin",
"plugin-run",
"plugin-desktop",
"plugin-network",
"plugin-window",
]

View File

@ -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"

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -1,14 +0,0 @@
<div class="lds-spinner">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>

View File

@ -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<String> {
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<T>(cx: Scope<T>) {
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<AppProp>) -> 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}"
}
}
}
})
}

View File

@ -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<u32> },
}
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
pub struct Cli {
#[clap(subcommand)]
command: Option<Commands>,
}
/// 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<u32>) -> Result<bool> {
// 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(())
}

View File

@ -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<Connection>,
pub dbus_path: Option<String>,
}
// SETTING_WIRELESS_MODE
// SETTING_IP4_CONFIG_METHOD_AUTO
/// Generate a NEW Connection to Use AccessPoint
fn new_conn(ap: &AccessPoint, password: Option<&str>) -> Result<SimpleConnection> {
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::<Result<()>>();
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<Self> {
// 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<AccessPoint> {
let conns: Vec<Connection> = self
.client
.connections()
.into_iter()
.map(|c| c.upcast())
.collect();
let mut access: BTreeMap<String, AccessPoint> = 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<AccessPoint> = 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::<Device>();
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<String, Variant> = 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(())
}
}

View File

@ -1,31 +1,33 @@
[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"
base64 = "0.21.5"
clap = { version = "4.4.11", features = ["derive"] }
env_logger = "0.10.1"
gdk-sys = "0.18.0"
gtk-sys = "0.18.0"
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"
png = "0.17.10"
rayon = "1.8.0"
regex = "1.10.2"
resvg = "0.36.0"
rmenu-plugin = { version = "0.0.1", path = "../rmenu-plugin" }
serde = { version = "1.0.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"

View File

@ -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<f64>,
/// Override Window X Position
#[arg(long)]
xpos: Option<f64>,
/// Override Window Y Position
#[arg(long)]
ypos: Option<f64>,
/// Override Window Focus on Startup
#[arg(long)]
focus: Option<bool>,
@ -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)

View File

@ -1,15 +1,21 @@
//! 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,
};
#[inline(always)]
fn _true() -> bool {
true
}
#[inline(always)]
fn _false() -> bool {
false
}
// parse supported modifiers from string
fn mod_from_str(s: &str) -> Option<Modifiers> {
@ -97,54 +103,66 @@ 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)],
open_menu: vec![],
close_menu: vec![],
move_next: vec![Keybind::new(Code::ArrowDown)],
move_prev: vec![Keybind::new(Code::ArrowUp)],
open_menu: vec![Keybind::new(Code::ArrowRight)],
close_menu: vec![Keybind::new(Code::ArrowLeft)],
jump_next: vec![Keybind::new(Code::PageDown)],
jump_prev: vec![Keybind::new(Code::PageUp)],
};
}
}
#[derive(Debug, PartialEq, Deserialize)]
pub struct LogicalSize<T> {
pub width: T,
pub height: T,
}
impl Default for LogicalSize<f64> {
fn default() -> Self {
Self {
width: 800.0,
height: 400.0,
}
}
}
#[inline(always)]
fn _title() -> String {
"RMenu Application Launcher".to_owned()
}
/// GUI Desktop Window Configuration Settings
#[derive(Debug, PartialEq, Deserialize)]
pub struct WindowConfig {
#[serde(default = "_title")]
pub title: String,
#[serde(default = "LogicalSize::default")]
pub size: LogicalSize<f64>,
pub position: LogicalPosition<f64>,
#[serde(default = "_true")]
pub focus: bool,
#[serde(default = "_false")]
pub decorate: bool,
#[serde(default = "_false")]
pub transparent: bool,
#[serde(default = "_true")]
pub resizable: bool,
#[serde(default = "_true")]
pub always_top: bool,
pub fullscreen: Option<bool>,
pub dark_mode: Option<bool>,
}
impl WindowConfig {
/// Retrieve Desktop Compatabible Fullscreen Settings
pub fn get_fullscreen(&self) -> Option<Fullscreen> {
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 },
title: _title(),
size: LogicalSize::default(),
focus: true,
decorate: false,
transparent: false,
resizable: true,
always_top: true,
fullscreen: None,
dark_mode: None,
@ -206,11 +224,6 @@ pub struct PluginConfig {
pub options: Option<Options>,
}
#[inline]
fn _true() -> bool {
true
}
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default)]
pub struct SearchConfig {
@ -237,20 +250,42 @@ impl Default for SearchConfig {
}
}
#[inline(always)]
fn _page_size() -> usize {
50
}
#[inline(always)]
fn _page_load() -> f64 {
0.8
}
#[inline(always)]
fn _jump_dist() -> usize {
5
}
/// Global RMenu Complete Configuration
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default)]
pub struct Config {
#[serde(default = "_page_size")]
pub page_size: usize,
#[serde(default = "_page_load")]
pub page_load: f64,
#[serde(default = "_jump_dist")]
pub jump_dist: usize,
#[serde(default = "_true")]
pub use_icons: bool,
#[serde(default = "_true")]
pub use_comments: bool,
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub plugins: BTreeMap<String, PluginConfig>,
#[serde(default)]
pub keybinds: KeyConfig,
#[serde(default)]
pub window: WindowConfig,
pub css: Option<String>,
pub terminal: Option<String>,
@ -259,9 +294,9 @@ pub struct Config {
impl Default for Config {
fn default() -> Self {
Self {
page_size: 50,
page_load: 0.8,
jump_dist: 5,
page_size: _page_size(),
page_load: _page_load(),
jump_dist: _jump_dist(),
use_icons: true,
use_comments: true,
search: Default::default(),

View File

@ -1,168 +1,95 @@
//! 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::icons::IconCache;
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> {
start: usize,
end: usize,
results: &'a Vec<&'a Entry>,
config: &'a Config,
cache: &'a IconCache,
}
#[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<T>(cx: Scope<T>) {
let eval = use_eval(cx);
let js = "document.getElementById(`search`).focus()";
let _ = eval(js);
page: usize,
search: String,
results: Vec<&'a Entry>,
data: &'a AppData,
icons: IconCache,
}
/// check if the current inputs match any of the given keybindings
@ -171,117 +98,304 @@ fn matches(bind: &Vec<Keybind>, 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<T: Display>(item: Option<T>) -> String {
item.map(|i| i.to_string()).unwrap_or_else(String::new)
}
/// main application function/loop
fn App<'a>(cx: Scope<App>) -> 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,
page: 0,
search: "".to_owned(),
results: vec![],
icons: IconCache::new().unwrap(),
data,
}
};
}
// handle keyboard events
state.handle_events(cx);
/// Render Current Page of Results
fn render_results_page(&mut self) -> String {
let size = self.data.config.page_size;
let start = self.page * size;
let max = (self.page + 1) * size;
let min = std::cmp::min(max, self.results.len());
let end = std::cmp::max(min, 1) - 1;
self.icons.prepare(&self.results[..]);
// skip generation if results are empty
if self.results.is_empty() {
return "".to_owned();
}
// generate results html from template
let template = ResultsTemplate {
start,
end,
config: &self.data.config,
results: &self.results,
cache: &self.icons,
};
template.render().unwrap()
}
// 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,
/// Update AppState w/ new Search and Render HTML Results
fn search(&mut self, search: String) -> String {
let sfn = build_searchfn(&self.data.config, &search);
self.pos = 0;
self.page = 0;
self.search = search;
self.results = self.data.entries.iter().filter(|e| sfn(e)).collect();
self.render_results_page()
}
/// 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());
}
/// Return Additional Page Results to Load (when nessesary)
fn append_results(&mut self, smooth: bool) -> Option<String> {
let pos = self.pos as f64;
let size = self.data.config.page_size as f64;
let pages = pos / size;
let ratio = (pos % size) / size;
if pages > self.page as f64 && ratio > self.data.config.page_load {
println!("loading next page!");
self.page += 1;
let results = self.render_results_page();
return Some(format!("append({}, {results:?}, {smooth})", self.pos));
}
None
}
/// Move Up a Number of Full Positions
fn move_up(&mut self, up: usize) -> Option<String> {
self.pos = std::cmp::max(self.pos, up) - up;
self.subpos = 0;
Some(format!("setpos({})", self.pos))
}
/// Move Down a Number of Full Positions
fn move_down(&mut self, down: usize) -> Option<String> {
let max = (self.page + 1) * self.data.config.page_size;
let n = std::cmp::max(self.results.len(), 1);
let end = std::cmp::min(max, n) - 1;
self.pos = std::cmp::min(self.pos + down, end);
self.subpos = 0;
match self.append_results(false) {
Some(operation) => Some(operation),
None => Some(format!("setpos({})", self.pos)),
}
}
/// Move Next w/ Context of Sub-Menus
fn move_next(&mut self) -> Option<String> {
if let Some(entry) = self.results.get(self.pos) {
if self.subpos > 0 && self.subpos < entry.actions.len() - 1 {
self.subpos += 1;
return Some(format!("subpos({}, {})", self.pos, self.subpos));
}
})
});
}
self.move_down(1)
}
// 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());
/// Move Previous w/ Context of Sub-Menus
fn move_prev(&mut self) -> Option<String> {
if self.subpos > 1 {
self.subpos -= 1;
return Some(format!("subpos({}, {})", self.pos, self.subpos));
}
if self.subpos == 1 {
self.subpos = 0;
return Some(format!("setpos({})", self.pos));
}
self.move_up(1)
}
// 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,
}
})
/// Move Position to Submenu (if one Exists)
fn open_menu(&mut self) -> Option<String> {
if let Some(result) = self.results.get(self.pos) {
let newpos = self.subpos + 1;
if result.actions.len() > newpos {
self.subpos = newpos;
return Some(format!("subpos({}, {})", self.pos, self.subpos));
}
}
None
}
/// Close SubMenu (if one is Open)
fn close_menu(&mut self) -> Option<String> {
self.subpos = 0;
Some(format!("setpos({})", self.pos))
}
/// Handle Search Event sent by UI
fn search_event(&mut self, search: String) -> Option<String> {
let results = self.search(search);
Some(format!("update({results:?})"))
}
//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<String> {
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_next()
} else if matches(&keybinds.move_prev, &mods, &code) {
self.move_prev()
} else if matches(&keybinds.open_menu, &mods, &code) {
self.open_menu()
} else if matches(&keybinds.close_menu, &mods, &code) {
self.close_menu()
} else if matches(&keybinds.jump_next, &mods, &code) {
self.move_down(self.data.config.jump_dist)
} else if matches(&keybinds.jump_prev, &mods, &code) {
self.move_up(self.data.config.jump_dist)
} 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<String> {
match event {
ClickEvent::Single { id } => {
if let Some((pos, subpos)) = self.parse_id_pos(&id) {
self.pos = pos;
self.subpos = subpos;
return match self.append_results(true) {
Some(op) => Some(op),
None => 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<String> {
// load additonal results when scrolled near bottom
let ratio = event.y as f64 / event.maxy as f64;
if ratio >= self.data.config.page_load {
self.page += 1;
let results = self.render_results_page();
return Some(format!("append(null, {results:?})"));
}
})
None
}
/// Parse and Process Raw UI Messages
fn handle_event(&mut self, event: &str) -> Option<String> {
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()
}
}
/// Update Gtk's Screen w/ Custom CSS to make Transparent
fn transparency_hack() {
use gdk_sys::gdk_screen_get_default;
use gtk_sys::*;
use std::ffi::CString;
// generate css-provider
let provider = unsafe { gtk_css_provider_new() };
// apply css to css-provider
let css = CString::new("* { background: transparent }").unwrap();
let clen = css.as_bytes().len();
let mut error = std::ptr::null_mut();
unsafe { gtk_css_provider_load_from_data(provider, css.as_ptr() as _, clen as _, &mut error) };
// retrieve screen and apply css- provider to screen
let prio = GTK_STYLE_PROVIDER_PRIORITY_APPLICATION;
let screen = unsafe { gdk_screen_get_default() };
unsafe { gtk_style_context_add_provider_for_screen(screen, provider as _, prio as _) };
}
/// 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 wcfg = &state.data.config.window;
let size = &wcfg.size;
let fullscreen = wcfg.fullscreen;
let transparent = wcfg.transparent;
let mut window = web_view::builder()
.title(&state.data.config.window.title)
.content(Content::Html(html))
.frameless(!wcfg.decorate)
.size(size.width as i32, size.height as i32)
.resizable(wcfg.resizable)
.debug(cfg!(debug_assertions))
.user_data(())
.invoke_handler(|webview, msg| {
if let Some(js) = state.handle_event(msg) {
webview.eval(&js)?;
};
Ok(())
})
.build()
.unwrap();
// manage transparency and fullscreen settings
if transparent {
window.set_color((0, 0, 0, 0));
#[cfg(target_os = "linux")]
transparency_hack();
}
if let Some(fullscreen) = fullscreen {
window.set_fullscreen(fullscreen);
}
window.run().unwrap();
}

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

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

View File

@ -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<Mutex<Vec<bool>>> = Lazy::new(|| Mutex::new(vec![]));
static TEMP_DIR: Lazy<PathBuf> = 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<String> {
// 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()
}

View File

@ -3,9 +3,8 @@ mod cli;
mod config;
mod exec;
mod gui;
mod image;
mod icons;
mod search;
mod state;
use clap::Parser;
use rmenu_plugin::{self_exe, Entry};
@ -13,11 +12,10 @@ 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,
@ -63,7 +61,7 @@ fn main() -> cli::Result<()> {
}
// genrate app context and run gui
gui::run(App {
gui::run(AppData {
name: "rmenu".to_owned(),
css,
theme,

View File

@ -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<dyn Fn(&Entry) -> bool> {
pub fn build_searchfn(cfg: &Config, search: &str) -> Box<dyn Fn(&Entry) -> bool> {
// build regex search expression
if cfg.search.use_regex {
let rgx = RegexBuilder::new(search)

View File

@ -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<T>(cx: Scope<T>, 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<KeyEvent>,
search_regex: Option<Regex>,
}
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<InnerState>,
app: &'a App,
results: Vec<&'a Entry>,
}
impl<'a> AppState<'a> {
/// Spawn new Application State Tracker
pub fn new<T>(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<Entry>) -> 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))
}
}

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>RMenu Application Launcher</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>{{ css }}</style>
</head>
<body>
<div id="content" class="content">
<div id="navbar" class="navbar">
<input
id="search"
type="text"
value="{{ search }}"
{% if let Some(v) = config.search.restrict %}pattern="{{ v }}"{% endif %}
{% if let Some(v) = config.search.min_length %}minlength="{{ v }}"{% endif %}
{% if let Some(v) = config.search.max_length %}maxlength="{{ v }}"{% endif %}
{% if let Some(v) = config.search.placeholder %}placeholder="{{ v }}"{% endif %}
oninput="search(this.value)">
</div>
<div id="results" class="results">
{{ results|safe }}
</div>
</div>
<script>{{ script|safe }}</script>
</body>
</html>

View File

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

View File

@ -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;
}
@ -55,7 +56,8 @@ input {
justify-content: left;
}
.result > div, .action > div {
.result div,
.action div {
margin: 2px 5px;
}

109
rmenu/web/index.js Normal file
View File

@ -0,0 +1,109 @@
/// Javasript for index.html
/* Variables */
const input = document.getElementById("search");
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(e) {
(e.key == "ArrowUp" || e.key == "ArrowDown") && e.preventDefault();
_send("keydown", { "key": e.key, "ctrl": e.ctrlKey, "shift": e.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 });
}
// remove active class from all current objects
function reset() {
const classes = ["active", "selected"];
for (const cname of classes) {
const selected = document.getElementsByClassName(cname);
const elems = Array.from(selected);
elems.forEach((e) => e.classList.remove(cname));
}
}
/// set selected-result position
function setpos(pos, smooth = false) {
reset();
// add selected to current position
const 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",
});
}
// set selected-result subposition
function subpos(pos, subpos) {
reset();
// activate submenu
const actions = document.getElementById(`result-${pos}-actions`);
if (!actions) return;
actions.classList.add("active");
// select current subposition
const action = document.getElementById(`result-${pos}-action-${subpos}`);
if (!action) return;
action.classList.add("selected");
}
/// Update Results HTML
function update(html) {
results.innerHTML = html;
setpos(0);
}
// Append Results HTML
function append(pos, html, smooth = false) {
results.innerHTML += html;
if (pos != null && pos != undefined) {
setpos(pos, smooth);
}
}
/* Init */
// start position at zero
setpos(0);
// capture relevant events
results.onscroll = scroll;
document.onkeydown = keydown;
document.addEventListener("DOMContentLoaded", focus);

View File

@ -15,7 +15,8 @@
}
html, body {
background-color: #24242480;
/* background-color: #383c4a2b !important; */
background-color: #24242480 !important;
}
input {
@ -26,7 +27,7 @@ input {
border: 1px;
border-color: #f5f5f540;
border-radius: 2px;
background-color: #363636;
background-color: #363636 !important;
}
input::placeholder {