1
0
Fork 0
mirror of https://github.com/Zedfrigg/ironbar.git synced 2025-07-01 02:31:04 +02:00

feat: common module options (show_if, on_click, tooltip)

The first three of many options that are common to all modules.

Resolves #36. Resolves partially #34.
This commit is contained in:
Jake Stanger 2022-11-28 21:55:08 +00:00
parent a3f90adaf1
commit c9e66d4664
No known key found for this signature in database
GPG key ID: C51FC8F9CB0BEA61
15 changed files with 600 additions and 125 deletions

View file

@ -6,7 +6,8 @@ use crate::modules::mpd::{PlayerCommand, SongUpdate};
use crate::modules::workspaces::WorkspaceUpdate;
use crate::modules::{Module, ModuleInfoBuilder, ModuleLocation, ModuleUpdateEvent, WidgetContext};
use crate::popup::Popup;
use crate::Config;
use crate::script::{OutputStream, Script};
use crate::{await_sync, Config};
use chrono::{DateTime, Local};
use color_eyre::Result;
use gtk::gdk::Monitor;
@ -16,8 +17,9 @@ use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use stray::message::NotifierItemCommand;
use stray::NotifierItemMessage;
use tokio::spawn;
use tokio::sync::mpsc;
use tracing::{debug, info};
use tracing::{debug, error, info, trace};
/// Creates a new window for a bar,
/// sets it up and adds its widgets.
@ -81,7 +83,11 @@ pub fn create_bar(
});
debug!("Showing bar");
win.show_all();
start.show();
center.show();
end.show();
content.show();
win.show();
Ok(())
}
@ -155,11 +161,60 @@ fn add_modules(
controller_tx: ui_tx,
};
let common = $module.common.clone();
let widget = $module.into_widget(context, &info)?;
content.add(&widget.widget);
let container = gtk::EventBox::new();
container.add(&widget.widget);
content.add(&container);
widget.widget.set_widget_name(info.module_name);
if let Some(show_if) = common.show_if {
let script = Script::new_polling(show_if);
let container = container.clone();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move {
script
.run(|(_, success)| {
tx.send(success)
.expect("Failed to send widget visibility toggle message");
})
.await;
});
rx.attach(None, move |success| {
if success {
container.show_all()
} else {
container.hide()
};
Continue(true)
});
} else {
container.show_all();
}
if let Some(on_click) = common.on_click {
let script = Script::new_polling(on_click);
container.connect_button_press_event(move |_, _| {
trace!("Running on-click script");
match await_sync(async { script.get_output().await }) {
Ok((OutputStream::Stderr(out), _)) => error!("{out}"),
Err(err) => error!("{err:?}"),
_ => {}
}
Inhibit(false)
});
}
if let Some(tooltip) = common.tooltip {
container.set_tooltip_text(Some(&tooltip));
}
let has_popup = widget.popup.is_some();
if let Some(popup_content) = widget.popup {
popup

View file

@ -7,6 +7,7 @@ use crate::modules::script::ScriptModule;
use crate::modules::sysinfo::SysInfoModule;
use crate::modules::tray::TrayModule;
use crate::modules::workspaces::WorkspacesModule;
use crate::script::ScriptInput;
use color_eyre::eyre::{Context, ContextCompat};
use color_eyre::{eyre, Help, Report};
use dirs::config_dir;
@ -18,6 +19,13 @@ use std::path::{Path, PathBuf};
use std::{env, fs};
use tracing::instrument;
#[derive(Debug, Deserialize, Clone)]
pub struct CommonConfig {
pub show_if: Option<ScriptInput>,
pub on_click: Option<ScriptInput>,
pub tooltip: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ModuleConfig {

View file

@ -1,3 +1,4 @@
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::Popup;
use chrono::{DateTime, Local};
@ -18,7 +19,10 @@ pub struct ClockModule {
/// Detail on available tokens can be found here:
/// <https://docs.rs/chrono/latest/chrono/format/strftime/index.html>
#[serde(default = "default_format")]
pub(crate) format: String,
format: String,
#[serde(flatten)]
pub common: CommonConfig,
}
fn default_format() -> String {

View file

@ -1,7 +1,8 @@
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::{ButtonGeometry, Popup};
use crate::script::exec_command;
use crate::config::CommonConfig;
use color_eyre::{Report, Result};
use crate::script::Script;
use gtk::prelude::*;
use gtk::{Button, Label, Orientation};
use serde::Deserialize;
@ -17,6 +18,9 @@ pub struct CustomModule {
bar: Vec<Widget>,
/// Widgets to add to the popup container
popup: Option<Vec<Widget>>,
#[serde(flatten)]
pub common: CommonConfig,
}
/// Attempts to parse an `Orientation` from `String`
@ -164,8 +168,11 @@ impl Module<gtk::Box> for CustomModule {
spawn(async move {
while let Some(event) = rx.recv().await {
if event.cmd.starts_with('!') {
debug!("executing command: '{}'", &event.cmd[1..]);
if let Err(err) = exec_command(&event.cmd[1..]) {
let script = Script::from(&event.cmd[1..]);
debug!("executing command: '{}'", script.cmd);
// TODO: Migrate to use script.run
if let Err(err) = script.get_output().await {
error!("{err:?}");
}
} else if event.cmd == "popup:toggle" {

View file

@ -1,4 +1,5 @@
use crate::clients::wayland::{self, ToplevelChange};
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::{await_sync, icon};
use color_eyre::Result;
@ -23,6 +24,9 @@ pub struct FocusedModule {
icon_size: i32,
/// GTK icon theme to use.
icon_theme: Option<String>,
#[serde(flatten)]
pub common: CommonConfig,
}
const fn default_icon_size() -> i32 {

View file

@ -4,6 +4,7 @@ mod open_state;
use self::item::{Item, ItemButton, Window};
use self::open_state::OpenState;
use crate::clients::wayland::{self, ToplevelChange};
use crate::config::CommonConfig;
use crate::icon::find_desktop_file;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use color_eyre::{Help, Report};
@ -33,6 +34,9 @@ pub struct LauncherModule {
/// Name of the GTK icon theme to use.
icon_theme: Option<String>,
#[serde(flatten)]
pub common: CommonConfig,
}
#[derive(Debug, Clone)]

View file

@ -1,4 +1,5 @@
use crate::clients::mpd::{get_client, get_duration, get_elapsed, MpdConnectionError};
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::Popup;
use color_eyre::Result;
@ -65,6 +66,9 @@ pub struct MpdModule {
/// Path to root of music directory.
#[serde(default = "default_music_dir")]
music_dir: PathBuf,
#[serde(flatten)]
pub common: CommonConfig,
}
fn default_socket() -> String {

View file

@ -1,39 +1,32 @@
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::script::exec_command;
use crate::script::{OutputStream, Script, ScriptMode};
use color_eyre::{Help, Report, Result};
use gtk::prelude::*;
use gtk::Label;
use serde::Deserialize;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::spawn;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::time::sleep;
use tokio::{select, spawn};
use tracing::error;
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
enum Mode {
Poll,
Watch,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ScriptModule {
/// Path to script to execute.
path: String,
/// Script execution mode
#[serde(default = "default_mode")]
mode: Mode,
mode: ScriptMode,
/// Time in milliseconds between executions.
#[serde(default = "default_interval")]
interval: u64,
#[serde(flatten)]
pub common: CommonConfig,
}
/// `Mode::Poll`
const fn default_mode() -> Mode {
Mode::Poll
const fn default_mode() -> ScriptMode {
ScriptMode::Poll
}
/// 5000ms
@ -41,6 +34,16 @@ const fn default_interval() -> u64 {
5000
}
impl From<&ScriptModule> for Script {
fn from(module: &ScriptModule) -> Self {
Self {
mode: module.mode,
cmd: module.path.clone(),
interval: module.interval,
}
}
}
impl Module<Label> for ScriptModule {
type SendMessage = String;
type ReceiveMessage = ();
@ -51,78 +54,22 @@ impl Module<Label> for ScriptModule {
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
_rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let interval = self.interval;
let path = self.path.clone();
let script: Script = self.into();
match self.mode {
Mode::Poll => spawn(async move {
loop {
match exec_command(&path) {
Ok(stdout) => tx
.send(ModuleUpdateEvent::Update(stdout))
.await
.expect("Failed to send stdout"),
Err(err) => error!("{:?}", err),
}
sleep(tokio::time::Duration::from_millis(interval)).await;
}
}),
Mode::Watch => spawn(async move {
loop {
let mut handle = Command::new("sh")
.args(["-c", &path])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.spawn()
.expect("Failed to spawn process");
let mut stdout_lines = BufReader::new(
handle
.stdout
.take()
.expect("Failed to take script handle stdout"),
)
.lines();
let mut stderr_lines = BufReader::new(
handle
.stderr
.take()
.expect("Failed to take script handle stderr"),
)
.lines();
loop {
select! {
_ = handle.wait() => break,
Ok(Some(line)) = stdout_lines.next_line() => {
tx.send(ModuleUpdateEvent::Update(line.to_string()))
.await
.expect("Failed to send stdout");
}
Ok(Some(line)) = stderr_lines.next_line() => {
error!("{:?}", Report::msg(line)
spawn(async move {
script.run(move |(out, _)| match out {
OutputStream::Stdout(stdout) => {
tx.try_send(ModuleUpdateEvent::Update(stdout))
.expect("Failed to send stdout"); }
OutputStream::Stderr(stderr) => {
error!("{:?}", Report::msg(stderr)
.wrap_err("Watched script error:")
.suggestion("Check the path to your script")
.suggestion("Check the script for errors")
.suggestion("If you expect the script to write to stderr, consider redirecting its output to /dev/null to suppress these messages")
)
}
}
}
while let Ok(Some(line)) = stdout_lines.next_line().await {
tx.send(ModuleUpdateEvent::Update(line.to_string()))
.await
.expect("Failed to send stdout");
}
sleep(tokio::time::Duration::from_millis(interval)).await;
}
}),
};
.suggestion("If you expect the script to write to stderr, consider redirecting its output to /dev/null to suppress these messages"));
}
}).await;
});
Ok(())
}

View file

@ -1,3 +1,4 @@
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use color_eyre::Result;
use gtk::prelude::*;
@ -19,6 +20,9 @@ pub struct SysInfoModule {
/// Number of seconds between refresh
#[serde(default = "Interval::default")]
interval: Interval,
#[serde(flatten)]
pub common: CommonConfig,
}
#[derive(Debug, Deserialize, Copy, Clone)]

View file

@ -1,5 +1,6 @@
use crate::await_sync;
use crate::clients::system_tray::get_tray_event_client;
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use color_eyre::Result;
use gtk::prelude::*;
@ -14,7 +15,10 @@ use tokio::sync::mpsc;
use tokio::sync::mpsc::{Receiver, Sender};
#[derive(Debug, Deserialize, Clone)]
pub struct TrayModule;
pub struct TrayModule {
#[serde(flatten)]
pub common: CommonConfig,
}
/// Gets a GTK `Image` component
/// for the status notifier item's icon.

View file

@ -1,5 +1,6 @@
use crate::await_sync;
use crate::clients::sway::{get_client, get_sub_client};
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use color_eyre::{Report, Result};
use gtk::prelude::*;
@ -19,6 +20,9 @@ pub struct WorkspacesModule {
/// Whether to display buttons for all monitors.
#[serde(default = "crate::config::default_false")]
all_monitors: bool,
#[serde(flatten)]
pub common: CommonConfig,
}
#[derive(Clone, Debug)]

View file

@ -1,36 +1,354 @@
use color_eyre::eyre::WrapErr;
use color_eyre::{Help, Report, Result};
use std::process::Command;
use tracing::instrument;
use color_eyre::{Report, Result};
use serde::Deserialize;
use std::fmt::{Display, Formatter};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::time::sleep;
use tokio::{select, spawn};
use tracing::{error, warn};
/// Attempts to execute a given command,
/// waiting for it to finish.
/// If the command returns status 0,
/// the `stdout` is returned.
/// Otherwise, an `Err` variant
/// containing the `stderr` is returned.
#[instrument]
pub fn exec_command(command: &str) -> Result<String> {
let output = Command::new("sh")
.arg("-c")
.arg(command)
.output()
.wrap_err("Failed to get script output")?;
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum ScriptInput {
String(String),
Struct(Script),
}
if output.status.success() {
let stdout = String::from_utf8(output.stdout)
.map(|output| output.trim().to_string())
.wrap_err("Script stdout not valid UTF-8")?;
#[derive(Debug, Deserialize, Clone, Copy, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ScriptMode {
Poll,
Watch,
}
Ok(stdout)
} else {
let stderr = String::from_utf8(output.stderr)
.map(|output| output.trim().to_string())
.wrap_err("Script stderr not valid UTF-8")?;
#[derive(Debug, Clone)]
pub enum OutputStream {
Stdout(String),
Stderr(String),
}
Err(Report::msg(stderr)
.wrap_err("Script returned non-zero error code")
.suggestion("Check the path to your script")
.suggestion("Check the script for errors"))
impl From<&str> for ScriptMode {
fn from(str: &str) -> Self {
match str {
"poll" | "p" => Self::Poll,
"watch" | "w" => Self::Watch,
_ => {
warn!("Invalid script mode: '{str}', falling back to polling");
ScriptMode::Poll
}
}
}
}
impl Default for ScriptMode {
fn default() -> Self {
Self::Poll
}
}
impl Display for ScriptMode {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
ScriptMode::Poll => "poll",
ScriptMode::Watch => "watch",
}
)
}
}
impl ScriptMode {
fn try_parse(str: &str) -> Result<Self> {
match str {
"poll" | "p" => Ok(Self::Poll),
"watch" | "w" => Ok(Self::Watch),
_ => Err(Report::msg(format!("Invalid script mode: {str}"))),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct Script {
#[serde(default = "ScriptMode::default")]
pub(crate) mode: ScriptMode,
pub cmd: String,
#[serde(default = "default_interval")]
pub(crate) interval: u64,
}
const fn default_interval() -> u64 {
5000
}
impl Default for Script {
fn default() -> Self {
Self {
mode: ScriptMode::default(),
interval: default_interval(),
cmd: String::new(),
}
}
}
impl From<ScriptInput> for Script {
fn from(input: ScriptInput) -> Self {
match input {
ScriptInput::String(string) => Self::from(string.as_str()),
ScriptInput::Struct(script) => script,
}
}
}
#[derive(Debug)]
enum ScriptInputToken {
Mode(ScriptMode),
Interval(u64),
Cmd(String),
Colon,
}
impl From<&str> for Script {
fn from(str: &str) -> Self {
let mut script = Self::default();
let mut tokens = vec![];
let mut chars = str.chars().collect::<Vec<_>>();
while !chars.is_empty() {
let char = chars[0];
let (token, skip) = match char {
':' => (ScriptInputToken::Colon, 1),
// interval
'0'..='9' => {
let interval_str = chars
.iter()
.take_while(|c| c.is_ascii_digit())
.collect::<String>();
(
ScriptInputToken::Interval(
interval_str.parse::<u64>().expect("Invalid interval"),
),
interval_str.len(),
)
}
// watching or polling
'w' | 'p' => {
let mode_str = chars.iter().take_while(|&c| c != &':').collect::<String>();
let len = mode_str.len();
let token = ScriptMode::try_parse(&mode_str)
.map_or(ScriptInputToken::Cmd(mode_str), |mode| {
ScriptInputToken::Mode(mode)
});
(token, len)
}
_ => {
let cmd_str = chars.iter().take_while(|_| true).collect::<String>();
let len = cmd_str.len();
(ScriptInputToken::Cmd(cmd_str), len)
}
};
tokens.push(token);
chars.drain(..skip);
}
for token in tokens {
match token {
ScriptInputToken::Mode(mode) => script.mode = mode,
ScriptInputToken::Interval(interval) => script.interval = interval,
ScriptInputToken::Cmd(cmd) => script.cmd = cmd,
ScriptInputToken::Colon => {}
}
}
script
}
}
impl Script {
pub fn new_polling(input: ScriptInput) -> Self {
let mut script = Self::from(input);
script.mode = ScriptMode::Poll;
script
}
pub async fn run<F>(&self, callback: F)
where
F: Fn((OutputStream, bool)),
{
loop {
match self.mode {
ScriptMode::Poll => match self.get_output().await {
Ok(output) => callback(output),
Err(err) => error!("{err:?}"),
},
ScriptMode::Watch => match self.spawn().await {
Ok(mut rx) => {
while let Some(msg) = rx.recv().await {
callback((msg, true));
}
}
Err(err) => error!("{err:?}"),
},
};
sleep(tokio::time::Duration::from_millis(self.interval)).await;
}
}
/// Attempts to execute a given command,
/// waiting for it to finish.
/// If the command returns status 0,
/// the `stdout` is returned.
/// Otherwise, an `Err` variant
/// containing the `stderr` is returned.
pub async fn get_output(&self) -> Result<(OutputStream, bool)> {
let output = Command::new("sh")
.args(["-c", &self.cmd])
.output()
.await
.wrap_err("Failed to get script output")?;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)
.map(|output| output.trim().to_string())
.wrap_err("Script stdout not valid UTF-8")?;
Ok((OutputStream::Stdout(stdout), true))
} else {
let stderr = String::from_utf8(output.stderr)
.map(|output| output.trim().to_string())
.wrap_err("Script stderr not valid UTF-8")?;
Ok((OutputStream::Stderr(stderr), false))
}
}
pub async fn spawn(&self) -> Result<mpsc::Receiver<OutputStream>> {
let mut handle = Command::new("sh")
.args(["-c", &self.cmd])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.spawn()?;
let mut stdout_lines = BufReader::new(
handle
.stdout
.take()
.expect("Failed to take script handle stdout"),
)
.lines();
let mut stderr_lines = BufReader::new(
handle
.stderr
.take()
.expect("Failed to take script handle stderr"),
)
.lines();
let (tx, rx) = mpsc::channel(32);
spawn(async move {
loop {
select! {
_ = handle.wait() => break,
Ok(Some(line)) = stdout_lines.next_line() => {
tx.send(OutputStream::Stdout(line)).await.expect("Failed to send stdout");
}
Ok(Some(line)) = stderr_lines.next_line() => {
tx.send(OutputStream::Stderr(line)).await.expect("Failed to send stderr");
}
}
}
});
Ok(rx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_basic() {
let cmd = "echo 'hello'";
let script = Script::from(cmd);
assert_eq!(script.cmd, cmd);
assert_eq!(script.interval, default_interval());
assert_eq!(script.mode, ScriptMode::default());
}
#[test]
fn test_parse_full() {
let cmd = "echo 'hello'";
let mode = ScriptMode::Watch;
let interval = 300;
let full_cmd = format!("{mode}:{interval}:{cmd}");
let script = Script::from(full_cmd.as_str());
assert_eq!(script.cmd, cmd);
assert_eq!(script.mode, mode);
assert_eq!(script.interval, interval);
}
#[test]
fn test_parse_interval_and_cmd() {
let cmd = "echo 'hello'";
let interval = 300;
let full_cmd = format!("{interval}:{cmd}");
let script = Script::from(full_cmd.as_str());
assert_eq!(script.cmd, cmd);
assert_eq!(script.interval, interval);
assert_eq!(script.mode, ScriptMode::default());
}
#[test]
fn test_parse_mode_and_cmd() {
let cmd = "echo 'hello'";
let mode = ScriptMode::Watch;
let full_cmd = format!("{mode}:{cmd}");
let script = Script::from(full_cmd.as_str());
assert_eq!(script.cmd, cmd);
assert_eq!(script.interval, default_interval());
assert_eq!(script.mode, mode);
}
#[test]
fn test_parse_cmd_with_colon() {
let cmd = "uptime | awk '{print \"Uptime: \" $1}'";
let script = Script::from(cmd);
assert_eq!(script.cmd, cmd);
assert_eq!(script.interval, default_interval());
assert_eq!(script.mode, ScriptMode::default());
}
#[test]
fn test_no_cmd() {
let mode = ScriptMode::Watch;
let interval = 300;
let full_cmd = format!("{mode}:{interval}");
let script = Script::from(full_cmd.as_str());
assert_eq!(script.cmd, ""); // TODO: Probably better handle this case
assert_eq!(script.interval, interval);
assert_eq!(script.mode, mode);
}
}