From 4ef05f7e71b1c564e1ab837518920b9f4bf6031d Mon Sep 17 00:00:00 2001 From: Bazyli Cyran Date: Wed, 18 Dec 2024 19:59:13 +0100 Subject: [PATCH] feat: Add powermenu plugin --- Cargo.lock | 33 +++++++ Cargo.toml | 1 + README.md | 3 + flake.nix | 1 + plugins/powermenu/Cargo.toml | 15 ++++ plugins/powermenu/README.md | 41 +++++++++ plugins/powermenu/src/actions.rs | 103 ++++++++++++++++++++++ plugins/powermenu/src/config.rs | 93 +++++++++++++++++++ plugins/powermenu/src/lib.rs | 147 +++++++++++++++++++++++++++++++ 9 files changed, 437 insertions(+) create mode 100644 plugins/powermenu/Cargo.toml create mode 100644 plugins/powermenu/README.md create mode 100644 plugins/powermenu/src/actions.rs create mode 100644 plugins/powermenu/src/config.rs create mode 100644 plugins/powermenu/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 551121be..c780757f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1437,6 +1437,27 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.85", +] + [[package]] name = "object" version = "0.31.1" @@ -1618,6 +1639,18 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "powermenu" +version = "0.1.0" +dependencies = [ + "abi_stable", + "anyrun-plugin", + "fuzzy-matcher", + "num_enum", + "ron", + "serde", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" diff --git a/Cargo.toml b/Cargo.toml index 65eec8aa..c5e65185 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "plugins/rink", "plugins/shell", "plugins/kidex", + "plugins/powermenu", "plugins/translate", "plugins/randr", "plugins/stdin", diff --git a/README.md b/README.md index 96f112c4..33467e0c 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ The flake provides multiple packages: - **applications** - the applications plugin - **dictionary** - the dictionary plugin - **kidex** - the kidex plugin +- **powermenu** - the powermenu plugin - **randr** - the randr plugin - **rink** - the rink plugin - **shell** - the shell plugin @@ -207,6 +208,8 @@ list of plugins in this repository is as follows: - Quickly translate text. - [Kidex](plugins/kidex/README.md) - File search provided by [Kidex](https://github.com/Kirottu/kidex). +- [Powermenu](/plugins/powermenu/README.md) + - System power menu actions: lock, log out, power off, etc. - [Randr](plugins/randr/README.md) - Rotate and resize; quickly change monitor configurations on the fly. - TODO: Only supports Hyprland, needs support for other compositors. diff --git a/flake.nix b/flake.nix index f22be33e..7c03e067 100644 --- a/flake.nix +++ b/flake.nix @@ -56,6 +56,7 @@ applications = mkPlugin "applications"; dictionary = mkPlugin "dictionary"; kidex = mkPlugin "kidex"; + powermenu = mkPlugin "powermenu"; randr = mkPlugin "randr"; rink = mkPlugin "rink"; shell = mkPlugin "shell"; diff --git a/plugins/powermenu/Cargo.toml b/plugins/powermenu/Cargo.toml new file mode 100644 index 00000000..251b92fc --- /dev/null +++ b/plugins/powermenu/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "powermenu" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +anyrun-plugin = { path = "../../anyrun-plugin" } +abi_stable = "0.11.1" +serde = { version = "1.0.204", features = ["derive"] } +ron = "0.8.1" +num_enum = "0.7.3" +fuzzy-matcher = "0.3.7" diff --git a/plugins/powermenu/README.md b/plugins/powermenu/README.md new file mode 100644 index 00000000..da1fb1be --- /dev/null +++ b/plugins/powermenu/README.md @@ -0,0 +1,41 @@ +# Powermenu + +A plugin to lock, logout, power off your machine, etc. + +## Usage + +Search for one of the following actions: lock, logout, power off, reboot, suspend, hibernate. +Select the action. +If prompted, confirm it. + +## Configuration + +```ron +// /powermenu.ron +Config( + lock: ( + command: "loginctl lock-session", + confirm: false, + ), + logout: ( + command: "loginctl terminate-user $USER", + confirm: true, + ), + poweroff: ( + command: "systemctl -i poweroff", + confirm: true, + ), + reboot: ( + command: "systemctl -i reboot", + confirm: true, + ), + suspend: ( + command: "systemctl -i suspend", + confirm: false, + ), + hibernate: ( + command: "systemctl -i hibernate", + confirm: false, + ), +) +``` diff --git a/plugins/powermenu/src/actions.rs b/plugins/powermenu/src/actions.rs new file mode 100644 index 00000000..d1d1ecd1 --- /dev/null +++ b/plugins/powermenu/src/actions.rs @@ -0,0 +1,103 @@ +use abi_stable::std_types::ROption; +use anyrun_plugin::Match; +use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +#[derive(Clone, Copy, IntoPrimitive, TryFromPrimitive)] +#[repr(u64)] +pub enum PowerAction { + Lock, + Logout, + Poweroff, + Reboot, + Suspend, + Hibernate, +} + +impl PowerAction { + const VALUES: [Self; 6] = [ + Self::Lock, + Self::Logout, + Self::Poweroff, + Self::Reboot, + Self::Suspend, + Self::Hibernate, + ]; + + pub const fn get_title(&self) -> &str { + match self { + Self::Lock => "Lock", + Self::Logout => "Log out", + Self::Poweroff => "Power off", + Self::Reboot => "Reboot", + Self::Suspend => "Suspend", + Self::Hibernate => "Hibernate", + } + } + pub const fn get_description(&self) -> &str { + match self { + Self::Lock => "Lock the session screen", + Self::Logout => "Terminate the session", + Self::Poweroff => "Shut down the system", + Self::Reboot => "Restart the system", + Self::Suspend => "Suspend the system to RAM", + Self::Hibernate => "Suspend the system to disk", + } + } + + pub const fn get_icon_name(&self) -> &str { + match self { + Self::Lock => "system-lock-screen", + Self::Logout => "system-log-out", + Self::Poweroff => "system-shutdown", + Self::Reboot => "system-reboot", + Self::Suspend => "system-suspend", + Self::Hibernate => "system-suspend-hibernate", + } + } + + pub fn as_match(self) -> Match { + Match { + title: self.get_title().into(), + icon: ROption::RSome(self.get_icon_name().into()), + use_pango: false, + description: ROption::RSome(self.get_description().into()), + id: ROption::RSome(self.into()), + } + } + + pub fn get_fuzzy_matching_values(phrase: &str) -> impl Iterator { + let fuzzy_matcher = SkimMatcherV2::default().ignore_case(); + let mut matches_with_scores = Self::VALUES + .into_iter() + .filter_map(|action| { + action + .get_fuzzy_score(&fuzzy_matcher, phrase) + .map(|score| (action, score)) + }) + .collect::>(); + matches_with_scores.sort_by_key(|(_action, score)| *score); + matches_with_scores + .into_iter() + .map(|(action, _score)| action) + } + + fn get_fuzzy_score(self, matcher: &impl FuzzyMatcher, phrase: &str) -> Option { + matcher + .fuzzy_match(self.get_title(), phrase) + .max(matcher.fuzzy_match(self.get_description(), phrase)) + } +} + +#[derive(PartialEq, Eq, IntoPrimitive, TryFromPrimitive)] +#[repr(u64)] +pub enum ConfirmAction { + Confirm, + Cancel, +} + +impl ConfirmAction { + pub fn is_confirmed(&self) -> bool { + *self == Self::Confirm + } +} diff --git a/plugins/powermenu/src/config.rs b/plugins/powermenu/src/config.rs new file mode 100644 index 00000000..e83ba01e --- /dev/null +++ b/plugins/powermenu/src/config.rs @@ -0,0 +1,93 @@ +use serde::Deserialize; + +use crate::actions::PowerAction; + +#[derive(Deserialize, Default)] +pub struct PowerActionConfig { + pub command: String, + pub confirm: bool, +} + +#[derive(Deserialize)] +pub struct Config { + #[serde(default = "Config::default_lock_config")] + lock: PowerActionConfig, + #[serde(default = "Config::default_logout_config")] + logout: PowerActionConfig, + #[serde(default = "Config::default_poweroff_config")] + poweroff: PowerActionConfig, + #[serde(default = "Config::default_reboot_config")] + reboot: PowerActionConfig, + #[serde(default = "Config::default_suspend_config")] + suspend: PowerActionConfig, + #[serde(default = "Config::default_hibernate_config")] + hibernate: PowerActionConfig, +} + +impl Config { + fn default_lock_config() -> PowerActionConfig { + PowerActionConfig { + command: String::from("loginctl lock-session"), + confirm: false, + } + } + + fn default_logout_config() -> PowerActionConfig { + PowerActionConfig { + command: String::from("loginctl terminate-user $USER"), + confirm: true, + } + } + + fn default_poweroff_config() -> PowerActionConfig { + PowerActionConfig { + command: String::from("systemctl -i poweroff"), + confirm: true, + } + } + + fn default_reboot_config() -> PowerActionConfig { + PowerActionConfig { + command: String::from("systemctl -i reboot"), + confirm: true, + } + } + + fn default_suspend_config() -> PowerActionConfig { + PowerActionConfig { + command: String::from("systemctl -i suspend"), + confirm: false, + } + } + + fn default_hibernate_config() -> PowerActionConfig { + PowerActionConfig { + command: String::from("systemctl -i hibernate"), + confirm: false, + } + } + + pub const fn get_action_config(&self, action: PowerAction) -> &PowerActionConfig { + match action { + PowerAction::Lock => &self.lock, + PowerAction::Logout => &self.logout, + PowerAction::Poweroff => &self.poweroff, + PowerAction::Reboot => &self.reboot, + PowerAction::Suspend => &self.suspend, + PowerAction::Hibernate => &self.hibernate, + } + } +} + +impl Default for Config { + fn default() -> Self { + Self { + lock: Self::default_lock_config(), + logout: Self::default_logout_config(), + poweroff: Self::default_poweroff_config(), + reboot: Self::default_reboot_config(), + suspend: Self::default_suspend_config(), + hibernate: Self::default_hibernate_config(), + } + } +} diff --git a/plugins/powermenu/src/lib.rs b/plugins/powermenu/src/lib.rs new file mode 100644 index 00000000..41112d5e --- /dev/null +++ b/plugins/powermenu/src/lib.rs @@ -0,0 +1,147 @@ +#![allow(clippy::needless_pass_by_value, clippy::wildcard_imports)] +mod actions; +mod config; + +use core::str; +use std::{ + fs, + io::Error, + process::{Command, Output}, +}; + +use abi_stable::std_types::{ROption, RString, RVec}; +use anyrun_plugin::*; +use ron::Result; + +use actions::{ConfirmAction, PowerAction}; +use config::{Config, PowerActionConfig}; + +pub struct State { + config: Config, + pending_action: Option, + error_message: Option, +} + +#[init] +fn init(config_dir: RString) -> State { + let config = fs::read_to_string(format!("{config_dir}/powermenu.ron")) + .map_or(Config::default(), |content| { + ron::from_str(&content).unwrap_or_default() + }); + + State { + config, + pending_action: None, + error_message: None, + } +} + +#[info] +fn info() -> PluginInfo { + PluginInfo { + name: "Power menu".into(), + icon: "computer".into(), + } +} + +#[get_matches] +fn get_matches(input: RString, state: &State) -> RVec { + if input.is_empty() { + vec![] + } else if let Some(ref error_message) = state.error_message { + get_error_matches(error_message) + } else if let Some(pending_action) = state.pending_action { + get_confirm_matches(pending_action) + } else { + PowerAction::get_fuzzy_matching_values(&input) + .map(PowerAction::as_match) + .collect() + } + .into() +} + +fn get_error_matches(error_message: &str) -> Vec { + vec![Match { + title: "ERROR!".into(), + icon: ROption::RSome("dialog-error".into()), + use_pango: false, + description: ROption::RSome(error_message.into()), + id: ROption::RSome(ConfirmAction::Confirm.into()), + }] +} + +fn get_confirm_matches(action_to_confirm: PowerAction) -> Vec { + vec![ + Match { + title: action_to_confirm.get_title().into(), + icon: ROption::RSome("go-next".into()), + use_pango: false, + description: ROption::RSome("Proceed with the selected action".into()), + id: ROption::RSome(ConfirmAction::Confirm.into()), + }, + Match { + title: "Cancel".into(), + icon: ROption::RSome("go-previous".into()), + use_pango: false, + description: ROption::RSome("Abort the selected action".into()), + id: ROption::RSome(ConfirmAction::Cancel.into()), + }, + ] +} + +#[handler] +fn handler(selection: Match, state: &mut State) -> HandleResult { + if state.error_message.is_some() { + return HandleResult::Close; + } + + let power_action_config = if let Some(ref pending_action) = state.pending_action { + let confirm_action = ConfirmAction::try_from(selection.id.unwrap()).unwrap(); + + if !confirm_action.is_confirmed() { + state.pending_action = None; + return HandleResult::Refresh(false); + } + + state.config.get_action_config(*pending_action) + } else { + let power_action = PowerAction::try_from(selection.id.unwrap()).unwrap(); + let power_action_config = state.config.get_action_config(power_action); + + if power_action_config.confirm { + state.pending_action = Some(power_action); + return HandleResult::Refresh(true); + }; + + power_action_config + }; + + let action_result = execute_power_action(power_action_config); + let error_message = get_error_message(action_result); + if error_message.is_some() { + state.error_message = error_message; + return HandleResult::Refresh(true); + } + + HandleResult::Close +} + +fn execute_power_action(action: &PowerActionConfig) -> Result { + Command::new("/usr/bin/env") + .arg("sh") + .arg("-c") + .arg(&action.command) + .output() +} + +fn get_error_message(command_result: Result) -> Option { + match command_result { + Err(err) => Some(format!("Could not run command: {err}")), + Ok(output) if !output.status.success() => Some(format!( + "{}, stderr: {}", + output.status, + String::from_utf8_lossy(output.stderr.as_ref()) + )), + Ok(_) => None, + } +}