mirror of
https://github.com/imgurbot12/rmenu.git
synced 2025-01-27 05:18:33 +01:00
feat: start of rework (again lol) using webview
This commit is contained in:
parent
4d8b895359
commit
7c3c3cf486
20 changed files with 448 additions and 1572 deletions
|
@ -5,6 +5,5 @@ members = [
|
|||
"rmenu-plugin",
|
||||
"plugin-run",
|
||||
"plugin-desktop",
|
||||
"plugin-network",
|
||||
"plugin-window",
|
||||
]
|
||||
|
|
|
@ -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"
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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(())
|
||||
}
|
|
@ -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(())
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<Modifiers> {
|
||||
|
@ -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<T> {
|
||||
pub width: T,
|
||||
pub height: T,
|
||||
}
|
||||
|
||||
impl<T> LogicalSize<T> {
|
||||
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<f64>,
|
||||
pub position: LogicalPosition<f64>,
|
||||
#[serde(default = "_true")]
|
||||
pub focus: bool,
|
||||
pub decorate: bool,
|
||||
|
@ -123,25 +130,11 @@ pub struct WindowConfig {
|
|||
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 },
|
||||
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,
|
||||
|
|
510
rmenu/src/gui.rs
510
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<T>(cx: Scope<T>) {
|
||||
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<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,
|
||||
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<String> {
|
||||
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<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_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<String> {
|
||||
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<String> {
|
||||
println!("scroll: {event:?}");
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
28
rmenu/templates/index.html
Normal file
28
rmenu/templates/index.html
Normal 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>
|
35
rmenu/templates/results.html
Normal file
35
rmenu/templates/results.html
Normal file
|
@ -0,0 +1,35 @@
|
|||
{%- for (i, entry) in results.iter().enumerate() %}
|
||||
<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"></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 id="result-{{ i }}-actions" class="actions">
|
||||
{%for action in entry.actions%}
|
||||
<div 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>
|
||||
</div>
|
||||
{%endfor%}
|
|
@ -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;
|
||||
}
|
||||
|
81
rmenu/web/index.js
Normal file
81
rmenu/web/index.js
Normal file
|
@ -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);
|
Loading…
Reference in a new issue