diff --git a/src/gui/widget/power_menu/custom.rs b/src/gui/widget/power_menu/custom.rs new file mode 100644 index 0000000..28d7ecf --- /dev/null +++ b/src/gui/widget/power_menu/custom.rs @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2025 max-ishere <47008271+max-ishere@users.noreply.github.com> +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! A fully customizable power menu widget. + +use adw::glib::markup_escape_text; +use relm4::{adw::prelude::*, prelude::*}; +use tokio::process; + +use crate::{ + demo, fl, + gui::{widget::power_menu::header_label, GAP}, + i18n::lowercase_first_char, +}; + +#[derive(Deserialize, Clone)] + +pub struct CustomPowerMenuConfig { + commands: Vec, +} + +#[derive(Deserialize, Clone)] +pub struct Command { + /// The title of the action. + #[serde(flatten)] + title: Title, + + /// Command to be executed. + #[serde(alias = "cmd")] + command: Vec, + + /// If `true`, a confirmation will be shown before the [`Self::command`] is executed. + /// + /// If [`Some`], use the value. Otherwise, try to derive the value from the [`Title::Action`], falling back to + /// `false`. Actions that involve a poweroff are inferred to require confirmation. + confirm: Option, + + /// The icon name to set for this action. A list of installed icons can be looked up using the `icon-library` app. + icon: Option, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum Title { + /// One of the `i18n`-supported actions + Action(Action), + + /// Arbitrary name string + Name(String), +} + +#[derive(Deserialize, Clone, Copy)] +#[serde(rename_all = "kebab-case")] +pub enum Action { + Poweroff, + Halt, + Reboot, + RebootFirmware, + Suspend, + Hibernate, + HybridSleep, +} + +pub struct CustomPowerMenu { + state: MenuState, + commands: Vec, +} + +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum MenuState { + Menu, + Confirm(usize), +} + +#[derive(Debug)] +pub enum CustomPowerMenuMsg { + Request(usize), + Confirm, + Cancel, +} + +#[relm4::component(pub, async)] +impl AsyncComponent for CustomPowerMenu { + type Init = CustomPowerMenuConfig; + type Input = CustomPowerMenuMsg; + type Output = (); + type CommandOutput = (); + + view! { + gtk::Box { + match model.state { + MenuState::Menu => gtk::Box::new(gtk::Orientation::Vertical, GAP) { + header_label("Custom") {}, + + #[iterate] + append: &action_buttons(&model.commands, sender.clone()), + }, + + MenuState::Confirm(index) =>>k::Box::new(gtk::Orientation::Vertical, GAP) { + gtk::Label { + #[watch] + set_markup: &format!("{}", + markup_escape_text(&fl!("power-menu-confirm-dialog-heading", + what = lowercase_first_char(&model.commands[index].fl())) + ) + ), + }, + + gtk::Box { + set_spacing:GAP, + + gtk::Button::with_label(&fl!("dialog-cancel")) { + connect_clicked => CustomPowerMenuMsg::Cancel, + }, + + gtk::Button { + add_css_class: "destructive-action", + + #[watch] + set_label: &model.commands[index].fl(), + + connect_clicked => CustomPowerMenuMsg::Confirm, + } + } + } + } + } + } + + async fn init( + CustomPowerMenuConfig { commands }: Self::Init, + root: Self::Root, + sender: relm4::AsyncComponentSender, + ) -> AsyncComponentParts { + let model = Self { + commands, + state: MenuState::Menu, + }; + let widgets = view_output!(); + + AsyncComponentParts { model, widgets } + } + + async fn update( + &mut self, + message: Self::Input, + sender: AsyncComponentSender, + _: &Self::Root, + ) { + use CustomPowerMenuMsg as M; + let index = match message { + M::Request(index) => index, + + M::Confirm => { + let MenuState::Confirm(what) = self.state else { + return; + }; + what + } + + M::Cancel => { + self.state = MenuState::Menu; + return; + } + }; + + let command = &self.commands[index]; + + use Action as A; + let require_confirm = self.state == MenuState::Menu + && (command.confirm == Some(true) + || matches!( + command.title, + Title::Action(A::Poweroff | A::Reboot | A::RebootFirmware) + )); + + if require_confirm { + self.state = MenuState::Confirm(index); + return; + } + + let fl = command.fl(); + + if demo() { + info!("Demo mode: not doing {fl}"); + self.state = MenuState::Menu; + + return; + } + + let command = process::Command::new(&command.command[0]) + .args(&command.command[1..]) + .status(); + + sender.oneshot_command(async move { + let Err(why) = command.await else { return }; + debug!("Failed to {fl}: {why}"); + }); + } +} + +fn action_buttons( + commands: &[Command], + sender: AsyncComponentSender, +) -> Vec { + let len = commands.len(); + + commands + .iter() + .enumerate() + .fold(Vec::with_capacity(len), |mut acc, (index, command)| { + let button = gtk::Button::new(); + + if let Some(icon_name) = command.icon() { + let icon = gtk::Image::new(); + icon.set_icon_name(Some(icon_name)); + + let label = gtk::Label::new(Some(&command.fl())); + + let container = gtk::Box::new(gtk::Orientation::Horizontal, GAP); + container.append(&icon); + container.append(&label); + + button.set_child(Some(&container)); + } else { + button.set_label(&command.fl()); + } + + let sender = sender.clone(); + button.connect_clicked(move |_| sender.input(CustomPowerMenuMsg::Request(index))); + + acc.push(button); + acc + }) +} + +impl Command { + fn fl(&self) -> String { + let action = match self.title { + Title::Action(action) => action, + + Title::Name(ref name) => return name.clone(), + }; + + use Action as A; + match action { + A::Poweroff => fl!("power-menu-poweroff"), + A::Halt => fl!("power-menu-halt"), + A::Reboot => fl!("power-menu-reboot"), + A::RebootFirmware => fl!("power-menu-reboot-firmware"), + A::Suspend => fl!("power-menu-suspend"), + A::Hibernate => fl!("power-menu-hibernate"), + A::HybridSleep => fl!("power-menu-hybrid-sleep"), + } + } + + fn icon(&self) -> Option<&str> { + let None = self.icon else { + return self.icon.as_deref(); + }; + + let Title::Action(action) = self.title else { + return None; + }; + + use Action as A; + Some(match action { + A::Poweroff => crate::gui::icons::POWEROFF, + A::Halt => crate::gui::icons::POWEROFF, + A::Reboot => crate::gui::icons::REBOOT, + A::RebootFirmware => crate::gui::icons::REBOOT_FIRMWARE, + A::Suspend => crate::gui::icons::SUSPEND, + A::Hibernate => crate::gui::icons::HIBERNATE, + A::HybridSleep => crate::gui::icons::HIBERNATE, + }) + } +} diff --git a/src/gui/widget/power_menu/mod.rs b/src/gui/widget/power_menu/mod.rs index 7793f1a..0a2e26d 100644 --- a/src/gui/widget/power_menu/mod.rs +++ b/src/gui/widget/power_menu/mod.rs @@ -4,6 +4,8 @@ //! A [serde-configurable][`PowerMenuConfig`] power menu. +use adw::glib::markup_escape_text; +use custom::{CustomPowerMenu, CustomPowerMenuConfig}; use relm4::prelude::*; use serde::Deserialize; use systemd::{SystemdPowerMenu, SystemdPowerMenuConfig}; @@ -11,6 +13,7 @@ use unix::{UnixPowerMenu, UnixPowerMenuConfig}; use crate::{fl, gui::icons}; +mod custom; mod systemd; mod unix; @@ -20,6 +23,7 @@ pub enum PowerMenuConfig { /// Systemd-aware widget Systemd(SystemdPowerMenuConfig), Unix(UnixPowerMenuConfig), + Custom(CustomPowerMenuConfig), } impl Default for PowerMenuConfig { @@ -31,6 +35,7 @@ impl Default for PowerMenuConfig { pub enum PowerMenu { Systemd(AsyncController), Unix(AsyncController), + Custom(AsyncController), } #[relm4::component(pub)] @@ -67,6 +72,12 @@ impl Component for PowerMenu { .launch(unix_power_menu_config) .detach(), ), + + Self::Init::Custom(custom_power_menu_config) => Self::Custom( + CustomPowerMenu::builder() + .launch(custom_power_menu_config) + .detach(), + ), }; let widgets = view_output!(); @@ -80,6 +91,17 @@ impl PowerMenu { match self { Self::Systemd(controller) => controller.widget(), Self::Unix(controller) => controller.widget(), + Self::Custom(controller) => controller.widget(), } } } + +fn header_label(backend: &str) -> gtk::Label { + let label = gtk::Label::new(None); + label.set_markup(&format!( + "{}\nBackend: {backend}", + markup_escape_text(&fl!("power-menu-tooltip")) + )); + + label +} diff --git a/src/gui/widget/power_menu/systemd.rs b/src/gui/widget/power_menu/systemd.rs index fec8c60..dfda05a 100644 --- a/src/gui/widget/power_menu/systemd.rs +++ b/src/gui/widget/power_menu/systemd.rs @@ -15,7 +15,11 @@ use relm4::{ }; use tokio::process::Command; -use crate::{demo, fl, gui::GAP, i18n::lowercase_first_char}; +use crate::{ + demo, fl, + gui::{widget::power_menu::header_label, GAP}, + i18n::lowercase_first_char, +}; #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] #[serde(rename_all = "kebab-case")] @@ -83,9 +87,7 @@ impl AsyncComponent for SystemdPowerMenu { #[transition(Crossfade)] match model { Self::Menu => >k::Box::new(gtk::Orientation::Vertical, GAP) { - gtk::Label { - set_markup: &format!("{} (Systemd)", fl!("power-menu-tooltip")), - }, + header_label("Systemd") {}, #[iterate] append: &action_buttons(actions, sender.clone()), diff --git a/src/gui/widget/power_menu/unix.rs b/src/gui/widget/power_menu/unix.rs index f77d614..168f1be 100644 --- a/src/gui/widget/power_menu/unix.rs +++ b/src/gui/widget/power_menu/unix.rs @@ -13,7 +13,7 @@ use tokio::process::Command; use crate::{ demo, fl, - gui::{icons, GAP}, + gui::{icons, widget::power_menu::header_label, GAP}, i18n::lowercase_first_char, }; @@ -57,9 +57,7 @@ impl AsyncComponent for UnixPowerMenu { #[transition(Crossfade)] match model { Self::Menu => >k::Box::new(gtk::Orientation::Vertical, GAP) { - gtk::Label { - set_markup: &format!("{} (Unix)", fl!("power-menu-tooltip")) - }, + header_label("Unix") {}, #[iterate] append: &action_buttons(actions, sender.clone()),