forked from mirrors/rmenu
Compare commits
5 Commits
master
...
feat/webvi
Author | SHA1 | Date | |
---|---|---|---|
|
e21e9a2cdb | ||
|
7b2741c4c1 | ||
|
e3598ebf2e | ||
|
3f4fd61aa8 | ||
|
7c3c3cf486 |
@ -5,6 +5,5 @@ members = [
|
|||||||
"rmenu-plugin",
|
"rmenu-plugin",
|
||||||
"plugin-run",
|
"plugin-run",
|
||||||
"plugin-desktop",
|
"plugin-desktop",
|
||||||
"plugin-network",
|
|
||||||
"plugin-window",
|
"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,33 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rmenu"
|
name = "rmenu"
|
||||||
version = "0.0.1"
|
version = "0.0.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cached = "0.44.0"
|
askama = "0.12.1"
|
||||||
clap = { version = "4.3.15", features = ["derive"] }
|
base64 = "0.21.5"
|
||||||
dioxus = "0.4.0"
|
clap = { version = "4.4.11", features = ["derive"] }
|
||||||
dioxus-desktop = "0.4.0"
|
env_logger = "0.10.1"
|
||||||
env_logger = "0.10.0"
|
gdk-sys = "0.18.0"
|
||||||
|
gtk-sys = "0.18.0"
|
||||||
heck = "0.4.1"
|
heck = "0.4.1"
|
||||||
keyboard-types = "0.6.2"
|
keyboard-types = "0.7.0"
|
||||||
lastlog = { version = "0.2.3", features = ["libc"] }
|
lastlog = { version = "0.2.3", features = ["libc"] }
|
||||||
log = "0.4.19"
|
log = "0.4.20"
|
||||||
once_cell = "1.18.0"
|
once_cell = "1.19.0"
|
||||||
png = "0.17.9"
|
png = "0.17.10"
|
||||||
quick-xml = "0.30.0"
|
rayon = "1.8.0"
|
||||||
regex = { version = "1.9.1" }
|
regex = "1.10.2"
|
||||||
resvg = "0.35.0"
|
resvg = "0.36.0"
|
||||||
rmenu-plugin = { version = "0.0.1", path = "../rmenu-plugin" }
|
rmenu-plugin = { version = "0.0.1", path = "../rmenu-plugin" }
|
||||||
serde = { version = "1.0.171", features = ["derive"] }
|
serde = { version = "1.0.193", features = ["derive"] }
|
||||||
serde_json = "1.0.103"
|
serde_json = "1.0.108"
|
||||||
serde_yaml = "0.9.24"
|
serde_yaml = "0.9.27"
|
||||||
shell-words = "1.1.0"
|
shell-words = "1.1.0"
|
||||||
shellexpand = "3.1.0"
|
shellexpand = "3.1.0"
|
||||||
strfmt = "0.2.4"
|
strfmt = "0.2.4"
|
||||||
thiserror = "1.0.43"
|
thiserror = "1.0.50"
|
||||||
which = "4.4.0"
|
web-view = "0.7.3"
|
||||||
|
which = "5.0.0"
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
///! RMenu CLI Implementation
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{BufRead, BufReader, Read};
|
use std::io::{BufRead, BufReader, Read};
|
||||||
use std::process::{Command, ExitStatus, Stdio};
|
use std::process::{Command, ExitStatus, Stdio};
|
||||||
@ -134,12 +135,6 @@ pub struct Args {
|
|||||||
/// Override Window Height
|
/// Override Window Height
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
height: Option<f64>,
|
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
|
/// Override Window Focus on Startup
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
focus: Option<bool>,
|
focus: Option<bool>,
|
||||||
@ -225,8 +220,6 @@ impl Args {
|
|||||||
cfg_replace!(config.window.title, self.title, true);
|
cfg_replace!(config.window.title, self.title, true);
|
||||||
cfg_replace!(config.window.size.width, self.width, true);
|
cfg_replace!(config.window.size.width, self.width, true);
|
||||||
cfg_replace!(config.window.size.height, self.height, 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.focus, self.focus, true);
|
||||||
cfg_replace!(config.window.decorate, self.decorate, true);
|
cfg_replace!(config.window.decorate, self.decorate, true);
|
||||||
cfg_replace!(config.window.transparent, self.transparent, true);
|
cfg_replace!(config.window.transparent, self.transparent, true);
|
||||||
@ -301,7 +294,6 @@ impl Args {
|
|||||||
let mut entries = vec![];
|
let mut entries = vec![];
|
||||||
for name in self.run.clone().into_iter() {
|
for name in self.run.clone().into_iter() {
|
||||||
// retrieve plugin configuration
|
// retrieve plugin configuration
|
||||||
log::info!("running plugin: {name:?}");
|
|
||||||
let plugin = config
|
let plugin = config
|
||||||
.plugins
|
.plugins
|
||||||
.get(&name)
|
.get(&name)
|
||||||
|
@ -1,15 +1,21 @@
|
|||||||
//! RMENU Configuration Implementations
|
/// RMenu Configuration Management
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
use heck::AsPascalCase;
|
use heck::AsPascalCase;
|
||||||
use keyboard_types::{Code, Modifiers};
|
use keyboard_types::{Code, Modifiers};
|
||||||
use rmenu_plugin::Options;
|
use rmenu_plugin::Options;
|
||||||
use serde::{de::Error, Deserialize};
|
use serde::{de::Error, Deserialize};
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use dioxus_desktop::tao::{
|
#[inline(always)]
|
||||||
dpi::{LogicalPosition, LogicalSize},
|
fn _true() -> bool {
|
||||||
window::Fullscreen,
|
true
|
||||||
};
|
}
|
||||||
|
|
||||||
|
#[inline(always)]
|
||||||
|
fn _false() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
// parse supported modifiers from string
|
// parse supported modifiers from string
|
||||||
fn mod_from_str(s: &str) -> Option<Modifiers> {
|
fn mod_from_str(s: &str) -> Option<Modifiers> {
|
||||||
@ -97,54 +103,66 @@ impl Default for KeyConfig {
|
|||||||
return Self {
|
return Self {
|
||||||
exec: vec![Keybind::new(Code::Enter)],
|
exec: vec![Keybind::new(Code::Enter)],
|
||||||
exit: vec![Keybind::new(Code::Escape)],
|
exit: vec![Keybind::new(Code::Escape)],
|
||||||
move_next: vec![Keybind::new(Code::ArrowUp)],
|
move_next: vec![Keybind::new(Code::ArrowDown)],
|
||||||
move_prev: vec![Keybind::new(Code::ArrowDown)],
|
move_prev: vec![Keybind::new(Code::ArrowUp)],
|
||||||
open_menu: vec![],
|
open_menu: vec![Keybind::new(Code::ArrowRight)],
|
||||||
close_menu: vec![],
|
close_menu: vec![Keybind::new(Code::ArrowLeft)],
|
||||||
jump_next: vec![Keybind::new(Code::PageDown)],
|
jump_next: vec![Keybind::new(Code::PageDown)],
|
||||||
jump_prev: vec![Keybind::new(Code::PageUp)],
|
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
|
/// GUI Desktop Window Configuration Settings
|
||||||
#[derive(Debug, PartialEq, Deserialize)]
|
#[derive(Debug, PartialEq, Deserialize)]
|
||||||
pub struct WindowConfig {
|
pub struct WindowConfig {
|
||||||
|
#[serde(default = "_title")]
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
#[serde(default = "LogicalSize::default")]
|
||||||
pub size: LogicalSize<f64>,
|
pub size: LogicalSize<f64>,
|
||||||
pub position: LogicalPosition<f64>,
|
|
||||||
#[serde(default = "_true")]
|
#[serde(default = "_true")]
|
||||||
pub focus: bool,
|
pub focus: bool,
|
||||||
|
#[serde(default = "_false")]
|
||||||
pub decorate: bool,
|
pub decorate: bool,
|
||||||
|
#[serde(default = "_false")]
|
||||||
pub transparent: bool,
|
pub transparent: bool,
|
||||||
#[serde(default = "_true")]
|
#[serde(default = "_true")]
|
||||||
|
pub resizable: bool,
|
||||||
|
#[serde(default = "_true")]
|
||||||
pub always_top: bool,
|
pub always_top: bool,
|
||||||
pub fullscreen: Option<bool>,
|
pub fullscreen: Option<bool>,
|
||||||
pub dark_mode: 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 {
|
impl Default for WindowConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
title: "RMenu - App Launcher".to_owned(),
|
title: _title(),
|
||||||
size: LogicalSize {
|
size: LogicalSize::default(),
|
||||||
width: 700.0,
|
|
||||||
height: 400.0,
|
|
||||||
},
|
|
||||||
position: LogicalPosition { x: 100.0, y: 100.0 },
|
|
||||||
focus: true,
|
focus: true,
|
||||||
decorate: false,
|
decorate: false,
|
||||||
transparent: false,
|
transparent: false,
|
||||||
|
resizable: true,
|
||||||
always_top: true,
|
always_top: true,
|
||||||
fullscreen: None,
|
fullscreen: None,
|
||||||
dark_mode: None,
|
dark_mode: None,
|
||||||
@ -206,11 +224,6 @@ pub struct PluginConfig {
|
|||||||
pub options: Option<Options>,
|
pub options: Option<Options>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn _true() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Deserialize)]
|
#[derive(Debug, PartialEq, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct SearchConfig {
|
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
|
/// Global RMenu Complete Configuration
|
||||||
#[derive(Debug, PartialEq, Deserialize)]
|
#[derive(Debug, PartialEq, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
#[serde(default = "_page_size")]
|
||||||
pub page_size: usize,
|
pub page_size: usize,
|
||||||
|
#[serde(default = "_page_load")]
|
||||||
pub page_load: f64,
|
pub page_load: f64,
|
||||||
|
#[serde(default = "_jump_dist")]
|
||||||
pub jump_dist: usize,
|
pub jump_dist: usize,
|
||||||
#[serde(default = "_true")]
|
#[serde(default = "_true")]
|
||||||
pub use_icons: bool,
|
pub use_icons: bool,
|
||||||
#[serde(default = "_true")]
|
#[serde(default = "_true")]
|
||||||
pub use_comments: bool,
|
pub use_comments: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub search: SearchConfig,
|
pub search: SearchConfig,
|
||||||
|
#[serde(default)]
|
||||||
pub plugins: BTreeMap<String, PluginConfig>,
|
pub plugins: BTreeMap<String, PluginConfig>,
|
||||||
|
#[serde(default)]
|
||||||
pub keybinds: KeyConfig,
|
pub keybinds: KeyConfig,
|
||||||
|
#[serde(default)]
|
||||||
pub window: WindowConfig,
|
pub window: WindowConfig,
|
||||||
pub css: Option<String>,
|
pub css: Option<String>,
|
||||||
pub terminal: Option<String>,
|
pub terminal: Option<String>,
|
||||||
@ -259,9 +294,9 @@ pub struct Config {
|
|||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
page_size: 50,
|
page_size: _page_size(),
|
||||||
page_load: 0.8,
|
page_load: _page_load(),
|
||||||
jump_dist: 5,
|
jump_dist: _jump_dist(),
|
||||||
use_icons: true,
|
use_icons: true,
|
||||||
use_comments: true,
|
use_comments: true,
|
||||||
search: Default::default(),
|
search: Default::default(),
|
||||||
|
612
rmenu/src/gui.rs
612
rmenu/src/gui.rs
@ -1,168 +1,95 @@
|
|||||||
//! RMENU GUI Implementation using Dioxus
|
/// Gui Implementation
|
||||||
#![allow(non_snake_case)]
|
use std::str::FromStr;
|
||||||
use std::fmt::Display;
|
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use askama::Template;
|
||||||
use keyboard_types::{Code, Modifiers};
|
use keyboard_types::{Code, Modifiers};
|
||||||
use rmenu_plugin::Entry;
|
use rmenu_plugin::Entry;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use web_view::*;
|
||||||
|
|
||||||
use crate::config::Keybind;
|
use crate::config::{Config, Keybind};
|
||||||
use crate::state::{AppState, KeyEvent};
|
use crate::exec::execute;
|
||||||
use crate::{App, DEFAULT_CSS_CONTENT};
|
use crate::icons::IconCache;
|
||||||
|
use crate::search::build_searchfn;
|
||||||
|
use crate::AppData;
|
||||||
|
|
||||||
/// spawn and run the app on the configured platform
|
static INDEX_JS: &'static str = include_str!("../web/index.js");
|
||||||
pub fn run(app: App) {
|
static INDEX_CSS: &'static str = include_str!("../web/index.css");
|
||||||
let theme = match app.config.window.dark_mode {
|
|
||||||
Some(dark) => match dark {
|
#[derive(Debug, Deserialize)]
|
||||||
true => Some(dioxus_desktop::tao::window::Theme::Dark),
|
struct KeyEvent {
|
||||||
false => Some(dioxus_desktop::tao::window::Theme::Light),
|
key: String,
|
||||||
},
|
ctrl: bool,
|
||||||
None => None,
|
shift: bool,
|
||||||
};
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Props)]
|
impl KeyEvent {
|
||||||
struct GEntry<'a> {
|
/// 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,
|
pos: usize,
|
||||||
subpos: usize,
|
subpos: usize,
|
||||||
index: usize,
|
page: usize,
|
||||||
entry: &'a Entry,
|
search: String,
|
||||||
state: AppState<'a>,
|
results: Vec<&'a Entry>,
|
||||||
}
|
data: &'a AppData,
|
||||||
|
icons: IconCache,
|
||||||
#[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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// check if the current inputs match any of the given keybindings
|
/// 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)
|
bind.iter().any(|b| mods.contains(b.mods) && &b.key == key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// retrieve string value for display-capable enum
|
impl<'a> AppState<'a> {
|
||||||
#[inline]
|
fn new(data: &'a AppData) -> Self {
|
||||||
fn get_str<T: Display>(item: Option<T>) -> String {
|
Self {
|
||||||
item.map(|i| i.to_string()).unwrap_or_else(String::new)
|
pos: 0,
|
||||||
}
|
subpos: 0,
|
||||||
|
page: 0,
|
||||||
|
search: "".to_owned(),
|
||||||
|
results: vec![],
|
||||||
|
icons: IconCache::new().unwrap(),
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// main application function/loop
|
/// Render Current Page of Results
|
||||||
fn App<'a>(cx: Scope<App>) -> Element {
|
fn render_results_page(&mut self) -> String {
|
||||||
let mut state = AppState::new(cx, cx.props);
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
// always ensure focus
|
/// Update AppState w/ new Search and Render HTML Results
|
||||||
focus(cx);
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
// log current position
|
/// Execute Action associated w/ Current Position/Subposition
|
||||||
let search = state.search();
|
fn execute(&self) {
|
||||||
let (pos, subpos) = state.position();
|
log::debug!("execute {} {}", self.pos, self.subpos);
|
||||||
log::debug!("search: {search:?}, pos: {pos}, {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());
|
||||||
|
}
|
||||||
|
|
||||||
// generate state tracker instances
|
/// Return Additional Page Results to Load (when nessesary)
|
||||||
let results = state.results(&cx.props.entries);
|
fn append_results(&mut self, smooth: bool) -> Option<String> {
|
||||||
let k_updater = state.partial_copy();
|
let pos = self.pos as f64;
|
||||||
let s_updater = state.partial_copy();
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// build keyboard actions event handler
|
/// Move Up a Number of Full Positions
|
||||||
let keybinds = &cx.props.config.keybinds;
|
fn move_up(&mut self, up: usize) -> Option<String> {
|
||||||
let keyboard_controls = move |e: KeyboardEvent| {
|
self.pos = std::cmp::max(self.pos, up) - up;
|
||||||
let code = e.code();
|
self.subpos = 0;
|
||||||
let mods = e.modifiers();
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
if matches(&keybinds.exec, &mods, &code) {
|
||||||
k_updater.set_event(KeyEvent::Exec);
|
self.execute();
|
||||||
|
None
|
||||||
} else if matches(&keybinds.exit, &mods, &code) {
|
} else if matches(&keybinds.exit, &mods, &code) {
|
||||||
k_updater.set_event(KeyEvent::Exit);
|
std::process::exit(0);
|
||||||
} else if matches(&keybinds.move_next, &mods, &code) {
|
} else if matches(&keybinds.move_next, &mods, &code) {
|
||||||
k_updater.set_event(KeyEvent::MoveNext);
|
self.move_next()
|
||||||
} else if matches(&keybinds.move_prev, &mods, &code) {
|
} else if matches(&keybinds.move_prev, &mods, &code) {
|
||||||
k_updater.set_event(KeyEvent::MovePrev);
|
self.move_prev()
|
||||||
} else if matches(&keybinds.open_menu, &mods, &code) {
|
} else if matches(&keybinds.open_menu, &mods, &code) {
|
||||||
k_updater.set_event(KeyEvent::OpenMenu);
|
self.open_menu()
|
||||||
} else if matches(&keybinds.close_menu, &mods, &code) {
|
} else if matches(&keybinds.close_menu, &mods, &code) {
|
||||||
k_updater.set_event(KeyEvent::CloseMenu);
|
self.close_menu()
|
||||||
} else if matches(&keybinds.jump_next, &mods, &code) {
|
} else if matches(&keybinds.jump_next, &mods, &code) {
|
||||||
k_updater.set_event(KeyEvent::JumpNext)
|
self.move_down(self.data.config.jump_dist)
|
||||||
} else if matches(&keybinds.jump_prev, &mods, &code) {
|
} else if matches(&keybinds.jump_prev, &mods, &code) {
|
||||||
k_updater.set_event(KeyEvent::JumpPrev)
|
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)")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClickEvent::Double { id } => {
|
||||||
|
if let Some((pos, subpos)) = self.parse_id_pos(&id) {
|
||||||
|
self.pos = pos;
|
||||||
|
self.subpos = subpos;
|
||||||
|
self.execute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
// handle keyboard events
|
/// Handle Scrolling Events sent by UI
|
||||||
state.handle_events(cx);
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// render results objects
|
/// Parse and Process Raw UI Messages
|
||||||
let rendered_results = results.iter().enumerate().map(|(i, e)| {
|
fn handle_event(&mut self, event: &str) -> Option<String> {
|
||||||
let state = state.partial_copy();
|
let message: Message = serde_json::from_str(&event).unwrap();
|
||||||
cx.render(rsx! {
|
match message {
|
||||||
TableEntry{
|
Message::Search { value } => self.search_event(value),
|
||||||
pos: pos,
|
Message::KeyDown(event) => self.key_event(event),
|
||||||
subpos: subpos,
|
Message::Click(event) => self.click_event(event),
|
||||||
index: i,
|
Message::Scroll(event) => self.scroll_event(event),
|
||||||
entry: e,
|
}
|
||||||
state: state,
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// get input settings
|
/// Render Initial Index HTML w/ AppState
|
||||||
let minlen = get_str(cx.props.config.search.min_length.as_ref());
|
fn render_index(&mut self) -> String {
|
||||||
let maxlen = get_str(cx.props.config.search.max_length.as_ref());
|
let results = self.search("".to_owned());
|
||||||
let placeholder = get_str(cx.props.config.search.placeholder.as_ref());
|
let index = IndexTemplate {
|
||||||
|
css: &format!("{INDEX_CSS}\n{}\n{}", self.data.css, self.data.theme),
|
||||||
// complete final rendering
|
search: &self.search,
|
||||||
cx.render(rsx! {
|
results: &results,
|
||||||
style { DEFAULT_CSS_CONTENT }
|
config: &self.data.config,
|
||||||
style { "{cx.props.theme}" }
|
script: &INDEX_JS,
|
||||||
style { "{cx.props.css}" }
|
};
|
||||||
div {
|
index.render().unwrap()
|
||||||
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 {
|
/// Update Gtk's Screen w/ Custom CSS to make Transparent
|
||||||
id: "search",
|
fn transparency_hack() {
|
||||||
value: "{search}",
|
use gdk_sys::gdk_screen_get_default;
|
||||||
minlength: "{minlen}",
|
use gtk_sys::*;
|
||||||
maxlength: "{maxlen}",
|
use std::ffi::CString;
|
||||||
placeholder: "{placeholder}",
|
// generate css-provider
|
||||||
oninput: move |e| s_updater.set_search(cx, e.value.clone()),
|
let provider = unsafe { gtk_css_provider_new() };
|
||||||
onkeydown: keyboard_controls,
|
// 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) };
|
||||||
div {
|
// retrieve screen and apply css- provider to screen
|
||||||
id: "results",
|
let prio = GTK_STYLE_PROVIDER_PRIORITY_APPLICATION;
|
||||||
class: "results",
|
let screen = unsafe { gdk_screen_get_default() };
|
||||||
rendered_results.into_iter()
|
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
116
rmenu/src/icons.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -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,9 +3,8 @@ mod cli;
|
|||||||
mod config;
|
mod config;
|
||||||
mod exec;
|
mod exec;
|
||||||
mod gui;
|
mod gui;
|
||||||
mod image;
|
mod icons;
|
||||||
mod search;
|
mod search;
|
||||||
mod state;
|
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use rmenu_plugin::{self_exe, Entry};
|
use rmenu_plugin::{self_exe, Entry};
|
||||||
@ -13,11 +12,10 @@ use rmenu_plugin::{self_exe, Entry};
|
|||||||
static CONFIG_DIR: &'static str = "~/.config/rmenu/";
|
static CONFIG_DIR: &'static str = "~/.config/rmenu/";
|
||||||
static DEFAULT_CSS: &'static str = "~/.config/rmenu/style.css";
|
static DEFAULT_CSS: &'static str = "~/.config/rmenu/style.css";
|
||||||
static DEFAULT_CONFIG: &'static str = "~/.config/rmenu/config.yaml";
|
static DEFAULT_CONFIG: &'static str = "~/.config/rmenu/config.yaml";
|
||||||
static DEFAULT_CSS_CONTENT: &'static str = include_str!("../public/default.css");
|
|
||||||
|
|
||||||
/// Application State for GUI
|
/// Application State for GUI
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub struct App {
|
pub struct AppData {
|
||||||
css: String,
|
css: String,
|
||||||
name: String,
|
name: String,
|
||||||
theme: String,
|
theme: String,
|
||||||
@ -63,7 +61,7 @@ fn main() -> cli::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// genrate app context and run gui
|
// genrate app context and run gui
|
||||||
gui::run(App {
|
gui::run(AppData {
|
||||||
name: "rmenu".to_owned(),
|
name: "rmenu".to_owned(),
|
||||||
css,
|
css,
|
||||||
theme,
|
theme,
|
||||||
|
@ -6,7 +6,7 @@ use crate::config::Config;
|
|||||||
|
|
||||||
/// Generate a new dynamic Search Function based on
|
/// Generate a new dynamic Search Function based on
|
||||||
/// Configurtaion Settings and Search-String
|
/// 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
|
// build regex search expression
|
||||||
if cfg.search.use_regex {
|
if cfg.search.use_regex {
|
||||||
let rgx = RegexBuilder::new(search)
|
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>
|
49
rmenu/templates/results.html
Normal file
49
rmenu/templates/results.html
Normal 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%}
|
@ -19,12 +19,13 @@ body,
|
|||||||
left: 0;
|
left: 0;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
height: 10vh;
|
||||||
width: -webkit-fill-available;
|
width: -webkit-fill-available;
|
||||||
}
|
}
|
||||||
|
|
||||||
.results {
|
.results {
|
||||||
height: 100vh;
|
height: 90vh;
|
||||||
margin-top: 50px;
|
margin-top: 10vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +56,8 @@ input {
|
|||||||
justify-content: left;
|
justify-content: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result > div, .action > div {
|
.result div,
|
||||||
|
.action div {
|
||||||
margin: 2px 5px;
|
margin: 2px 5px;
|
||||||
}
|
}
|
||||||
|
|
109
rmenu/web/index.js
Normal file
109
rmenu/web/index.js
Normal 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);
|
@ -15,7 +15,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
background-color: #24242480;
|
/* background-color: #383c4a2b !important; */
|
||||||
|
background-color: #24242480 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
@ -26,7 +27,7 @@ input {
|
|||||||
border: 1px;
|
border: 1px;
|
||||||
border-color: #f5f5f540;
|
border-color: #f5f5f540;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background-color: #363636;
|
background-color: #363636 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder {
|
input::placeholder {
|
||||||
|
Loading…
Reference in New Issue
Block a user