1
0
Fork 0
mirror of https://github.com/Zedfrigg/ironbar.git synced 2025-09-16 03:36:58 +02:00

Merge branch 'master' into develop

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	src/clients/networkmanager.rs
#	src/modules/networkmanager.rs
#	src/modules/volume.rs
This commit is contained in:
Reinout Meliesie 2025-07-14 11:10:38 +02:00
commit c42024d48a
Signed by: zedfrigg
GPG key ID: 3AFCC06481308BC6
159 changed files with 13519 additions and 5655 deletions

View file

@ -1,16 +1,16 @@
use crate::Ironbar;
use crate::config::{BarConfig, BarPosition, MarginConfig, ModuleConfig};
use crate::modules::{BarModuleFactory, ModuleInfo, ModuleLocation};
use crate::popup::Popup;
use crate::Ironbar;
use color_eyre::Result;
use glib::Propagation;
use gtk::gdk::Monitor;
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, IconTheme, Orientation, Window, WindowType};
use gtk::{Application, ApplicationWindow, Orientation, Window, WindowType};
use gtk_layer_shell::LayerShell;
use std::rc::Rc;
use std::time::Duration;
use tracing::{debug, info};
use tracing::{debug, error, info};
#[derive(Debug, Clone)]
enum Inner {
@ -22,6 +22,7 @@ enum Inner {
pub struct Bar {
name: String,
monitor_name: String,
monitor_size: (i32, i32),
position: BarPosition,
ironbar: Rc<Ironbar>,
@ -41,6 +42,7 @@ impl Bar {
pub fn new(
app: &Application,
monitor_name: String,
monitor_size: (i32, i32),
config: BarConfig,
ironbar: Rc<Ironbar>,
) -> Self {
@ -89,6 +91,7 @@ impl Bar {
Self {
name,
monitor_name,
monitor_size,
position,
ironbar,
window,
@ -146,7 +149,7 @@ impl Bar {
}
}
let load_result = self.load_modules(config, monitor)?;
let load_result = self.load_modules(config, monitor, self.monitor_size)?;
self.show(!start_hidden);
@ -243,12 +246,12 @@ impl Bar {
}
/// Loads the configured modules onto a bar.
fn load_modules(&self, config: BarConfig, monitor: &Monitor) -> Result<BarLoadResult> {
let icon_theme = IconTheme::new();
if let Some(ref theme) = config.icon_theme {
icon_theme.set_custom_theme(Some(theme));
}
fn load_modules(
&self,
config: BarConfig,
monitor: &Monitor,
output_size: (i32, i32),
) -> Result<BarLoadResult> {
let app = &self.window.application().expect("to exist");
macro_rules! info {
@ -259,13 +262,17 @@ impl Bar {
monitor,
output_name: &self.monitor_name,
location: $location,
icon_theme: &icon_theme,
}
};
}
// popup ignores module location so can bodge this for now
let popup = Popup::new(&info!(ModuleLocation::Left), config.popup_gap);
let popup = Popup::new(
&self.ironbar,
&info!(ModuleLocation::Left),
output_size,
config.popup_gap,
);
let popup = Rc::new(popup);
if let Some(modules) = config.start {
@ -333,7 +340,7 @@ impl Bar {
/// Sets the window visibility status
pub fn set_visible(&self, visible: bool) {
self.window.set_visible(visible)
self.window.set_visible(visible);
}
pub fn set_exclusive(&self, exclusive: bool) {
@ -374,7 +381,10 @@ fn add_modules(
let module_factory = BarModuleFactory::new(ironbar.clone(), popup.clone()).into();
for config in modules {
config.create(&module_factory, content, info)?;
let name = config.name();
if let Err(err) = config.create(&module_factory, content, info) {
error!("failed to create module {name}: {:?}", err);
}
}
Ok(())
@ -384,9 +394,10 @@ pub fn create_bar(
app: &Application,
monitor: &Monitor,
monitor_name: String,
monitor_size: (i32, i32),
config: BarConfig,
ironbar: Rc<Ironbar>,
) -> Result<Bar> {
let bar = Bar::new(app, monitor_name, config, ironbar);
let bar = Bar::new(app, monitor_name, monitor_size, config, ironbar);
bar.init(monitor)
}

261
src/channels.rs Normal file
View file

@ -0,0 +1,261 @@
use crate::modules::ModuleUpdateEvent;
use crate::spawn;
use smithay_client_toolkit::reexports::calloop;
use std::fmt::Debug;
use tokio::sync::{broadcast, mpsc};
pub trait SyncSenderExt<T> {
/// Asynchronously sends a message on the channel,
/// panicking if it cannot be sent.
///
/// This should be used in cases where sending should *never* fail,
/// or where failing indicates a serious bug.
fn send_expect(&self, message: T);
}
impl<T> SyncSenderExt<T> for std::sync::mpsc::Sender<T> {
#[inline]
fn send_expect(&self, message: T) {
self.send(message).expect(crate::error::ERR_CHANNEL_SEND);
}
}
impl<T> SyncSenderExt<T> for calloop::channel::Sender<T> {
#[inline]
fn send_expect(&self, message: T) {
self.send(message).expect(crate::error::ERR_CHANNEL_SEND);
}
}
impl<T: Debug> SyncSenderExt<T> for broadcast::Sender<T> {
#[inline]
fn send_expect(&self, message: T) {
self.send(message).expect(crate::error::ERR_CHANNEL_SEND);
}
}
pub trait AsyncSenderExt<T>: Sync + Send + Sized + Clone {
/// Asynchronously sends a message on the channel,
/// panicking if it cannot be sent.
///
/// This should be used in cases where sending should *never* fail,
/// or where failing indicates a serious bug.
fn send_expect(&self, message: T) -> impl Future<Output = ()> + Send;
/// Asynchronously sends a message on the channel,
/// spawning a task to allow it to be sent in the background,
/// and panicking if it cannot be sent.
///
/// Note that this function will return *before* the message is sent.
///
/// This should be used in cases where sending should *never* fail,
/// or where failing indicates a serious bug.
#[inline]
fn send_spawn(&self, message: T)
where
Self: 'static,
T: Send + 'static,
{
let tx = self.clone();
spawn(async move { tx.send_expect(message).await });
}
/// Shorthand for [`AsyncSenderExt::send_expect`]
/// when sending a [`ModuleUpdateEvent::Update`].
#[inline]
async fn send_update<U: Clone>(&self, update: U)
where
Self: AsyncSenderExt<ModuleUpdateEvent<U>>,
{
self.send_expect(ModuleUpdateEvent::Update(update)).await;
}
/// Shorthand for [`AsyncSenderExt::send_spawn`]
/// when sending a [`ModuleUpdateEvent::Update`].
#[inline]
fn send_update_spawn<U>(&self, update: U)
where
Self: AsyncSenderExt<ModuleUpdateEvent<U>> + 'static,
U: Clone + Send + 'static,
{
self.send_spawn(ModuleUpdateEvent::Update(update));
}
}
impl<T: Send> AsyncSenderExt<T> for mpsc::Sender<T> {
#[inline]
async fn send_expect(&self, message: T) {
self.send(message)
.await
.expect(crate::error::ERR_CHANNEL_SEND);
}
}
pub trait MpscReceiverExt<T> {
/// Spawns a `GLib` future on the local thread, and calls `rx.recv()`
/// in a loop, passing the message to `f`.
///
/// This allows use of `GObjects` and futures in the same context.#
///
/// `deps` is a single reference, or tuple of references of clonable objects,
/// to be consumed inside the closure.
/// This avoids needing to `element.clone()` everywhere.
fn recv_glib<D, Fn>(self, deps: D, f: Fn)
where
D: Dependency,
D::Target: 'static,
Fn: FnMut(&D::Target, T) + 'static;
}
impl<T: 'static> MpscReceiverExt<T> for mpsc::Receiver<T> {
fn recv_glib<D, Fn>(mut self, deps: D, mut f: Fn)
where
D: Dependency,
D::Target: 'static,
Fn: FnMut(&D::Target, T) + 'static,
{
let deps = deps.clone_content();
glib::spawn_future_local(async move {
while let Some(val) = self.recv().await {
f(&deps, val);
}
});
}
}
pub trait BroadcastReceiverExt<T>
where
T: Debug + Clone + 'static,
{
/// Spawns a `GLib` future on the local thread, and calls `rx.recv()`
/// in a loop, passing the message to `f`.
///
/// This allows use of `GObjects` and futures in the same context.
///
/// `deps` is a single reference, or tuple of references of clonable objects,
/// to be consumed inside the closure.
/// This avoids needing to `element.clone()` everywhere.
fn recv_glib<D, Fn>(self, deps: D, f: Fn)
where
D: Dependency,
D::Target: 'static,
Fn: FnMut(&D::Target, T) + 'static;
/// Like [`BroadcastReceiverExt::recv_glib`], but the closure must return a [`Future`].
fn recv_glib_async<D, Fn, F>(self, deps: D, f: Fn)
where
D: Dependency,
D::Target: 'static,
Fn: FnMut(&D::Target, T) -> F + 'static,
F: Future;
}
impl<T> BroadcastReceiverExt<T> for broadcast::Receiver<T>
where
T: Debug + Clone + 'static,
{
fn recv_glib<D, Fn>(mut self, deps: D, mut f: Fn)
where
D: Dependency,
D::Target: 'static,
Fn: FnMut(&D::Target, T) + 'static,
{
let deps = deps.clone_content();
glib::spawn_future_local(async move {
loop {
match self.recv().await {
Ok(val) => f(&deps, val),
Err(broadcast::error::RecvError::Lagged(count)) => {
tracing::warn!(
"Channel lagged behind by {count}, this may result in unexpected or broken behaviour"
);
}
Err(err) => {
tracing::error!("{err:?}");
break;
}
}
}
});
}
fn recv_glib_async<D, Fn, F>(mut self, deps: D, mut f: Fn)
where
D: Dependency,
D::Target: 'static,
Fn: FnMut(&D::Target, T) -> F + 'static,
F: Future,
{
let deps = deps.clone_content();
glib::spawn_future_local(async move {
loop {
match self.recv().await {
Ok(val) => {
f(&deps, val).await;
}
Err(broadcast::error::RecvError::Lagged(count)) => {
tracing::warn!(
"Channel lagged behind by {count}, this may result in unexpected or broken behaviour"
);
}
Err(err) => {
tracing::error!("{err:?}");
break;
}
}
}
});
}
}
/// `recv_glib` callback dependency
/// or dependency tuple.
pub trait Dependency: Clone {
type Target;
fn clone_content(&self) -> Self::Target;
}
impl Dependency for () {
type Target = ();
fn clone_content(&self) -> Self::Target {}
}
impl<'a, T> Dependency for &'a T
where
T: Clone + 'a,
{
type Target = T;
fn clone_content(&self) -> T {
T::clone(self)
}
}
macro_rules! impl_dependency {
($($idx:tt $t:ident),+) => {
impl<'a, $($t),+> Dependency for ($(&'a $t),+)
where
$($t: Clone + 'a),+
{
type Target = ($($t),+);
fn clone_content(&self) -> Self::Target {
($(self.$idx.clone()),+)
}
}
};
}
impl_dependency!(0 T1, 1 T2);
impl_dependency!(0 T1, 1 T2, 2 T3);
impl_dependency!(0 T1, 1 T2, 2 T3, 3 T4);
impl_dependency!(0 T1, 1 T2, 2 T3, 3 T4, 4 T5);
impl_dependency!(0 T1, 1 T2, 2 T3, 3 T4, 4 T5, 5 T6);
impl_dependency!(0 T1, 1 T2, 2 T3, 3 T4, 4 T5, 5 T6, 6 T7);
impl_dependency!(0 T1, 1 T2, 2 T3, 3 T4, 4 T5, 5 T6, 6 T7, 7 T8);
impl_dependency!(0 T1, 1 T2, 2 T3, 3 T4, 4 T5, 5 T6, 6 T7, 7 T8, 8 T9);
impl_dependency!(0 T1, 1 T2, 2 T3, 3 T4, 4 T5, 5 T6, 6 T7, 7 T8, 8 T9, 9 T10);
impl_dependency!(0 T1, 1 T2, 2 T3, 3 T4, 4 T5, 5 T6, 6 T7, 7 T8, 8 T9, 9 T10, 10 T11);
impl_dependency!(0 T1, 1 T2, 2 T3, 3 T4, 4 T5, 5 T6, 6 T7, 7 T8, 8 T9, 9 T10, 10 T11, 11 T12);

View file

@ -1,6 +1,5 @@
use crate::error::ExitCode;
use crate::ipc::commands::Command;
use crate::ipc::responses::Response;
use crate::ipc::{Command, Response};
use clap::{Parser, ValueEnum};
use serde::{Deserialize, Serialize};
use std::process::exit;
@ -46,6 +45,7 @@ pub fn handle_response(response: Response, format: Format) {
Format::Plain => match response {
Response::Ok => println!("ok"),
Response::OkValue { value } => println!("{value}"),
Response::Multi { values } => println!("{}", values.join("\n")),
Response::Err { message } => eprintln!("error\n{}", message.unwrap_or_default()),
},
Format::Json => println!(

View file

@ -1,7 +1,8 @@
use super::wayland::{self, ClipboardItem};
use crate::{arc_mut, lock, register_client, spawn, try_send};
use indexmap::map::Iter;
use crate::channels::AsyncSenderExt;
use crate::{arc_mut, lock, register_client, spawn};
use indexmap::IndexMap;
use indexmap::map::Iter;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use tracing::{debug, trace};
@ -46,7 +47,7 @@ impl Client {
let senders = lock!(senders);
let iter = senders.iter();
for (tx, _) in iter {
try_send!(tx, ClipboardEvent::Add(item.clone()));
tx.send_spawn(ClipboardEvent::Add(item.clone()));
}
lock!(cache).insert(item, senders.len());
@ -74,16 +75,17 @@ impl Client {
let removed_id = lock!(cache)
.remove_ref_first()
.expect("Clipboard cache unexpectedly empty");
try_send!(tx, ClipboardEvent::Remove(removed_id));
tx.send_spawn(ClipboardEvent::Remove(removed_id));
}
try_send!(tx, ClipboardEvent::Add(item.clone()));
tx.send_spawn(ClipboardEvent::Add(item.clone()));
}
},
|existing_id| {
let senders = lock!(senders);
let iter = senders.iter();
for (tx, _) in iter {
try_send!(tx, ClipboardEvent::Activate(existing_id));
tx.send_spawn(ClipboardEvent::Activate(existing_id));
}
},
);
@ -106,7 +108,7 @@ impl Client {
let iter = cache.iter();
for (_, (item, _)) in iter {
try_send!(tx, ClipboardEvent::Add(item.clone()));
tx.send_spawn(ClipboardEvent::Add(item.clone()));
}
}
@ -130,7 +132,7 @@ impl Client {
let senders = lock!(self.senders);
let iter = senders.iter();
for (tx, _) in iter {
try_send!(tx, ClipboardEvent::Activate(id));
tx.send_spawn(ClipboardEvent::Activate(id));
}
}
@ -140,7 +142,7 @@ impl Client {
let senders = lock!(self.senders);
let iter = senders.iter();
for (tx, _) in iter {
try_send!(tx, ClipboardEvent::Remove(id));
tx.send_spawn(ClipboardEvent::Remove(id));
}
}
}

View file

@ -1,37 +1,73 @@
use super::{Visibility, Workspace, WorkspaceClient, WorkspaceUpdate};
use crate::{arc_mut, lock, send, spawn_blocking};
#[cfg(feature = "bindmode+hyprland")]
use super::{BindModeClient, BindModeUpdate};
#[cfg(feature = "keyboard+hyprland")]
use super::{KeyboardLayoutClient, KeyboardLayoutUpdate};
use super::{Visibility, Workspace};
use crate::channels::SyncSenderExt;
use crate::{arc_mut, lock, spawn_blocking};
use color_eyre::Result;
use hyprland::data::{Workspace as HWorkspace, Workspaces};
use hyprland::ctl::switch_xkb_layout;
use hyprland::data::{Devices, Workspace as HWorkspace, Workspaces};
use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial};
use hyprland::event_listener::EventListener;
use hyprland::prelude::*;
use hyprland::shared::{HyprDataVec, WorkspaceType};
use tokio::sync::broadcast::{channel, Receiver, Sender};
use tracing::{debug, error, info};
use tokio::sync::broadcast::{Receiver, Sender, channel};
use tracing::{debug, error, info, warn};
#[cfg(feature = "workspaces")]
use super::WorkspaceUpdate;
#[derive(Debug)]
struct TxRx<T> {
tx: Sender<T>,
_rx: Receiver<T>,
}
impl<T: Clone> TxRx<T> {
fn new() -> Self {
let (tx, rx) = channel(16);
Self { tx, _rx: rx }
}
}
#[derive(Debug)]
pub struct Client {
workspace_tx: Sender<WorkspaceUpdate>,
_workspace_rx: Receiver<WorkspaceUpdate>,
#[cfg(feature = "workspaces+hyprland")]
workspace: TxRx<WorkspaceUpdate>,
#[cfg(feature = "keyboard+hyprland")]
keyboard_layout: TxRx<KeyboardLayoutUpdate>,
#[cfg(feature = "bindmode+hyprland")]
bindmode: TxRx<BindModeUpdate>,
}
impl Client {
pub(crate) fn new() -> Self {
let (workspace_tx, workspace_rx) = channel(16);
let instance = Self {
workspace_tx,
_workspace_rx: workspace_rx,
#[cfg(feature = "workspaces+hyprland")]
workspace: TxRx::new(),
#[cfg(feature = "keyboard+hyprland")]
keyboard_layout: TxRx::new(),
#[cfg(feature = "bindmode+hyprland")]
bindmode: TxRx::new(),
};
instance.listen_workspace_events();
instance.listen_events();
instance
}
fn listen_workspace_events(&self) {
fn listen_events(&self) {
info!("Starting Hyprland event listener");
let tx = self.workspace_tx.clone();
#[cfg(feature = "workspaces+hyprland")]
let workspace_tx = self.workspace.tx.clone();
#[cfg(feature = "keyboard+hyprland")]
let keyboard_layout_tx = self.keyboard_layout.tx.clone();
#[cfg(feature = "bindmode+hyprland")]
let bindmode_tx = self.bindmode.tx.clone();
spawn_blocking(move || {
let mut event_listener = EventListener::new();
@ -40,179 +76,319 @@ impl Client {
let lock = arc_mut!(());
// cache the active workspace since Hyprland doesn't give us the prev active
let active = Self::get_active_workspace().expect("Failed to get active workspace");
let active = arc_mut!(Some(active));
#[cfg(feature = "workspaces+hyprland")]
Self::listen_workspace_events(&workspace_tx, &mut event_listener, &lock);
{
let tx = tx.clone();
let lock = lock.clone();
let active = active.clone();
#[cfg(feature = "keyboard+hyprland")]
Self::listen_keyboard_events(&keyboard_layout_tx, &mut event_listener, &lock);
event_listener.add_workspace_added_handler(move |workspace_type| {
let _lock = lock!(lock);
debug!("Added workspace: {workspace_type:?}");
#[cfg(feature = "bindmode+hyprland")]
Self::listen_bindmode_events(&bindmode_tx, &mut event_listener, &lock);
let workspace_name = get_workspace_name(workspace_type);
let prev_workspace = lock!(active);
if let Err(err) = event_listener.start_listener() {
error!("Failed to start listener: {err:#}");
}
});
}
let workspace = Self::get_workspace(&workspace_name, prev_workspace.as_ref());
#[cfg(feature = "workspaces+hyprland")]
fn listen_workspace_events(
tx: &Sender<WorkspaceUpdate>,
event_listener: &mut EventListener,
lock: &std::sync::Arc<std::sync::Mutex<()>>,
) {
let active = Self::get_active_workspace().map_or_else(
|err| {
error!("Failed to get active workspace: {err:#?}");
None
},
Some,
);
let active = arc_mut!(active);
if let Some(workspace) = workspace {
send!(tx, WorkspaceUpdate::Add(workspace));
{
let tx = tx.clone();
let lock = lock.clone();
let active = active.clone();
event_listener.add_workspace_added_handler(move |event| {
let _lock = lock!(lock);
debug!("Added workspace: {event:?}");
let workspace_name = get_workspace_name(event.name);
let prev_workspace = lock!(active);
let workspace = Self::get_workspace(&workspace_name, prev_workspace.as_ref());
match workspace {
Ok(Some(workspace)) => {
tx.send_expect(WorkspaceUpdate::Add(workspace));
}
});
}
Err(e) => error!("Failed to get workspace: {e:#}"),
_ => {}
}
});
}
{
let tx = tx.clone();
let lock = lock.clone();
let active = active.clone();
{
let tx = tx.clone();
let lock = lock.clone();
let active = active.clone();
event_listener.add_workspace_change_handler(move |workspace_type| {
let _lock = lock!(lock);
event_listener.add_workspace_changed_handler(move |event| {
let _lock = lock!(lock);
let mut prev_workspace = lock!(active);
let mut prev_workspace = lock!(active);
debug!(
"Received workspace change: {:?} -> {workspace_type:?}",
prev_workspace.as_ref().map(|w| &w.id)
);
debug!(
"Received workspace change: {:?} -> {event:?}",
prev_workspace.as_ref().map(|w| &w.id)
);
let workspace_name = get_workspace_name(workspace_type);
let workspace = Self::get_workspace(&workspace_name, prev_workspace.as_ref());
let workspace_name = get_workspace_name(event.name);
let workspace = Self::get_workspace(&workspace_name, prev_workspace.as_ref());
workspace.map_or_else(
|| {
error!("Unable to locate workspace");
},
|workspace| {
// there may be another type of update so dispatch that regardless of focus change
if !workspace.visibility.is_focused() {
Self::send_focus_change(&mut prev_workspace, workspace, &tx);
}
},
);
});
}
{
let tx = tx.clone();
let lock = lock.clone();
let active = active.clone();
event_listener.add_active_monitor_change_handler(move |event_data| {
let _lock = lock!(lock);
let workspace_type = event_data.workspace;
let mut prev_workspace = lock!(active);
debug!(
"Received active monitor change: {:?} -> {workspace_type:?}",
prev_workspace.as_ref().map(|w| &w.name)
);
let workspace_name = get_workspace_name(workspace_type);
let workspace = Self::get_workspace(&workspace_name, prev_workspace.as_ref());
if let Some((false, workspace)) =
workspace.map(|w| (w.visibility.is_focused(), w))
{
match workspace {
Ok(Some(workspace)) if !workspace.visibility.is_focused() => {
Self::send_focus_change(&mut prev_workspace, workspace, &tx);
} else {
}
Ok(None) => {
error!("Unable to locate workspace");
}
});
}
Err(e) => error!("Failed to get workspace: {e:#}"),
_ => {}
}
});
}
{
let tx = tx.clone();
let lock = lock.clone();
{
let tx = tx.clone();
let lock = lock.clone();
let active = active.clone();
event_listener.add_workspace_moved_handler(move |event_data| {
let _lock = lock!(lock);
let workspace_type = event_data.workspace;
debug!("Received workspace move: {workspace_type:?}");
event_listener.add_active_monitor_changed_handler(move |event_data| {
let _lock = lock!(lock);
let Some(workspace_type) = event_data.workspace_name else {
warn!("Received active monitor change with no workspace name");
return;
};
let mut prev_workspace = lock!(active);
let mut prev_workspace = lock!(active);
let workspace_name = get_workspace_name(workspace_type);
let workspace = Self::get_workspace(&workspace_name, prev_workspace.as_ref());
debug!(
"Received active monitor change: {:?} -> {workspace_type:?}",
prev_workspace.as_ref().map(|w| &w.name)
);
if let Some(workspace) = workspace {
send!(tx, WorkspaceUpdate::Move(workspace.clone()));
let workspace_name = get_workspace_name(workspace_type);
let workspace = Self::get_workspace(&workspace_name, prev_workspace.as_ref());
if !workspace.visibility.is_focused() {
Self::send_focus_change(&mut prev_workspace, workspace, &tx);
}
match workspace {
Ok(Some(workspace)) if !workspace.visibility.is_focused() => {
Self::send_focus_change(&mut prev_workspace, workspace, &tx);
}
Ok(None) => {
error!("Unable to locate workspace");
}
Err(e) => error!("Failed to get workspace: {e:#}"),
_ => {}
}
});
}
{
let tx = tx.clone();
let lock = lock.clone();
event_listener.add_workspace_moved_handler(move |event_data| {
let _lock = lock!(lock);
let workspace_type = event_data.name;
debug!("Received workspace move: {workspace_type:?}");
let mut prev_workspace = lock!(active);
let workspace_name = get_workspace_name(workspace_type);
let workspace = Self::get_workspace(&workspace_name, prev_workspace.as_ref());
match workspace {
Ok(Some(workspace)) if !workspace.visibility.is_focused() => {
Self::send_focus_change(&mut prev_workspace, workspace, &tx);
}
Ok(None) => {
error!("Unable to locate workspace");
}
Err(e) => error!("Failed to get workspace: {e:#}"),
_ => {}
}
});
}
{
let tx = tx.clone();
let lock = lock.clone();
event_listener.add_workspace_renamed_handler(move |data| {
let _lock = lock!(lock);
debug!("Received workspace rename: {data:?}");
tx.send_expect(WorkspaceUpdate::Rename {
id: data.id as i64,
name: data.name,
});
});
}
{
let tx = tx.clone();
let lock = lock.clone();
event_listener.add_workspace_deleted_handler(move |data| {
let _lock = lock!(lock);
debug!("Received workspace destroy: {data:?}");
tx.send_expect(WorkspaceUpdate::Remove(data.id as i64));
});
}
{
let tx = tx.clone();
let lock = lock.clone();
event_listener.add_urgent_state_changed_handler(move |address| {
let _lock = lock!(lock);
debug!("Received urgent state: {address:?}");
let clients = match hyprland::data::Clients::get() {
Ok(clients) => clients,
Err(err) => {
error!("Failed to get clients: {err}");
return;
}
};
clients.iter().find(|c| c.address == address).map_or_else(
|| {
error!("Unable to locate client");
},
|c| {
tx.send_expect(WorkspaceUpdate::Urgent {
id: c.workspace.id as i64,
urgent: true,
});
},
);
});
}
}
#[cfg(feature = "keyboard+hyprland")]
fn listen_keyboard_events(
keyboard_layout_tx: &Sender<KeyboardLayoutUpdate>,
event_listener: &mut EventListener,
lock: &std::sync::Arc<std::sync::Mutex<()>>,
) {
let tx = keyboard_layout_tx.clone();
let lock = lock.clone();
event_listener.add_layout_changed_handler(move |layout_event| {
let _lock = lock!(lock);
let layout = if layout_event.layout_name.is_empty() {
// FIXME: This field is empty due to bug in `hyprland-rs_0.4.0-alpha.3`. Which is already fixed in last betas
// The layout may be empty due to a bug in `hyprland-rs`, because of which the `layout_event` is incorrect.
//
// Instead of:
// ```
// LayoutEvent {
// keyboard_name: "keychron-keychron-c2",
// layout_name: "English (US)",
// }
// ```
//
// We get:
// ```
// LayoutEvent {
// keyboard_name: "keychron-keychron-c2,English (US)",
// layout_name: "",
// }
// ```
//
// Here we are trying to recover `layout_name` from `keyboard_name`
let layout = layout_event.keyboard_name.as_str().split(',').nth(1);
let Some(layout) = layout else {
error!(
"Failed to get layout from string: {}. The failed logic is a workaround for a bug in `hyprland 0.4.0-alpha.3`", layout_event.keyboard_name);
return;
};
layout.into()
}
else {
layout_event.layout_name
};
{
let tx = tx.clone();
let lock = lock.clone();
debug!("Received layout: {layout:?}");
tx.send_expect(KeyboardLayoutUpdate(layout));
});
}
event_listener.add_workspace_rename_handler(move |data| {
let _lock = lock!(lock);
#[cfg(feature = "bindmode+hyprland")]
fn listen_bindmode_events(
bindmode_tx: &Sender<BindModeUpdate>,
event_listener: &mut EventListener,
lock: &std::sync::Arc<std::sync::Mutex<()>>,
) {
let tx = bindmode_tx.clone();
let lock = lock.clone();
send!(
tx,
WorkspaceUpdate::Rename {
id: data.workspace_id as i64,
name: data.workspace_name
}
);
});
}
event_listener.add_sub_map_changed_handler(move |bind_mode| {
let _lock = lock!(lock);
debug!("Received bind mode: {bind_mode:?}");
{
event_listener.add_workspace_destroy_handler(move |data| {
let _lock = lock!(lock);
debug!("Received workspace destroy: {data:?}");
send!(tx, WorkspaceUpdate::Remove(data.workspace_id as i64));
});
}
event_listener
.start_listener()
.expect("Failed to start listener");
tx.send_expect(BindModeUpdate {
name: bind_mode,
pango_markup: false,
});
});
}
/// Sends a `WorkspaceUpdate::Focus` event
/// and updates the active workspace cache.
#[cfg(feature = "workspaces+hyprland")]
fn send_focus_change(
prev_workspace: &mut Option<Workspace>,
workspace: Workspace,
tx: &Sender<WorkspaceUpdate>,
) {
send!(
tx,
WorkspaceUpdate::Focus {
old: prev_workspace.take(),
new: workspace.clone(),
}
);
tx.send_expect(WorkspaceUpdate::Focus {
old: prev_workspace.take(),
new: workspace.clone(),
});
tx.send_expect(WorkspaceUpdate::Urgent {
id: workspace.id,
urgent: false,
});
prev_workspace.replace(workspace);
}
/// Gets a workspace by name from the server, given the active workspace if known.
fn get_workspace(name: &str, active: Option<&Workspace>) -> Option<Workspace> {
Workspaces::get()
.expect("Failed to get workspaces")
.into_iter()
.find_map(|w| {
if w.name == name {
let vis = Visibility::from((&w, active.map(|w| w.name.as_ref()), &|w| {
create_is_visible()(w)
}));
#[cfg(feature = "workspaces+hyprland")]
fn get_workspace(name: &str, active: Option<&Workspace>) -> Result<Option<Workspace>> {
let workspace = Workspaces::get()?.into_iter().find_map(|w| {
if w.name == name {
let vis = Visibility::from((&w, active.map(|w| w.name.as_ref()), &|w| {
create_is_visible()(w)
}));
Some(Workspace::from((vis, w)))
} else {
None
}
})
Some(Workspace::from((vis, w)))
} else {
None
}
});
Ok(workspace)
}
/// Gets the active workspace from the server.
@ -222,43 +398,100 @@ impl Client {
}
}
impl WorkspaceClient for Client {
fn focus(&self, id: String) -> Result<()> {
let identifier = id.parse::<i32>().map_or_else(
|_| WorkspaceIdentifierWithSpecial::Name(&id),
WorkspaceIdentifierWithSpecial::Id,
);
#[cfg(feature = "workspaces+hyprland")]
impl super::WorkspaceClient for Client {
fn focus(&self, id: i64) {
let identifier = WorkspaceIdentifierWithSpecial::Id(id as i32);
Dispatch::call(DispatchType::Workspace(identifier))?;
Ok(())
if let Err(e) = Dispatch::call(DispatchType::Workspace(identifier)) {
error!("Couldn't focus workspace '{id}': {e:#}");
}
}
fn subscribe_workspace_change(&self) -> Receiver<WorkspaceUpdate> {
let rx = self.workspace_tx.subscribe();
fn subscribe(&self) -> Receiver<WorkspaceUpdate> {
let rx = self.workspace.tx.subscribe();
{
let tx = self.workspace_tx.clone();
let active_id = HWorkspace::get_active().ok().map(|active| active.name);
let is_visible = create_is_visible();
let active_id = HWorkspace::get_active().ok().map(|active| active.name);
let is_visible = create_is_visible();
match Workspaces::get() {
Ok(workspaces) => {
let workspaces = workspaces
.into_iter()
.map(|w| {
let vis = Visibility::from((&w, active_id.as_deref(), &is_visible));
Workspace::from((vis, w))
})
.collect();
let workspaces = Workspaces::get()
.expect("Failed to get workspaces")
.into_iter()
.map(|w| {
let vis = Visibility::from((&w, active_id.as_deref(), &is_visible));
Workspace::from((vis, w))
})
.collect();
send!(tx, WorkspaceUpdate::Init(workspaces));
self.workspace
.tx
.send_expect(WorkspaceUpdate::Init(workspaces));
}
Err(e) => {
error!("Failed to get workspaces: {e:#}");
}
}
rx
}
}
#[cfg(feature = "keyboard+hyprland")]
impl KeyboardLayoutClient for Client {
fn set_next_active(&self) {
let Ok(devices) = Devices::get() else {
error!("Failed to get devices");
return;
};
let device = devices
.keyboards
.iter()
.find(|k| k.main)
.map(|k| k.name.clone());
if let Some(device) = device {
if let Err(e) =
switch_xkb_layout::call(device, switch_xkb_layout::SwitchXKBLayoutCmdTypes::Next)
{
error!("Failed to switch keyboard layout due to Hyprland error: {e}");
}
} else {
error!("Failed to get keyboard device from hyprland");
}
}
fn subscribe(&self) -> Receiver<KeyboardLayoutUpdate> {
let rx = self.keyboard_layout.tx.subscribe();
match Devices::get().map(|devices| {
devices
.keyboards
.iter()
.find(|k| k.main)
.map(|k| k.active_keymap.clone())
}) {
Ok(Some(layout)) => {
self.keyboard_layout
.tx
.send_expect(KeyboardLayoutUpdate(layout));
}
Ok(None) => error!("Failed to get current keyboard layout hyprland"),
Err(err) => error!("Failed to get devices: {err:#?}"),
}
rx
}
}
#[cfg(feature = "bindmode+hyprland")]
impl BindModeClient for Client {
fn subscribe(&self) -> Result<Receiver<BindModeUpdate>> {
Ok(self.bindmode.tx.subscribe())
}
}
fn get_workspace_name(name: WorkspaceType) -> String {
match name {
WorkspaceType::Regular(name) => name,

View file

@ -1,4 +1,5 @@
use crate::{await_sync, register_fallible_client};
use crate::clients::ClientResult;
use crate::register_fallible_client;
use cfg_if::cfg_if;
use color_eyre::{Help, Report, Result};
use std::fmt::{Debug, Display, Formatter};
@ -6,16 +7,20 @@ use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::debug;
#[cfg(feature = "workspaces+hyprland")]
#[cfg(feature = "hyprland")]
pub mod hyprland;
#[cfg(feature = "workspaces+sway")]
#[cfg(feature = "niri")]
pub mod niri;
#[cfg(feature = "sway")]
pub mod sway;
pub enum Compositor {
#[cfg(feature = "workspaces+sway")]
#[cfg(feature = "sway")]
Sway,
#[cfg(feature = "workspaces+hyprland")]
#[cfg(feature = "hyprland")]
Hyprland,
#[cfg(feature = "niri")]
Niri,
Unsupported,
}
@ -25,10 +30,12 @@ impl Display for Compositor {
f,
"{}",
match self {
#[cfg(feature = "workspaces+sway")]
#[cfg(any(feature = "sway"))]
Self::Sway => "Sway",
#[cfg(feature = "workspaces+hyprland")]
#[cfg(any(feature = "hyprland"))]
Self::Hyprland => "Hyprland",
#[cfg(feature = "workspaces+niri")]
Self::Niri => "Niri",
Self::Unsupported => "Unsupported",
}
)
@ -41,32 +48,90 @@ impl Compositor {
fn get_current() -> Self {
if std::env::var("SWAYSOCK").is_ok() {
cfg_if! {
if #[cfg(feature = "workspaces+sway")] { Self::Sway }
if #[cfg(feature = "sway")] { Self::Sway }
else { tracing::error!("Not compiled with Sway support"); Self::Unsupported }
}
} else if std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() {
cfg_if! {
if #[cfg(feature = "workspaces+hyprland")] { Self::Hyprland }
if #[cfg(feature = "hyprland")] { Self::Hyprland }
else { tracing::error!("Not compiled with Hyprland support"); Self::Unsupported }
}
} else if std::env::var("NIRI_SOCKET").is_ok() {
cfg_if! {
if #[cfg(feature = "niri")] { Self::Niri }
else {tracing::error!("Not compiled with Niri support"); Self::Unsupported }
}
} else {
Self::Unsupported
}
}
#[cfg(feature = "bindmode")]
pub fn create_bindmode_client(
clients: &mut super::Clients,
) -> ClientResult<dyn BindModeClient + Send + Sync> {
let current = Self::get_current();
debug!("Getting keyboard_layout client for: {current}");
match current {
#[cfg(feature = "bindmode+sway")]
Self::Sway => Ok(clients.sway()?),
#[cfg(feature = "bindmode+hyprland")]
Self::Hyprland => Ok(clients.hyprland()),
#[cfg(feature = "niri")]
Self::Niri => Err(Report::msg("Unsupported compositor")
.note("Currently bindmode is only supported by Sway and Hyprland")),
Self::Unsupported => Err(Report::msg("Unsupported compositor")
.note("Currently bindmode is only supported by Sway and Hyprland")),
#[allow(unreachable_patterns)]
_ => Err(Report::msg("Unsupported compositor")
.note("Bindmode feature is disabled for this compositor")),
}
}
#[cfg(feature = "keyboard")]
pub fn create_keyboard_layout_client(
clients: &mut super::Clients,
) -> ClientResult<dyn KeyboardLayoutClient + Send + Sync> {
let current = Self::get_current();
debug!("Getting keyboard_layout client for: {current}");
match current {
#[cfg(feature = "keyboard+sway")]
Self::Sway => Ok(clients.sway()?),
#[cfg(feature = "keyboard+hyprland")]
Self::Hyprland => Ok(clients.hyprland()),
#[cfg(feature = "niri")]
Self::Niri => Err(Report::msg("Unsupported compositor").note(
"Currently keyboard layout functionality are only supported by Sway and Hyprland",
)),
Self::Unsupported => Err(Report::msg("Unsupported compositor").note(
"Currently keyboard layout functionality are only supported by Sway and Hyprland",
)),
#[allow(unreachable_patterns)]
_ => Err(Report::msg("Unsupported compositor")
.note("Keyboard layout feature is disabled for this compositor")),
}
}
/// Creates a new instance of
/// the workspace client for the current compositor.
pub fn create_workspace_client() -> Result<Arc<dyn WorkspaceClient + Send + Sync>> {
#[cfg(feature = "workspaces")]
pub fn create_workspace_client(
clients: &mut super::Clients,
) -> Result<Arc<dyn WorkspaceClient + Send + Sync>> {
let current = Self::get_current();
debug!("Getting workspace client for: {current}");
match current {
#[cfg(feature = "workspaces+sway")]
Self::Sway => await_sync(async { sway::Client::new().await })
.map(|client| Arc::new(client) as Arc<dyn WorkspaceClient + Send + Sync>),
Self::Sway => Ok(clients.sway()?),
#[cfg(feature = "workspaces+hyprland")]
Self::Hyprland => Ok(Arc::new(hyprland::Client::new())),
Self::Hyprland => Ok(clients.hyprland()),
#[cfg(feature = "workspaces+niri")]
Self::Niri => Ok(Arc::new(niri::Client::new())),
Self::Unsupported => Err(Report::msg("Unsupported compositor")
.note("Currently workspaces are only supported by Sway and Hyprland")),
.note("Currently workspaces are only supported by Sway, Niri and Hyprland")),
#[allow(unreachable_patterns)]
_ => Err(Report::msg("Unsupported compositor")
.note("Workspaces feature is disabled for this compositor")),
}
}
}
@ -83,29 +148,29 @@ pub struct Workspace {
pub visibility: Visibility,
}
/// Indicates workspace visibility. Visible workspaces have a boolean flag to indicate if they are also focused.
/// Yes, this is the same signature as Option<bool>, but it's impl is a lot more suited for our case.
/// Indicates workspace visibility.
/// Visible workspaces have a boolean flag to indicate if they are also focused.
#[derive(Debug, Copy, Clone)]
pub enum Visibility {
Visible(bool),
Visible { focused: bool },
Hidden,
}
impl Visibility {
pub fn visible() -> Self {
Self::Visible(false)
Self::Visible { focused: false }
}
pub fn focused() -> Self {
Self::Visible(true)
Self::Visible { focused: true }
}
pub fn is_visible(self) -> bool {
matches!(self, Self::Visible(_))
matches!(self, Self::Visible { .. })
}
pub fn is_focused(self) -> bool {
if let Self::Visible(focused) = self {
if let Self::Visible { focused } = self {
focused
} else {
false
@ -114,6 +179,11 @@ impl Visibility {
}
#[derive(Debug, Clone)]
#[cfg(feature = "keyboard")]
pub struct KeyboardLayoutUpdate(pub String);
#[derive(Debug, Clone)]
#[cfg(feature = "workspaces")]
pub enum WorkspaceUpdate {
/// Provides an initial list of workspaces.
/// This is re-sent to all subscribers when a new subscription is created.
@ -132,6 +202,12 @@ pub enum WorkspaceUpdate {
name: String,
},
/// The urgent state of a node changed.
Urgent {
id: i64,
urgent: bool,
},
/// An update was triggered by the compositor but this was not mapped by Ironbar.
///
/// This is purely used for ergonomics within the compositor clients
@ -139,12 +215,44 @@ pub enum WorkspaceUpdate {
Unknown,
}
pub trait WorkspaceClient: Debug + Send + Sync {
/// Requests the workspace with this name is focused.
fn focus(&self, name: String) -> Result<()>;
/// Creates a new to workspace event receiver.
fn subscribe_workspace_change(&self) -> broadcast::Receiver<WorkspaceUpdate>;
#[derive(Clone, Debug)]
#[cfg(feature = "bindmode")]
pub struct BindModeUpdate {
/// The binding mode that became active.
pub name: String,
/// Whether the mode should be parsed as pango markup.
pub pango_markup: bool,
}
#[cfg(feature = "workspaces")]
pub trait WorkspaceClient: Debug + Send + Sync {
/// Requests the workspace with this id is focused.
fn focus(&self, id: i64);
/// Creates a new to workspace event receiver.
fn subscribe(&self) -> broadcast::Receiver<WorkspaceUpdate>;
}
#[cfg(feature = "workspaces")]
register_fallible_client!(dyn WorkspaceClient, workspaces);
#[cfg(feature = "keyboard")]
pub trait KeyboardLayoutClient: Debug + Send + Sync {
/// Switches to the next layout.
fn set_next_active(&self);
/// Creates a new to keyboard layout event receiver.
fn subscribe(&self) -> broadcast::Receiver<KeyboardLayoutUpdate>;
}
#[cfg(feature = "keyboard")]
register_fallible_client!(dyn KeyboardLayoutClient, keyboard_layout);
#[cfg(feature = "bindmode")]
pub trait BindModeClient: Debug + Send + Sync {
/// Add a callback for bindmode updates.
fn subscribe(&self) -> Result<broadcast::Receiver<BindModeUpdate>>;
}
#[cfg(feature = "bindmode")]
register_fallible_client!(dyn BindModeClient, bindmode);

View file

@ -0,0 +1,117 @@
/// Taken from the `niri_ipc` crate.
/// Only a relevant snippet has been extracted
/// to reduce compile times.
use crate::clients::compositor::Workspace as IronWorkspace;
use crate::{await_sync, clients::compositor::Visibility};
use color_eyre::eyre::{Result, eyre};
use core::str;
use serde::{Deserialize, Serialize};
use std::{env, path::Path};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
net::UnixStream,
};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum Request {
Action(Action),
EventStream,
}
pub type Reply = Result<Response, String>;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum Response {
Handled,
Workspaces(Vec<Workspace>),
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum Action {
FocusWorkspace { reference: WorkspaceReferenceArg },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceReferenceArg {
Name(String),
Id(u64),
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Workspace {
pub id: u64,
pub idx: u8,
pub name: Option<String>,
pub output: Option<String>,
pub is_active: bool,
pub is_focused: bool,
}
impl From<&Workspace> for IronWorkspace {
fn from(workspace: &Workspace) -> IronWorkspace {
// Workspaces in niri don't neccessarily have names.
// If the niri workspace has a name then it is assigned as is,
// but if it does not have a name, the monitor index is used.
Self {
id: workspace.id as i64,
name: workspace.name.clone().unwrap_or(workspace.idx.to_string()),
monitor: workspace.output.clone().unwrap_or_default(),
visibility: if workspace.is_active {
Visibility::Visible {
focused: workspace.is_focused,
}
} else {
Visibility::Hidden
},
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum Event {
WorkspacesChanged { workspaces: Vec<Workspace> },
WorkspaceActivated { id: u64, focused: bool },
Other,
}
#[derive(Debug)]
pub struct Connection(UnixStream);
impl Connection {
pub async fn connect() -> Result<Self> {
let socket_path =
env::var_os("NIRI_SOCKET").ok_or_else(|| eyre!("NIRI_SOCKET not found!"))?;
Self::connect_to(socket_path).await
}
pub async fn connect_to(path: impl AsRef<Path>) -> Result<Self> {
let raw_stream = UnixStream::connect(path.as_ref()).await?;
let stream = raw_stream;
Ok(Self(stream))
}
pub async fn send(
&mut self,
request: Request,
) -> Result<(Reply, impl FnMut() -> Result<Event> + '_)> {
let Self(stream) = self;
let mut buf = serde_json::to_string(&request)?;
stream.write_all(buf.as_bytes()).await?;
stream.shutdown().await?;
buf.clear();
let mut reader = BufReader::new(stream);
reader.read_line(&mut buf).await?;
let reply = serde_json::from_str(&buf)?;
let events = move || {
buf.clear();
await_sync(async {
reader.read_line(&mut buf).await.unwrap_or(0);
});
let event: Event = serde_json::from_str(&buf).unwrap_or(Event::Other);
Ok(event)
};
Ok((reply, events))
}
}

View file

@ -0,0 +1,223 @@
use super::{Workspace as IronWorkspace, WorkspaceClient, WorkspaceUpdate};
use crate::channels::SyncSenderExt;
use crate::clients::compositor::Visibility;
use crate::{arc_rw, read_lock, spawn, write_lock};
use color_eyre::Report;
use connection::{Action, Connection, Event, Request, WorkspaceReferenceArg};
use std::sync::{Arc, RwLock};
use tokio::sync::broadcast;
use tracing::{debug, error, warn};
mod connection;
#[derive(Debug)]
pub struct Client {
tx: broadcast::Sender<WorkspaceUpdate>,
_rx: broadcast::Receiver<WorkspaceUpdate>,
workspaces: Arc<RwLock<Vec<IronWorkspace>>>,
}
impl Client {
pub fn new() -> Self {
let (tx, rx) = broadcast::channel(32);
let tx2 = tx.clone();
let workspace_state = arc_rw!(vec![]);
let workspace_state2 = workspace_state.clone();
spawn(async move {
let mut conn = Connection::connect().await?;
let (_, mut event_listener) = conn.send(Request::EventStream).await?;
let mut first_event = true;
loop {
let events = match event_listener() {
Ok(Event::WorkspacesChanged { workspaces }) => {
debug!("WorkspacesChanged: {:?}", workspaces);
// Niri only has a WorkspacesChanged Event and Ironbar has 4 events which have to be handled: Add, Remove, Rename and Move.
// This is handled by keeping a previous state of workspaces and comparing with the new state for changes.
let new_workspaces: Vec<IronWorkspace> = workspaces
.into_iter()
.map(|w| IronWorkspace::from(&w))
.collect();
let mut updates: Vec<WorkspaceUpdate> = vec![];
if first_event {
// Niri's WorkspacesChanged event does not initially sort workspaces by ID when first output,
// which makes sort = added meaningless. Therefore, new_workspaces are sorted by ID here to ensure a consistent addition order.
let mut new_workspaces = new_workspaces.clone();
new_workspaces.sort_by_key(|w| w.id);
updates.push(WorkspaceUpdate::Init(new_workspaces));
first_event = false;
} else {
// first pass - add/update
for workspace in &new_workspaces {
let workspace_state = read_lock!(workspace_state);
let old_workspace = workspace_state
.iter()
.find(|&w: &&IronWorkspace| w.id == workspace.id);
match old_workspace {
None => updates.push(WorkspaceUpdate::Add(workspace.clone())),
Some(old_workspace) => {
if workspace.name != old_workspace.name {
updates.push(WorkspaceUpdate::Rename {
id: workspace.id,
name: workspace.name.clone(),
});
}
if workspace.monitor != old_workspace.monitor {
updates.push(WorkspaceUpdate::Move(workspace.clone()));
}
}
}
}
// second pass - delete
for workspace in read_lock!(workspace_state).iter() {
let exists = new_workspaces.iter().any(|w| w.id == workspace.id);
if !exists {
updates.push(WorkspaceUpdate::Remove(workspace.id));
}
}
}
*write_lock!(workspace_state) = new_workspaces;
updates
}
Ok(Event::WorkspaceActivated { id, focused }) => {
debug!("WorkspaceActivated: id: {}, focused: {}", id, focused);
// workspace with id is activated, if focus is true then it is also focused
// if focused is true then focus has changed => find old focused workspace. set it to inactive and set current
//
// we use indexes here as both new/old need to be mutable
let new_index = read_lock!(workspace_state)
.iter()
.position(|w| w.id == id as i64);
if let Some(new_index) = new_index {
if focused {
let old_index = read_lock!(workspace_state)
.iter()
.position(|w| w.visibility.is_focused());
if let Some(old_index) = old_index {
write_lock!(workspace_state)[new_index].visibility =
Visibility::focused();
if read_lock!(workspace_state)[old_index].monitor
== read_lock!(workspace_state)[new_index].monitor
{
write_lock!(workspace_state)[old_index].visibility =
Visibility::Hidden;
} else {
write_lock!(workspace_state)[old_index].visibility =
Visibility::visible();
}
vec![WorkspaceUpdate::Focus {
old: Some(read_lock!(workspace_state)[old_index].clone()),
new: read_lock!(workspace_state)[new_index].clone(),
}]
} else {
write_lock!(workspace_state)[new_index].visibility =
Visibility::focused();
vec![WorkspaceUpdate::Focus {
old: None,
new: read_lock!(workspace_state)[new_index].clone(),
}]
}
} else {
// if focused is false means active workspace on a particular monitor has changed =>
// change all workspaces on monitor to inactive and change current workspace as active
write_lock!(workspace_state)[new_index].visibility =
Visibility::visible();
let old_index = read_lock!(workspace_state).iter().position(|w| {
(w.visibility.is_focused() || w.visibility.is_visible())
&& w.monitor
== read_lock!(workspace_state)[new_index].monitor
});
if let Some(old_index) = old_index {
write_lock!(workspace_state)[old_index].visibility =
Visibility::Hidden;
vec![]
} else {
vec![]
}
}
} else {
warn!("No workspace with id for new focus/visible workspace found");
vec![]
}
}
Ok(Event::Other) => {
vec![]
}
Err(err) => {
error!("{err:?}");
break;
}
};
for event in events {
tx.send_expect(event);
}
}
Ok::<(), Report>(())
});
Self {
tx: tx2,
_rx: rx,
workspaces: workspace_state2,
}
}
}
impl WorkspaceClient for Client {
fn focus(&self, id: i64) {
debug!("focusing workspace with id: {}", id);
// this does annoyingly require spawning a separate connection for every focus call
// the alternative is sticking the conn behind a mutex which could perform worse
spawn(async move {
let mut conn = Connection::connect().await?;
let command = Request::Action(Action::FocusWorkspace {
reference: WorkspaceReferenceArg::Id(id as u64),
});
if let Err(err) = conn.send(command).await {
error!("failed to send command: {err:?}");
}
Ok::<(), Report>(())
});
}
fn subscribe(&self) -> broadcast::Receiver<WorkspaceUpdate> {
let rx = self.tx.subscribe();
let workspaces = read_lock!(self.workspaces);
if !workspaces.is_empty() {
self.tx
.send_expect(WorkspaceUpdate::Init(workspaces.clone()));
}
rx
}
}

View file

@ -1,85 +1,66 @@
use super::{Visibility, Workspace, WorkspaceClient, WorkspaceUpdate};
use crate::{await_sync, send, spawn};
use color_eyre::{Report, Result};
use futures_lite::StreamExt;
use std::sync::Arc;
use swayipc_async::{Connection, Event, EventType, Node, WorkspaceChange, WorkspaceEvent};
use tokio::sync::broadcast::{channel, Receiver, Sender};
use tokio::sync::Mutex;
use tracing::{info, trace};
use super::{Visibility, Workspace};
use crate::channels::SyncSenderExt;
use crate::clients::sway::Client;
use crate::{await_sync, error, spawn};
use color_eyre::Report;
use swayipc_async::{InputChange, InputEvent, Node, WorkspaceChange, WorkspaceEvent};
use tokio::sync::broadcast::{Receiver, channel};
#[derive(Debug)]
pub struct Client {
client: Arc<Mutex<Connection>>,
workspace_tx: Sender<WorkspaceUpdate>,
_workspace_rx: Receiver<WorkspaceUpdate>,
}
#[cfg(feature = "workspaces")]
use super::WorkspaceUpdate;
impl Client {
pub(crate) async fn new() -> Result<Self> {
// Avoid using `arc_mut!` here because we need tokio Mutex.
let client = Arc::new(Mutex::new(Connection::new().await?));
info!("Sway IPC subscription client connected");
#[cfg(feature = "workspaces+sway")]
impl super::WorkspaceClient for Client {
fn focus(&self, id: i64) {
let client = self.connection().clone();
spawn(async move {
let mut client = client.lock().await;
let (workspace_tx, workspace_rx) = channel(16);
let name = client
.get_workspaces()
.await?
.into_iter()
.find(|w| w.id == id)
.map(|w| w.name);
{
// create 2nd client as subscription takes ownership
let client = Connection::new().await?;
let workspace_tx = workspace_tx.clone();
let Some(name) = name else {
return Err(Report::msg(format!("couldn't find workspace with id {id}")));
};
spawn(async move {
let event_types = [EventType::Workspace];
let mut events = client.subscribe(event_types).await?;
if let Err(e) = client.run_command(format!("workspace {name}")).await {
return Err(Report::msg(format!(
"Couldn't focus workspace '{id}': {e:#}"
)));
}
while let Some(event) = events.next().await {
trace!("event: {:?}", event);
if let Event::Workspace(event) = event? {
let event = WorkspaceUpdate::from(*event);
if !matches!(event, WorkspaceUpdate::Unknown) {
workspace_tx.send(event)?;
}
};
}
Ok::<(), Report>(())
});
}
Ok(Self {
client,
workspace_tx,
_workspace_rx: workspace_rx,
})
}
}
impl WorkspaceClient for Client {
fn focus(&self, id: String) -> Result<()> {
await_sync(async move {
let mut client = self.client.lock().await;
client.run_command(format!("workspace {id}")).await
})?;
Ok(())
Ok(())
});
}
fn subscribe_workspace_change(&self) -> Receiver<WorkspaceUpdate> {
let rx = self.workspace_tx.subscribe();
fn subscribe(&self) -> Receiver<WorkspaceUpdate> {
let (tx, rx) = channel(16);
{
let tx = self.workspace_tx.clone();
let client = self.client.clone();
let client = self.connection().clone();
await_sync(async {
let mut client = client.lock().await;
let workspaces = client.get_workspaces().await.expect("to get workspaces");
// TODO: this needs refactoring
await_sync(async {
let mut client = client.lock().await;
let workspaces = client.get_workspaces().await.expect("to get workspaces");
let event =
WorkspaceUpdate::Init(workspaces.into_iter().map(Workspace::from).collect());
let event =
WorkspaceUpdate::Init(workspaces.into_iter().map(Workspace::from).collect());
send!(tx, event);
});
}
tx.send_expect(event);
drop(client);
self.add_listener::<WorkspaceEvent>(move |event| {
let update = WorkspaceUpdate::from(event.clone());
tx.send_expect(update);
})
.await
.expect("to add listener");
});
rx
}
@ -135,6 +116,7 @@ impl From<&swayipc_async::Workspace> for Visibility {
}
}
#[cfg(feature = "workspaces")]
impl From<WorkspaceEvent> for WorkspaceUpdate {
fn from(event: WorkspaceEvent) -> Self {
match event.change {
@ -151,7 +133,136 @@ impl From<WorkspaceEvent> for WorkspaceUpdate {
WorkspaceChange::Move => {
Self::Move(event.current.expect("Missing current workspace").into())
}
WorkspaceChange::Rename => {
if let Some(node) = event.current {
Self::Rename {
id: node.id,
name: node.name.unwrap_or_default(),
}
} else {
Self::Unknown
}
}
WorkspaceChange::Urgent => {
if let Some(node) = event.current {
Self::Urgent {
id: node.id,
urgent: node.urgent,
}
} else {
Self::Unknown
}
}
_ => Self::Unknown,
}
}
}
#[cfg(feature = "keyboard+sway")]
use super::{KeyboardLayoutClient, KeyboardLayoutUpdate};
#[cfg(feature = "keyboard+sway")]
impl KeyboardLayoutClient for Client {
fn set_next_active(&self) {
let client = self.connection().clone();
spawn(async move {
let mut client = client.lock().await;
let inputs = client.get_inputs().await.expect("to get inputs");
if let Some(keyboard) = inputs
.into_iter()
.find(|i| i.xkb_active_layout_name.is_some())
{
if let Err(e) = client
.run_command(format!(
"input {} xkb_switch_layout next",
keyboard.identifier
))
.await
{
error!("Failed to switch keyboard layout due to Sway error: {e}");
}
} else {
error!("Failed to get keyboard identifier from Sway");
}
});
}
fn subscribe(&self) -> Receiver<KeyboardLayoutUpdate> {
let (tx, rx) = channel(16);
let client = self.connection().clone();
await_sync(async {
let mut client = client.lock().await;
let inputs = client.get_inputs().await.expect("to get inputs");
if let Some(layout) = inputs.into_iter().find_map(|i| i.xkb_active_layout_name) {
tx.send_expect(KeyboardLayoutUpdate(layout));
} else {
error!("Failed to get keyboard layout from Sway!");
}
drop(client);
self.add_listener::<InputEvent>(move |event| {
if let Ok(layout) = KeyboardLayoutUpdate::try_from(event.clone()) {
tx.send_expect(layout);
}
})
.await
.expect("to add listener");
});
rx
}
}
#[cfg(feature = "keyboard+sway")]
impl TryFrom<InputEvent> for KeyboardLayoutUpdate {
type Error = ();
fn try_from(value: InputEvent) -> Result<Self, Self::Error> {
match value.change {
InputChange::XkbLayout => {
if let Some(layout) = value.input.xkb_active_layout_name {
Ok(KeyboardLayoutUpdate(layout))
} else {
Err(())
}
}
_ => Err(()),
}
}
}
#[cfg(feature = "bindmode+sway")]
use super::{BindModeClient, BindModeUpdate};
#[cfg(feature = "bindmode+sway")]
impl BindModeClient for Client {
fn subscribe(&self) -> Result<Receiver<BindModeUpdate>, Report> {
let (tx, rx) = channel(16);
await_sync(async {
self.add_listener::<swayipc_async::ModeEvent>(move |mode| {
tracing::trace!("mode: {:?}", mode);
// when no binding is active the bindmode is named "default", but we must display
// nothing in this case.
let name = if mode.change == "default" {
String::new()
} else {
mode.change.clone()
};
tx.send_expect(BindModeUpdate {
name,
pango_markup: mode.pango_markup,
});
})
.await
})?;
Ok(rx)
}
}

236
src/clients/libinput.rs Normal file
View file

@ -0,0 +1,236 @@
use crate::channels::SyncSenderExt;
use crate::{Ironbar, arc_rw, read_lock, spawn, write_lock};
use color_eyre::{Report, Result};
use colpetto::event::{AsRawEvent, DeviceEvent, KeyState, KeyboardEvent};
use colpetto::{DeviceCapability, Libinput};
use evdev_rs::DeviceWrapper;
use evdev_rs::enums::{EV_KEY, EV_LED, EventCode, int_to_ev_key};
use futures_lite::StreamExt;
use rustix::fs::{Mode, OFlags, open};
use rustix::io::Errno;
use std::ffi::{CStr, CString, c_int};
use std::os::fd::{FromRawFd, IntoRawFd, RawFd};
use std::os::unix::io::OwnedFd;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tokio::sync::broadcast;
use tokio::task::LocalSet;
use tokio::time::sleep;
use tracing::{debug, error};
#[derive(Debug, Copy, Clone)]
pub enum Key {
Caps,
Num,
Scroll,
}
impl From<Key> for EV_KEY {
fn from(value: Key) -> Self {
match value {
Key::Caps => Self::KEY_CAPSLOCK,
Key::Num => Self::KEY_NUMLOCK,
Key::Scroll => Self::KEY_SCROLLLOCK,
}
}
}
impl TryFrom<EV_KEY> for Key {
type Error = Report;
fn try_from(value: EV_KEY) -> std::result::Result<Self, Self::Error> {
match value {
EV_KEY::KEY_CAPSLOCK => Ok(Key::Caps),
EV_KEY::KEY_NUMLOCK => Ok(Key::Num),
EV_KEY::KEY_SCROLLLOCK => Ok(Key::Scroll),
_ => Err(Report::msg("provided key is not supported toggle key")),
}
}
}
impl Key {
fn get_state<P: AsRef<Path>>(self, device_path: P) -> Result<bool> {
let device = evdev_rs::Device::new_from_path(device_path)?;
match self {
Self::Caps => device.event_value(&EventCode::EV_LED(EV_LED::LED_CAPSL)),
Self::Num => device.event_value(&EventCode::EV_LED(EV_LED::LED_NUML)),
Self::Scroll => device.event_value(&EventCode::EV_LED(EV_LED::LED_SCROLLL)),
}
.map(|v| v > 0)
.ok_or_else(|| Report::msg("failed to get key status"))
}
}
#[derive(Debug, Copy, Clone)]
pub struct KeyEvent {
pub key: Key,
pub state: bool,
}
#[derive(Debug, Copy, Clone)]
pub enum Event {
Device,
Key(KeyEvent),
}
struct KeyData<P: AsRef<Path>> {
device_path: P,
key: EV_KEY,
}
impl<P: AsRef<Path>> TryFrom<KeyData<P>> for Event {
type Error = Report;
fn try_from(data: KeyData<P>) -> Result<Self> {
let key = Key::try_from(data.key)?;
key.get_state(data.device_path)
.map(|state| KeyEvent { key, state })
.map(Event::Key)
}
}
#[derive(Debug)]
pub struct Client {
tx: broadcast::Sender<Event>,
_rx: broadcast::Receiver<Event>,
seat: String,
known_devices: Arc<RwLock<Vec<PathBuf>>>,
}
impl Client {
pub fn init(seat: String) -> Arc<Self> {
let client = Arc::new(Self::new(seat));
{
let client = client.clone();
std::thread::spawn(move || {
let local = LocalSet::new();
local.spawn_local(async move {
if let Err(err) = client.run().await {
error!("{err:?}");
}
});
Ironbar::runtime().block_on(local);
});
}
client
}
fn new(seat: String) -> Self {
let (tx, rx) = broadcast::channel(4);
Self {
tx,
_rx: rx,
seat,
known_devices: arc_rw!(vec![]),
}
}
fn open_restricted(path: &CStr, flags: c_int) -> std::result::Result<RawFd, i32> {
open(path, OFlags::from_bits_retain(flags as u32), Mode::empty())
.map(IntoRawFd::into_raw_fd)
.map_err(Errno::raw_os_error)
}
fn close_restricted(fd: c_int) {
drop(unsafe { OwnedFd::from_raw_fd(fd) });
}
async fn run(&self) -> Result<()> {
let mut libinput = Libinput::with_tracing(Self::open_restricted, Self::close_restricted)?;
libinput.udev_assign_seat(CString::new(&*self.seat)?.as_c_str())?;
let mut stream = libinput.event_stream()?;
while let Some(event) = stream.try_next().await? {
match event {
colpetto::Event::Device(DeviceEvent::Added(event)) => {
let device = event.device();
if !device.has_capability(DeviceCapability::Keyboard) {
continue;
}
let name = device.name();
let Some(device) = event.device().udev_device() else {
continue;
};
if let Some(device_path) = device.devnode() {
// not all devices which report as keyboards actually are one -
// fire test event so we can figure out if it is
let caps_event: Result<Event> = KeyData {
device_path,
key: EV_KEY::KEY_CAPSLOCK,
}
.try_into();
if caps_event.is_ok() {
debug!(
"new keyboard device: {} | {}",
name.to_string_lossy(),
device_path.display()
);
write_lock!(self.known_devices).push(device_path.to_path_buf());
self.tx.send_expect(Event::Device);
}
}
}
colpetto::Event::Keyboard(KeyboardEvent::Key(event))
if event.key_state() == KeyState::Released =>
{
let Some(device) = event.device().udev_device() else {
continue;
};
let Some(
key @ (EV_KEY::KEY_CAPSLOCK | EV_KEY::KEY_NUMLOCK | EV_KEY::KEY_SCROLLLOCK),
) = int_to_ev_key(event.key())
else {
continue;
};
if let Some(device_path) = device.devnode().map(PathBuf::from) {
let tx = self.tx.clone();
// need to spawn a task to avoid blocking
spawn(async move {
// wait for kb to change
sleep(Duration::from_millis(50)).await;
let data = KeyData { device_path, key };
if let Ok(event) = data.try_into() {
tx.send_expect(event);
}
});
}
}
_ => {}
}
}
Err(Report::msg("unexpected end of stream"))
}
pub fn get_state(&self, key: Key) -> bool {
read_lock!(self.known_devices)
.iter()
.map(|device_path| key.get_state(device_path))
.filter_map(Result::ok)
.reduce(|state, curr| state || curr)
.unwrap_or_default()
}
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
self.tx.subscribe()
}
}

View file

@ -1,21 +1,33 @@
use crate::{await_sync, Ironbar};
use crate::{Ironbar, await_sync};
use color_eyre::Result;
use std::collections::HashMap;
use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;
#[cfg(feature = "clipboard")]
pub mod clipboard;
#[cfg(feature = "workspaces")]
#[cfg(any(
feature = "bindmode",
feature = "hyprland",
feature = "keyboard",
feature = "workspaces",
))]
pub mod compositor;
#[cfg(feature = "keyboard")]
pub mod libinput;
#[cfg(feature = "cairo")]
pub mod lua;
#[cfg(feature = "music")]
pub mod music;
#[cfg(feature = "network_manager")]
pub mod networkmanager;
#[cfg(feature = "sway")]
pub mod sway;
#[cfg(feature = "notifications")]
pub mod swaync;
#[cfg(feature = "sys_info")]
pub mod sysinfo;
#[cfg(feature = "tray")]
pub mod tray;
#[cfg(feature = "upower")]
@ -31,16 +43,28 @@ pub struct Clients {
wayland: Option<Arc<wayland::Client>>,
#[cfg(feature = "workspaces")]
workspaces: Option<Arc<dyn compositor::WorkspaceClient>>,
#[cfg(feature = "sway")]
sway: Option<Arc<sway::Client>>,
#[cfg(feature = "hyprland")]
hyprland: Option<Arc<compositor::hyprland::Client>>,
#[cfg(feature = "bindmode")]
bindmode: Option<Arc<dyn compositor::BindModeClient>>,
#[cfg(feature = "clipboard")]
clipboard: Option<Arc<clipboard::Client>>,
#[cfg(feature = "keyboard")]
libinput: HashMap<Box<str>, Arc<libinput::Client>>,
#[cfg(feature = "keyboard")]
keyboard_layout: Option<Arc<dyn compositor::KeyboardLayoutClient>>,
#[cfg(feature = "cairo")]
lua: Option<Rc<lua::LuaEngine>>,
#[cfg(feature = "music")]
music: std::collections::HashMap<music::ClientType, Arc<dyn music::MusicClient>>,
music: HashMap<music::ClientType, Arc<dyn music::MusicClient>>,
#[cfg(feature = "network_manager")]
network_manager: Option<Arc<networkmanager::Client>>,
#[cfg(feature = "notifications")]
notifications: Option<Arc<swaync::Client>>,
#[cfg(feature = "sys_info")]
sys_info: Option<Arc<sysinfo::Client>>,
#[cfg(feature = "tray")]
tray: Option<Arc<tray::Client>>,
#[cfg(feature = "upower")]
@ -73,18 +97,68 @@ impl Clients {
#[cfg(feature = "workspaces")]
pub fn workspaces(&mut self) -> ClientResult<dyn compositor::WorkspaceClient> {
let client = match &self.workspaces {
Some(workspaces) => workspaces.clone(),
None => {
let client = compositor::Compositor::create_workspace_client()?;
self.workspaces.replace(client.clone());
client
}
let client = if let Some(workspaces) = &self.workspaces {
workspaces.clone()
} else {
let client = compositor::Compositor::create_workspace_client(self)?;
self.workspaces.replace(client.clone());
client
};
Ok(client)
}
#[cfg(feature = "keyboard")]
pub fn keyboard_layout(&mut self) -> ClientResult<dyn compositor::KeyboardLayoutClient> {
let client = if let Some(keyboard_layout) = &self.keyboard_layout {
keyboard_layout.clone()
} else {
let client = compositor::Compositor::create_keyboard_layout_client(self)?;
self.keyboard_layout.replace(client.clone());
client
};
Ok(client)
}
#[cfg(feature = "bindmode")]
pub fn bindmode(&mut self) -> ClientResult<dyn compositor::BindModeClient> {
let client = if let Some(client) = &self.bindmode {
client.clone()
} else {
let client = compositor::Compositor::create_bindmode_client(self)?;
self.bindmode.replace(client.clone());
client
};
Ok(client)
}
#[cfg(feature = "sway")]
pub fn sway(&mut self) -> ClientResult<sway::Client> {
let client = if let Some(client) = &self.sway {
client.clone()
} else {
let client = await_sync(async { sway::Client::new().await })?;
let client = Arc::new(client);
self.sway.replace(client.clone());
client
};
Ok(client)
}
#[cfg(feature = "hyprland")]
pub fn hyprland(&mut self) -> Arc<compositor::hyprland::Client> {
if let Some(client) = &self.hyprland {
client.clone()
} else {
let client = Arc::new(compositor::hyprland::Client::new());
self.hyprland.replace(client.clone());
client
}
}
#[cfg(feature = "cairo")]
pub fn lua(&mut self, config_dir: &Path) -> Rc<lua::LuaEngine> {
self.lua
@ -92,6 +166,17 @@ impl Clients {
.clone()
}
#[cfg(feature = "keyboard")]
pub fn libinput(&mut self, seat: &str) -> Arc<libinput::Client> {
if let Some(client) = self.libinput.get(seat) {
client.clone()
} else {
let client = libinput::Client::init(seat.to_string());
self.libinput.insert(seat.into(), client.clone());
client
}
}
#[cfg(feature = "music")]
pub fn music(&mut self, client_type: music::ClientType) -> Arc<dyn music::MusicClient> {
self.music
@ -102,55 +187,68 @@ impl Clients {
#[cfg(feature = "network_manager")]
pub fn network_manager(&mut self) -> ClientResult<networkmanager::Client> {
match &self.network_manager {
Some(client) => Ok(client.clone()),
None => {
let client = networkmanager::create_client()?;
self.network_manager = Some(client.clone());
Ok(client)
}
if let Some(client) = &self.network_manager {
Ok(client.clone())
} else {
let client = await_sync(async move { networkmanager::create_client().await })?;
self.network_manager = Some(client.clone());
Ok(client)
}
}
#[cfg(feature = "notifications")]
pub fn notifications(&mut self) -> ClientResult<swaync::Client> {
let client = match &self.notifications {
Some(client) => client.clone(),
None => {
let client = await_sync(async { swaync::Client::new().await })?;
let client = Arc::new(client);
self.notifications.replace(client.clone());
client
}
let client = if let Some(client) = &self.notifications {
client.clone()
} else {
let client = await_sync(async { swaync::Client::new().await })?;
let client = Arc::new(client);
self.notifications.replace(client.clone());
client
};
Ok(client)
}
#[cfg(feature = "sys_info")]
pub fn sys_info(&mut self) -> Arc<sysinfo::Client> {
self.sys_info
.get_or_insert_with(|| {
let client = Arc::new(sysinfo::Client::new());
#[cfg(feature = "ipc")]
Ironbar::variable_manager().register_namespace("sysinfo", client.clone());
client
})
.clone()
}
#[cfg(feature = "tray")]
pub fn tray(&mut self) -> ClientResult<tray::Client> {
let client = match &self.tray {
Some(client) => client.clone(),
None => {
let service_name = format!("{}-{}", env!("CARGO_CRATE_NAME"), Ironbar::unique_id());
let client = await_sync(async { tray::Client::new(&service_name).await })?;
let client = Arc::new(client);
self.tray.replace(client.clone());
client
}
let client = if let Some(client) = &self.tray {
client.clone()
} else {
let client = await_sync(async { tray::Client::new().await })?;
let client = Arc::new(client);
self.tray.replace(client.clone());
client
};
Ok(client)
}
#[cfg(feature = "upower")]
pub fn upower(&mut self) -> Arc<zbus::fdo::PropertiesProxy<'static>> {
self.upower
.get_or_insert_with(|| {
crate::await_sync(async { upower::create_display_proxy().await })
})
.clone()
pub fn upower(&mut self) -> ClientResult<zbus::fdo::PropertiesProxy<'static>> {
let client = if let Some(client) = &self.upower {
client.clone()
} else {
let client = await_sync(async { upower::create_display_proxy().await })?;
self.upower.replace(client.clone());
client
};
Ok(client)
}
#[cfg(feature = "volume")]

View file

@ -70,13 +70,17 @@ pub trait MusicClient: Debug + Send + Sync {
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum ClientType {
#[cfg(feature = "music+mpd")]
Mpd { host: String, music_dir: PathBuf },
#[cfg(feature = "music+mpris")]
Mpris,
}
pub fn create_client(client_type: ClientType) -> Arc<dyn MusicClient> {
match client_type {
#[cfg(feature = "music+mpd")]
ClientType::Mpd { host, music_dir } => Arc::new(mpd::Client::new(host, music_dir)),
#[cfg(feature = "music+mpris")]
ClientType::Mpris => Arc::new(mpris::Client::new()),
}
}

View file

@ -1,14 +1,15 @@
use super::{
MusicClient, PlayerState, PlayerUpdate, ProgressTick, Status, Track, TICK_INTERVAL_MS,
MusicClient, PlayerState, PlayerUpdate, ProgressTick, Status, TICK_INTERVAL_MS, Track,
};
use crate::{await_sync, send, spawn, Ironbar};
use crate::channels::SyncSenderExt;
use crate::{Ironbar, await_sync, spawn};
use color_eyre::Report;
use color_eyre::Result;
use mpd_client::client::{ConnectionEvent, Subsystem};
use mpd_client::commands::{self, SeekMode};
use mpd_client::responses::{PlayState, Song};
use mpd_client::tag::Tag;
use mpd_utils::{mpd_client, PersistentClient};
use mpd_utils::{PersistentClient, mpd_client};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
@ -97,7 +98,7 @@ impl Client {
let status = Status::from(status);
let update = PlayerUpdate::Update(Box::new(track), status);
send!(tx, update);
tx.send_expect(update);
}
Ok(())
@ -113,7 +114,7 @@ impl Client {
elapsed: status.elapsed,
});
send!(tx, update);
tx.send_expect(update);
}
}
}

View file

@ -1,6 +1,7 @@
use super::{MusicClient, PlayerState, PlayerUpdate, Status, Track, TICK_INTERVAL_MS};
use super::{MusicClient, PlayerState, PlayerUpdate, Status, TICK_INTERVAL_MS, Track};
use crate::channels::SyncSenderExt;
use crate::clients::music::ProgressTick;
use crate::{arc_mut, lock, send, spawn_blocking};
use crate::{arc_mut, lock, spawn_blocking};
use color_eyre::Result;
use mpris::{DBusError, Event, Metadata, PlaybackStatus, Player, PlayerFinder};
use std::cmp;
@ -47,10 +48,14 @@ impl Client {
)) if transport_error.name() == Some(NO_ACTIVE_PLAYER)
|| transport_error.name() == Some(NO_REPLY) =>
{
Vec::new()
vec![]
}
_ => {
error!("D-Bus error getting MPRIS players: {e:?}");
vec![]
}
_ => panic!("Failed to connect to D-Bus"),
});
// Acquire the lock of current_player before players to avoid deadlock.
// There are places where we lock on current_player and players, but we always lock on current_player first.
// This is because we almost never need to lock on players without locking on current_player.
@ -133,7 +138,7 @@ impl Client {
let mut players_locked = lock!(players);
players_locked.remove(identity);
if players_locked.is_empty() {
send!(tx, PlayerUpdate::Update(Box::new(None), Status::default()));
tx.send_expect(PlayerUpdate::Update(Box::new(None), Status::default()));
}
};
@ -208,7 +213,7 @@ impl Client {
let track = Track::from(metadata);
let player_update = PlayerUpdate::Update(Box::new(Some(track)), status);
send!(tx, player_update);
tx.send_expect(player_update);
Ok(())
}
@ -238,7 +243,7 @@ impl Client {
duration: metadata.length(),
});
send!(tx, update);
tx.send_expect(update);
}
}
}
@ -286,14 +291,22 @@ impl MusicClient for Client {
fn seek(&self, duration: Duration) -> Result<()> {
if let Some(player) = Self::get_player(self) {
let pos = player.get_position().unwrap_or_default();
// if possible, use `set_position` instead of `seek` because some players have issues with seeking
// see https://github.com/JakeStanger/ironbar/issues/970
if let Ok(metadata) = player.get_metadata() {
if let Some(track_id) = metadata.track_id() {
player.set_position(track_id, &duration)?;
} else {
let pos = player.get_position().unwrap_or_default();
let duration = duration.as_micros() as i64;
let position = pos.as_micros() as i64;
let duration = duration.as_micros() as i64;
let position = pos.as_micros() as i64;
let seek = cmp::max(duration, 0) - position;
let seek = cmp::max(duration, 0) - position;
player.seek(seek)?;
player.seek(seek)?;
}
}
} else {
error!("Could not find player");
}
@ -315,7 +328,9 @@ impl MusicClient for Client {
state: PlayerState::Stopped,
volume_percent: None,
};
send!(self.tx, PlayerUpdate::Update(Box::new(None), status));
self.tx
.send_expect(PlayerUpdate::Update(Box::new(None), status));
}
rx

181
src/clients/sway.rs Normal file
View file

@ -0,0 +1,181 @@
use crate::spawn;
use color_eyre::{Report, Result};
use futures_lite::StreamExt;
use std::sync::Arc;
use swayipc_async::{Connection, Event, EventType};
use tokio::sync::Mutex;
use tracing::{info, trace};
type SyncFn<T> = dyn Fn(&T) + Sync + Send;
struct TaskState {
join_handle: Option<tokio::task::JoinHandle<Result<()>>>,
// could have been a `HashMap<EventType, Vec<Box<dyn Fn(&Event) + Sync + Send>>>`, but we don't
// expect enough listeners to justify the constant overhead of a hashmap.
listeners: Arc<Vec<(EventType, Box<SyncFn<Event>>)>>,
}
pub struct Client {
connection: Arc<Mutex<Connection>>,
task_state: Mutex<TaskState>,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("client", &"Connection")
.field("task_state", &format_args!("<...>"))
.finish()
}
}
impl Client {
pub(crate) async fn new() -> Result<Self> {
// Avoid using `arc_mut!` here because we need tokio Mutex.
let client = Arc::new(Mutex::new(Connection::new().await?));
info!("Sway IPC subscription client connected");
Ok(Self {
connection: client,
task_state: Mutex::new(TaskState {
listeners: Arc::new(Vec::new()),
join_handle: None,
}),
})
}
pub fn connection(&self) -> &Arc<Mutex<Connection>> {
&self.connection
}
pub async fn add_listener<T: SwayIpcEvent>(
&self,
f: impl Fn(&T) + Sync + Send + 'static,
) -> Result<()> {
self.add_listener_type(
T::EVENT_TYPE,
Box::new(move |event| {
let event = T::from_event(event).expect("event type mismatch");
f(event);
}),
)
.await
}
pub async fn add_listener_type(
&self,
event_type: EventType,
f: Box<SyncFn<Event>>,
) -> Result<()> {
// abort current running task
let TaskState {
join_handle,
listeners,
} = &mut *self.task_state.lock().await;
if let Some(handle) = join_handle.take() {
handle.abort();
let _ = handle.await;
}
// Only the task and self have a reference to listeners, and we just abort the task. This
// is the only reference to listeners, so we can safely get a mutable reference.
let listeners_mut = Arc::get_mut(listeners)
.ok_or_else(|| Report::msg("Failed to get mutable reference to listeners"))?;
listeners_mut.push((event_type, f));
// create new client as subscription takes ownership
let client = Connection::new().await?;
let event_types = listeners.iter().map(|(t, _)| *t).collect::<Vec<_>>();
let listeners = listeners.clone();
let handle = spawn(async move {
let mut events = client.subscribe(&event_types).await?;
while let Some(event) = events.next().await {
trace!("event: {:?}", event);
let event = event?;
let ty = sway_event_to_event_type(&event);
for (t, f) in listeners.iter() {
if *t == ty {
f(&event);
}
}
}
Ok::<(), Report>(())
});
*join_handle = Some(handle);
Ok(())
}
}
fn sway_event_to_event_type(event: &Event) -> EventType {
match event {
Event::Workspace(_) => EventType::Workspace,
Event::Mode(_) => EventType::Mode,
Event::Window(_) => EventType::Window,
Event::BarConfigUpdate(_) => EventType::BarConfigUpdate,
Event::Binding(_) => EventType::Binding,
Event::Shutdown(_) => EventType::Shutdown,
Event::Tick(_) => EventType::Tick,
Event::BarStateUpdate(_) => EventType::BarStateUpdate,
Event::Input(_) => EventType::Input,
_ => todo!(),
}
}
pub trait SwayIpcEvent {
const EVENT_TYPE: EventType;
fn from_event(e: &Event) -> Option<&Self>;
}
macro_rules! sway_ipc_event_impl {
(@ $($t:tt)*) => { $($t)* };
($t:ty, $v:expr, $($m:tt)*) => {
sway_ipc_event_impl! {@
impl SwayIpcEvent for $t {
const EVENT_TYPE: EventType = $v;
fn from_event(e: &Event) -> Option<&Self> {
match e {
$($m)* (x) => Some(x),
_ => None,
}
}
}
}
};
}
sway_ipc_event_impl!(
swayipc_async::WorkspaceEvent,
EventType::Workspace,
Event::Workspace
);
sway_ipc_event_impl!(swayipc_async::ModeEvent, EventType::Mode, Event::Mode);
sway_ipc_event_impl!(swayipc_async::WindowEvent, EventType::Window, Event::Window);
sway_ipc_event_impl!(
swayipc_async::BarConfig,
EventType::BarConfigUpdate,
Event::BarConfigUpdate
);
sway_ipc_event_impl!(
swayipc_async::BindingEvent,
EventType::Binding,
Event::Binding
);
sway_ipc_event_impl!(
swayipc_async::ShutdownEvent,
EventType::Shutdown,
Event::Shutdown
);
sway_ipc_event_impl!(swayipc_async::TickEvent, EventType::Tick, Event::Tick);
sway_ipc_event_impl!(
swayipc_async::BarStateUpdateEvent,
EventType::BarStateUpdate,
Event::BarStateUpdate
);
sway_ipc_event_impl!(swayipc_async::InputEvent, EventType::Input, Event::Input);

View file

@ -20,12 +20,14 @@
//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html
//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces,
#[zbus::dbus_proxy(
use zbus::proxy;
#[proxy(
interface = "org.erikreider.swaync.cc",
default_service = "org.erikreider.swaync.cc",
default_path = "/org/erikreider/swaync/cc"
)]
trait SwayNc {
pub trait SwayNc {
/// AddInhibitor method
fn add_inhibitor(&self, application_id: &str) -> zbus::Result<bool>;
@ -90,11 +92,11 @@ trait SwayNc {
fn toggle_visibility(&self) -> zbus::Result<()>;
/// Subscribe signal
#[dbus_proxy(signal)]
#[zbus(signal)]
fn subscribe(&self, count: u32, dnd: bool, cc_open: bool) -> zbus::Result<()>;
/// SubscribeV2 signal
#[dbus_proxy(signal)]
#[zbus(signal)]
fn subscribe_v2(
&self,
count: u32,
@ -104,8 +106,8 @@ trait SwayNc {
) -> zbus::Result<()>;
/// Inhibited property
#[dbus_proxy(property)]
#[zbus(property)]
fn inhibited(&self) -> zbus::Result<bool>;
#[dbus_proxy(property)]
#[zbus(property)]
fn set_inhibited(&self, value: bool) -> zbus::Result<()>;
}

View file

@ -1,6 +1,7 @@
mod dbus;
use crate::{register_fallible_client, send, spawn};
use crate::channels::SyncSenderExt;
use crate::{register_fallible_client, spawn};
use color_eyre::{Report, Result};
use dbus::SwayNcProxy;
use serde::Deserialize;
@ -54,9 +55,13 @@ impl Client {
spawn(async move {
while let Some(ev) = stream.next().await {
let ev = ev.body::<Event>().expect("to deserialize");
let ev = ev
.message()
.body()
.deserialize::<Event>()
.expect("to deserialize");
debug!("Received event: {ev:?}");
send!(tx, ev);
tx.send_expect(ev);
}
});
}

621
src/clients/sysinfo.rs Normal file
View file

@ -0,0 +1,621 @@
use crate::modules::sysinfo::Interval;
use crate::{lock, register_client};
use color_eyre::{Report, Result};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::fmt::Debug;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use sysinfo::{Components, Disks, LoadAvg, Networks, RefreshKind, System};
#[repr(u64)]
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)]
pub enum Prefix {
#[default]
None = 1,
Kilo = 1000,
Mega = Prefix::Kilo as u64 * 1000,
Giga = Prefix::Mega as u64 * 1000,
Tera = Prefix::Giga as u64 * 1000,
Peta = Prefix::Tera as u64 * 1000,
Kibi = 1024,
Mebi = Prefix::Kibi as u64 * 1024,
Gibi = Prefix::Mebi as u64 * 1024,
Tebi = Prefix::Gibi as u64 * 1024,
Pebi = Prefix::Tebi as u64 * 1024,
// # Units
// These are special cases
// where you'd actually want to do slightly more than a prefix alone.
// Included as part of the prefix system for simplicity.
KiloBit = 128,
MegaBit = Prefix::KiloBit as u64 * 1024,
GigaBit = Prefix::MegaBit as u64 * 1024,
}
#[derive(Debug, Clone)]
pub enum Function {
None,
Sum,
Min,
Max,
Mean,
Name(String),
}
impl FromStr for Function {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"sum" => Ok(Self::Sum),
"min" => Ok(Self::Min),
"max" => Ok(Self::Max),
"mean" => Ok(Self::Mean),
"" => Err(()),
_ => Ok(Self::Name(s.to_string())),
}
}
}
#[derive(Debug)]
pub struct ValueSet {
values: HashMap<Box<str>, Value>,
}
impl FromIterator<(Box<str>, Value)> for ValueSet {
fn from_iter<T: IntoIterator<Item = (Box<str>, Value)>>(iter: T) -> Self {
Self {
values: iter.into_iter().collect(),
}
}
}
impl ValueSet {
fn values(&self, prefix: Prefix) -> impl Iterator<Item = f64> + use<'_> {
self.values
.values()
.map(move |v| v.get(prefix))
.filter(|v| !v.is_nan())
}
pub fn apply(&self, function: &Function, prefix: Prefix) -> f64 {
match function {
Function::None => 0.0,
Function::Sum => self.sum(prefix),
Function::Min => self.min(prefix),
Function::Max => self.max(prefix),
Function::Mean => self.mean(prefix),
Function::Name(name) => self
.values
.get(&Box::from(name.as_str()))
.map(|v| v.get(prefix))
.unwrap_or_default(),
}
}
fn sum(&self, prefix: Prefix) -> f64 {
self.values(prefix).sum()
}
fn min(&self, prefix: Prefix) -> f64 {
self.values(prefix)
.min_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
.unwrap_or_default()
}
fn max(&self, prefix: Prefix) -> f64 {
self.values(prefix)
.max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
.unwrap_or_default()
}
fn mean(&self, prefix: Prefix) -> f64 {
self.sum(prefix) / self.values.len() as f64
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub struct Value {
value: f64,
prefix: Prefix,
}
impl Value {
pub fn new(value: f64) -> Self {
Self::new_with_prefix(value, Prefix::None)
}
pub fn new_with_prefix(value: f64, prefix: Prefix) -> Self {
Self { value, prefix }
}
pub fn get(self, prefix: Prefix) -> f64 {
if prefix == self.prefix {
self.value
} else {
let scale = self.prefix as u64 as f64 / prefix as u64 as f64;
self.value * scale
}
}
}
#[derive(Debug)]
pub struct Client {
system: Mutex<System>,
disks: Mutex<Disks>,
components: Mutex<Components>,
networks: Mutex<Networks>,
load_average: Mutex<LoadAvg>,
}
impl Client {
pub fn new() -> Self {
let refresh_kind = RefreshKind::everything().without_processes();
let system = System::new_with_specifics(refresh_kind);
let disks = Disks::new_with_refreshed_list();
let components = Components::new_with_refreshed_list();
let networks = Networks::new_with_refreshed_list();
let load_average = System::load_average();
Self {
system: Mutex::new(system),
disks: Mutex::new(disks),
components: Mutex::new(components),
networks: Mutex::new(networks),
load_average: Mutex::new(load_average),
}
}
pub fn refresh_cpu(&self) {
lock!(self.system).refresh_cpu_all();
}
pub fn refresh_memory(&self) {
lock!(self.system).refresh_memory();
}
pub fn refresh_network(&self) {
lock!(self.networks).refresh(true);
}
pub fn refresh_temps(&self) {
lock!(self.components).refresh(true);
}
pub fn refresh_disks(&self) {
lock!(self.disks).refresh(true);
}
pub fn refresh_load_average(&self) {
*lock!(self.load_average) = System::load_average();
}
pub fn cpu_frequency(&self) -> ValueSet {
lock!(self.system)
.cpus()
.iter()
.map(|cpu| {
(
cpu.name().into(),
Value::new_with_prefix(cpu.frequency() as f64, Prefix::Mega),
)
})
.collect()
}
pub fn cpu_percent(&self) -> ValueSet {
lock!(self.system)
.cpus()
.iter()
.map(|cpu| (cpu.name().into(), Value::new(cpu.cpu_usage() as f64)))
.collect()
}
pub fn memory_free(&self) -> Value {
Value::new(lock!(self.system).free_memory() as f64)
}
pub fn memory_available(&self) -> Value {
Value::new(lock!(self.system).available_memory() as f64)
}
pub fn memory_total(&self) -> Value {
Value::new(lock!(self.system).total_memory() as f64)
}
pub fn memory_used(&self) -> Value {
Value::new(lock!(self.system).used_memory() as f64)
}
pub fn memory_percent(&self) -> Value {
let total = lock!(self.system).total_memory() as f64;
let used = lock!(self.system).used_memory() as f64;
Value::new(used / total * 100.0)
}
pub fn swap_free(&self) -> Value {
Value::new(lock!(self.system).free_swap() as f64)
}
pub fn swap_total(&self) -> Value {
Value::new(lock!(self.system).total_swap() as f64)
}
pub fn swap_used(&self) -> Value {
Value::new(lock!(self.system).used_swap() as f64)
}
pub fn swap_percent(&self) -> Value {
let total = lock!(self.system).total_swap() as f64;
let used = lock!(self.system).used_swap() as f64;
Value::new(used / total * 100.0)
}
pub fn temp_c(&self) -> ValueSet {
lock!(self.components)
.iter()
.map(|comp| {
(
comp.label().into(),
Value::new(comp.temperature().unwrap_or_default() as f64),
)
})
.collect()
}
pub fn temp_f(&self) -> ValueSet {
lock!(self.components)
.iter()
.map(|comp| {
(
comp.label().into(),
Value::new(c_to_f(comp.temperature().unwrap_or_default() as f64)),
)
})
.collect()
}
pub fn disk_free(&self) -> ValueSet {
lock!(self.disks)
.iter()
.map(|disk| {
(
disk.mount_point().to_string_lossy().into(),
Value::new(disk.available_space() as f64),
)
})
.collect()
}
pub fn disk_total(&self) -> ValueSet {
lock!(self.disks)
.iter()
.map(|disk| {
(
disk.mount_point().to_string_lossy().into(),
Value::new(disk.total_space() as f64),
)
})
.collect()
}
pub fn disk_used(&self) -> ValueSet {
lock!(self.disks)
.iter()
.map(|disk| {
(
disk.mount_point().to_string_lossy().into(),
Value::new((disk.total_space() - disk.available_space()) as f64),
)
})
.collect()
}
pub fn disk_percent(&self) -> ValueSet {
lock!(self.disks)
.iter()
.map(|disk| {
(
disk.mount_point().to_string_lossy().into(),
Value::new(
(disk.total_space() - disk.available_space()) as f64
/ disk.total_space() as f64
* 100.0,
),
)
})
.collect()
}
pub fn disk_read(&self, interval: Interval) -> ValueSet {
lock!(self.disks)
.iter()
.map(|disk| {
(
disk.mount_point().to_string_lossy().into(),
Value::new(disk.usage().read_bytes as f64 / interval.disks() as f64),
)
})
.collect()
}
pub fn disk_write(&self, interval: Interval) -> ValueSet {
lock!(self.disks)
.iter()
.map(|disk| {
(
disk.mount_point().to_string_lossy().into(),
Value::new(disk.usage().written_bytes as f64 / interval.disks() as f64),
)
})
.collect()
}
pub fn net_down(&self, interval: Interval) -> ValueSet {
lock!(self.networks)
.iter()
.map(|(name, net)| {
(
name.as_str().into(),
Value::new(net.received() as f64 / interval.networks() as f64),
)
})
.collect()
}
pub fn net_up(&self, interval: Interval) -> ValueSet {
lock!(self.networks)
.iter()
.map(|(name, net)| {
(
name.as_str().into(),
Value::new(net.transmitted() as f64 / interval.networks() as f64),
)
})
.collect()
}
pub fn load_average_1(&self) -> Value {
Value::new(lock!(self.load_average).one)
}
pub fn load_average_5(&self) -> Value {
Value::new(lock!(self.load_average).five)
}
pub fn load_average_15(&self) -> Value {
Value::new(lock!(self.load_average).fifteen)
}
/// Gets system uptime formatted as `HH:mm`.
pub fn uptime() -> String {
let uptime = System::uptime();
let hours = uptime / 3600;
format!("{:0>2}:{:0>2}", hours, (uptime % 3600) / 60)
}
}
register_client!(Client, sys_info);
const fn c_to_f(c: f64) -> f64 {
c / 5.0 * 9.0 + 32.0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenType {
CpuFrequency,
CpuPercent,
MemoryFree,
MemoryAvailable,
MemoryTotal,
MemoryUsed,
MemoryPercent,
SwapFree,
SwapTotal,
SwapUsed,
SwapPercent,
TempC,
TempF,
DiskFree,
DiskTotal,
DiskUsed,
DiskPercent,
DiskRead,
DiskWrite,
NetDown,
NetUp,
LoadAverage1,
LoadAverage5,
LoadAverage15,
Uptime,
}
impl FromStr for TokenType {
type Err = Report;
fn from_str(s: &str) -> Result<Self> {
match s {
"cpu_frequency" => Ok(Self::CpuFrequency),
"cpu_percent" => Ok(Self::CpuPercent),
"memory_free" => Ok(Self::MemoryFree),
"memory_available" => Ok(Self::MemoryAvailable),
"memory_total" => Ok(Self::MemoryTotal),
"memory_used" => Ok(Self::MemoryUsed),
"memory_percent" => Ok(Self::MemoryPercent),
"swap_free" => Ok(Self::SwapFree),
"swap_total" => Ok(Self::SwapTotal),
"swap_used" => Ok(Self::SwapUsed),
"swap_percent" => Ok(Self::SwapPercent),
"temp_c" => Ok(Self::TempC),
"temp_f" => Ok(Self::TempF),
"disk_free" => Ok(Self::DiskFree),
"disk_total" => Ok(Self::DiskTotal),
"disk_used" => Ok(Self::DiskUsed),
"disk_percent" => Ok(Self::DiskPercent),
"disk_read" => Ok(Self::DiskRead),
"disk_write" => Ok(Self::DiskWrite),
"net_down" => Ok(Self::NetDown),
"net_up" => Ok(Self::NetUp),
"load_average_1" => Ok(Self::LoadAverage1),
"load_average_5" => Ok(Self::LoadAverage5),
"load_average_15" => Ok(Self::LoadAverage15),
"uptime" => Ok(Self::Uptime),
_ => Err(Report::msg(format!("invalid token type: '{s}'"))),
}
}
}
#[cfg(feature = "ipc")]
use crate::ironvar::Namespace;
#[cfg(feature = "ipc")]
impl Namespace for Client {
fn get(&self, key: &str) -> Option<String> {
let get = |value: Value| Some(value.get(Prefix::None).to_string());
let token = TokenType::from_str(key).ok()?;
match token {
TokenType::CpuFrequency => None,
TokenType::CpuPercent => None,
TokenType::MemoryFree => get(self.memory_free()),
TokenType::MemoryAvailable => get(self.memory_available()),
TokenType::MemoryTotal => get(self.memory_total()),
TokenType::MemoryUsed => get(self.memory_used()),
TokenType::MemoryPercent => get(self.memory_percent()),
TokenType::SwapFree => get(self.swap_free()),
TokenType::SwapTotal => get(self.swap_total()),
TokenType::SwapUsed => get(self.swap_used()),
TokenType::SwapPercent => get(self.swap_percent()),
TokenType::TempC => None,
TokenType::TempF => None,
TokenType::DiskFree => None,
TokenType::DiskTotal => None,
TokenType::DiskUsed => None,
TokenType::DiskPercent => None,
TokenType::DiskRead => None,
TokenType::DiskWrite => None,
TokenType::NetDown => None,
TokenType::NetUp => None,
TokenType::LoadAverage1 => get(self.load_average_1()),
TokenType::LoadAverage5 => get(self.load_average_5()),
TokenType::LoadAverage15 => get(self.load_average_15()),
TokenType::Uptime => Some(Client::uptime()),
}
}
fn list(&self) -> Vec<String> {
vec![
"memory_free",
"memory_available",
"memory_total",
"memory_used",
"memory_percent",
"swap_free",
"swap_total",
"swap_used",
"swap_percent",
"load_average_1",
"load_average_5",
"load_average_15",
"uptime",
]
.into_iter()
.map(ToString::to_string)
.collect()
}
fn namespaces(&self) -> Vec<String> {
vec![
"cpu_frequency",
"cpu_percent",
"temp_c",
"temp_f",
"disk_free",
"disk_total",
"disk_used",
"disk_percent",
"disk_read",
"disk_write",
"net_down",
"net_up",
]
.into_iter()
.map(ToString::to_string)
.collect()
}
fn get_namespace(&self, key: &str) -> Option<Arc<dyn Namespace + Sync + Send>> {
let token = TokenType::from_str(key).ok()?;
match token {
TokenType::CpuFrequency => Some(Arc::new(self.cpu_frequency())),
TokenType::CpuPercent => Some(Arc::new(self.cpu_percent())),
TokenType::MemoryFree => None,
TokenType::MemoryAvailable => None,
TokenType::MemoryTotal => None,
TokenType::MemoryUsed => None,
TokenType::MemoryPercent => None,
TokenType::SwapFree => None,
TokenType::SwapTotal => None,
TokenType::SwapUsed => None,
TokenType::SwapPercent => None,
TokenType::TempC => Some(Arc::new(self.temp_c())),
TokenType::TempF => Some(Arc::new(self.temp_f())),
TokenType::DiskFree => Some(Arc::new(self.disk_free())),
TokenType::DiskTotal => Some(Arc::new(self.disk_total())),
TokenType::DiskUsed => Some(Arc::new(self.disk_used())),
TokenType::DiskPercent => Some(Arc::new(self.disk_percent())),
TokenType::DiskRead => Some(Arc::new(self.disk_read(Interval::All(1)))),
TokenType::DiskWrite => Some(Arc::new(self.disk_write(Interval::All(1)))),
TokenType::NetDown => Some(Arc::new(self.net_down(Interval::All(1)))),
TokenType::NetUp => Some(Arc::new(self.net_up(Interval::All(1)))),
TokenType::LoadAverage1 => None,
TokenType::LoadAverage5 => None,
TokenType::LoadAverage15 => None,
TokenType::Uptime => None,
}
}
}
#[cfg(feature = "ipc")]
impl Namespace for ValueSet {
fn get(&self, key: &str) -> Option<String> {
let function = Function::from_str(key).ok()?;
Some(self.apply(&function, Prefix::None).to_string())
}
fn list(&self) -> Vec<String> {
let mut vec = vec!["sum", "min", "max", "mean"]
.into_iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
vec.extend(self.values.keys().map(ToString::to_string));
vec
}
fn namespaces(&self) -> Vec<String> {
vec![]
}
fn get_namespace(&self, _key: &str) -> Option<Arc<dyn Namespace + Sync + Send>> {
None
}
}

View file

@ -1,35 +0,0 @@
use crate::register_client;
use std::sync::Arc;
use upower_dbus::UPowerProxy;
use zbus::fdo::PropertiesProxy;
pub async fn create_display_proxy() -> Arc<PropertiesProxy<'static>> {
let dbus = Box::pin(zbus::Connection::system())
.await
.expect("failed to create connection to system bus");
let device_proxy = UPowerProxy::new(&dbus)
.await
.expect("failed to create upower proxy");
let display_device = device_proxy
.get_display_device()
.await
.unwrap_or_else(|_| panic!("failed to get display device for {device_proxy:?}"));
let path = display_device.path().to_owned();
let proxy = PropertiesProxy::builder(&dbus)
.destination("org.freedesktop.UPower")
.expect("failed to set proxy destination address")
.path(path)
.expect("failed to set proxy path")
.cache_properties(zbus::CacheProperties::No)
.build()
.await
.expect("failed to build proxy");
Arc::new(proxy)
}
register_client!(PropertiesProxy<'static>, upower);

159
src/clients/upower/dbus.rs Normal file
View file

@ -0,0 +1,159 @@
/// Originally taken from `upower-dbus` crate
/// <https://github.com/pop-os/upower-dbus/blob/main/LICENSE>
// Copyright 2021 System76 <info@system76.com>
// SPDX-License-Identifier: MPL-2.0
use zbus::proxy;
use zbus::zvariant::OwnedValue;
#[derive(Debug, Copy, Clone, PartialEq, Eq, OwnedValue)]
#[repr(u32)]
pub enum BatteryState {
Unknown = 0,
Charging = 1,
Discharging = 2,
Empty = 3,
FullyCharged = 4,
PendingCharge = 5,
PendingDischarge = 6,
}
#[derive(Debug, Copy, Clone, OwnedValue)]
#[repr(u32)]
pub enum BatteryType {
Unknown = 0,
LinePower = 1,
Battery = 2,
Ups = 3,
Monitor = 4,
Mouse = 5,
Keyboard = 6,
Pda = 7,
Phone = 8,
}
#[derive(Debug, Copy, Clone, OwnedValue)]
#[repr(u32)]
pub enum BatteryLevel {
Unknown = 0,
None = 1,
Low = 3,
Critical = 4,
Normal = 6,
High = 7,
Full = 8,
}
#[proxy(
interface = "org.freedesktop.UPower.Device",
default_service = "org.freedesktop.UPower",
assume_defaults = false
)]
pub trait Device {
#[zbus(property)]
fn battery_level(&self) -> zbus::Result<BatteryLevel>;
#[zbus(property)]
fn capacity(&self) -> zbus::Result<f64>;
#[zbus(property)]
fn energy(&self) -> zbus::Result<f64>;
#[zbus(property)]
fn energy_empty(&self) -> zbus::Result<f64>;
#[zbus(property)]
fn energy_full(&self) -> zbus::Result<f64>;
#[zbus(property)]
fn energy_full_design(&self) -> zbus::Result<f64>;
#[zbus(property)]
fn has_history(&self) -> zbus::Result<bool>;
#[zbus(property)]
fn has_statistics(&self) -> zbus::Result<bool>;
#[zbus(property)]
fn icon_name(&self) -> zbus::Result<String>;
#[zbus(property)]
fn is_present(&self) -> zbus::Result<bool>;
#[zbus(property)]
fn is_rechargeable(&self) -> zbus::Result<bool>;
#[zbus(property)]
fn luminosity(&self) -> zbus::Result<f64>;
#[zbus(property)]
fn model(&self) -> zbus::Result<String>;
#[zbus(property)]
fn native_path(&self) -> zbus::Result<String>;
#[zbus(property)]
fn online(&self) -> zbus::Result<bool>;
#[zbus(property)]
fn percentage(&self) -> zbus::Result<f64>;
#[zbus(property)]
fn power_supply(&self) -> zbus::Result<bool>;
fn refresh(&self) -> zbus::Result<()>;
#[zbus(property)]
fn serial(&self) -> zbus::Result<String>;
#[zbus(property)]
fn state(&self) -> zbus::Result<BatteryState>;
#[zbus(property)]
fn temperature(&self) -> zbus::Result<f64>;
#[zbus(property, name = "Type")]
fn type_(&self) -> zbus::Result<BatteryType>;
#[zbus(property)]
fn vendor(&self) -> zbus::Result<String>;
#[zbus(property)]
fn voltage(&self) -> zbus::Result<f64>;
}
#[proxy(interface = "org.freedesktop.UPower", assume_defaults = true)]
pub trait UPower {
/// EnumerateDevices method
fn enumerate_devices(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>;
/// GetCriticalAction method
fn get_critical_action(&self) -> zbus::Result<String>;
/// GetDisplayDevice method
#[zbus(object = "Device")]
fn get_display_device(&self);
/// DeviceAdded signal
#[zbus(signal)]
fn device_added(&self, device: zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>;
/// DeviceRemoved signal
#[zbus(signal)]
fn device_removed(&self, device: zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>;
/// DaemonVersion property
#[zbus(property)]
fn daemon_version(&self) -> zbus::Result<String>;
/// LidIsClosed property
#[zbus(property)]
fn lid_is_closed(&self) -> zbus::Result<bool>;
/// LidIsPresent property
#[zbus(property)]
fn lid_is_present(&self) -> zbus::Result<bool>;
/// OnBattery property
#[zbus(property)]
fn on_battery(&self) -> zbus::Result<bool>;
}

33
src/clients/upower/mod.rs Normal file
View file

@ -0,0 +1,33 @@
mod dbus;
use crate::clients::ClientResult;
use crate::register_fallible_client;
use dbus::UPowerProxy;
use std::sync::Arc;
use zbus::fdo::PropertiesProxy;
use zbus::proxy::CacheProperties;
pub use dbus::BatteryState;
pub async fn create_display_proxy() -> ClientResult<PropertiesProxy<'static>> {
let dbus = Box::pin(zbus::Connection::system()).await?;
let device_proxy = UPowerProxy::new(&dbus).await?;
let display_device = device_proxy.get_display_device().await?;
let path = display_device.inner().path();
let proxy = PropertiesProxy::builder(&dbus)
.destination("org.freedesktop.UPower")
.expect("failed to set proxy destination address")
.path(path)
.expect("failed to set proxy path")
.cache_properties(CacheProperties::No)
.build()
.await?;
Ok(Arc::new(proxy))
}
register_fallible_client!(PropertiesProxy<'static>, upower);

View file

@ -1,7 +1,7 @@
mod sink;
mod sink_input;
use crate::{arc_mut, lock, register_client, send, spawn_blocking, APP_ID};
use crate::{APP_ID, arc_mut, lock, register_client, spawn_blocking};
use libpulse_binding::callbacks::ListResult;
use libpulse_binding::context::introspect::{Introspector, ServerInfo};
use libpulse_binding::context::subscribe::{Facility, InterestMaskSet, Operation};
@ -12,8 +12,9 @@ use libpulse_binding::volume::{ChannelVolumes, Volume};
use std::fmt::{Debug, Formatter};
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
use tracing::{debug, error, info, warn};
use tracing::{debug, error, info, trace, warn};
use crate::channels::SyncSenderExt;
pub use sink::Sink;
pub use sink_input::SinkInput;
@ -230,6 +231,8 @@ fn on_event(
return;
};
trace!("server event: {facility:?}, op: {op:?}, i: {i}");
match facility {
Facility::Server => on_server_event(context, &data.sinks, &data.default_sink_name, tx),
Facility::Sink => sink::on_event(context, &data.sinks, &data.default_sink_name, tx, op, i),
@ -269,7 +272,7 @@ fn set_default_sink(
{
sink.active = true;
debug!("Set sink active: {}", sink.name);
send!(tx, Event::UpdateSink(sink.clone()));
tx.send_expect(Event::UpdateSink(sink.clone()));
} else {
warn!("Couldn't find sink: {}", default_sink_name);
}

View file

@ -1,13 +1,14 @@
use super::{percent_to_volume, volume_to_percent, ArcMutVec, Client, ConnectionState, Event};
use crate::{lock, send};
use super::{ArcMutVec, Client, ConnectionState, Event, percent_to_volume, volume_to_percent};
use crate::channels::SyncSenderExt;
use crate::lock;
use libpulse_binding::callbacks::ListResult;
use libpulse_binding::context::Context;
use libpulse_binding::context::introspect::SinkInfo;
use libpulse_binding::context::subscribe::Operation;
use libpulse_binding::context::Context;
use libpulse_binding::def::SinkState;
use std::sync::{mpsc, Arc, Mutex};
use std::sync::{Arc, Mutex, mpsc};
use tokio::sync::broadcast;
use tracing::{debug, error};
use tracing::{debug, error, instrument, trace};
#[derive(Debug, Clone)]
pub struct Sink {
@ -41,16 +42,19 @@ impl From<&SinkInfo<'_>> for Sink {
}
impl Client {
#[instrument(level = "trace")]
pub fn sinks(&self) -> Arc<Mutex<Vec<Sink>>> {
self.data.sinks.clone()
}
#[instrument(level = "trace")]
pub fn set_default_sink(&self, name: &str) {
if let ConnectionState::Connected { context, .. } = &*lock!(self.connection) {
lock!(context).set_default_sink(name, |_| {});
}
}
#[instrument(level = "trace")]
pub fn set_sink_volume(&self, name: &str, volume_percent: f64) {
if let ConnectionState::Connected { introspector, .. } = &mut *lock!(self.connection) {
let (tx, rx) = mpsc::channel();
@ -59,7 +63,7 @@ impl Client {
let ListResult::Item(info) = info else {
return;
};
send!(tx, info.volume);
tx.send_expect(info.volume);
});
let new_volume = percent_to_volume(volume_percent);
@ -73,6 +77,7 @@ impl Client {
}
}
#[instrument(level = "trace")]
pub fn set_sink_muted(&self, name: &str, muted: bool) {
if let ConnectionState::Connected { introspector, .. } = &mut *lock!(self.connection) {
introspector.set_sink_mute_by_name(name, muted, None);
@ -122,8 +127,10 @@ pub fn add(info: ListResult<&SinkInfo>, sinks: &ArcMutVec<Sink>, tx: &broadcast:
return;
};
trace!("adding {info:?}");
lock!(sinks).push(info.into());
send!(tx, Event::AddSink(info.into()));
tx.send_expect(Event::AddSink(info.into()));
}
fn update(
@ -136,6 +143,8 @@ fn update(
return;
};
trace!("updating {info:?}");
{
let mut sinks = lock!(sinks);
let Some(pos) = sinks.iter().position(|sink| sink.index == info.index) else {
@ -162,14 +171,16 @@ fn update(
}
}
send!(tx, Event::UpdateSink(sink));
tx.send_expect(Event::UpdateSink(sink));
}
fn remove(index: u32, sinks: &ArcMutVec<Sink>, tx: &broadcast::Sender<Event>) {
trace!("removing {index}");
let mut sinks = lock!(sinks);
if let Some(pos) = sinks.iter().position(|s| s.index == index) {
let info = sinks.remove(pos);
send!(tx, Event::RemoveSink(info.name));
tx.send_expect(Event::RemoveSink(info.name));
}
}

View file

@ -1,12 +1,13 @@
use super::{percent_to_volume, volume_to_percent, ArcMutVec, Client, ConnectionState, Event};
use crate::{lock, send};
use super::{ArcMutVec, Client, ConnectionState, Event, percent_to_volume, volume_to_percent};
use crate::channels::SyncSenderExt;
use crate::lock;
use libpulse_binding::callbacks::ListResult;
use libpulse_binding::context::Context;
use libpulse_binding::context::introspect::SinkInputInfo;
use libpulse_binding::context::subscribe::Operation;
use libpulse_binding::context::Context;
use std::sync::{mpsc, Arc, Mutex};
use std::sync::{Arc, Mutex, mpsc};
use tokio::sync::broadcast;
use tracing::{debug, error};
use tracing::{debug, error, instrument, trace};
#[derive(Debug, Clone)]
pub struct SinkInput {
@ -35,10 +36,12 @@ impl From<&SinkInputInfo<'_>> for SinkInput {
}
impl Client {
#[instrument(level = "trace")]
pub fn sink_inputs(&self) -> Arc<Mutex<Vec<SinkInput>>> {
self.data.sink_inputs.clone()
}
#[instrument(level = "trace")]
pub fn set_input_volume(&self, index: u32, volume_percent: f64) {
if let ConnectionState::Connected { introspector, .. } = &mut *lock!(self.connection) {
let (tx, rx) = mpsc::channel();
@ -47,7 +50,7 @@ impl Client {
let ListResult::Item(info) = info else {
return;
};
send!(tx, info.volume);
tx.send_expect(info.volume);
});
let new_volume = percent_to_volume(volume_percent);
@ -61,6 +64,7 @@ impl Client {
}
}
#[instrument(level = "trace")]
pub fn set_input_muted(&self, index: u32, muted: bool) {
if let ConnectionState::Connected { introspector, .. } = &mut *lock!(self.connection) {
introspector.set_sink_input_mute(index, muted, None);
@ -112,8 +116,10 @@ pub fn add(
return;
};
trace!("adding {info:?}");
lock!(inputs).push(info.into());
send!(tx, Event::AddInput(info.into()));
tx.send_expect(Event::AddInput(info.into()));
}
fn update(
@ -125,6 +131,8 @@ fn update(
return;
};
trace!("updating {info:?}");
{
let mut inputs = lock!(inputs);
let Some(pos) = inputs.iter().position(|input| input.index == info.index) else {
@ -135,14 +143,16 @@ fn update(
inputs[pos] = info.into();
}
send!(tx, Event::UpdateInput(info.into()));
tx.send_expect(Event::UpdateInput(info.into()));
}
fn remove(index: u32, inputs: &ArcMutVec<SinkInput>, tx: &broadcast::Sender<Event>) {
let mut inputs = lock!(inputs);
trace!("removing {index}");
if let Some(pos) = inputs.iter().position(|s| s.index == index) {
let info = inputs.remove(pos);
send!(tx, Event::RemoveInput(info.index));
tx.send_expect(Event::RemoveInput(info.index));
}
}

View file

@ -2,17 +2,18 @@ mod macros;
mod wl_output;
mod wl_seat;
use crate::error::{ExitCode, ERR_CHANNEL_RECV};
use crate::{arc_mut, lock, register_client, send, spawn, spawn_blocking};
use crate::error::{ERR_CHANNEL_RECV, ExitCode};
use crate::{arc_mut, lock, register_client, spawn, spawn_blocking};
use std::process::exit;
use std::sync::{Arc, Mutex};
use crate::channels::SyncSenderExt;
use calloop_channel::Event::Msg;
use cfg_if::cfg_if;
use color_eyre::Report;
use color_eyre::{Help, Report};
use smithay_client_toolkit::output::OutputState;
use smithay_client_toolkit::reexports::calloop::EventLoop;
use smithay_client_toolkit::reexports::calloop::channel as calloop_channel;
use smithay_client_toolkit::reexports::calloop::{EventLoop, LoopHandle};
use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
use smithay_client_toolkit::registry::{ProvidesRegistryState, RegistryState};
use smithay_client_toolkit::seat::SeatState;
@ -43,7 +44,6 @@ cfg_if! {
use self::wlr_data_control::device::DataControlDevice;
use self::wlr_data_control::manager::DataControlDeviceManagerState;
use self::wlr_data_control::source::CopyPasteSource;
use self::wlr_data_control::SelectionOfferItem;
use wayland_client::protocol::wl_seat::WlSeat;
pub use wlr_data_control::{ClipboardItem, ClipboardValue};
@ -76,6 +76,8 @@ pub enum Request {
ToplevelInfoAll,
#[cfg(feature = "launcher")]
ToplevelFocus(usize),
#[cfg(feature = "launcher")]
ToplevelMinimize(usize),
#[cfg(feature = "clipboard")]
CopyToClipboard(ClipboardItem),
@ -150,12 +152,12 @@ impl Client {
spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
Event::Output(event) => send!(output_tx, event),
Event::Output(event) => output_tx.send_expect(event),
#[cfg(any(feature = "focused", feature = "launcher"))]
Event::Toplevel(event) => send!(toplevel_tx, event),
Event::Toplevel(event) => toplevel_tx.send_expect(event),
#[cfg(feature = "clipboard")]
Event::Clipboard(item) => send!(clipboard_tx, item),
};
Event::Clipboard(item) => clipboard_tx.send_expect(item),
}
}
});
}
@ -175,7 +177,7 @@ impl Client {
/// Sends a request to the environment event loop,
/// and returns the response.
fn send_request(&self, request: Request) -> Response {
send!(self.tx, request);
self.tx.send_expect(request);
lock!(self.rx).recv().expect(ERR_CHANNEL_RECV)
}
@ -193,7 +195,6 @@ pub struct Environment {
seat_state: SeatState,
queue_handle: QueueHandle<Self>,
loop_handle: LoopHandle<'static, Self>,
event_tx: mpsc::Sender<Event>,
response_tx: std::sync::mpsc::Sender<Response>,
@ -204,14 +205,12 @@ pub struct Environment {
// -- clipboard --
#[cfg(feature = "clipboard")]
data_control_device_manager_state: DataControlDeviceManagerState,
data_control_device_manager_state: Option<DataControlDeviceManagerState>,
#[cfg(feature = "clipboard")]
data_control_devices: Vec<DataControlDeviceEntry>,
#[cfg(feature = "clipboard")]
copy_paste_sources: Vec<CopyPasteSource>,
#[cfg(feature = "clipboard")]
selection_offers: Vec<SelectionOfferItem>,
// local state
#[cfg(feature = "clipboard")]
@ -265,12 +264,30 @@ impl Environment {
let output_state = OutputState::new(&globals, &qh);
let seat_state = SeatState::new(&globals, &qh);
#[cfg(any(feature = "focused", feature = "launcher"))]
ToplevelManagerState::bind(&globals, &qh)
.expect("to bind to wlr_foreign_toplevel_manager global");
if let Err(err) = ToplevelManagerState::bind(&globals, &qh) {
error!("{:?}",
Report::new(err)
.wrap_err("Failed to bind to wlr_foreign_toplevel_manager global")
.note("This is likely a due to the current compositor not supporting the required protocol")
.note("launcher and focused modules will not work")
);
}
#[cfg(feature = "clipboard")]
let data_control_device_manager_state = DataControlDeviceManagerState::bind(&globals, &qh)
.expect("to bind to wlr_data_control_device_manager global");
let data_control_device_manager_state = match DataControlDeviceManagerState::bind(
&globals, &qh,
) {
Ok(state) => Some(state),
Err(err) => {
error!("{:?}",
Report::new(err)
.wrap_err("Failed to bind to wlr_data_control_device global")
.note("This is likely a due to the current compositor not supporting the required protocol")
.note("clipboard module will not work")
);
None
}
};
let mut env = Self {
registry_state,
@ -279,7 +296,6 @@ impl Environment {
#[cfg(feature = "clipboard")]
data_control_device_manager_state,
queue_handle: qh,
loop_handle: loop_handle.clone(),
event_tx,
response_tx,
#[cfg(any(feature = "focused", feature = "launcher"))]
@ -290,8 +306,6 @@ impl Environment {
#[cfg(feature = "clipboard")]
copy_paste_sources: vec![],
#[cfg(feature = "clipboard")]
selection_offers: vec![],
#[cfg(feature = "clipboard")]
clipboard: arc_mut!(None),
};
@ -320,12 +334,12 @@ impl Environment {
match event {
Msg(Request::Roundtrip) => {
debug!("received roundtrip request");
send!(env.response_tx, Response::Ok);
env.response_tx.send_expect(Response::Ok);
}
#[cfg(feature = "ipc")]
Msg(Request::OutputInfoAll) => {
let infos = env.output_info_all();
send!(env.response_tx, Response::OutputInfoAll(infos));
env.response_tx.send_expect(Response::OutputInfoAll(infos));
}
#[cfg(any(feature = "focused", feature = "launcher"))]
Msg(Request::ToplevelInfoAll) => {
@ -334,31 +348,46 @@ impl Environment {
.iter()
.filter_map(ToplevelHandle::info)
.collect();
send!(env.response_tx, Response::ToplevelInfoAll(infos));
env.response_tx
.send_expect(Response::ToplevelInfoAll(infos));
}
#[cfg(feature = "launcher")]
Msg(Request::ToplevelFocus(id)) => {
let handle = env
.handles
.iter()
.find(|handle| handle.info().map_or(false, |info| info.id == id));
.find(|handle| handle.info().is_some_and(|info| info.id == id));
if let Some(handle) = handle {
let seat = env.default_seat();
handle.focus(&seat);
}
send!(env.response_tx, Response::Ok);
env.response_tx.send_expect(Response::Ok);
}
#[cfg(feature = "launcher")]
Msg(Request::ToplevelMinimize(id)) => {
let handle = env
.handles
.iter()
.find(|handle| handle.info().is_some_and(|info| info.id == id));
if let Some(handle) = handle {
handle.minimize();
}
env.response_tx.send_expect(Response::Ok);
}
#[cfg(feature = "clipboard")]
Msg(Request::CopyToClipboard(item)) => {
env.copy_to_clipboard(item);
send!(env.response_tx, Response::Ok);
env.response_tx.send_expect(Response::Ok);
}
#[cfg(feature = "clipboard")]
Msg(Request::ClipboardItem) => {
let item = lock!(env.clipboard).clone();
send!(env.response_tx, Response::ClipboardItem(item));
env.response_tx.send_expect(Response::ClipboardItem(item));
}
calloop_channel::Event::Closed => error!("request channel unexpectedly closed"),
}

View file

@ -1,5 +1,5 @@
use super::{Client, Environment, Event};
use crate::try_send;
use crate::channels::AsyncSenderExt;
use smithay_client_toolkit::output::{OutputHandler, OutputInfo, OutputState};
use tokio::sync::broadcast;
use tracing::{debug, error};
@ -12,7 +12,7 @@ pub struct OutputEvent {
pub event_type: OutputEventType,
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputEventType {
New,
Update,
@ -63,13 +63,10 @@ impl OutputHandler for Environment {
fn new_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, output: WlOutput) {
debug!("Handler received new output");
if let Some(info) = self.output_state.info(&output) {
try_send!(
self.event_tx,
Event::Output(OutputEvent {
output: info,
event_type: OutputEventType::New
})
);
self.event_tx.send_spawn(Event::Output(OutputEvent {
output: info,
event_type: OutputEventType::New,
}));
} else {
error!("Output is missing information!");
}
@ -78,13 +75,10 @@ impl OutputHandler for Environment {
fn update_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, output: WlOutput) {
debug!("Handle received output update");
if let Some(info) = self.output_state.info(&output) {
try_send!(
self.event_tx,
Event::Output(OutputEvent {
output: info,
event_type: OutputEventType::Update
})
);
self.event_tx.send_spawn(Event::Output(OutputEvent {
output: info,
event_type: OutputEventType::Update,
}));
} else {
error!("Output is missing information!");
}
@ -93,13 +87,10 @@ impl OutputHandler for Environment {
fn output_destroyed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, output: WlOutput) {
debug!("Handle received output destruction");
if let Some(info) = self.output_state.info(&output) {
try_send!(
self.event_tx,
Event::Output(OutputEvent {
output: info,
event_type: OutputEventType::Destroyed
})
);
self.event_tx.send_spawn(Event::Output(OutputEvent {
output: info,
event_type: OutputEventType::Destroyed,
}));
} else {
error!("Output is missing information!");
}

View file

@ -1,6 +1,6 @@
use super::Environment;
use smithay_client_toolkit::seat::{Capability, SeatHandler, SeatState};
use tracing::debug;
use tracing::{debug, error};
use wayland_client::protocol::wl_seat::WlSeat;
use wayland_client::{Connection, QueueHandle};
@ -37,7 +37,11 @@ impl SeatHandler for Environment {
{
debug!("Adding new data control device");
// create the data device here for this seat
let data_control_device_manager = &self.data_control_device_manager_state;
let Some(data_control_device_manager) = &self.data_control_device_manager_state else {
error!("data_control_device_manager not available, cannot copy");
return;
};
let data_control_device = data_control_device_manager.get_data_device(qh, &seat);
self.data_control_devices
.push(super::DataControlDeviceEntry {

View file

@ -6,7 +6,7 @@ use crate::error::ERR_WAYLAND_DATA;
use crate::lock;
use std::sync::{Arc, Mutex};
use tracing::warn;
use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHandle};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, event_created_child};
use wayland_protocols_wlr::data_control::v1::client::{
zwlr_data_control_device_v1::{Event, ZwlrDataControlDeviceV1},
zwlr_data_control_offer_v1::ZwlrDataControlOfferV1,
@ -37,7 +37,9 @@ pub trait DataControlDeviceDataExt: Send + Sync {
fn selection_mime_types(&self) -> Vec<String> {
let inner = self.data_control_device_data();
lock!(lock!(inner.inner).selection_offer)
let offer = &lock!(inner.inner).selection_offer;
lock!(offer)
.as_ref()
.map(|offer| {
let data = offer
@ -51,14 +53,14 @@ pub trait DataControlDeviceDataExt: Send + Sync {
/// Get the active selection offer if it exists.
fn selection_offer(&self) -> Option<SelectionOffer> {
let inner = self.data_control_device_data();
lock!(lock!(inner.inner).selection_offer)
.as_ref()
.and_then(|offer| {
let data = offer
.data::<Self::DataControlOfferInner>()
.expect(ERR_WAYLAND_DATA);
data.as_selection_offer()
})
let offer = &lock!(inner.inner).selection_offer;
lock!(offer).as_ref().and_then(|offer| {
let data = offer
.data::<Self::DataControlOfferInner>()
.expect(ERR_WAYLAND_DATA);
data.as_selection_offer()
})
}
}
@ -159,7 +161,9 @@ where
}
}
Event::Finished => {
warn!("Data control offer is no longer valid, but has not been dropped by client. This could cause clipboard issues.");
warn!(
"Data control offer is no longer valid, but has not been dropped by client. This could cause clipboard issues."
);
}
_ => {}
}

View file

@ -4,23 +4,29 @@ pub mod offer;
pub mod source;
use self::device::{DataControlDeviceDataExt, DataControlDeviceHandler};
use self::offer::{DataControlDeviceOffer, DataControlOfferHandler, SelectionOffer};
use self::offer::{DataControlDeviceOffer, DataControlOfferHandler};
use self::source::DataControlSourceHandler;
use super::{Client, Environment, Event, Request, Response};
use crate::{lock, try_send, Ironbar};
use crate::channels::AsyncSenderExt;
use crate::{Ironbar, lock, spawn};
use color_eyre::Result;
use device::DataControlDevice;
use glib::Bytes;
use nix::fcntl::{fcntl, F_GETPIPE_SZ, F_SETPIPE_SZ};
use nix::sys::epoll::{Epoll, EpollCreateFlags, EpollEvent, EpollFlags, EpollTimeout};
use rustix::buffer::spare_capacity;
use rustix::event::epoll;
use rustix::event::epoll::CreateFlags;
use rustix::fs::Timespec;
use rustix::pipe::{fcntl_getpipe_size, fcntl_setpipe_size};
use smithay_client_toolkit::data_device_manager::WritePipe;
use smithay_client_toolkit::reexports::calloop::{PostAction, RegistrationToken};
use std::cmp::min;
use std::fmt::{Debug, Formatter};
use std::fs::File;
use std::io::{ErrorKind, Read, Write};
use std::os::fd::{AsRawFd, OwnedFd, RawFd};
use std::io::Write;
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use std::sync::Arc;
use std::time::Duration;
use std::{fs, io};
use tokio::io::AsyncReadExt;
use tokio::sync::broadcast;
use tracing::{debug, error, trace};
use wayland_client::{Connection, QueueHandle};
@ -28,12 +34,6 @@ use wayland_protocols_wlr::data_control::v1::client::zwlr_data_control_source_v1
const INTERNAL_MIME_TYPE: &str = "x-ironbar-internal";
#[derive(Debug)]
pub struct SelectionOfferItem {
offer: SelectionOffer,
token: Option<RegistrationToken>,
}
/// Represents a value which can be read/written
/// to/from the system clipboard and surrounding metadata.
///
@ -148,6 +148,11 @@ impl Environment {
pub fn copy_to_clipboard(&mut self, item: ClipboardItem) {
debug!("Copying item to clipboard: {item:?}");
let Some(data_control_device_manager) = &self.data_control_device_manager_state else {
error!("data_control_device_manager not available, cannot copy");
return;
};
let seat = self.default_seat();
let Some(device) = self
.data_control_devices
@ -157,9 +162,8 @@ impl Environment {
return;
};
let source = self
.data_control_device_manager_state
.create_copy_paste_source(&self.queue_handle, [INTERNAL_MIME_TYPE, &item.mime_type]);
let source = data_control_device_manager
.create_copy_paste_source(&self.queue_handle, [&item.mime_type, INTERNAL_MIME_TYPE]);
source.set_selection(&device.device);
self.copy_paste_sources.push(source);
@ -168,22 +172,20 @@ impl Environment {
}
/// Reads an offer file handle into a new `ClipboardItem`.
fn read_file(mime_type: &MimeType, file: &mut File) -> io::Result<ClipboardItem> {
async fn read_file(
mime_type: &MimeType,
file: &mut tokio::net::unix::pipe::Receiver,
) -> io::Result<ClipboardItem> {
let mut buf = vec![];
file.read_to_end(&mut buf).await?;
let value = match mime_type.category {
MimeTypeCategory::Text => {
let mut txt = String::new();
file.read_to_string(&mut txt)?;
let txt = String::from_utf8_lossy(&buf).to_string();
ClipboardValue::Text(txt)
}
MimeTypeCategory::Image => {
let mut bytes = vec![];
file.read_to_end(&mut bytes)?;
debug!("Read bytes: {}", bytes.len());
let bytes = Bytes::from(&bytes);
let bytes = Bytes::from(&buf);
ClipboardValue::Image(bytes)
}
};
@ -214,68 +216,33 @@ impl DataControlDeviceHandler for Environment {
}
if let Some(offer) = data_device.selection_offer() {
self.selection_offers
.push(SelectionOfferItem { offer, token: None });
let cur_offer = self
.selection_offers
.last_mut()
.expect("Failed to get current offer");
// clear prev
let Some(mime_type) = MimeType::parse_multiple(&mime_types) else {
lock!(self.clipboard).take();
// send an event so the clipboard module is aware it's changed
try_send!(
self.event_tx,
Event::Clipboard(ClipboardItem {
id: usize::MAX,
mime_type: String::new().into(),
value: Arc::new(ClipboardValue::Other)
})
);
self.event_tx.send_spawn(Event::Clipboard(ClipboardItem {
id: usize::MAX,
mime_type: String::new().into(),
value: Arc::new(ClipboardValue::Other),
}));
return;
};
debug!("Receiving mime type: {}", mime_type.value);
if let Ok(read_pipe) = cur_offer.offer.receive(mime_type.value.clone()) {
let offer_clone = cur_offer.offer.clone();
if let Ok(mut read_pipe) = offer.receive(mime_type.value.clone()) {
let tx = self.event_tx.clone();
let clipboard = self.clipboard.clone();
let token =
self.loop_handle
.insert_source(read_pipe, move |(), file, state| unsafe {
let item = state
.selection_offers
.iter()
.position(|o| o.offer == offer_clone)
.map(|p| state.selection_offers.remove(p))
.expect("Failed to find selection offer item");
match Self::read_file(&mime_type, file.get_mut()) {
Ok(item) => {
lock!(clipboard).replace(item.clone());
try_send!(tx, Event::Clipboard(item));
}
Err(err) => error!("{err:?}"),
}
state
.loop_handle
.remove(item.token.expect("Missing item token"));
PostAction::Remove
});
match token {
Ok(token) => {
cur_offer.token.replace(token);
spawn(async move {
match Self::read_file(&mime_type, &mut read_pipe).await {
Ok(item) => {
lock!(clipboard).replace(item.clone());
tx.send_spawn(Event::Clipboard(item));
}
Err(err) => error!("{err:?}"),
}
Err(err) => error!("Failed to insert read pipe event: {err:?}"),
}
});
}
}
}
@ -313,7 +280,7 @@ impl DataControlSourceHandler for Environment {
source: &ZwlrDataControlSourceV1,
mime: String,
write_pipe: WritePipe,
) {
) -> Result<()> {
debug!("Handler received source send request event ({mime})");
if let Some(item) = lock!(self.clipboard).clone() {
@ -330,32 +297,34 @@ impl DataControlSourceHandler for Environment {
ClipboardValue::Image(bytes) => bytes.as_ref(),
ClipboardValue::Other => panic!(
"{:?}",
io::Error::new(ErrorKind::Other, "Attempted to copy unsupported mime type")
io::Error::other("Attempted to copy unsupported mime type")
),
};
let pipe_size = set_pipe_size(fd.as_raw_fd(), bytes.len())
.expect("Failed to increase pipe size");
let pipe_size =
set_pipe_size(fd.as_fd(), bytes.len()).expect("Failed to increase pipe size");
let mut file = File::from(fd.try_clone().expect("to be able to clone"));
debug!("Writing {} bytes", bytes.len());
let mut events = (0..16).map(|_| EpollEvent::empty()).collect::<Vec<_>>();
let epoll_event = EpollEvent::new(EpollFlags::EPOLLOUT, 0);
let epoll = epoll::create(CreateFlags::CLOEXEC)?;
epoll::add(
&epoll,
fd,
epoll::EventData::new_u64(0),
epoll::EventFlags::OUT,
)?;
let epoll_fd =
Epoll::new(EpollCreateFlags::empty()).expect("to get valid file descriptor");
epoll_fd
.add(fd, epoll_event)
.expect("to send valid epoll operation");
let mut events = Vec::with_capacity(16);
let timeout = EpollTimeout::from(100u16);
while !bytes.is_empty() {
let chunk = &bytes[..min(pipe_size as usize, bytes.len())];
let chunk = &bytes[..min(pipe_size, bytes.len())];
epoll_fd
.wait(&mut events, timeout)
.expect("Failed to wait to epoll");
epoll::wait(
&epoll,
spare_capacity(&mut events),
Some(&Timespec::try_from(Duration::from_millis(100))?),
)?;
match file.write(chunk) {
Ok(written) => {
@ -371,9 +340,11 @@ impl DataControlSourceHandler for Environment {
debug!("Done writing");
} else {
error!("Failed to find source");
error!("Failed to find source (mime: '{mime}')");
}
}
Ok(())
}
fn cancelled(
@ -398,7 +369,7 @@ impl DataControlSourceHandler for Environment {
/// it will be clamped at this.
///
/// Returns the new size if succeeded.
fn set_pipe_size(fd: RawFd, size: usize) -> io::Result<i32> {
fn set_pipe_size(fd: BorrowedFd, size: usize) -> io::Result<usize> {
// clamp size at kernel max
let max_pipe_size = fs::read_to_string("/proc/sys/fs/pipe-max-size")
.expect("Failed to find pipe-max-size virtual kernel file")
@ -408,23 +379,24 @@ fn set_pipe_size(fd: RawFd, size: usize) -> io::Result<i32> {
let size = min(size, max_pipe_size);
let curr_size = fcntl(fd, F_GETPIPE_SZ)? as usize;
let curr_size = fcntl_getpipe_size(fd)?;
trace!("Current pipe size: {curr_size}");
let new_size = if size > curr_size {
trace!("Requesting pipe size increase to (at least): {size}");
let res = fcntl(fd, F_SETPIPE_SZ(size as i32))?;
fcntl_setpipe_size(fd, size)?;
let res = fcntl_getpipe_size(fd)?;
trace!("New pipe size: {res}");
if res < size as i32 {
if res < size {
return Err(io::Error::last_os_error());
}
res
} else {
size as i32
size
};
Ok(new_size)

View file

@ -1,12 +1,11 @@
use super::manager::DataControlDeviceManagerState;
use crate::lock;
use nix::fcntl::OFlag;
use nix::unistd::pipe2;
use rustix::pipe::{PipeFlags, pipe_with};
use smithay_client_toolkit::data_device_manager::data_offer::DataOfferError;
use smithay_client_toolkit::data_device_manager::ReadPipe;
use std::ops::DerefMut;
use std::os::fd::AsFd;
use std::sync::{Arc, Mutex};
use tokio::net::unix::pipe::Receiver;
use tracing::trace;
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
use wayland_protocols_wlr::data_control::v1::client::zwlr_data_control_offer_v1::{
@ -36,8 +35,8 @@ impl PartialEq for SelectionOffer {
}
impl SelectionOffer {
pub fn receive(&self, mime_type: String) -> Result<ReadPipe, DataOfferError> {
unsafe { receive(&self.data_offer, mime_type) }.map_err(DataOfferError::Io)
pub fn receive(&self, mime_type: String) -> Result<Receiver, DataOfferError> {
receive(&self.data_offer, mime_type).map_err(DataOfferError::Io)
}
}
@ -169,14 +168,11 @@ where
///
/// Fails if too many file descriptors were already open and a pipe
/// could not be created.
pub unsafe fn receive(
offer: &ZwlrDataControlOfferV1,
mime_type: String,
) -> std::io::Result<ReadPipe> {
pub fn receive(offer: &ZwlrDataControlOfferV1, mime_type: String) -> std::io::Result<Receiver> {
// create a pipe
let (readfd, writefd) = pipe2(OFlag::O_CLOEXEC)?;
let (readfd, writefd) = pipe_with(PipeFlags::CLOEXEC)?;
offer.receive(mime_type, writefd.as_fd());
Ok(ReadPipe::from(readfd))
Receiver::from_owned_fd(readfd)
}

View file

@ -1,6 +1,8 @@
use super::device::DataControlDevice;
use super::manager::DataControlDeviceManagerState;
use color_eyre::Result;
use smithay_client_toolkit::data_device_manager::WritePipe;
use tracing::error;
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
use wayland_protocols_wlr::data_control::v1::client::zwlr_data_control_source_v1::{
Event, ZwlrDataControlSourceV1,
@ -23,7 +25,7 @@ impl DataControlSourceDataExt for DataControlSourceData {
///
/// The functions defined in this trait are called as `DataSource` events are received from the compositor.
pub trait DataControlSourceHandler: Sized {
/// This may be called multiple times, once for each accepted mime type from the destination, if any.
// /// This may be called multiple times, once for each accepted mime type from the destination, if any.
// fn accept_mime(
// &mut self,
// conn: &Connection,
@ -41,7 +43,7 @@ pub trait DataControlSourceHandler: Sized {
source: &ZwlrDataControlSourceV1,
mime: String,
fd: WritePipe,
);
) -> Result<()>;
/// The data source is no longer valid
/// Cleanup & destroy this resource
@ -68,7 +70,9 @@ where
) {
match event {
Event::Send { mime_type, fd } => {
state.send_request(conn, qh, source, mime_type, fd.into());
if let Err(err) = state.send_request(conn, qh, source, mime_type, fd.into()) {
error!("{err:#}");
}
}
Event::Cancelled => {
state.cancelled(conn, qh, source);

View file

@ -1,5 +1,5 @@
use super::manager::ToplevelManagerState;
use crate::{lock, Ironbar};
use crate::{Ironbar, lock};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use tracing::trace;
@ -33,6 +33,11 @@ impl ToplevelHandle {
trace!("Activating handle");
self.handle.activate(seat);
}
pub fn minimize(&self) {
trace!("Minimizing handle");
self.handle.set_minimized();
}
}
#[derive(Debug, Default)]
@ -146,7 +151,7 @@ where
ToplevelHandle {
handle: handle.clone(),
},
)
);
}
Event::Done if !lock!(data.inner).closed => {
{

View file

@ -4,7 +4,7 @@ use smithay_client_toolkit::globals::{GlobalData, ProvidesBoundGlobal};
use std::marker::PhantomData;
use tracing::{debug, warn};
use wayland_client::globals::{BindError, GlobalList};
use wayland_client::{event_created_child, Connection, Dispatch, QueueHandle};
use wayland_client::{Connection, Dispatch, QueueHandle, event_created_child};
use wayland_protocols_wlr::foreign_toplevel::v1::client::{
zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1,
zwlr_foreign_toplevel_manager_v1::{Event, ZwlrForeignToplevelManagerV1},
@ -67,7 +67,9 @@ where
state.toplevel(conn, qhandle);
}
Event::Finished => {
warn!("Foreign toplevel manager is no longer valid, but has not been dropped by client. This could cause window tracking issues.");
warn!(
"Foreign toplevel manager is no longer valid, but has not been dropped by client. This could cause window tracking issues."
);
}
_ => {}
}

View file

@ -4,11 +4,11 @@ pub mod manager;
use self::handle::ToplevelHandleHandler;
use self::manager::ToplevelManagerHandler;
use super::{Client, Environment, Event, Request, Response};
use crate::try_send;
use tokio::sync::broadcast;
use tracing::{debug, error, trace};
use wayland_client::{Connection, QueueHandle};
use crate::channels::AsyncSenderExt;
pub use handle::{ToplevelHandle, ToplevelInfo};
#[derive(Debug, Clone)]
@ -36,6 +36,15 @@ impl Client {
}
}
/// Minimizes the toplevel with the provided ID.
#[cfg(feature = "launcher")]
pub fn toplevel_minimize(&self, handle_id: usize) {
match self.send_request(Request::ToplevelMinimize(handle_id)) {
Response::Ok => (),
_ => unreachable!(),
}
}
/// Subscribes to events from toplevels.
pub fn subscribe_toplevels(&self) -> broadcast::Receiver<ToplevelEvent> {
self.toplevel_channel.0.subscribe()
@ -54,10 +63,16 @@ impl ToplevelHandleHandler for Environment {
match handle.info() {
Some(info) => {
if info.app_id.is_empty() {
trace!("ignoring xwayland dialog");
return;
}
trace!("Adding new handle: {info:?}");
self.handles.push(handle.clone());
if let Some(info) = handle.info() {
try_send!(self.event_tx, Event::Toplevel(ToplevelEvent::New(info)));
self.event_tx
.send_spawn(Event::Toplevel(ToplevelEvent::New(info)));
}
}
None => {
@ -78,7 +93,8 @@ impl ToplevelHandleHandler for Environment {
Some(info) => {
trace!("Updating handle: {info:?}");
if let Some(info) = handle.info() {
try_send!(self.event_tx, Event::Toplevel(ToplevelEvent::Update(info)));
self.event_tx
.send_spawn(Event::Toplevel(ToplevelEvent::Update(info)));
}
}
None => {
@ -97,7 +113,8 @@ impl ToplevelHandleHandler for Environment {
self.handles.retain(|h| h != &handle);
if let Some(info) = handle.info() {
try_send!(self.event_tx, Event::Toplevel(ToplevelEvent::Remove(info)));
self.event_tx
.send_spawn(Event::Toplevel(ToplevelEvent::Remove(info)));
}
}
}

View file

@ -1,9 +1,9 @@
use crate::dynamic_value::{dynamic_string, DynamicBool};
use crate::dynamic_value::{DynamicBool, dynamic_string};
use crate::script::{Script, ScriptInput};
use glib::Propagation;
use gtk::gdk::ScrollDirection;
use gtk::prelude::*;
use gtk::{EventBox, Orientation, Revealer, RevealerTransitionType};
use gtk::{EventBox, Justification, Orientation, Revealer, RevealerTransitionType};
use serde::Deserialize;
use tracing::trace;
@ -198,6 +198,28 @@ impl From<ModuleOrientation> for Orientation {
}
}
#[derive(Debug, Default, Deserialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum ModuleJustification {
#[default]
Left,
Right,
Center,
Fill,
}
impl From<ModuleJustification> for Justification {
fn from(o: ModuleJustification) -> Self {
match o {
ModuleJustification::Left => Self::Left,
ModuleJustification::Right => Self::Right,
ModuleJustification::Center => Self::Center,
ModuleJustification::Fill => Self::Fill,
}
}
}
impl TransitionType {
pub const fn to_revealer_transition_type(
&self,
@ -246,6 +268,13 @@ impl CommonConfig {
let script = match event.direction() {
ScrollDirection::Up => scroll_up_script.as_ref(),
ScrollDirection::Down => scroll_down_script.as_ref(),
ScrollDirection::Smooth => {
if event.scroll_deltas().unwrap_or_default().1 > 0.0 {
scroll_down_script.as_ref()
} else {
scroll_up_script.as_ref()
}
}
_ => None,
};
@ -272,8 +301,7 @@ impl CommonConfig {
install_oneshot!(self.on_mouse_exit, connect_leave_notify_event);
if let Some(tooltip) = self.tooltip {
let container = container.clone();
dynamic_string(&tooltip, move |string| {
dynamic_string(&tooltip, container, move |container, string| {
container.set_tooltip_text(Some(&string));
});
}
@ -285,19 +313,15 @@ impl CommonConfig {
container.show_all();
},
|show_if| {
// need to keep clone here for the notify callback
let container = container.clone();
{
let revealer = revealer.clone();
let container = container.clone();
show_if.subscribe(move |success| {
if success {
container.show_all();
}
revealer.set_reveal_child(success);
});
}
show_if.subscribe((revealer, &container), |(revealer, container), success| {
if success {
container.show_all();
}
revealer.set_reveal_child(success);
});
revealer.connect_child_revealed_notify(move |revealer| {
if !revealer.reveals_child() {

View file

@ -37,26 +37,24 @@ impl<'de> Deserialize<'de> for MonitorConfig {
pub fn deserialize_layer<'de, D>(deserializer: D) -> Result<gtk_layer_shell::Layer, D::Error>
where
D: serde::Deserializer<'de>,
D: Deserializer<'de>,
{
use gtk_layer_shell::Layer;
let value = Option::<String>::deserialize(deserializer)?;
value
.map(|v| match v.as_str() {
"background" => Ok(Layer::Background),
"bottom" => Ok(Layer::Bottom),
"top" => Ok(Layer::Top),
"overlay" => Ok(Layer::Overlay),
_ => Err(serde::de::Error::custom("invalid value for orientation")),
})
.unwrap_or(Ok(Layer::Top))
value.map_or(Ok(Layer::Top), |v| match v.as_str() {
"background" => Ok(Layer::Background),
"bottom" => Ok(Layer::Bottom),
"top" => Ok(Layer::Top),
"overlay" => Ok(Layer::Overlay),
_ => Err(serde::de::Error::custom("invalid value for orientation")),
})
}
#[cfg(feature = "schema")]
pub fn schema_layer(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
pub fn schema_layer(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::JsonSchema;
let mut schema: schemars::schema::SchemaObject = <String>::json_schema(gen).into();
let mut schema: schemars::schema::SchemaObject = <String>::json_schema(generator).into();
schema.enum_values = Some(vec![
"background".into(),
"bottom".into(),
@ -79,7 +77,7 @@ impl BarPosition {
/// Gets the angle that label text should be displayed at
/// based on this position.
pub const fn get_angle(self) -> f64 {
pub const fn angle(self) -> f64 {
match self {
Self::Top | Self::Bottom => 0.0,
Self::Left => 90.0,

35
src/config/layout.rs Normal file
View file

@ -0,0 +1,35 @@
use crate::config::{ModuleJustification, ModuleOrientation};
use crate::modules::ModuleInfo;
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct LayoutConfig {
/// The orientation to display the widget contents.
/// Setting to vertical will rotate text 90 degrees.
///
/// **Valid options**: `horizontal`, `vertical`
/// <br>
/// **Default**: `horizontal`
orientation: Option<ModuleOrientation>,
/// The justification (alignment) of the widget text shown on the bar.
///
/// **Valid options**: `left`, `right`, `center`, `fill`
/// <br>
/// **Default**: `left`
#[serde(default)]
pub justify: ModuleJustification,
}
impl LayoutConfig {
pub fn orientation(&self, info: &ModuleInfo) -> gtk::Orientation {
self.orientation
.map_or(info.bar_position.orientation(), ModuleOrientation::into)
}
pub fn angle(&self, info: &ModuleInfo) -> f64 {
self.orientation
.map_or(info.bar_position.angle(), ModuleOrientation::to_angle)
}
}

View file

@ -1,25 +1,35 @@
mod common;
mod r#impl;
mod layout;
mod truncate;
#[cfg(feature = "bindmode")]
use crate::modules::bindmode::Bindmode;
#[cfg(feature = "cairo")]
use crate::modules::cairo::CairoModule;
#[cfg(feature = "clipboard")]
use crate::modules::clipboard::ClipboardModule;
#[cfg(feature = "clock")]
use crate::modules::clock::ClockModule;
#[cfg(feature = "custom")]
use crate::modules::custom::CustomModule;
#[cfg(feature = "focused")]
use crate::modules::focused::FocusedModule;
#[cfg(feature = "keyboard")]
use crate::modules::keyboard::KeyboardModule;
#[cfg(feature = "label")]
use crate::modules::label::LabelModule;
#[cfg(feature = "launcher")]
use crate::modules::launcher::LauncherModule;
#[cfg(feature = "menu")]
use crate::modules::menu::MenuModule;
#[cfg(feature = "music")]
use crate::modules::music::MusicModule;
#[cfg(feature = "network_manager")]
use crate::modules::networkmanager::NetworkManagerModule;
#[cfg(feature = "notifications")]
use crate::modules::notifications::NotificationsModule;
#[cfg(feature = "script")]
use crate::modules::script::ScriptModule;
#[cfg(feature = "sys_info")]
use crate::modules::sysinfo::SysInfoModule;
@ -35,37 +45,46 @@ use crate::modules::workspaces::WorkspacesModule;
use crate::modules::{AnyModuleFactory, ModuleFactory, ModuleInfo};
use cfg_if::cfg_if;
use color_eyre::Result;
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::HashMap;
#[cfg(feature = "schema")]
use schemars::JsonSchema;
pub use self::common::{CommonConfig, ModuleOrientation, TransitionType};
pub use self::truncate::TruncateMode;
pub use self::common::{CommonConfig, ModuleJustification, ModuleOrientation, TransitionType};
pub use self::layout::LayoutConfig;
pub use self::truncate::{EllipsizeMode, TruncateMode};
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub enum ModuleConfig {
#[cfg(feature = "bindmode")]
Bindmode(Box<Bindmode>),
#[cfg(feature = "cairo")]
Cairo(Box<CairoModule>),
#[cfg(feature = "clipboard")]
Clipboard(Box<ClipboardModule>),
#[cfg(feature = "clock")]
Clock(Box<ClockModule>),
#[cfg(feature = "custom")]
Custom(Box<CustomModule>),
#[cfg(feature = "focused")]
Focused(Box<FocusedModule>),
#[cfg(feature = "keyboard")]
Keyboard(Box<KeyboardModule>),
#[cfg(feature = "label")]
Label(Box<LabelModule>),
#[cfg(feature = "launcher")]
Launcher(Box<LauncherModule>),
#[cfg(feature = "menu")]
Menu(Box<MenuModule>),
#[cfg(feature = "music")]
Music(Box<MusicModule>),
#[cfg(feature = "network_manager")]
NetworkManager(Box<NetworkManagerModule>),
#[cfg(feature = "notifications")]
Notifications(Box<NotificationsModule>),
#[cfg(feature = "script")]
Script(Box<ScriptModule>),
#[cfg(feature = "sys_info")]
SysInfo(Box<SysInfoModule>),
@ -93,24 +112,33 @@ impl ModuleConfig {
}
match self {
#[cfg(feature = "bindmode")]
Self::Bindmode(module) => create!(module),
#[cfg(feature = "cairo")]
Self::Cairo(module) => create!(module),
#[cfg(feature = "clipboard")]
Self::Clipboard(module) => create!(module),
#[cfg(feature = "clock")]
Self::Clock(module) => create!(module),
#[cfg(feature = "custom")]
Self::Custom(module) => create!(module),
#[cfg(feature = "focused")]
Self::Focused(module) => create!(module),
#[cfg(feature = "keyboard")]
Self::Keyboard(module) => create!(module),
#[cfg(feature = "label")]
Self::Label(module) => create!(module),
#[cfg(feature = "launcher")]
Self::Launcher(module) => create!(module),
#[cfg(feature = "menu")]
Self::Menu(module) => create!(module),
#[cfg(feature = "music")]
Self::Music(module) => create!(module),
#[cfg(feature = "network_manager")]
Self::NetworkManager(module) => create!(module),
#[cfg(feature = "notifications")]
Self::Notifications(module) => create!(module),
#[cfg(feature = "script")]
Self::Script(module) => create!(module),
#[cfg(feature = "sys_info")]
Self::SysInfo(module) => create!(module),
@ -124,6 +152,53 @@ impl ModuleConfig {
Self::Workspaces(module) => create!(module),
}
}
pub fn name(&self) -> String {
match self {
#[cfg(feature = "bindmode")]
ModuleConfig::Bindmode(_) => "Bindmode",
#[cfg(feature = "cairo")]
ModuleConfig::Cairo(_) => "Cario",
#[cfg(feature = "clipboard")]
ModuleConfig::Clipboard(_) => "Clipboard",
#[cfg(feature = "clock")]
ModuleConfig::Clock(_) => "Clock",
#[cfg(feature = "custom")]
ModuleConfig::Custom(_) => "Custom",
#[cfg(feature = "focused")]
ModuleConfig::Focused(_) => "Focused",
#[cfg(feature = "keyboard")]
ModuleConfig::Keyboard(_) => "Keyboard",
#[cfg(feature = "label")]
ModuleConfig::Label(_) => "Label",
#[cfg(feature = "launcher")]
ModuleConfig::Launcher(_) => "Launcher",
#[cfg(feature = "menu")]
ModuleConfig::Menu(_) => "Menu",
#[cfg(feature = "music")]
ModuleConfig::Music(_) => "Music",
#[cfg(feature = "network_manager")]
ModuleConfig::NetworkManager(_) => "NetworkManager",
#[cfg(feature = "notifications")]
ModuleConfig::Notifications(_) => "Notifications",
#[cfg(feature = "script")]
ModuleConfig::Script(_) => "Script",
#[cfg(feature = "sys_info")]
ModuleConfig::SysInfo(_) => "SysInfo",
#[cfg(feature = "tray")]
ModuleConfig::Tray(_) => "Tray",
#[cfg(feature = "upower")]
ModuleConfig::Upower(_) => "UPower",
#[cfg(feature = "volume")]
ModuleConfig::Volume(_) => "Volume",
#[cfg(feature = "workspaces")]
ModuleConfig::Workspaces(_) => "Workspaces",
// in case no modules are compiled
#[allow(unreachable_patterns)]
_ => "",
}
.to_string()
}
}
#[derive(Debug, Clone)]
@ -272,12 +347,6 @@ pub struct BarConfig {
#[serde(default)]
pub autohide: Option<u64>,
/// The name of the GTK icon theme to use.
/// Leave unset to use the default Adwaita theme.
///
/// **Default**: `null`
pub icon_theme: Option<String>,
/// An array of modules to append to the start of the bar.
/// Depending on the orientation, this is either the top of the left edge.
///
@ -325,10 +394,12 @@ impl Default for BarConfig {
height: default_bar_height(),
start_hidden: None,
autohide: None,
icon_theme: None,
#[cfg(feature = "label")]
start: Some(vec![ModuleConfig::Label(
LabelModule::new(" Using default config".to_string()).into(),
)]),
#[cfg(not(feature = "label"))]
start: None,
center,
end,
anchor_to_edges: default_true(),
@ -376,6 +447,19 @@ pub struct Config {
///
/// Providing this option overrides the single, global `bar` option.
pub monitors: Option<HashMap<String, MonitorConfig>>,
/// The name of the GTK icon theme to use.
/// Leave unset to use the default Adwaita theme.
///
/// **Default**: `null`
pub icon_theme: Option<String>,
/// Map of app IDs (or classes) to icon names,
/// overriding the app's default icon.
///
/// **Default**: `{}`
#[serde(default)]
pub icon_overrides: HashMap<String, String>,
}
const fn default_layer() -> gtk_layer_shell::Layer {
@ -397,3 +481,7 @@ pub const fn default_false() -> bool {
pub const fn default_true() -> bool {
true
}
pub fn default_launch_command() -> String {
String::from("gtk-launch {app_name}")
}

View file

@ -1,19 +1,21 @@
use gtk::pango::EllipsizeMode as GtkEllipsizeMode;
use gtk::prelude::*;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone, Copy)]
#[derive(Debug, Deserialize, Clone, Copy, Default)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum EllipsizeMode {
None,
Start,
Middle,
#[default]
End,
}
impl From<EllipsizeMode> for GtkEllipsizeMode {
fn from(value: EllipsizeMode) -> Self {
match value {
EllipsizeMode::None => Self::None,
EllipsizeMode::Start => Self::Start,
EllipsizeMode::Middle => Self::Middle,
EllipsizeMode::End => Self::End,
@ -27,10 +29,23 @@ impl From<EllipsizeMode> for GtkEllipsizeMode {
///
/// The option can be configured in one of two modes.
///
/// **Default**: `Auto (end)`
///
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(untagged)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum TruncateMode {
/// Do not truncate content.
///
/// Setting this option may cause excessively long content to overflow other widgets,
/// shifting them off-screen.
///
/// # Example
///
/// ```corn
/// { truncate = "off" }
Off,
/// Auto mode lets GTK decide when to ellipsize.
///
/// To use this mode, set the truncate option to a string
@ -44,7 +59,7 @@ pub enum TruncateMode {
///
/// **Valid options**: `start`, `middle`, `end`
/// <br>
/// **Default**: `null`
/// **Default**: `end`
Auto(EllipsizeMode),
/// Length mode defines a fixed point at which to ellipsize.
@ -88,36 +103,34 @@ pub enum TruncateMode {
},
}
impl TruncateMode {
const fn mode(&self) -> EllipsizeMode {
match self {
Self::Length { mode, .. } | Self::Auto(mode) => *mode,
}
impl Default for TruncateMode {
fn default() -> Self {
Self::Auto(EllipsizeMode::default())
}
}
const fn length(&self) -> Option<i32> {
impl TruncateMode {
pub const fn length(&self) -> Option<i32> {
match self {
Self::Auto(_) => None,
Self::Auto(_) | Self::Off => None,
Self::Length { length, .. } => *length,
}
}
const fn max_length(&self) -> Option<i32> {
pub const fn max_length(&self) -> Option<i32> {
match self {
Self::Auto(_) => None,
Self::Auto(_) | Self::Off => None,
Self::Length { max_length, .. } => *max_length,
}
}
}
pub fn truncate_label(&self, label: &gtk::Label) {
label.set_ellipsize(self.mode().into());
if let Some(length) = self.length() {
label.set_width_chars(length);
}
if let Some(length) = self.max_length() {
label.set_max_width_chars(length);
}
impl From<TruncateMode> for GtkEllipsizeMode {
fn from(value: TruncateMode) -> Self {
let mode = match value {
TruncateMode::Off => EllipsizeMode::None,
TruncateMode::Length { mode, .. } | TruncateMode::Auto(mode) => mode,
};
mode.into()
}
}

View file

@ -1,37 +1,302 @@
use std::collections::{HashMap, HashSet};
use crate::spawn;
use color_eyre::{Help, Report, Result};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use tracing::warn;
use std::process::{Command, Stdio};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::Mutex;
use tracing::{debug, error};
use walkdir::{DirEntry, WalkDir};
use crate::lock;
type DesktopFile = HashMap<String, Vec<String>>;
fn desktop_files() -> &'static Mutex<HashMap<PathBuf, DesktopFile>> {
static DESKTOP_FILES: OnceLock<Mutex<HashMap<PathBuf, DesktopFile>>> = OnceLock::new();
DESKTOP_FILES.get_or_init(|| Mutex::new(HashMap::new()))
#[derive(Debug, Clone)]
enum DesktopFileRef {
Unloaded(PathBuf),
Loaded(DesktopFile),
}
fn desktop_files_look_out_keys() -> &'static HashSet<&'static str> {
static DESKTOP_FILES_LOOK_OUT_KEYS: OnceLock<HashSet<&'static str>> = OnceLock::new();
DESKTOP_FILES_LOOK_OUT_KEYS
.get_or_init(|| HashSet::from(["Name", "StartupWMClass", "Exec", "Icon"]))
impl DesktopFileRef {
async fn get(&mut self) -> Result<DesktopFile> {
match self {
DesktopFileRef::Unloaded(path) => {
let (tx, rx) = tokio::sync::oneshot::channel();
let path = path.clone();
spawn(async move { tx.send(Self::load(&path).await) });
let file = rx.await??;
*self = DesktopFileRef::Loaded(file.clone());
Ok(file)
}
DesktopFileRef::Loaded(file) => Ok(file.clone()),
}
}
async fn load(file_path: &Path) -> Result<DesktopFile> {
debug!("loading applications file: {}", file_path.display());
let file = tokio::fs::File::open(file_path).await?;
let mut desktop_file = DesktopFile::new(
file_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
);
let mut lines = BufReader::new(file).lines();
let mut has_name = false;
let mut has_type = false;
let mut has_wm_class = false;
let mut has_exec = false;
let mut has_icon = false;
let mut has_categories = false;
let mut has_no_display = false;
while let Ok(Some(line)) = lines.next_line().await {
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key {
"Name" if !has_name => {
desktop_file.name = Some(value.to_string());
has_name = true;
}
"Type" if !has_type => {
desktop_file.app_type = Some(value.to_string());
has_type = true;
}
"StartupWMClass" if !has_wm_class => {
desktop_file.startup_wm_class = Some(value.to_string());
has_wm_class = true;
}
"Exec" if !has_exec => {
desktop_file.exec = Some(value.to_string());
has_exec = true;
}
"Icon" if !has_icon => {
desktop_file.icon = Some(value.to_string());
has_icon = true;
}
"Categories" if !has_categories => {
desktop_file.categories = value.split(';').map(|s| s.to_string()).collect();
has_categories = true;
}
"NoDisplay" if !has_no_display => {
desktop_file.no_display = Some(value.parse()?);
has_no_display = true;
}
_ => {}
}
// parsing complete - don't bother with the rest of the lines
if has_name
&& has_type
&& has_wm_class
&& has_exec
&& has_icon
&& has_categories
&& has_no_display
{
break;
}
}
Ok(desktop_file)
}
}
/// Finds directories that should contain `.desktop` files
/// and exist on the filesystem.
fn find_application_dirs() -> Vec<PathBuf> {
#[derive(Debug, Clone)]
pub struct DesktopFile {
pub file_name: String,
pub name: Option<String>,
pub app_type: Option<String>,
pub startup_wm_class: Option<String>,
pub exec: Option<String>,
pub icon: Option<String>,
pub categories: Vec<String>,
pub no_display: Option<bool>,
}
impl DesktopFile {
fn new(file_name: String) -> Self {
Self {
file_name,
name: None,
app_type: None,
startup_wm_class: None,
exec: None,
icon: None,
categories: vec![],
no_display: None,
}
}
}
type FileMap = HashMap<Box<str>, DesktopFileRef>;
/// Desktop file cache and resolver.
///
/// Files are lazy-loaded as required on resolution.
#[derive(Debug, Clone)]
pub struct DesktopFiles {
files: Arc<Mutex<FileMap>>,
}
impl Default for DesktopFiles {
fn default() -> Self {
Self::new()
}
}
impl DesktopFiles {
/// Creates a new instance,
/// scanning disk to generate a list of (unloaded) file refs in the process.
pub fn new() -> Self {
let desktop_files: FileMap = dirs()
.iter()
.flat_map(|path| files(path))
.map(|file| {
(
file.file_stem()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
.to_string()
.into(),
DesktopFileRef::Unloaded(file),
)
})
.collect();
debug!("resolved {} files", desktop_files.len());
Self {
files: Arc::new(Mutex::new(desktop_files)),
}
}
pub async fn get_all(&self) -> Result<Vec<DesktopFile>> {
let mut files = self.files.lock().await;
let mut res = Vec::with_capacity(files.len());
for file in files.values_mut() {
let file = file.get().await?;
res.push(file);
}
Ok(res)
}
/// Attempts to locate a applications file by file name or contents.
///
/// Input should typically be the app id, app name or icon.
pub async fn find(&self, input: &str) -> Result<Option<DesktopFile>> {
let mut res = self.find_by_file_name(input).await?;
if res.is_none() {
res = self.find_by_file_contents(input).await?;
}
debug!("found match for app_id {input}: {}", res.is_some());
Ok(res)
}
/// Checks file names for an exact or partial match of the provided input.
async fn find_by_file_name(&self, input: &str) -> Result<Option<DesktopFile>> {
let mut files = self.files.lock().await;
let mut file_ref = files
.iter_mut()
.find(|&(name, _)| name.eq_ignore_ascii_case(input));
if file_ref.is_none() {
file_ref = files.iter_mut().find(
|&(name, _)| // this will attempt to find flatpak apps that are in the format
// `com.company.app` or `com.app.something`
input
.split(&[' ', ':', '@', '.', '_'][..])
.any(|part| name.eq_ignore_ascii_case(part)),
);
}
let file_ref = file_ref.map(|(_, file)| file);
if let Some(file_ref) = file_ref {
let file = file_ref.get().await?;
Ok(Some(file))
} else {
Ok(None)
}
}
/// Checks file contents for an exact or partial match of the provided input.
async fn find_by_file_contents(&self, app_id: &str) -> Result<Option<DesktopFile>> {
let mut files = self.files.lock().await;
// first pass - check name for exact match
for (_, file_ref) in files.iter_mut() {
let file = file_ref.get().await?;
if let Some(name) = &file.name {
if name.eq_ignore_ascii_case(app_id) {
return Ok(Some(file));
}
}
}
// second pass - check name for partial match
for (_, file_ref) in files.iter_mut() {
let file = file_ref.get().await?;
if let Some(name) = &file.name {
if name.to_lowercase().contains(app_id) {
return Ok(Some(file));
}
}
}
// third pass - check remaining fields for partial match
for (_, file_ref) in files.iter_mut() {
let file = file_ref.get().await?;
if let Some(name) = &file.exec {
if name.to_lowercase().contains(app_id) {
return Ok(Some(file));
}
}
if let Some(name) = &file.startup_wm_class {
if name.to_lowercase().contains(app_id) {
return Ok(Some(file));
}
}
if let Some(name) = &file.icon {
if name.to_lowercase().contains(app_id) {
return Ok(Some(file));
}
}
}
Ok(None)
}
}
/// Gets a list of paths to all directories
/// containing `.applications` files.
fn dirs() -> Vec<PathBuf> {
let mut dirs = vec![
PathBuf::from("/usr/share/applications"), // system installed apps
PathBuf::from("/var/lib/flatpak/exports/share/applications"), // flatpak apps
];
let xdg_dirs = env::var_os("XDG_DATA_DIRS");
if let Some(xdg_dirs) = xdg_dirs {
for mut xdg_dir in env::split_paths(&xdg_dirs).map(PathBuf::from) {
let xdg_dirs = env::var("XDG_DATA_DIRS");
if let Ok(xdg_dirs) = xdg_dirs {
for mut xdg_dir in env::split_paths(&xdg_dirs) {
xdg_dir.push("applications");
dirs.push(xdg_dir);
}
@ -43,157 +308,85 @@ fn find_application_dirs() -> Vec<PathBuf> {
dirs.push(user_dir);
}
dirs.into_iter().filter(|dir| dir.exists()).collect()
dirs.into_iter().filter(|dir| dir.exists()).rev().collect()
}
/// Finds all the desktop files
fn find_desktop_files() -> Vec<PathBuf> {
let dirs = find_application_dirs();
dirs.into_iter()
.flat_map(|dir| {
WalkDir::new(dir)
.max_depth(5)
.into_iter()
.filter_map(Result::ok)
.map(DirEntry::into_path)
.filter(|file| file.is_file() && file.extension().unwrap_or_default() == "desktop")
})
/// Gets a list of all `.applications` files in the provided directory.
///
/// The directory is recursed to a maximum depth of 5.
fn files(dir: &Path) -> Vec<PathBuf> {
WalkDir::new(dir)
.max_depth(5)
.into_iter()
.filter_map(Result::ok)
.map(DirEntry::into_path)
.filter(|file| file.is_file() && file.extension().unwrap_or_default() == "desktop")
.collect()
}
/// Attempts to locate a `.desktop` file for an app id
pub fn find_desktop_file(app_id: &str) -> Option<PathBuf> {
// this is necessary to invalidate the cache
let files = find_desktop_files();
find_desktop_file_by_filename(app_id, &files)
.or_else(|| find_desktop_file_by_filedata(app_id, &files))
/// Starts a `.desktop` file with the provided formatted command.
pub fn open_program(file_name: &str, str: &str) {
let expanded = str.replace("{app_name}", file_name);
let launch_command_parts: Vec<&str> = expanded.split_whitespace().collect();
if let Err(err) = Command::new(&launch_command_parts[0])
.args(&launch_command_parts[1..])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
error!(
"{:?}",
Report::new(err)
.wrap_err("Failed to run launch command.")
.suggestion("Perhaps the applications file is invalid?")
);
}
}
/// Finds the correct desktop file using a simple condition check
fn find_desktop_file_by_filename(app_id: &str, files: &[PathBuf]) -> Option<PathBuf> {
let with_names = files
.iter()
.map(|f| {
(
f,
f.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase(),
)
})
.collect::<Vec<_>>();
#[cfg(test)]
mod tests {
use super::*;
with_names
.iter()
// first pass - check for exact match
.find(|(_, name)| name.eq_ignore_ascii_case(app_id))
// second pass - check for substring
.or_else(|| {
with_names.iter().find(|(_, name)| {
// this will attempt to find flatpak apps that are in the format
// `com.company.app` or `com.app.something`
app_id
.split(&[' ', ':', '@', '.', '_'][..])
.any(|part| name.eq_ignore_ascii_case(part))
})
})
.map(|(file, _)| file.into())
}
/// Finds the correct desktop file using the keys in `DESKTOP_FILES_LOOK_OUT_KEYS`
fn find_desktop_file_by_filedata(app_id: &str, files: &[PathBuf]) -> Option<PathBuf> {
let app_id = &app_id.to_lowercase();
let mut desktop_files_cache = lock!(desktop_files());
let files = files
.iter()
.filter_map(|file| {
let parsed_desktop_file = parse_desktop_file(file)?;
desktop_files_cache.insert(file.clone(), parsed_desktop_file.clone());
Some((file.clone(), parsed_desktop_file))
})
.collect::<Vec<_>>();
let file = files
.iter()
// first pass - check name key for exact match
.find(|(_, desktop_file)| {
desktop_file
.get("Name")
.is_some_and(|names| names.iter().any(|name| name.eq_ignore_ascii_case(app_id)))
})
// second pass - check name key for substring
.or_else(|| {
files.iter().find(|(_, desktop_file)| {
desktop_file.get("Name").is_some_and(|names| {
names
.iter()
.any(|name| name.to_lowercase().contains(app_id))
})
})
})
// third pass - check all keys for substring
.or_else(|| {
files.iter().find(|(_, desktop_file)| {
desktop_file
.values()
.flatten()
.any(|value| value.to_lowercase().contains(app_id))
})
});
file.map(|(path, _)| path).cloned()
}
/// Parses a desktop file into a hashmap of keys/vector(values).
fn parse_desktop_file(path: &Path) -> Option<DesktopFile> {
let Ok(file) = fs::read_to_string(path) else {
warn!("Couldn't Open File: {}", path.display());
return None;
};
let mut desktop_file: DesktopFile = DesktopFile::new();
file.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
let key = key.trim();
let value = value.trim();
if desktop_files_look_out_keys().contains(key) {
Some((key, value))
} else {
None
}
})
.for_each(|(key, value)| {
desktop_file
.entry(key.to_string())
.or_default()
.push(value.to_string());
});
Some(desktop_file)
}
/// Attempts to get the icon name from the app's `.desktop` file.
pub fn get_desktop_icon_name(app_id: &str) -> Option<String> {
let path = find_desktop_file(app_id)?;
let mut desktop_files_cache = lock!(desktop_files());
let desktop_file = match desktop_files_cache.get(&path) {
Some(desktop_file) => desktop_file,
_ => desktop_files_cache
.entry(path.clone())
.or_insert_with(|| parse_desktop_file(&path).expect("desktop_file")),
};
let mut icons = desktop_file.get("Icon").into_iter().flatten();
icons.next().map(std::string::ToString::to_string)
fn setup() {
unsafe {
let pwd = env::current_dir().unwrap();
env::set_var("XDG_DATA_DIRS", format!("{}/test-configs", pwd.display()));
}
}
#[tokio::test]
async fn find_by_filename() {
setup();
let desktop_files = DesktopFiles::new();
let file = desktop_files.find_by_file_name("firefox").await.unwrap();
assert!(file.is_some());
assert_eq!(file.unwrap().file_name, "firefox.desktop");
}
#[tokio::test]
async fn find_by_file_contents() {
setup();
let desktop_files = DesktopFiles::new();
let file = desktop_files.find_by_file_contents("427520").await.unwrap();
assert!(file.is_some());
assert_eq!(file.unwrap().file_name, "Factorio.desktop");
}
#[tokio::test]
async fn parser() {
let mut file_ref =
DesktopFileRef::Unloaded(PathBuf::from("test-configs/applications/firefox.desktop"));
let file = file_ref.get().await.unwrap();
assert_eq!(file.name, Some("Firefox".to_string()));
assert_eq!(file.icon, Some("firefox".to_string()));
assert_eq!(file.exec, Some("/usr/lib/firefox/firefox %u".to_string()));
assert_eq!(file.startup_wm_class, Some("firefox".to_string()));
assert_eq!(file.app_type, Some("Application".to_string()));
}
}

View file

@ -1,7 +1,8 @@
use crate::script::Script;
use crate::{glib_recv_mpsc, spawn, try_send};
#[cfg(feature = "ipc")]
use crate::{send_async, Ironbar};
use crate::Ironbar;
use crate::channels::{AsyncSenderExt, Dependency, MpscReceiverExt};
use crate::script::Script;
use crate::spawn;
use cfg_if::cfg_if;
use serde::Deserialize;
use tokio::sync::mpsc;
@ -18,9 +19,11 @@ pub enum DynamicBool {
}
impl DynamicBool {
pub fn subscribe<F>(self, mut f: F)
pub fn subscribe<D, F>(self, deps: D, f: F)
where
F: FnMut(bool) + 'static,
D: Dependency,
D::Target: Clone + 'static,
F: FnMut(&D::Target, bool) + 'static,
{
let value = match self {
Self::Unknown(input) => {
@ -42,14 +45,14 @@ impl DynamicBool {
let (tx, rx) = mpsc::channel(32);
glib_recv_mpsc!(rx, val => f(val));
rx.recv_glib(deps, f);
spawn(async move {
match value {
DynamicBool::Script(script) => {
script
.run(None, |_, success| {
try_send!(tx, success);
tx.send_spawn(success);
})
.await;
}
@ -58,11 +61,11 @@ impl DynamicBool {
let variable_manager = Ironbar::variable_manager();
let variable_name = variable[1..].into(); // remove hash
let mut rx = crate::write_lock!(variable_manager).subscribe(variable_name);
let mut rx = variable_manager.subscribe(variable_name);
while let Ok(value) = rx.recv().await {
let has_value = value.is_some_and(|s| is_truthy(&s));
send_async!(tx, has_value);
tx.send_expect(has_value).await;
}
}
DynamicBool::Unknown(_) => unreachable!(),

View file

@ -1,7 +1,8 @@
use crate::script::{OutputStream, Script};
#[cfg(feature = "ipc")]
use crate::Ironbar;
use crate::{arc_mut, glib_recv_mpsc, lock, spawn, try_send};
use crate::channels::{AsyncSenderExt, Dependency, MpscReceiverExt};
use crate::script::{OutputStream, Script};
use crate::{arc_mut, lock, spawn};
use tokio::sync::mpsc;
/// A segment of a dynamic string,
@ -22,12 +23,14 @@ enum DynamicStringSegment {
///
/// ```rs
/// dynamic_string(&text, move |string| {
/// label.set_markup(&string);
/// label.set_label_escaped(&string);
/// });
/// ```
pub fn dynamic_string<F>(input: &str, mut f: F)
pub fn dynamic_string<D, F>(input: &str, deps: D, f: F)
where
F: FnMut(String) + 'static,
D: Dependency,
D::Target: Clone + 'static,
F: FnMut(&D::Target, String) + 'static,
{
let (tokens, is_static) = parse_input(input);
@ -55,7 +58,7 @@ where
let _: String = std::mem::replace(&mut label_parts[i], out);
let string = label_parts.join("");
try_send!(tx, string);
tx.send_spawn(string);
}
})
.await;
@ -71,7 +74,7 @@ where
spawn(async move {
let variable_manager = Ironbar::variable_manager();
let mut rx = crate::write_lock!(variable_manager).subscribe(name);
let mut rx = variable_manager.subscribe(name);
while let Ok(value) = rx.recv().await {
if let Some(value) = value {
@ -80,7 +83,7 @@ where
let _: String = std::mem::replace(&mut label_parts[i], value);
let string = label_parts.join("");
try_send!(tx, string);
tx.send_spawn(string);
}
}
});
@ -88,12 +91,12 @@ where
}
}
glib_recv_mpsc!(rx , val => f(val));
rx.recv_glib(deps, f);
// initialize
if is_static {
let label_parts = lock!(label_parts).join("");
try_send!(tx, label_parts);
tx.send_spawn(label_parts);
}
}

View file

@ -1,6 +1,8 @@
use glib::IsA;
use crate::config::TruncateMode;
use glib::{IsA, markup_escape_text};
use gtk::pango::EllipsizeMode;
use gtk::prelude::*;
use gtk::{Orientation, Widget};
use gtk::{Label, Orientation, Widget};
/// Represents a widget's size
/// and location relative to the bar's start edge.
@ -18,6 +20,8 @@ pub struct WidgetGeometry {
pub trait IronbarGtkExt {
/// Adds a new CSS class to the widget.
fn add_class(&self, class: &str);
/// Removes a CSS class from the widget
fn remove_class(&self, class: &str);
/// Gets the geometry for the widget
fn geometry(&self, orientation: Orientation) -> WidgetGeometry;
@ -32,6 +36,10 @@ impl<W: IsA<Widget>> IronbarGtkExt for W {
self.style_context().add_class(class);
}
fn remove_class(&self, class: &str) {
self.style_context().remove_class(class);
}
fn geometry(&self, orientation: Orientation) -> WidgetGeometry {
let allocation = self.allocation();
@ -75,3 +83,36 @@ impl<W: IsA<Widget>> IronbarGtkExt for W {
unsafe { self.set_data(key, value) }
}
}
pub trait IronbarLabelExt {
/// Sets the label value to the provided string.
///
/// If the label does not contain markup `span` tags,
/// the text is escaped to avoid issues with special characters (ie `&`).
/// Otherwise, the text is used verbatim, and it is up to the user to escape.
fn set_label_escaped(&self, label: &str);
fn truncate(&self, mode: TruncateMode);
}
impl IronbarLabelExt for Label {
fn set_label_escaped(&self, label: &str) {
if label.contains("<span") {
self.set_label(label);
} else {
self.set_label(&markup_escape_text(label));
}
}
fn truncate(&self, mode: TruncateMode) {
self.set_ellipsize(<TruncateMode as Into<EllipsizeMode>>::into(mode));
if let Some(length) = mode.length() {
self.set_width_chars(length);
}
if let Some(length) = mode.max_length() {
self.set_max_width_chars(length);
}
}
}

View file

@ -1,55 +1,196 @@
use super::ImageProvider;
use crate::gtk_helpers::IronbarGtkExt;
use crate::gtk_helpers::{IronbarGtkExt, IronbarLabelExt};
use crate::image;
use gtk::prelude::*;
use gtk::{Button, IconTheme, Image, Label, Orientation};
use gtk::{Button, Image, Label, Orientation};
use std::ops::Deref;
#[cfg(any(feature = "music", feature = "workspaces", feature = "clipboard"))]
pub fn new_icon_button(input: &str, icon_theme: &IconTheme, size: i32) -> Button {
let button = Button::new();
if ImageProvider::is_definitely_image_input(input) {
let image = Image::new();
image.add_class("image");
image.add_class("icon");
match ImageProvider::parse(input, icon_theme, false, size)
.map(|provider| provider.load_into_image(image.clone()))
{
Some(_) => {
button.set_image(Some(&image));
button.set_always_show_image(true);
}
None => {
button.set_label(input);
}
}
} else {
button.set_label(input);
}
button
#[derive(Debug, Clone)]
#[cfg(any(
feature = "cairo",
feature = "clipboard",
feature = "clipboard",
feature = "keyboard",
feature = "launcher",
feature = "music",
feature = "workspaces",
))]
pub struct IconButton {
button: Button,
label: Label,
}
#[cfg(feature = "music")]
pub fn new_icon_label(input: &str, icon_theme: &IconTheme, size: i32) -> gtk::Box {
let container = gtk::Box::new(Orientation::Horizontal, 0);
#[cfg(any(
feature = "cairo",
feature = "clipboard",
feature = "clipboard",
feature = "keyboard",
feature = "launcher",
feature = "music",
feature = "workspaces",
))]
impl IconButton {
pub fn new(input: &str, size: i32, image_provider: image::Provider) -> Self {
let button = Button::new();
let image = Image::new();
let label = Label::new(Some(input));
if image::Provider::is_explicit_input(input) {
image.add_class("image");
image.add_class("icon");
let image = image.clone();
let label = label.clone();
let button = button.clone();
let input = input.to_string(); // ew
glib::spawn_future_local(async move {
if let Ok(true) = image_provider
.load_into_image(&input, size, false, &image)
.await
{
button.set_image(Some(&image));
button.set_always_show_image(true);
} else {
button.set_child(Some(&label));
label.show();
}
});
} else {
button.set_child(Some(&label));
label.show();
}
Self { button, label }
}
pub fn label(&self) -> &Label {
&self.label
}
}
#[cfg(any(
feature = "clipboard",
feature = "keyboard",
feature = "music",
feature = "workspaces",
feature = "cairo",
feature = "clipboard",
feature = "launcher",
))]
impl Deref for IconButton {
type Target = Button;
fn deref(&self) -> &Self::Target {
&self.button
}
}
#[cfg(any(feature = "keyboard", feature = "music", feature = "workspaces"))]
pub struct IconLabel {
provider: image::Provider,
container: gtk::Box,
label: Label,
image: Image,
size: i32,
}
#[cfg(any(feature = "keyboard", feature = "music", feature = "workspaces"))]
impl IconLabel {
pub fn new(input: &str, size: i32, image_provider: &image::Provider) -> Self {
let container = gtk::Box::new(Orientation::Horizontal, 0);
let label = Label::builder().use_markup(true).build();
label.add_class("icon");
label.add_class("text-icon");
if ImageProvider::is_definitely_image_input(input) {
let image = Image::new();
image.add_class("icon");
image.add_class("image");
container.add(&image);
ImageProvider::parse(input, icon_theme, false, size)
.map(|provider| provider.load_into_image(image));
} else {
let label = Label::builder().use_markup(true).label(input).build();
label.add_class("icon");
label.add_class("text-icon");
container.add(&label);
if image::Provider::is_explicit_input(input) {
let image = image.clone();
let label = label.clone();
let image_provider = image_provider.clone();
let input = input.to_string();
glib::spawn_future_local(async move {
let res = image_provider
.load_into_image(&input, size, false, &image)
.await;
if matches!(res, Ok(true)) {
image.show();
} else {
label.set_label_escaped(&input);
label.show();
}
});
} else {
label.set_label_escaped(input);
label.show();
}
Self {
provider: image_provider.clone(),
container,
label,
image,
size,
}
}
container
pub fn set_label(&self, input: Option<&str>) {
let label = &self.label;
let image = &self.image;
if let Some(input) = input {
if image::Provider::is_explicit_input(input) {
let provider = self.provider.clone();
let size = self.size;
let label = label.clone();
let image = image.clone();
let input = input.to_string();
glib::spawn_future_local(async move {
let res = provider.load_into_image(&input, size, false, &image).await;
if matches!(res, Ok(true)) {
label.hide();
image.show();
} else {
label.set_label_escaped(&input);
image.hide();
label.show();
}
});
} else {
label.set_label_escaped(input);
image.hide();
label.show();
}
} else {
label.hide();
image.hide();
}
}
pub fn label(&self) -> &Label {
&self.label
}
}
#[cfg(any(feature = "keyboard", feature = "music", feature = "workspaces"))]
impl Deref for IconLabel {
type Target = gtk::Box;
fn deref(&self) -> &Self::Target {
&self.container
}
}

View file

@ -1,7 +1,19 @@
#[cfg(any(feature = "music", feature = "workspaces", feature = "clipboard"))]
#[cfg(any(
feature = "clipboard",
feature = "keyboard",
feature = "launcher",
feature = "music",
feature = "workspaces",
))]
mod gtk;
mod provider;
#[cfg(any(feature = "music", feature = "workspaces"))]
#[cfg(any(
feature = "clipboard",
feature = "keyboard",
feature = "launcher",
feature = "music",
feature = "workspaces",
))]
pub use self::gtk::*;
pub use provider::ImageProvider;
pub use provider::{Provider, create_and_load_surface};

View file

@ -1,114 +1,195 @@
use crate::desktop_file::get_desktop_icon_name;
#[cfg(feature = "http")]
use crate::{glib_recv_mpsc, send_async, spawn};
use cfg_if::cfg_if;
use crate::desktop_file::DesktopFiles;
use crate::{arc_mut, lock};
use color_eyre::{Help, Report, Result};
use gtk::cairo::Surface;
use gtk::gdk::ffi::gdk_cairo_surface_create_from_pixbuf;
use gtk::gdk_pixbuf::Pixbuf;
use gtk::gio::{Cancellable, MemoryInputStream};
use gtk::prelude::*;
use gtk::{IconLookupFlags, IconTheme};
use std::path::{Path, PathBuf};
#[cfg(feature = "http")]
use tokio::sync::mpsc;
use tracing::{debug, warn};
use gtk::{IconLookupFlags, IconTheme, Image};
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tracing::{debug, trace, warn};
cfg_if!(
if #[cfg(feature = "http")] {
use gtk::gio::{Cancellable, MemoryInputStream};
use tracing::error;
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
struct ImageRef {
size: i32,
location: Option<ImageLocation>,
theme: IconTheme,
}
impl ImageRef {
fn new(size: i32, location: Option<ImageLocation>, theme: IconTheme) -> Self {
Self {
size,
location,
theme,
}
}
);
}
#[derive(Debug)]
enum ImageLocation<'a> {
Icon {
name: String,
theme: &'a IconTheme,
},
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
enum ImageLocation {
Icon(String),
Local(PathBuf),
Steam(String),
#[cfg(feature = "http")]
Remote(reqwest::Url),
}
pub struct ImageProvider<'a> {
location: ImageLocation<'a>,
size: i32,
#[derive(Debug)]
struct Cache {
location_cache: HashMap<(Box<str>, i32), ImageRef>,
pixbuf_cache: HashMap<ImageRef, Option<Pixbuf>>,
}
impl<'a> ImageProvider<'a> {
/// Attempts to parse the image input to find its location.
/// Errors if no valid location type can be found.
impl Cache {
fn new() -> Self {
Self {
location_cache: HashMap::new(),
pixbuf_cache: HashMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct Provider {
desktop_files: DesktopFiles,
icon_theme: RefCell<Option<IconTheme>>,
overrides: HashMap<String, String>,
cache: Arc<Mutex<Cache>>,
}
impl Provider {
pub fn new(desktop_files: DesktopFiles, overrides: &mut HashMap<String, String>) -> Self {
let mut overrides_map = HashMap::with_capacity(overrides.len());
overrides_map.extend(overrides.drain());
Self {
desktop_files,
icon_theme: RefCell::new(None),
overrides: overrides_map,
cache: arc_mut!(Cache::new()),
}
}
/// Attempts to resolve the provided input into a `Pixbuf`,
/// and load that `Pixbuf` into the provided `Image` widget.
///
/// Note this checks that icons exist in theme, or files exist on disk
/// but no other check is performed.
pub fn parse(input: &str, theme: &'a IconTheme, use_fallback: bool, size: i32) -> Option<Self> {
let location = Self::get_location(input, theme, size, use_fallback, 0)?;
debug!("Resolved {input} --> {location:?} (size: {size})");
Some(Self { location, size })
}
/// Returns true if the input starts with a prefix
/// that is supported by the parser
/// (ie the parser would not fallback to checking the input).
pub fn is_definitely_image_input(input: &str) -> bool {
input.starts_with("icon:")
|| input.starts_with("file://")
|| input.starts_with("http://")
|| input.starts_with("https://")
|| input.starts_with('/')
}
fn get_location(
/// If `use_fallback` is `true`, a fallback icon will be used
/// where an image cannot be found.
///
/// Returns `true` if the image was successfully loaded,
/// or `false` if the image could not be found.
/// May also return an error if the resolution or loading process failed.
pub async fn load_into_image(
&self,
input: &str,
theme: &'a IconTheme,
size: i32,
use_fallback: bool,
recurse_depth: usize,
) -> Option<ImageLocation<'a>> {
macro_rules! fallback {
() => {
if use_fallback {
Some(Self::get_fallback_icon(theme))
} else {
None
}
};
image: &Image,
) -> Result<bool> {
let image_ref = self.get_ref(input, size).await?;
debug!("image ref for {input}: {:?}", image_ref);
let pixbuf = if let Some(pixbuf) = lock!(self.cache).pixbuf_cache.get(&image_ref) {
pixbuf.clone()
} else {
let pixbuf = Self::get_pixbuf(&image_ref, image.scale_factor(), use_fallback).await?;
lock!(self.cache)
.pixbuf_cache
.insert(image_ref, pixbuf.clone());
pixbuf
};
if let Some(ref pixbuf) = pixbuf {
create_and_load_surface(pixbuf, image)?;
}
const MAX_RECURSE_DEPTH: usize = 2;
Ok(pixbuf.is_some())
}
let should_parse_desktop_file = !Self::is_definitely_image_input(input);
/// Like [`Provider::load_into_image`], but does not return an error if the image could not be found.
///
/// If an image is not resolved, a warning is logged. Errors are also logged.
pub async fn load_into_image_silent(
&self,
input: &str,
size: i32,
use_fallback: bool,
image: &Image,
) {
match self.load_into_image(input, size, use_fallback, image).await {
Ok(true) => {}
Ok(false) => warn!("failed to resolve image: {input}"),
Err(e) => warn!("failed to load image: {input}: {e:?}"),
}
}
/// Returns the `ImageRef` for the provided input.
///
/// This contains the location of the image if it can be resolved.
/// The ref will be loaded from cache if present.
async fn get_ref(&self, input: &str, size: i32) -> Result<ImageRef> {
let key = (input.into(), size);
if let Some(location) = lock!(self.cache).location_cache.get(&key) {
Ok(location.clone())
} else {
let location = self.resolve_location(input, size, 0).await?;
let image_ref = ImageRef::new(size, location, self.icon_theme());
lock!(self.cache)
.location_cache
.insert(key, image_ref.clone());
Ok(image_ref)
}
}
/// Attempts to resolve the provided input into an `ImageLocation`.
///
/// This will resolve all of:
/// - The current icon theme
/// - The file on disk
/// - Steam icons
/// - Desktop files (`Icon` keys)
/// - HTTP(S) URLs
async fn resolve_location(
&self,
input: &str,
size: i32,
recurse_depth: u8,
) -> Result<Option<ImageLocation>> {
const MAX_RECURSE_DEPTH: u8 = 2;
let input = self.overrides.get(input).map_or(input, String::as_str);
let should_parse_desktop_file = !Self::is_explicit_input(input);
let (input_type, input_name) = input
.split_once(':')
.map_or((None, input), |(t, n)| (Some(t), n));
match input_type {
Some(input_type) if input_type == "icon" => Some(ImageLocation::Icon {
name: input_name.to_string(),
theme,
}),
Some(input_type) if input_type == "file" => Some(ImageLocation::Local(PathBuf::from(
let location = match input_type {
Some(_t @ "icon") => Some(ImageLocation::Icon(input_name.to_string())),
Some(_t @ "file") => Some(ImageLocation::Local(PathBuf::from(
input_name[2..].to_string(),
))),
#[cfg(feature = "http")]
Some(input_type) if input_type == "http" || input_type == "https" => {
input.parse().ok().map(ImageLocation::Remote)
}
None if input.starts_with("steam_app_") => Some(ImageLocation::Steam(
Some(_t @ ("http" | "https")) => input_name.parse().ok().map(ImageLocation::Remote),
None if input_name.starts_with("steam_app_") => Some(ImageLocation::Steam(
input_name.chars().skip("steam_app_".len()).collect(),
)),
None if theme
.lookup_icon(input, size, IconLookupFlags::empty())
None if self
.icon_theme()
.lookup_icon(input_name, size, IconLookupFlags::empty())
.is_some() =>
{
Some(ImageLocation::Icon {
name: input_name.to_string(),
theme,
})
Some(ImageLocation::Icon(input_name.to_string()))
}
Some(input_type) => {
warn!(
@ -116,173 +197,154 @@ impl<'a> ImageProvider<'a> {
Report::msg(format!("Unsupported image type: {input_type}"))
.note("You may need to recompile with support if available")
);
fallback!()
None
}
None if PathBuf::from(input_name).is_file() => {
Some(ImageLocation::Local(PathBuf::from(input_name)))
}
None if recurse_depth == MAX_RECURSE_DEPTH => fallback!(),
None if recurse_depth == MAX_RECURSE_DEPTH => None,
None if should_parse_desktop_file => {
if let Some(location) = get_desktop_icon_name(input_name).map(|input| {
Self::get_location(&input, theme, size, use_fallback, recurse_depth + 1)
}) {
location
} else {
warn!("Failed to find image: {input}");
fallback!()
}
}
None => {
warn!("Failed to find image: {input}");
fallback!()
}
}
}
let location = self
.desktop_files
.find(input_name)
.await?
.and_then(|input| input.icon);
/// Attempts to fetch the image from the location
/// and load it into the provided `GTK::Image` widget.
pub fn load_into_image(&self, image: gtk::Image) -> Result<()> {
// handle remote locations async to avoid blocking UI thread while downloading
#[cfg(feature = "http")]
if let ImageLocation::Remote(url) = &self.location {
let url = url.clone();
let (tx, rx) = mpsc::channel(64);
spawn(async move {
let bytes = Self::get_bytes_from_http(url).await;
if let Ok(bytes) = bytes {
send_async!(tx, bytes);
}
});
{
let size = self.size;
glib_recv_mpsc!(rx, bytes => {
let stream = MemoryInputStream::from_bytes(&bytes);
let scale = image.scale_factor();
let scaled_size = size * scale;
let pixbuf = Pixbuf::from_stream_at_scale(
&stream,
scaled_size,
scaled_size,
true,
Some(&Cancellable::new()),
);
// Different error types makes this a bit awkward
match pixbuf.map(|pixbuf| Self::create_and_load_surface(&pixbuf, &image))
{
Ok(Err(err)) => error!("{err:?}"),
Err(err) => error!("{err:?}"),
_ => {}
if let Some(location) = location {
if location == input_name {
None
} else {
Box::pin(self.resolve_location(&location, size, recurse_depth + 1)).await?
}
});
} else {
None
}
}
} else {
self.load_into_image_sync(&image)?;
None => None,
};
#[cfg(not(feature = "http"))]
self.load_into_image_sync(&image)?;
Ok(())
Ok(location)
}
/// Attempts to synchronously fetch an image from location
/// and load into into the image.
fn load_into_image_sync(&self, image: &gtk::Image) -> Result<()> {
let scale = image.scale_factor();
let pixbuf = match &self.location {
ImageLocation::Icon { name, theme } => self.get_from_icon(name, theme, scale),
ImageLocation::Local(path) => self.get_from_file(path, scale),
ImageLocation::Steam(steam_id) => self.get_from_steam_id(steam_id, scale),
#[cfg(feature = "http")]
_ => unreachable!(), // handled above
}?;
Self::create_and_load_surface(&pixbuf, image)
}
/// Attempts to create a Cairo surface from the provided `Pixbuf`,
/// using the provided scaling factor.
/// The surface is then loaded into the provided image.
/// Attempts to load the provided `ImageRef` into a `Pixbuf`.
///
/// This is necessary for HiDPI since `Pixbuf`s are always treated as scale factor 1.
pub fn create_and_load_surface(pixbuf: &Pixbuf, image: &gtk::Image) -> Result<()> {
let surface = unsafe {
let ptr = gdk_cairo_surface_create_from_pixbuf(
pixbuf.as_ptr(),
image.scale_factor(),
std::ptr::null_mut(),
);
Surface::from_raw_full(ptr)
/// If `use_fallback` is `true`, a fallback icon will be used
/// where an image cannot be found.
async fn get_pixbuf(
image_ref: &ImageRef,
scale: i32,
use_fallback: bool,
) -> Result<Option<Pixbuf>> {
const FALLBACK_ICON_NAME: &str = "dialog-question-symbolic";
let buf = match &image_ref.location {
Some(ImageLocation::Icon(name)) => image_ref.theme.load_icon_for_scale(
name,
image_ref.size,
scale,
IconLookupFlags::FORCE_SIZE,
),
Some(ImageLocation::Local(path)) => {
let scaled_size = image_ref.size * scale;
Pixbuf::from_file_at_scale(path, scaled_size, scaled_size, true).map(Some)
}
Some(ImageLocation::Steam(app_id)) => {
let path = dirs::data_dir().map_or_else(
|| Err(Report::msg("Missing XDG data dir")),
|dir| Ok(dir.join(format!("icons/hicolor/32x32/apps/steam_icon_{app_id}.png"))),
)?;
let scaled_size = image_ref.size * scale;
Pixbuf::from_file_at_scale(path, scaled_size, scaled_size, true).map(Some)
}
#[cfg(feature = "http")]
Some(ImageLocation::Remote(uri)) => {
let res = reqwest::get(uri.clone()).await?;
let status = res.status();
let bytes = if status.is_success() {
let bytes = res.bytes().await?;
Ok(glib::Bytes::from_owned(bytes))
} else {
Err(Report::msg(format!(
"Received non-success HTTP code ({status})"
)))
}?;
let stream = MemoryInputStream::from_bytes(&bytes);
let scaled_size = image_ref.size * scale;
Pixbuf::from_stream_at_scale(
&stream,
scaled_size,
scaled_size,
true,
Some(&Cancellable::new()),
)
.map(Some)
}
None if use_fallback => image_ref.theme.load_icon_for_scale(
FALLBACK_ICON_NAME,
image_ref.size,
scale,
IconLookupFlags::empty(),
),
None => Ok(None),
}?;
image.set_from_surface(Some(&surface));
Ok(())
Ok(buf)
}
/// Attempts to get a `Pixbuf` from the GTK icon theme.
fn get_from_icon(&self, name: &str, theme: &IconTheme, scale: i32) -> Result<Pixbuf> {
let pixbuf =
match theme.lookup_icon_for_scale(name, self.size, scale, IconLookupFlags::empty()) {
Some(_) => theme.load_icon(name, self.size * scale, IconLookupFlags::FORCE_SIZE),
None => Ok(None),
}?;
pixbuf.map_or_else(
|| Err(Report::msg("Icon theme does not contain icon '{name}'")),
Ok,
)
/// Returns true if the input starts with a prefix
/// that is supported by the parser
/// (i.e. the parser would not fall back to checking the input).
pub fn is_explicit_input(input: &str) -> bool {
input.starts_with("icon:")
|| input.starts_with("file://")
|| input.starts_with("http://")
|| input.starts_with("https://")
|| input.starts_with('/')
}
/// Attempts to get a `Pixbuf` from a local file.
fn get_from_file(&self, path: &Path, scale: i32) -> Result<Pixbuf> {
let scaled_size = self.size * scale;
let pixbuf = Pixbuf::from_file_at_scale(path, scaled_size, scaled_size, true)?;
Ok(pixbuf)
pub fn icon_theme(&self) -> IconTheme {
self.icon_theme
.borrow()
.clone()
.expect("theme should be set at startup")
}
/// Attempts to get a `Pixbuf` from a local file,
/// using the Steam game ID to look it up.
fn get_from_steam_id(&self, steam_id: &str, scale: i32) -> Result<Pixbuf> {
// TODO: Can we load this from icon theme with app id `steam_icon_{}`?
let path = dirs::data_dir().map_or_else(
|| Err(Report::msg("Missing XDG data dir")),
|dir| {
Ok(dir.join(format!(
"icons/hicolor/32x32/apps/steam_icon_{steam_id}.png"
)))
},
)?;
/// Sets the custom icon theme name.
/// If no name is provided, the system default is used.
pub fn set_icon_theme(&self, theme: Option<&str>) {
trace!("Setting icon theme to {:?}", theme);
self.get_from_file(&path, scale)
}
/// Attempts to get `Bytes` from an HTTP resource asynchronously.
#[cfg(feature = "http")]
async fn get_bytes_from_http(url: reqwest::Url) -> Result<glib::Bytes> {
let res = reqwest::get(url).await?;
let status = res.status();
if status.is_success() {
let bytes = res.bytes().await?;
Ok(glib::Bytes::from_owned(bytes))
*self.icon_theme.borrow_mut() = if theme.is_some() {
let icon_theme = IconTheme::new();
icon_theme.set_custom_theme(theme);
Some(icon_theme)
} else {
Err(Report::msg(format!(
"Received non-success HTTP code ({status})"
)))
}
}
fn get_fallback_icon(theme: &'a IconTheme) -> ImageLocation<'a> {
ImageLocation::Icon {
name: "dialog-question-symbolic".to_string(),
theme,
}
IconTheme::default()
};
}
}
/// Attempts to create a Cairo `Surface` from the provided `Pixbuf`,
/// using the provided scaling factor.
/// The surface is then loaded into the provided image.
///
/// This is necessary for HiDPI since `Pixbuf`s are always treated as scale factor 1.
pub fn create_and_load_surface(pixbuf: &Pixbuf, image: &Image) -> Result<()> {
let surface = unsafe {
let ptr = gdk_cairo_surface_create_from_pixbuf(
pixbuf.as_ptr(),
image.scale_factor(),
std::ptr::null_mut(),
);
Surface::from_raw_full(ptr)
}?;
image.set_from_surface(Some(&surface));
Ok(())
}

View file

@ -52,7 +52,7 @@ pub enum IronvarCommand {
},
/// Gets the current value of all `ironvar`s.
List,
List { namespace: Option<Box<str>> },
}
#[derive(Args, Debug, Serialize, Deserialize)]

View file

@ -23,7 +23,9 @@ impl Ipc {
.join("ironbar-ipc.sock");
if format!("{}", ipc_socket_file.display()).len() > 100 {
warn!("The IPC socket file's absolute path exceeds 100 bytes, the socket may fail to create.");
warn!(
"The IPC socket file's absolute path exceeds 100 bytes, the socket may fail to create."
);
}
Self {

View file

@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
pub enum Response {
Ok,
OkValue { value: String },
Multi { values: Vec<String> },
Err { message: Option<String> },
}

View file

@ -1,54 +1,67 @@
use super::Response;
use crate::Ironbar;
use crate::bar::Bar;
use crate::ipc::{BarCommand, BarCommandType};
use crate::modules::PopupButton;
use crate::Ironbar;
use std::rc::Rc;
pub fn handle_command(command: BarCommand, ironbar: &Rc<Ironbar>) -> Response {
let bar = ironbar.bar_by_name(&command.name);
let Some(bar) = bar else {
return Response::error("Invalid bar name");
};
pub fn handle_command(command: &BarCommand, ironbar: &Rc<Ironbar>) -> Response {
use BarCommandType::*;
match command.subcommand {
Show => set_visible(&bar, true),
Hide => set_visible(&bar, false),
SetVisible { visible } => set_visible(&bar, visible),
ToggleVisible => set_visible(&bar, !bar.visible()),
GetVisible => Response::OkValue {
value: bar.visible().to_string(),
},
ShowPopup { widget_name } => show_popup(&bar, widget_name),
HidePopup => hide_popup(&bar),
SetPopupVisible {
widget_name,
visible,
} => {
if visible {
show_popup(&bar, widget_name)
} else {
hide_popup(&bar)
}
}
TogglePopup { widget_name } => {
if bar.popup().visible() {
hide_popup(&bar)
} else {
show_popup(&bar, widget_name)
}
}
GetPopupVisible => Response::OkValue {
value: bar.popup().visible().to_string(),
},
SetExclusive { exclusive } => {
bar.set_exclusive(exclusive);
let bars = ironbar.bars_by_name(&command.name);
Response::Ok
}
}
bars.into_iter()
.map(|bar| match &command.subcommand {
Show => set_visible(&bar, true),
Hide => set_visible(&bar, false),
SetVisible { visible } => set_visible(&bar, *visible),
ToggleVisible => set_visible(&bar, !bar.visible()),
GetVisible => Response::OkValue {
value: bar.visible().to_string(),
},
ShowPopup { widget_name } => show_popup(&bar, widget_name),
HidePopup => hide_popup(&bar),
SetPopupVisible {
widget_name,
visible,
} => {
if *visible {
show_popup(&bar, widget_name)
} else {
hide_popup(&bar)
};
Response::Ok
}
TogglePopup { widget_name } => {
if bar.popup().visible() {
hide_popup(&bar)
} else {
show_popup(&bar, widget_name)
};
Response::Ok
}
GetPopupVisible => Response::OkValue {
value: bar.popup().visible().to_string(),
},
SetExclusive { exclusive } => {
bar.set_exclusive(*exclusive);
Response::Ok
}
})
.reduce(|acc, rsp| match (acc, rsp) {
// If all responses are Ok, return one Ok. We assume we'll never mix Ok and OkValue.
(Response::Ok, _) => Response::Ok,
// Two or more OkValues create a multi:
(Response::OkValue { value: v1 }, Response::OkValue { value: v2 }) => Response::Multi {
values: vec![v1, v2],
},
(Response::Multi { mut values }, Response::OkValue { value: v }) => {
values.push(v);
Response::Multi { values }
}
_ => unreachable!(),
})
.unwrap_or(Response::error("Invalid bar name"))
}
fn set_visible(bar: &Bar, visible: bool) -> Response {
@ -56,7 +69,7 @@ fn set_visible(bar: &Bar, visible: bool) -> Response {
Response::Ok
}
fn show_popup(bar: &Bar, widget_name: String) -> Response {
fn show_popup(bar: &Bar, widget_name: &str) -> Response {
let popup = bar.popup();
// only one popup per bar, so hide if open for another widget

View file

@ -1,36 +1,71 @@
use crate::ipc::commands::IronvarCommand;
use crate::ipc::Response;
use crate::{read_lock, write_lock, Ironbar};
use crate::Ironbar;
use crate::ipc::{IronvarCommand, Response};
use crate::ironvar::{Namespace, WritableNamespace};
use std::sync::Arc;
pub fn handle_command(command: IronvarCommand) -> Response {
match command {
IronvarCommand::Set { key, value } => {
let variable_manager = Ironbar::variable_manager();
let mut variable_manager = write_lock!(variable_manager);
match variable_manager.set(key, value) {
match variable_manager.set(&key, value) {
Ok(()) => Response::Ok,
Err(err) => Response::error(&format!("{err}")),
}
}
IronvarCommand::Get { key } => {
IronvarCommand::Get { mut key } => {
let variable_manager = Ironbar::variable_manager();
let value = read_lock!(variable_manager).get(&key);
let mut ns: Arc<dyn Namespace + Sync + Send> = variable_manager;
if key.contains('.') {
for part in key.split('.') {
ns = if let Some(ns) = ns.get_namespace(part) {
ns.clone()
} else {
key = part.into();
break;
};
}
}
let value = ns.get(&key);
match value {
Some(value) => Response::OkValue { value },
None => Response::error("Variable not found"),
}
}
IronvarCommand::List => {
IronvarCommand::List { namespace } => {
let variable_manager = Ironbar::variable_manager();
let mut ns: Arc<dyn Namespace + Sync + Send> = variable_manager;
let mut values = read_lock!(variable_manager)
if let Some(namespace) = namespace {
for part in namespace.split('.') {
ns = match ns.get_namespace(part) {
Some(ns) => ns.clone(),
None => return Response::error("Namespace not found"),
};
}
}
let mut namespaces = ns
.namespaces()
.iter()
.map(|ns| format!("<{ns}>"))
.collect::<Vec<_>>();
namespaces.sort();
let mut value = namespaces.join("\n");
let mut values = ns
.get_all()
.iter()
.map(|(k, v)| format!("{k}: {}", v.get().unwrap_or_default()))
.map(|(k, v)| format!("{k}: {v}"))
.collect::<Vec<_>>();
values.sort();
let value = values.join("\n");
value.push('\n');
value.push_str(&values.join("\n"));
Response::OkValue { value }
}

View file

@ -6,18 +6,18 @@ use std::path::Path;
use std::rc::Rc;
use color_eyre::{Report, Result};
use gtk::prelude::*;
use gtk::Application;
use gtk::prelude::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::mpsc::{self, Receiver, Sender};
use tracing::{debug, error, info, warn};
use super::Ipc;
use crate::channels::{AsyncSenderExt, MpscReceiverExt};
use crate::ipc::{Command, Response};
use crate::style::load_css;
use crate::{glib_recv_mpsc, send_async, spawn, try_send, Ironbar};
use super::Ipc;
use crate::{Ironbar, spawn};
impl Ipc {
/// Starts the IPC server on its socket.
@ -65,10 +65,9 @@ impl Ipc {
}
});
let application = application.clone();
glib_recv_mpsc!(cmd_rx, command => {
let res = Self::handle_command(command, &application, &ironbar);
try_send!(res_tx, res);
cmd_rx.recv_glib(application, move |application, command| {
let res = Self::handle_command(command, application, &ironbar);
res_tx.send_spawn(res);
});
}
@ -91,7 +90,7 @@ impl Ipc {
debug!("Received command: {command:?}");
send_async!(cmd_tx, command);
cmd_tx.send_expect(command).await;
let res = res_rx
.recv()
.await
@ -143,14 +142,14 @@ impl Ipc {
}
Command::LoadCss { path } => {
if path.exists() {
load_css(path);
load_css(path, application.clone());
Response::Ok
} else {
Response::error("File not found")
}
}
Command::Var(cmd) => ironvar::handle_command(cmd),
Command::Bar(cmd) => bar::handle_command(cmd, ironbar),
Command::Bar(cmd) => bar::handle_command(&cmd, ironbar),
}
}

View file

@ -1,13 +1,37 @@
#![doc = include_str!("../docs/Ironvars.md")]
use crate::send;
use crate::channels::SyncSenderExt;
use crate::{arc_rw, read_lock, write_lock};
use color_eyre::{Report, Result};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tokio::sync::broadcast;
type NamespaceTrait = Arc<dyn Namespace + Sync + Send>;
pub trait Namespace {
fn get(&self, key: &str) -> Option<String>;
fn list(&self) -> Vec<String>;
fn get_all(&self) -> HashMap<Box<str>, String> {
self.list()
.into_iter()
.filter_map(|name| self.get(&name).map(|value| (name.into(), value)))
.collect()
}
fn namespaces(&self) -> Vec<String>;
fn get_namespace(&self, key: &str) -> Option<NamespaceTrait>;
}
pub trait WritableNamespace: Namespace {
fn set(&self, key: &str, value: String) -> Result<()>;
}
/// Global singleton manager for `IronVar` variables.
pub struct VariableManager {
variables: HashMap<Box<str>, IronVar>,
variables: Arc<RwLock<HashMap<Box<str>, IronVar>>>,
namespaces: Arc<RwLock<HashMap<Box<str>, NamespaceTrait>>>,
}
impl Default for VariableManager {
@ -19,41 +43,15 @@ impl Default for VariableManager {
impl VariableManager {
pub fn new() -> Self {
Self {
variables: HashMap::new(),
variables: arc_rw!(HashMap::new()),
namespaces: arc_rw!(HashMap::new()),
}
}
/// Sets the value for a variable,
/// creating it if it does not exist.
pub fn set(&mut self, key: Box<str>, value: String) -> Result<()> {
if Self::key_is_valid(&key) {
if let Some(var) = self.variables.get_mut(&key) {
var.set(Some(value));
} else {
let var = IronVar::new(Some(value));
self.variables.insert(key, var);
}
Ok(())
} else {
Err(Report::msg("Invalid key"))
}
}
/// Gets the current value of an `ironvar`.
/// Prefer to use `subscribe` where possible.
pub fn get(&self, key: &str) -> Option<String> {
self.variables.get(key).and_then(IronVar::get)
}
pub fn get_all(&self) -> &HashMap<Box<str>, IronVar> {
&self.variables
}
/// Subscribes to an `ironvar`, creating it if it does not exist.
/// Any time the var is set, its value is sent on the channel.
pub fn subscribe(&mut self, key: Box<str>) -> broadcast::Receiver<Option<String>> {
self.variables
pub fn subscribe(&self, key: Box<str>) -> broadcast::Receiver<Option<String>> {
write_lock!(self.variables)
.entry(key)
.or_insert_with(|| IronVar::new(None))
.subscribe()
@ -65,6 +63,72 @@ impl VariableManager {
.chars()
.all(|char| char.is_alphanumeric() || char == '_' || char == '-')
}
pub fn register_namespace<N>(&self, name: &str, namespace: Arc<N>)
where
N: Namespace + Sync + Send + 'static,
{
write_lock!(self.namespaces).insert(name.into(), namespace);
}
}
impl Namespace for VariableManager {
fn get(&self, key: &str) -> Option<String> {
if key.contains('.') {
let (ns, key) = key.split_once('.')?;
let namespaces = read_lock!(self.namespaces);
let ns = namespaces.get(ns)?;
ns.get(key).as_deref().map(ToOwned::to_owned)
} else {
read_lock!(self.variables).get(key).and_then(IronVar::get)
}
}
fn list(&self) -> Vec<String> {
read_lock!(self.variables)
.keys()
.map(ToString::to_string)
.collect()
}
fn get_all(&self) -> HashMap<Box<str>, String> {
read_lock!(self.variables)
.iter()
.filter_map(|(k, v)| v.get().map(|value| (k.clone(), value)))
.collect()
}
fn namespaces(&self) -> Vec<String> {
read_lock!(self.namespaces)
.keys()
.map(ToString::to_string)
.collect()
}
fn get_namespace(&self, key: &str) -> Option<NamespaceTrait> {
read_lock!(self.namespaces).get(key).cloned()
}
}
impl WritableNamespace for VariableManager {
/// Sets the value for a variable,
/// creating it if it does not exist.
fn set(&self, key: &str, value: String) -> Result<()> {
if Self::key_is_valid(key) {
if let Some(var) = write_lock!(self.variables).get_mut(&Box::from(key)) {
var.set(Some(value));
} else {
let var = IronVar::new(Some(value));
write_lock!(self.variables).insert(key.into(), var);
}
Ok(())
} else {
Err(Report::msg("Invalid key"))
}
}
}
/// Ironbar dynamic variable representation.
@ -94,14 +158,14 @@ impl IronVar {
/// The change is broadcast to all receivers.
fn set(&mut self, value: Option<String>) {
self.value.clone_from(&value);
send!(self.tx, value);
self.tx.send_expect(value);
}
/// Subscribes to the variable.
/// The latest value is immediately sent to all receivers.
fn subscribe(&self) -> broadcast::Receiver<Option<String>> {
let rx = self.tx.subscribe();
send!(self.tx, self.value.clone());
self.tx.send_expect(self.value.clone());
rx
}
}

View file

@ -1,14 +1,15 @@
use color_eyre::Result;
use dirs::data_dir;
use glib::{LogLevel, LogWriterOutput};
use std::{env, panic};
use strip_ansi_escapes::Writer;
use tracing::error;
use tracing::{debug, error, info, warn};
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_appender::rolling::Rotation;
use tracing_error::ErrorLayer;
use tracing_subscriber::fmt::{Layer, MakeWriter};
use tracing_subscriber::prelude::*;
use tracing_subscriber::{fmt, EnvFilter};
use tracing_subscriber::{EnvFilter, fmt};
struct MakeFileWriter {
file_writer: NonBlocking,
@ -31,7 +32,11 @@ impl<'a> MakeWriter<'a> for MakeFileWriter {
pub fn install_logging() -> Result<WorkerGuard> {
// Disable backtraces by default
if env::var("RUST_LIB_BACKTRACE").is_err() {
env::set_var("RUST_LIB_BACKTRACE", "0");
// as this is the very first thing we do (before runtimes are set up)
// we can be sure that it only runs in a single-thread context
unsafe {
env::set_var("RUST_LIB_BACKTRACE", "0");
}
}
// keep guard in scope
@ -89,5 +94,33 @@ fn install_tracing() -> Result<WorkerGuard> {
)
.init();
glib::log_set_writer_func(|level, fields| {
const KEY_DOMAIN: &str = "GLIB_DOMAIN";
const KEY_MESSAGE: &str = "MESSAGE";
let domain = fields
.iter()
.find(|f| f.key() == KEY_DOMAIN)
.and_then(|f| f.value_str())
.unwrap_or("Glib Unknown");
let message = fields
.iter()
.find(|f| f.key() == KEY_MESSAGE)
.and_then(|f| f.value_str())
.unwrap_or_default();
match level {
LogLevel::Error => error!(target: "GTK", "[{domain}] {message}"),
LogLevel::Critical => error!(target: "GTK", "[{domain}] CRITICAL: {message}"),
LogLevel::Warning => warn!(target: "GTK", "[{domain}] {message}"),
LogLevel::Message => info!(target: "GTK", "[{domain}] MESSAGE: {message}"),
LogLevel::Info => info!(target: "GTK", "[{domain}] MESSAGE: {message}"),
LogLevel::Debug => debug!(target: "GTK", "[{domain}] {message}"),
}
LogWriterOutput::Handled
});
Ok(guard)
}

View file

@ -35,13 +35,14 @@ macro_rules! module_impl {
/// send_async!(tx, "my message");
/// ```
#[macro_export]
#[deprecated(since = "0.17.0", note = "Use `AsyncSenderExt::send_expect` instead")]
macro_rules! send_async {
($tx:expr, $msg:expr) => {
$tx.send($msg).await.expect($crate::error::ERR_CHANNEL_SEND)
};
}
/// Sends a message on an synchronous `Sender` using `send()`
/// Sends a message on a synchronous `Sender` using `send()`
/// Panics if the message cannot be sent.
///
/// # Usage:
@ -50,13 +51,14 @@ macro_rules! send_async {
/// send!(tx, "my message");
/// ```
#[macro_export]
#[deprecated(since = "0.17.0", note = "Use `SyncSenderExt::send_expect` instead")]
macro_rules! send {
($tx:expr, $msg:expr) => {
$tx.send($msg).expect($crate::error::ERR_CHANNEL_SEND)
};
}
/// Sends a message on an synchronous `Sender` using `try_send()`
/// Sends a message on a synchronous `Sender` using `try_send()`
/// Panics if the message cannot be sent.
///
/// # Usage:
@ -65,12 +67,34 @@ macro_rules! send {
/// try_send!(tx, "my message");
/// ```
#[macro_export]
#[deprecated(since = "0.17.0", note = "Use `AsyncSenderExt::send_spawn` instead")]
macro_rules! try_send {
($tx:expr, $msg:expr) => {
$tx.try_send($msg).expect($crate::error::ERR_CHANNEL_SEND)
};
}
/// Sends a message, wrapped inside a `ModuleUpdateEvent::Update` variant,
/// on an asynchronous `Sender` using `send()`.
///
/// This is a convenience wrapper around `send_async`
/// to avoid needing to write the full enum every time.
///
/// Panics if the message cannot be sent.
///
/// # Usage:
///
/// ```rs
/// module_update!(tx, "my event");
/// ```
#[macro_export]
#[deprecated(since = "0.17.0", note = "Use `AsyncSenderExt::send_update` instead")]
macro_rules! module_update {
($tx:expr, $msg:expr) => {
send_async!($tx, $crate::modules::ModuleUpdateEvent::Update($msg))
};
}
/// Spawns a `GLib` future on the local thread, and calls `rx.recv()`
/// in a loop.
///
@ -85,7 +109,13 @@ macro_rules! try_send {
/// glib_recv(rx, msg => println!("{msg}"));
/// ```
#[macro_export]
#[deprecated(
since = "0.17.0",
note = "Use `BroadcastReceiverExt::recv_glib` instead"
)]
macro_rules! glib_recv {
($rx:expr, $func:ident) => { glib_recv!($rx, ev => $func(ev)) };
($rx:expr, $val:ident => $expr:expr) => {{
glib::spawn_future_local(async move {
// re-delcare in case ie `context.subscribe()` is passed directly
@ -121,7 +151,10 @@ macro_rules! glib_recv {
/// glib_recv_mpsc(rx, msg => println!("{msg}"));
/// ```
#[macro_export]
#[deprecated(since = "0.17.0", note = "Use `MpscReceiverExt::recv_glib` instead")]
macro_rules! glib_recv_mpsc {
($rx:expr, $func:ident) => { glib_recv_mpsc!($rx, ev => $func(ev)) };
($rx:expr, $val:ident => $expr:expr) => {{
glib::spawn_future_local(async move {
// re-delcare in case ie `context.subscribe()` is passed directly

View file

@ -7,36 +7,36 @@ use std::path::PathBuf;
use std::process::exit;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
#[cfg(feature = "ipc")]
use std::sync::RwLock;
use std::sync::{mpsc, Arc, Mutex, OnceLock};
use std::sync::{Arc, OnceLock, mpsc};
use cfg_if::cfg_if;
#[cfg(feature = "cli")]
use clap::Parser;
use color_eyre::eyre::Result;
use color_eyre::Report;
use color_eyre::eyre::Result;
use dirs::config_dir;
use glib::PropertySet;
use gtk::Application;
use gtk::gdk::Display;
use gtk::prelude::*;
use gtk::Application;
use smithay_client_toolkit::output::OutputInfo;
use tokio::runtime::Runtime;
use tokio::task::{block_in_place, JoinHandle};
use tokio::task::{JoinHandle, block_in_place};
use tracing::{debug, error, info, warn};
use universal_config::ConfigLoader;
use crate::bar::{create_bar, Bar};
use crate::clients::wayland::OutputEventType;
use crate::bar::{Bar, create_bar};
use crate::channels::SyncSenderExt;
use crate::clients::Clients;
use crate::clients::wayland::OutputEventType;
use crate::config::{Config, MonitorConfig};
use crate::desktop_file::DesktopFiles;
use crate::error::ExitCode;
#[cfg(feature = "ipc")]
use crate::ironvar::VariableManager;
use crate::ironvar::{VariableManager, WritableNamespace};
use crate::style::load_css;
mod bar;
mod channels;
#[cfg(feature = "cli")]
mod cli;
mod clients;
@ -79,14 +79,17 @@ fn run_with_args() {
#[cfg(feature = "schema")]
if args.print_schema {
let schema = schemars::schema_for!(Config);
println!("{}", serde_json::to_string_pretty(&schema).unwrap());
println!(
"{}",
serde_json::to_string_pretty(&schema).expect("to be serializable")
);
return;
}
match args.command {
Some(command) => {
if args.debug {
eprintln!("REQUEST: {command:?}")
eprintln!("REQUEST: {command:?}");
}
let rt = create_runtime();
@ -95,13 +98,16 @@ fn run_with_args() {
match ipc.send(command, args.debug).await {
Ok(res) => {
if args.debug {
eprintln!("RESPONSE: {res:?}")
eprintln!("RESPONSE: {res:?}");
}
cli::handle_response(res, args.format.unwrap_or_default())
cli::handle_response(res, args.format.unwrap_or_default());
}
Err(err) => error!("{err:?}"),
};
Err(err) => {
error!("{err:#}");
exit(ExitCode::IpcResponseError as i32)
}
}
});
}
None => start_ironbar(),
@ -114,17 +120,26 @@ pub struct Ironbar {
clients: Rc<RefCell<Clients>>,
config: Rc<RefCell<Config>>,
config_dir: PathBuf,
desktop_files: DesktopFiles,
image_provider: image::Provider,
}
impl Ironbar {
fn new() -> Self {
let (config, config_dir) = load_config();
let (mut config, config_dir) = load_config();
let desktop_files = DesktopFiles::new();
let image_provider =
image::Provider::new(desktop_files.clone(), &mut config.icon_overrides);
Self {
bars: Rc::new(RefCell::new(vec![])),
clients: Rc::new(RefCell::new(Clients::new())),
config: Rc::new(RefCell::new(config)),
config_dir,
desktop_files,
image_provider,
}
}
@ -153,7 +168,7 @@ impl Ironbar {
return;
}
running.set(true);
running.store(true, Ordering::Relaxed);
cfg_if! {
if #[cfg(feature = "ipc")] {
@ -177,7 +192,7 @@ impl Ironbar {
);
if style_path.exists() {
load_css(style_path);
load_css(style_path, app.clone());
}
let (tx, rx) = mpsc::channel();
@ -199,17 +214,21 @@ impl Ironbar {
.expect("Error setting Ctrl-C handler");
let hold = app.hold();
send!(activate_tx, hold);
activate_tx.send_expect(hold);
});
{
let instance = instance2;
let instance = instance2.clone();
let app = app.clone();
glib::spawn_future_local(async move {
let _hold = activate_rx.recv().expect("to receive activation signal");
debug!("Received activation signal, initialising bars");
instance
.image_provider
.set_icon_theme(instance.config.borrow().icon_theme.as_deref());
while let Ok(event) = rx_outputs.recv().await {
match event.event_type {
OutputEventType::New => {
@ -258,24 +277,35 @@ impl Ironbar {
/// Gets the `Ironvar` manager singleton.
#[cfg(feature = "ipc")]
#[must_use]
pub fn variable_manager() -> Arc<RwLock<VariableManager>> {
static VARIABLE_MANAGER: OnceLock<Arc<RwLock<VariableManager>>> = OnceLock::new();
pub fn variable_manager() -> Arc<VariableManager> {
static VARIABLE_MANAGER: OnceLock<Arc<VariableManager>> = OnceLock::new();
VARIABLE_MANAGER
.get_or_init(|| arc_rw!(VariableManager::new()))
.get_or_init(|| Arc::new(VariableManager::new()))
.clone()
}
/// Gets a clone of a bar by its unique name.
#[must_use]
pub fn desktop_files(&self) -> DesktopFiles {
self.desktop_files.clone()
}
#[must_use]
pub fn image_provider(&self) -> image::Provider {
self.image_provider.clone()
}
/// Gets clones of bars by their name.
///
/// Since the bar contains mostly GTK objects,
/// Since the bars contain mostly GTK objects,
/// the clone is cheap enough to not worry about.
#[must_use]
pub fn bar_by_name(&self, name: &str) -> Option<Bar> {
pub fn bars_by_name(&self, name: &str) -> Vec<Bar> {
self.bars
.borrow()
.iter()
.find(|&bar| bar.name() == name)
.filter(|&bar| bar.name() == name)
.cloned()
.collect()
}
/// Re-reads the config file from disk and replaces the active config.
@ -330,7 +360,7 @@ fn load_config() -> (Config, PathBuf) {
if let Some(ironvars) = config.ironvar_defaults.take() {
let variable_manager = Ironbar::variable_manager();
for (k, v) in ironvars {
if write_lock!(variable_manager).set(k.clone(), v).is_err() {
if variable_manager.set(&k, v).is_err() {
warn!("Ignoring invalid ironvar: '{k}'");
}
}
@ -357,36 +387,20 @@ fn load_output_bars(
app: &Application,
output: &OutputInfo,
) -> Result<Vec<Bar>> {
// Hack to track monitor positions due to new GTK3/wlroots bug:
// https://github.com/swaywm/sway/issues/8164
// This relies on Wayland always tracking monitors in the same order as GDK.
// We also need this static to ensure hot-reloading continues to work as best we can.
static INDEX_MAP: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
let output_size = output.logical_size.unwrap_or_default();
let Some(monitor_name) = &output.name else {
return Err(Report::msg("Output missing monitor name"));
};
let map = INDEX_MAP.get_or_init(|| Mutex::new(vec![]));
let index = lock!(map).iter().position(|n| n == monitor_name);
let index = match index {
Some(index) => index,
None => {
lock!(map).push(monitor_name.clone());
lock!(map).len() - 1
}
};
let config = ironbar.config.borrow();
let display = get_display();
// let pos = output.logical_position.unwrap_or_default();
// let monitor = display
// .monitor_at_point(pos.0, pos.1)
// .expect("monitor to exist");
let monitor = display.monitor(index as i32).expect("monitor to exist");
let pos = output.logical_position.unwrap_or_default();
let monitor = display
.monitor_at_point(pos.0, pos.1)
.expect("monitor to exist");
let show_default_bar =
config.bar.start.is_some() || config.bar.center.is_some() || config.bar.end.is_some();
@ -401,6 +415,7 @@ fn load_output_bars(
app,
&monitor,
monitor_name.to_string(),
output_size,
config.clone(),
ironbar.clone(),
)?]
@ -412,6 +427,7 @@ fn load_output_bars(
app,
&monitor,
monitor_name.to_string(),
output_size,
config.clone(),
ironbar.clone(),
)
@ -421,6 +437,7 @@ fn load_output_bars(
app,
&monitor,
monitor_name.to_string(),
output_size,
config.bar.clone(),
ironbar.clone(),
)?],
@ -458,7 +475,7 @@ where
/// Blocks on a `Future` until it resolves.
///
/// This is not an `async` operation
/// so can be used outside of an async function.
/// so can be used outside an async function.
///
/// Use sparingly, as this risks blocking the UI thread!
/// Prefer async functions wherever possible.

101
src/modules/bindmode.rs Normal file
View file

@ -0,0 +1,101 @@
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::clients::compositor::BindModeUpdate;
use crate::config::{CommonConfig, LayoutConfig, TruncateMode};
use crate::gtk_helpers::IronbarLabelExt;
use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext};
use crate::{module_impl, spawn};
use color_eyre::Result;
use gtk::Label;
use gtk::prelude::*;
use serde::Deserialize;
use tokio::sync::mpsc;
use tracing::{info, trace};
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Bindmode {
// -- Common --
/// See [truncate options](module-level-options#truncate-mode).
///
/// **Default**: `null`
pub truncate: Option<TruncateMode>,
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
impl Module<Label> for Bindmode {
type SendMessage = BindModeUpdate;
type ReceiveMessage = ();
module_impl!("bindmode");
fn spawn_controller(
&self,
_info: &ModuleInfo,
context: &WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> Result<()> {
info!("Bindmode module started");
let client = context.try_client::<dyn crate::clients::compositor::BindModeClient>()?;
let tx = context.tx.clone();
let mut rx = client.subscribe()?;
spawn(async move {
while let Ok(ev) = rx.recv().await {
tx.send_update(ev).await;
}
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleParts<Label>> {
let label = Label::builder()
.use_markup(true)
.angle(self.layout.angle(info))
.justify(self.layout.justify.into())
.build();
if let Some(truncate) = self.truncate {
label.truncate(truncate);
}
// Send a dummy event on init so that the widget starts hidden
{
let tx = context.tx.clone();
tx.send_spawn(ModuleUpdateEvent::Update(BindModeUpdate {
name: String::new(),
pango_markup: true,
}));
}
context.subscribe().recv_glib(&label, |label, mode| {
trace!("mode: {:?}", mode);
label.set_use_markup(mode.pango_markup);
label.set_label_escaped(&mode.name);
if mode.name.is_empty() {
label.hide();
} else {
label.show();
}
});
Ok(ModuleParts {
widget: label,
popup: None,
})
}
}

View file

@ -1,14 +1,15 @@
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext};
use crate::{glib_recv, module_impl, spawn, try_send};
use crate::modules::{Module, ModuleInfo, ModuleParts, WidgetContext};
use crate::{module_impl, spawn};
use cairo::{Format, ImageSurface};
use glib::translate::IntoGlibPtr;
use glib::Propagation;
use gtk::prelude::*;
use glib::translate::ToGlibPtr;
use gtk::DrawingArea;
use gtk::prelude::*;
use mlua::{Error, Function, LightUserData};
use notify::event::ModifyKind;
use notify::{recommended_watcher, Event, EventKind, RecursiveMode, Watcher};
use notify::{Event, EventKind, RecursiveMode, Watcher, recommended_watcher};
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
@ -74,7 +75,7 @@ impl Module<gtk::Box> for CairoModule {
where
<Self as Module<gtk::Box>>::SendMessage: Clone,
{
let path = self.path.to_path_buf();
let path = self.path.clone();
let tx = context.tx.clone();
spawn(async move {
@ -87,7 +88,7 @@ impl Module<gtk::Box> for CairoModule {
debug!("{event:?}");
if event.paths.first().is_some_and(|p| p == &path) {
try_send!(tx, ModuleUpdateEvent::Update(()));
tx.send_update_spawn(());
}
}
Err(e) => error!("Error occurred when watching stylesheet: {:?}", e),
@ -146,35 +147,37 @@ impl Module<gtk::Box> for CairoModule {
let path = self.path.clone();
area.connect_draw(move |_, cr| {
let function: Function = lua
.load(include_str!("../../lua/draw.lua"))
.eval()
.expect("to be valid");
let function: Function = lua
.load(include_str!("../../lua/draw.lua"))
.eval()
.expect("to be valid");
area.connect_draw(move |_, cr| {
if let Err(err) = cr.set_source_surface(&surface, 0.0, 0.0) {
error!("{err}");
return Propagation::Stop;
}
let ptr = unsafe { cr.clone().into_glib_ptr().cast() };
let ptr = cr.to_glib_full();
// mlua needs a valid return type, even if we don't return anything
if let Err(err) =
function.call::<_, Option<bool>>((id.as_str(), LightUserData(ptr)))
function.call::<Option<bool>>((id.as_str(), LightUserData(ptr.cast())))
{
match err {
Error::RuntimeError(message) => {
let message = message.split_once("]:").expect("to exist").1;
error!("[lua runtime error] {}:{message}", path.display())
}
_ => error!("{err}"),
if let Error::RuntimeError(message) = err {
let message = message.split_once("]:").expect("to exist").1;
error!("[lua runtime error] {}:{message}", path.display());
} else {
error!("{err}");
}
return Propagation::Stop;
}
unsafe {
cairo::ffi::cairo_destroy(ptr);
}
Propagation::Proceed
});
}
@ -189,22 +192,20 @@ impl Module<gtk::Box> for CairoModule {
}
});
glib_recv!(context.subscribe(), _ev => {
context.subscribe().recv_glib((), move |(), _ev| {
let res = fs::read_to_string(&self.path)
.map(|s| s.replace("function draw", format!("function __draw_{id}").as_str()));
match res {
Ok(script) => {
match lua.load(&script).exec() {
Ok(_) => {},
Err(Error::SyntaxError { message, ..}) => {
let message = message.split_once("]:").expect("to exist").1;
error!("[lua syntax error] {}:{message}", self.path.display())
},
Err(err) => error!("lua error: {err:?}")
Ok(script) => match lua.load(&script).exec() {
Ok(()) => {}
Err(Error::SyntaxError { message, .. }) => {
let message = message.split_once("]:").expect("to exist").1;
error!("[lua syntax error] {}:{message}", self.path.display());
}
Err(err) => error!("lua error: {err:?}"),
},
Err(err) => error!("{err:?}")
Err(err) => error!("{err:?}"),
}
});

View file

@ -1,11 +1,14 @@
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::clients::clipboard::{self, ClipboardEvent};
use crate::clients::wayland::{ClipboardItem, ClipboardValue};
use crate::config::{CommonConfig, TruncateMode};
use crate::image::new_icon_button;
use crate::config::{CommonConfig, LayoutConfig, TruncateMode};
use crate::gtk_helpers::IronbarGtkExt;
use crate::gtk_helpers::IronbarLabelExt;
use crate::image::IconButton;
use crate::modules::{
Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, PopupButton, WidgetContext,
};
use crate::{glib_recv, module_impl, spawn, try_send};
use crate::{module_impl, spawn};
use glib::Propagation;
use gtk::gdk_pixbuf::Pixbuf;
use gtk::gio::{Cancellable, MemoryInputStream};
@ -13,7 +16,8 @@ use gtk::prelude::*;
use gtk::{Button, EventBox, Image, Label, Orientation, RadioButton, Widget};
use serde::Deserialize;
use std::collections::HashMap;
use tokio::sync::{broadcast, mpsc};
use std::ops::Deref;
use tokio::sync::mpsc;
use tracing::{debug, error};
#[derive(Debug, Deserialize, Clone)]
@ -46,6 +50,10 @@ pub struct ClipboardModule {
/// **Default**: `null`
truncate: Option<TruncateMode>,
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
@ -102,18 +110,16 @@ impl Module<Button> for ClipboardModule {
match event {
ClipboardEvent::Add(item) => {
let msg = match item.value.as_ref() {
ClipboardValue::Other => {
ModuleUpdateEvent::Update(ControllerEvent::Deactivate)
}
_ => ModuleUpdateEvent::Update(ControllerEvent::Add(item.id, item)),
ClipboardValue::Other => ControllerEvent::Deactivate,
_ => ControllerEvent::Add(item.id, item),
};
try_send!(tx, msg);
tx.send_update_spawn(msg);
}
ClipboardEvent::Remove(id) => {
try_send!(tx, ModuleUpdateEvent::Update(ControllerEvent::Remove(id)));
tx.send_update_spawn(ControllerEvent::Remove(id));
}
ClipboardEvent::Activate(id) => {
try_send!(tx, ModuleUpdateEvent::Update(ControllerEvent::Activate(id)));
tx.send_update_spawn(ControllerEvent::Activate(id));
}
}
}
@ -141,27 +147,28 @@ impl Module<Button> for ClipboardModule {
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> color_eyre::Result<ModuleParts<Button>> {
let button = new_icon_button(&self.icon, info.icon_theme, self.icon_size);
button.style_context().add_class("btn");
let button = IconButton::new(&self.icon, self.icon_size, context.ironbar.image_provider());
button.label().set_angle(self.layout.angle(info));
button.label().set_justify(self.layout.justify.into());
button.add_class("btn");
let tx = context.tx.clone();
button.connect_clicked(move |button| {
try_send!(tx, ModuleUpdateEvent::TogglePopup(button.popup_id()));
tx.send_spawn(ModuleUpdateEvent::TogglePopup(button.popup_id()));
});
let rx = context.subscribe();
let popup = self
.into_popup(context.controller_tx.clone(), rx, context, info)
.into_popup(context, info)
.into_popup_parts(vec![&button]);
Ok(ModuleParts::new(button, popup))
Ok(ModuleParts::new(button.deref().clone(), popup))
}
fn into_popup(
self,
tx: mpsc::Sender<Self::ReceiveMessage>,
rx: broadcast::Receiver<Self::SendMessage>,
_context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box>
where
@ -177,9 +184,9 @@ impl Module<Button> for ClipboardModule {
let mut items = HashMap::new();
{
let hidden_option = hidden_option.clone();
glib_recv!(rx, event => {
context
.subscribe()
.recv_glib(&hidden_option, move |hidden_option, event| {
match event {
ControllerEvent::Add(id, item) => {
debug!("Adding new value with ID {}", id);
@ -189,13 +196,13 @@ impl Module<Button> for ClipboardModule {
let button = match item.value.as_ref() {
ClipboardValue::Text(value) => {
let button = RadioButton::from_widget(&hidden_option);
let button = RadioButton::from_widget(hidden_option);
let label = Label::new(Some(value));
button.add(&label);
if let Some(truncate) = self.truncate {
truncate.truncate_label(&label);
label.truncate(truncate);
}
button.style_context().add_class("text");
@ -209,16 +216,24 @@ impl Module<Button> for ClipboardModule {
64,
true,
Some(&Cancellable::new()),
)
.expect("Failed to read Pixbuf from stream");
let image = Image::from_pixbuf(Some(&pixbuf));
);
let button = RadioButton::from_widget(&hidden_option);
button.set_image(Some(&image));
button.set_always_show_image(true);
button.style_context().add_class("image");
match pixbuf {
Ok(pixbuf) => {
let image = Image::from_pixbuf(Some(&pixbuf));
button
let button = RadioButton::from_widget(hidden_option);
button.set_image(Some(&image));
button.set_always_show_image(true);
button.style_context().add_class("image");
button
}
Err(err) => {
error!("{err:?}");
return;
}
}
}
ClipboardValue::Other => unreachable!(),
};
@ -233,7 +248,7 @@ impl Module<Button> for ClipboardModule {
button_wrapper.set_above_child(true);
{
let tx = tx.clone();
let tx = context.controller_tx.clone();
button_wrapper.connect_button_press_event(
move |button_wrapper, event| {
// left click
@ -242,7 +257,7 @@ impl Module<Button> for ClipboardModule {
.expect("Failed to get id from button name");
debug!("Copying item with id: {id}");
try_send!(tx, UIEvent::Copy(id));
tx.send_spawn(UIEvent::Copy(id));
}
Propagation::Stop
@ -255,7 +270,7 @@ impl Module<Button> for ClipboardModule {
remove_button.style_context().add_class("btn-remove");
{
let tx = tx.clone();
let tx = context.controller_tx.clone();
let entries = entries.clone();
let row = row.clone();
@ -264,7 +279,7 @@ impl Module<Button> for ClipboardModule {
.expect("Failed to get id from button name");
debug!("Removing item with id: {id}");
try_send!(tx, UIEvent::Remove(id));
tx.send_spawn(UIEvent::Remove(id));
entries.remove(&row);
});
@ -305,7 +320,6 @@ impl Module<Button> for ClipboardModule {
}
}
});
}
container.show_all();
hidden_option.hide();

View file

@ -1,19 +1,20 @@
use std::env;
use chrono::{DateTime, Local, Locale};
use chrono::{DateTime, Datelike, Local, Locale};
use color_eyre::Result;
use gtk::prelude::*;
use gtk::{Align, Button, Calendar, Label, Orientation};
use serde::Deserialize;
use tokio::sync::{broadcast, mpsc};
use tokio::sync::mpsc;
use tokio::time::sleep;
use crate::config::{CommonConfig, ModuleOrientation};
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::config::{CommonConfig, LayoutConfig};
use crate::gtk_helpers::IronbarGtkExt;
use crate::modules::{
Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, PopupButton, WidgetContext,
};
use crate::{glib_recv, module_impl, send_async, spawn, try_send};
use crate::{module_impl, spawn};
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
@ -49,14 +50,9 @@ pub struct ClockModule {
#[serde(default = "default_locale")]
locale: String,
/// The orientation to display the widget contents.
/// Setting to vertical will rotate text 90 degrees.
///
/// **Valid options**: `horizontal`, `vertical`
/// <br>
/// **Default**: `horizontal`
#[serde(default)]
orientation: ModuleOrientation,
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
@ -69,7 +65,7 @@ impl Default for ClockModule {
format: default_format(),
format_popup: default_popup_format(),
locale: default_locale(),
orientation: ModuleOrientation::Horizontal,
layout: LayoutConfig::default(),
common: Some(CommonConfig::default()),
}
}
@ -112,7 +108,7 @@ impl Module<Button> for ClockModule {
spawn(async move {
loop {
let date = Local::now();
send_async!(tx, ModuleUpdateEvent::Update(date));
tx.send_update(date).await;
sleep(tokio::time::Duration::from_millis(500)).await;
}
});
@ -127,32 +123,29 @@ impl Module<Button> for ClockModule {
) -> Result<ModuleParts<Button>> {
let button = Button::new();
let label = Label::builder()
.angle(self.orientation.to_angle())
.angle(self.layout.angle(info))
.use_markup(true)
.justify(self.layout.justify.into())
.build();
button.add(&label);
let tx = context.tx.clone();
button.connect_clicked(move |button| {
try_send!(tx, ModuleUpdateEvent::TogglePopup(button.popup_id()));
tx.send_spawn(ModuleUpdateEvent::TogglePopup(button.popup_id()));
});
let format = self.format.clone();
let locale = Locale::try_from(self.locale.as_str()).unwrap_or(Locale::POSIX);
let rx = context.subscribe();
glib_recv!(rx, date => {
rx.recv_glib((), move |(), date| {
let date_string = format!("{}", date.format_localized(&format, locale));
label.set_label(&date_string);
});
let popup = self
.into_popup(
context.controller_tx.clone(),
context.subscribe(),
context,
info,
)
.into_popup(context, info)
.into_popup_parts(vec![&button]);
Ok(ModuleParts::new(button, popup))
@ -160,9 +153,7 @@ impl Module<Button> for ClockModule {
fn into_popup(
self,
_tx: mpsc::Sender<Self::ReceiveMessage>,
rx: broadcast::Receiver<Self::SendMessage>,
_context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box> {
let container = gtk::Box::new(Orientation::Vertical, 0);
@ -182,11 +173,18 @@ impl Module<Button> for ClockModule {
let format = self.format_popup;
let locale = Locale::try_from(self.locale.as_str()).unwrap_or(Locale::POSIX);
glib_recv!(rx, date => {
context.subscribe().recv_glib((), move |(), date| {
let date_string = format!("{}", date.format_localized(&format, locale));
clock.set_label(&date_string);
});
// Reset selected date on each popup open
context.popup.window.connect_show(move |_| {
let date = Local::now();
calendar.select_day(date.day());
calendar.select_month(date.month() - 1, date.year() as u32);
});
container.show_all();
Some(container)

View file

@ -5,6 +5,31 @@ use crate::modules::custom::WidgetConfig;
use gtk::prelude::*;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum ModuleAlignment {
/// Align widget to the start (left for horizontal, top for vertical).
Start,
/// Align widget to the center.
Center,
/// Align widget to the end (right for horizontal, bottom for vertical).
End,
/// Stretch widget to fill available space.
Fill,
}
impl From<ModuleAlignment> for gtk::Align {
fn from(align: ModuleAlignment) -> Self {
match align {
ModuleAlignment::Start => gtk::Align::Start,
ModuleAlignment::Center => gtk::Align::Center,
ModuleAlignment::End => gtk::Align::End,
ModuleAlignment::Fill => gtk::Align::Fill,
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BoxWidget {
@ -21,10 +46,21 @@ pub struct BoxWidget {
/// Whether child widgets should be horizontally or vertically added.
///
/// **Valid options**: `horizontal`, `vertical`, `h`, `v`
/// <br />
/// **Default**: `horizontal`
orientation: Option<ModuleOrientation>,
/// Horizontal alignment of the box relative to its parent.
///
/// **Valid options**: `start`, `center`, `end`, `fill`
/// **Default**: `fill`
halign: Option<ModuleAlignment>,
/// Vertical alignment of the box relative to its parent.
///
/// **Valid options**: `start`, `center`, `end`, `fill`
/// **Default**: `fill`
valign: Option<ModuleAlignment>,
/// Modules and widgets to add to this box.
///
/// **Default**: `null`
@ -47,6 +83,12 @@ impl CustomWidget for BoxWidget {
}
}
let horizontal_alignment = self.halign.unwrap_or(ModuleAlignment::Fill);
let vertical_alignment = self.valign.unwrap_or(ModuleAlignment::Fill);
container.set_halign(horizontal_alignment.into());
container.set_valign(vertical_alignment.into());
container
}
}

View file

@ -1,13 +1,14 @@
use gtk::prelude::*;
use gtk::{Button, Label, Orientation};
use gtk::{Button, Label};
use serde::Deserialize;
use crate::config::ModuleOrientation;
use crate::dynamic_value::dynamic_string;
use crate::modules::PopupButton;
use crate::{build, try_send};
use super::{CustomWidget, CustomWidgetContext, ExecEvent, WidgetConfig};
use crate::build;
use crate::channels::AsyncSenderExt;
use crate::config::LayoutConfig;
use crate::dynamic_value::dynamic_string;
use crate::gtk_helpers::IronbarLabelExt;
use crate::modules::PopupButton;
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
@ -37,13 +38,9 @@ pub struct ButtonWidget {
/// **Default**: `null`
on_click: Option<String>,
/// Orientation of the button.
///
/// **Valid options**: `horizontal`, `vertical`, `h`, `v`
/// <br />
/// **Default**: `horizontal`
#[serde(default)]
orientation: ModuleOrientation,
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// Modules and widgets to add to this box.
///
@ -59,7 +56,7 @@ impl CustomWidget for ButtonWidget {
context.popup_buttons.borrow_mut().push(button.clone());
if let Some(widgets) = self.widgets {
let container = gtk::Box::new(Orientation::Horizontal, 0);
let container = gtk::Box::new(self.layout.orientation(context.info), 0);
for widget in widgets {
widget.widget.add_to(&container, &context, widget.common);
@ -70,12 +67,14 @@ impl CustomWidget for ButtonWidget {
let label = Label::new(None);
label.set_use_markup(true);
label.set_angle(self.orientation.to_angle());
if !context.is_popup {
label.set_angle(self.layout.angle(context.info));
}
button.add(&label);
dynamic_string(&text, move |string| {
label.set_markup(&string);
dynamic_string(&text, (), move |(), string| {
label.set_label_escaped(&string);
});
}
@ -83,14 +82,11 @@ impl CustomWidget for ButtonWidget {
let tx = context.tx.clone();
button.connect_clicked(move |button| {
try_send!(
tx,
ExecEvent {
cmd: exec.clone(),
args: None,
id: button.try_popup_id().unwrap_or(usize::MAX), // may not be a popup button
}
);
tx.send_spawn(ExecEvent {
cmd: exec.clone(),
args: None,
id: button.try_popup_id().unwrap_or(usize::MAX), // may not be a popup button
});
});
}

View file

@ -1,10 +1,8 @@
use gtk::prelude::*;
use gtk::Image;
use serde::Deserialize;
use crate::build;
use crate::dynamic_value::dynamic_string;
use crate::image::ImageProvider;
use gtk::Image;
use gtk::prelude::*;
use serde::Deserialize;
use super::{CustomWidget, CustomWidgetContext};
@ -46,15 +44,15 @@ impl CustomWidget for ImageWidget {
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
let gtk_image = build!(self, Self::Widget);
{
dynamic_string(&self.src, &gtk_image, move |gtk_image, src| {
let gtk_image = gtk_image.clone();
let icon_theme = context.icon_theme.clone();
dynamic_string(&self.src, move |src| {
ImageProvider::parse(&src, &icon_theme, false, self.size)
.map(|image| image.load_into_image(gtk_image.clone()));
let image_provider = context.image_provider.clone();
glib::spawn_future_local(async move {
image_provider
.load_into_image_silent(&src, self.size, false, &gtk_image)
.await;
});
}
});
gtk_image
}

View file

@ -1,12 +1,12 @@
use gtk::prelude::*;
use gtk::Label;
use gtk::prelude::*;
use serde::Deserialize;
use crate::build;
use crate::config::ModuleOrientation;
use crate::dynamic_value::dynamic_string;
use super::{CustomWidget, CustomWidgetContext};
use crate::build;
use crate::config::{LayoutConfig, TruncateMode};
use crate::dynamic_value::dynamic_string;
use crate::gtk_helpers::IronbarLabelExt;
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
@ -28,32 +28,37 @@ pub struct LabelWidget {
/// **Required**
label: String,
/// Orientation of the label.
/// Setting to vertical will rotate text 90 degrees.
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// See [truncate options](module-level-options#truncate-mode).
///
/// **Valid options**: `horizontal`, `vertical`, `h`, `v`
/// <br />
/// **Default**: `horizontal`
#[serde(default)]
orientation: ModuleOrientation,
/// **Default**: `null`
truncate: Option<TruncateMode>,
}
impl CustomWidget for LabelWidget {
type Widget = Label;
fn into_widget(self, _context: CustomWidgetContext) -> Self::Widget {
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
let label = build!(self, Self::Widget);
label.set_angle(self.orientation.to_angle());
if !context.is_popup {
label.set_angle(self.layout.angle(context.info));
}
label.set_justify(self.layout.justify.into());
label.set_use_markup(true);
{
let label = label.clone();
dynamic_string(&self.label, move |string| {
label.set_markup(&string);
});
if let Some(truncate) = self.truncate {
label.truncate(truncate);
}
dynamic_string(&self.label, &label, move |label, string| {
label.set_label_escaped(&string);
});
label
}
}

View file

@ -5,26 +5,27 @@ mod label;
mod progress;
mod slider;
use self::r#box::BoxWidget;
use self::image::ImageWidget;
use self::label::LabelWidget;
use self::r#box::BoxWidget;
use self::slider::SliderWidget;
use crate::channels::AsyncSenderExt;
use crate::config::{CommonConfig, ModuleConfig};
use crate::modules::custom::button::ButtonWidget;
use crate::modules::custom::progress::ProgressWidget;
use crate::modules::{
wrap_widget, AnyModuleFactory, BarModuleFactory, Module, ModuleInfo, ModuleParts, ModulePopup,
ModuleUpdateEvent, PopupButton, PopupModuleFactory, WidgetContext,
AnyModuleFactory, BarModuleFactory, Module, ModuleInfo, ModuleParts, ModulePopup,
ModuleUpdateEvent, PopupButton, PopupModuleFactory, WidgetContext, wrap_widget,
};
use crate::script::Script;
use crate::{module_impl, send_async, spawn};
use crate::{module_impl, spawn};
use color_eyre::Result;
use gtk::prelude::*;
use gtk::{Button, IconTheme, Orientation};
use gtk::{Button, Orientation};
use serde::Deserialize;
use std::cell::RefCell;
use std::rc::Rc;
use tokio::sync::{broadcast, mpsc};
use tokio::sync::mpsc;
use tracing::{debug, error};
#[derive(Debug, Deserialize, Clone)]
@ -91,9 +92,10 @@ struct CustomWidgetContext<'a> {
info: &'a ModuleInfo<'a>,
tx: &'a mpsc::Sender<ExecEvent>,
bar_orientation: Orientation,
icon_theme: &'a IconTheme,
is_popup: bool,
popup_buttons: Rc<RefCell<Vec<Button>>>,
module_factory: AnyModuleFactory,
image_provider: crate::image::Provider,
}
trait CustomWidget {
@ -132,7 +134,7 @@ pub fn set_length<W: WidgetExt>(widget: &W, length: i32, bar_orientation: Orient
Orientation::Horizontal => widget.set_width_request(length),
Orientation::Vertical => widget.set_height_request(length),
_ => {}
};
}
}
impl WidgetOrModule {
@ -202,16 +204,14 @@ impl Module<gtk::Box> for CustomModule {
debug!("executing command: '{}'", script.cmd);
let args = event.args.unwrap_or_default();
if let Err(err) = script.get_output(Some(&args)).await {
error!("{err:?}");
}
script.run_as_oneshot(Some(&args));
} else if event.cmd == "popup:toggle" {
send_async!(tx, ModuleUpdateEvent::TogglePopup(event.id));
tx.send_expect(ModuleUpdateEvent::TogglePopup(event.id))
.await;
} else if event.cmd == "popup:open" {
send_async!(tx, ModuleUpdateEvent::OpenPopup(event.id));
tx.send_expect(ModuleUpdateEvent::OpenPopup(event.id)).await;
} else if event.cmd == "popup:close" {
send_async!(tx, ModuleUpdateEvent::ClosePopup);
tx.send_expect(ModuleUpdateEvent::ClosePopup).await;
} else {
error!("Received invalid command: '{}'", event.cmd);
}
@ -235,10 +235,11 @@ impl Module<gtk::Box> for CustomModule {
info,
tx: &context.controller_tx,
bar_orientation: orientation,
icon_theme: info.icon_theme,
is_popup: false,
popup_buttons: popup_buttons.clone(),
module_factory: BarModuleFactory::new(context.ironbar.clone(), context.popup.clone())
.into(),
image_provider: context.ironbar.image_provider(),
};
self.bar.clone().into_iter().for_each(|widget| {
@ -257,12 +258,7 @@ impl Module<gtk::Box> for CustomModule {
.map_or(usize::MAX, PopupButton::popup_id);
let popup = self
.into_popup(
context.controller_tx.clone(),
context.subscribe(),
context,
info,
)
.into_popup(context, info)
.into_popup_parts_owned(popup_buttons.take());
Ok(ModuleParts {
@ -273,8 +269,6 @@ impl Module<gtk::Box> for CustomModule {
fn into_popup(
self,
tx: mpsc::Sender<Self::ReceiveMessage>,
_rx: broadcast::Receiver<Self::SendMessage>,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Option<gtk::Box>
@ -286,10 +280,11 @@ impl Module<gtk::Box> for CustomModule {
if let Some(popup) = self.popup {
let custom_context = CustomWidgetContext {
info,
tx: &tx,
bar_orientation: info.bar_position.orientation(),
icon_theme: info.icon_theme,
tx: &context.controller_tx,
bar_orientation: Orientation::Horizontal,
is_popup: true,
popup_buttons: Rc::new(RefCell::new(vec![])),
image_provider: context.ironbar.image_provider(),
module_factory: PopupModuleFactory::new(
context.ironbar,
context.popup,

View file

@ -1,16 +1,16 @@
use gtk::prelude::*;
use gtk::ProgressBar;
use gtk::prelude::*;
use serde::Deserialize;
use tokio::sync::mpsc;
use tracing::error;
use super::{CustomWidget, CustomWidgetContext};
use crate::channels::{AsyncSenderExt, MpscReceiverExt};
use crate::config::ModuleOrientation;
use crate::dynamic_value::dynamic_string;
use crate::modules::custom::set_length;
use crate::script::{OutputStream, Script, ScriptInput};
use crate::{build, glib_recv_mpsc, spawn, try_send};
use super::{CustomWidget, CustomWidgetContext};
use crate::{build, spawn};
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
@ -87,7 +87,7 @@ impl CustomWidget for ProgressWidget {
script
.run(None, move |stream, _success| match stream {
OutputStream::Stdout(out) => match out.parse::<f64>() {
Ok(value) => try_send!(tx, value),
Ok(value) => tx.send_spawn(value),
Err(err) => error!("{err:?}"),
},
OutputStream::Stderr(err) => error!("{err:?}"),
@ -95,14 +95,13 @@ impl CustomWidget for ProgressWidget {
.await;
});
glib_recv_mpsc!(rx, value => progress.set_fraction(value / self.max));
rx.recv_glib((), move |(), value| progress.set_fraction(value / self.max));
}
if let Some(text) = self.label {
let progress = progress.clone();
progress.set_show_text(true);
dynamic_string(&text, move |string| {
dynamic_string(&text, &progress, move |progress, string| {
progress.set_text(Some(&string));
});
}

View file

@ -2,18 +2,18 @@ use glib::Propagation;
use std::cell::Cell;
use std::ops::Neg;
use gtk::prelude::*;
use gtk::Scale;
use gtk::prelude::*;
use serde::Deserialize;
use tokio::sync::mpsc;
use tracing::error;
use super::{CustomWidget, CustomWidgetContext, ExecEvent};
use crate::channels::{AsyncSenderExt, MpscReceiverExt};
use crate::config::ModuleOrientation;
use crate::modules::custom::set_length;
use crate::script::{OutputStream, Script, ScriptInput};
use crate::{build, glib_recv_mpsc, spawn, try_send};
use super::{CustomWidget, CustomWidgetContext, ExecEvent};
use crate::{build, spawn};
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
@ -134,14 +134,11 @@ impl CustomWidget for SliderWidget {
let val = val.clamp(min, max);
if val != prev_value.get() {
try_send!(
tx,
ExecEvent {
cmd: on_change.clone(),
args: Some(vec![val.to_string()]),
id: usize::MAX // ignored
}
);
tx.send_spawn(ExecEvent {
cmd: on_change.clone(),
args: Some(vec![val.to_string()]),
id: usize::MAX, // ignored
});
prev_value.set(val);
}
@ -160,7 +157,7 @@ impl CustomWidget for SliderWidget {
script
.run(None, move |stream, _success| match stream {
OutputStream::Stdout(out) => match out.parse() {
Ok(value) => try_send!(tx, value),
Ok(value) => tx.send_spawn(value),
Err(err) => error!("{err:?}"),
},
OutputStream::Stderr(err) => error!("{err:?}"),
@ -168,7 +165,7 @@ impl CustomWidget for SliderWidget {
.await;
});
glib_recv_mpsc!(rx, value => scale.set_value(value));
rx.recv_glib((), move |(), value| scale.set_value(value));
}
scale

View file

@ -1,12 +1,13 @@
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::clients::wayland::{self, ToplevelEvent};
use crate::config::{CommonConfig, TruncateMode};
use crate::config::{CommonConfig, LayoutConfig, TruncateMode};
use crate::gtk_helpers::IronbarGtkExt;
use crate::image::ImageProvider;
use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext};
use crate::{glib_recv, module_impl, send_async, spawn, try_send};
use crate::gtk_helpers::IronbarLabelExt;
use crate::modules::{Module, ModuleInfo, ModuleParts, WidgetContext};
use crate::{module_impl, spawn};
use color_eyre::Result;
use gtk::prelude::*;
use gtk::Label;
use gtk::prelude::*;
use serde::Deserialize;
use tokio::sync::mpsc;
use tracing::debug;
@ -37,6 +38,10 @@ pub struct FocusedModule {
/// **Default**: `null`
truncate: Option<TruncateMode>,
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
@ -49,6 +54,7 @@ impl Default for FocusedModule {
show_title: crate::config::default_true(),
icon_size: default_icon_size(),
truncate: None,
layout: LayoutConfig::default(),
common: Some(CommonConfig::default()),
}
}
@ -84,11 +90,9 @@ impl Module<gtk::Box> for FocusedModule {
if let Some(focused) = focused {
current = Some(focused.id);
try_send!(
tx,
ModuleUpdateEvent::Update(Some((focused.title.clone(), focused.app_id)))
);
};
tx.send_update(Some((focused.title.clone(), focused.app_id)))
.await;
}
while let Ok(event) = wlrx.recv().await {
match event {
@ -98,24 +102,19 @@ impl Module<gtk::Box> for FocusedModule {
current = Some(info.id);
send_async!(
tx,
ModuleUpdateEvent::Update(Some((
info.title.clone(),
info.app_id.clone()
)))
);
tx.send_update(Some((info.title.clone(), info.app_id)))
.await;
} else if info.id == current.unwrap_or_default() {
debug!("Clearing focus");
current = None;
send_async!(tx, ModuleUpdateEvent::Update(None));
tx.send_update(None).await;
}
}
ToplevelEvent::Remove(info) => {
if info.focused {
debug!("Clearing focus");
current = None;
send_async!(tx, ModuleUpdateEvent::Update(None));
tx.send_update(None).await;
}
}
ToplevelEvent::New(_) => {}
@ -131,9 +130,7 @@ impl Module<gtk::Box> for FocusedModule {
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleParts<gtk::Box>> {
let icon_theme = info.icon_theme;
let container = gtk::Box::new(info.bar_position.orientation(), 5);
let container = gtk::Box::new(self.layout.orientation(info), 5);
let icon = gtk::Image::new();
if self.show_icon {
@ -141,35 +138,47 @@ impl Module<gtk::Box> for FocusedModule {
container.add(&icon);
}
let label = Label::new(None);
let label = Label::builder()
.angle(self.layout.angle(info))
.justify(self.layout.justify.into())
.build();
label.add_class("label");
if let Some(truncate) = self.truncate {
truncate.truncate_label(&label);
label.truncate(truncate);
}
container.add(&label);
{
let icon_theme = icon_theme.clone();
glib_recv!(context.subscribe(), data => {
if let Some((name, id)) = data {
if self.show_icon {
match ImageProvider::parse(&id, &icon_theme, true, self.icon_size)
.map(|image| image.load_into_image(icon.clone()))
{
Some(Ok(())) => icon.show(),
_ => icon.hide(),
}
}
let image_provider = context.ironbar.image_provider();
if self.show_title {
label.show();
label.set_label(&name);
context.subscribe().recv_glib_async((), move |(), data| {
let icon = icon.clone();
let label = label.clone();
let image_provider = image_provider.clone();
async move {
if let Some((name, id)) = data {
if self.show_icon {
match image_provider
.load_into_image(&id, self.icon_size, true, &icon)
.await
{
Ok(true) => icon.show(),
_ => icon.hide(),
}
}
if self.show_title {
label.show();
label.set_label(&name);
}
} else {
icon.hide();
label.hide();
}
} else {
icon.hide();
label.hide();
}
});
}

347
src/modules/keyboard.rs Normal file
View file

@ -0,0 +1,347 @@
use std::collections::HashMap;
use color_eyre::Result;
use color_eyre::eyre::Report;
use gtk::prelude::*;
use serde::Deserialize;
use tokio::sync::mpsc;
use tracing::{debug, trace};
use super::{Module, ModuleInfo, ModuleParts, WidgetContext};
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::clients::compositor::{self, KeyboardLayoutUpdate};
use crate::clients::libinput::{Event, Key, KeyEvent};
use crate::config::{CommonConfig, LayoutConfig};
use crate::gtk_helpers::IronbarGtkExt;
use crate::image::{IconButton, IconLabel};
use crate::{module_impl, spawn};
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct KeyboardModule {
/// Whether to show capslock indicator.
///
/// **Default**: `true`
#[serde(default = "crate::config::default_true")]
show_caps: bool,
/// Whether to show num lock indicator.
///
/// **Default**: `true`
#[serde(default = "crate::config::default_true")]
show_num: bool,
/// Whether to show scroll lock indicator.
///
/// **Default**: `true`
#[serde(default = "crate::config::default_true")]
show_scroll: bool,
/// Whether to show the current keyboard layout.
///
/// **Default**: `true`
#[serde(default = "crate::config::default_true")]
show_layout: bool,
/// Size to render the icons at, in pixels (image icons only).
///
/// **Default** `32`
#[serde(default = "default_icon_size")]
icon_size: i32,
/// Player state icons.
///
/// See [icons](#icons).
#[serde(default)]
icons: Icons,
/// The Wayland seat to attach to.
/// You almost certainly do not need to change this.
///
/// **Default**: `seat0`
#[serde(default = "default_seat")]
seat: String,
// -- common --
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
struct Icons {
/// Icon to show when capslock is enabled.
///
/// **Default**: `󰪛`
#[serde(default = "default_icon_caps")]
caps_on: String,
/// Icon to show when capslock is disabled.
///
/// **Default**: `""`
#[serde(default)]
caps_off: String,
/// Icon to show when num lock is enabled.
///
/// **Default**: ``
#[serde(default = "default_icon_num")]
num_on: String,
/// Icon to show when num lock is disabled.
///
/// **Default**: `""`
#[serde(default)]
num_off: String,
/// Icon to show when scroll lock is enabled.
///
/// **Default**: ``
#[serde(default = "default_icon_scroll")]
scroll_on: String,
/// Icon to show when scroll lock is disabled.
///
/// **Default**: `""`
#[serde(default)]
scroll_off: String,
/// Map of icons or labels to show for a particular keyboard layout.
///
/// If a layout is not present in the map,
/// it will fall back to using its actual name.
///
/// **Default**: `{}`
///
/// # Example
///
/// ```corn
/// {
/// type = "keyboard"
/// show_layout = true
/// icons.layout_map.'English (US)' = "EN"
/// icons.layout_map.Ukrainian = "UA"
/// }
/// ```
#[serde(default)]
layout_map: HashMap<String, String>,
}
impl Default for Icons {
fn default() -> Self {
Self {
caps_on: default_icon_caps(),
caps_off: String::new(),
num_on: default_icon_num(),
num_off: String::new(),
scroll_on: default_icon_scroll(),
scroll_off: String::new(),
layout_map: HashMap::new(),
}
}
}
const fn default_icon_size() -> i32 {
32
}
fn default_seat() -> String {
String::from("seat0")
}
fn default_icon_caps() -> String {
String::from("󰪛")
}
fn default_icon_num() -> String {
String::from("")
}
fn default_icon_scroll() -> String {
String::from("")
}
#[derive(Debug, Clone)]
pub enum KeyboardUpdate {
Key(KeyEvent),
Layout(KeyboardLayoutUpdate),
}
impl Module<gtk::Box> for KeyboardModule {
type SendMessage = KeyboardUpdate;
type ReceiveMessage = ();
module_impl!("keyboard");
fn spawn_controller(
&self,
_info: &ModuleInfo,
context: &WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
mut rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let client = context.ironbar.clients.borrow_mut().libinput(&self.seat);
let tx = context.tx.clone();
spawn(async move {
let mut rx = client.subscribe();
while let Ok(ev) = rx.recv().await {
match ev {
Event::Device => {
for key in [Key::Caps, Key::Num, Key::Scroll] {
let event = KeyEvent {
key,
state: client.get_state(key),
};
tx.send_update(KeyboardUpdate::Key(event)).await;
}
}
Event::Key(ev) => {
tx.send_update(KeyboardUpdate::Key(ev)).await;
}
}
}
});
let client = context.try_client::<dyn compositor::KeyboardLayoutClient>()?;
{
let client = client.clone();
let tx = context.tx.clone();
spawn(async move {
let mut srx = client.subscribe();
trace!("Set up keyboard_layout subscription");
loop {
match srx.recv().await {
Ok(payload) => {
debug!("Received update: {payload:?}");
tx.send_update(KeyboardUpdate::Layout(payload)).await;
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
tracing::warn!(
"Channel lagged behind by {count}, this may result in unexpected or broken behaviour"
);
}
Err(err) => {
tracing::error!("{err:?}");
break;
}
}
}
});
}
// Change keyboard layout
spawn(async move {
trace!("Setting up keyboard_layout UI event handler");
while let Some(()) = rx.recv().await {
client.set_next_active();
}
Ok::<(), Report>(())
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleParts<gtk::Box>> {
let container = gtk::Box::new(self.layout.orientation(info), 0);
let image_provider = context.ironbar.image_provider();
let caps = IconLabel::new(&self.icons.caps_off, self.icon_size, &image_provider);
let num = IconLabel::new(&self.icons.num_off, self.icon_size, &image_provider);
let scroll = IconLabel::new(&self.icons.scroll_off, self.icon_size, &image_provider);
caps.label().set_angle(self.layout.angle(info));
caps.label().set_justify(self.layout.justify.into());
num.label().set_angle(self.layout.angle(info));
num.label().set_justify(self.layout.justify.into());
scroll.label().set_angle(self.layout.angle(info));
scroll.label().set_justify(self.layout.justify.into());
let layout_button = IconButton::new("", self.icon_size, image_provider);
if self.show_caps {
caps.add_class("key");
caps.add_class("caps");
container.add(&*caps);
}
if self.show_num {
num.add_class("key");
num.add_class("num");
container.add(&*num);
}
if self.show_scroll {
scroll.add_class("key");
scroll.add_class("scroll");
container.add(&*scroll);
}
if self.show_layout {
layout_button.add_class("layout");
container.add(&*layout_button);
}
{
let tx = context.controller_tx.clone();
layout_button.connect_clicked(move |_| {
tx.send_spawn(());
});
}
let icons = self.icons;
context
.subscribe()
.recv_glib((), move |(), ev: KeyboardUpdate| match ev {
KeyboardUpdate::Key(ev) => {
let parts = match (ev.key, ev.state) {
(Key::Caps, true) if self.show_caps => {
Some((&caps, icons.caps_on.as_str()))
}
(Key::Caps, false) if self.show_caps => {
Some((&caps, icons.caps_off.as_str()))
}
(Key::Num, true) if self.show_num => Some((&num, icons.num_on.as_str())),
(Key::Num, false) if self.show_num => Some((&num, icons.num_off.as_str())),
(Key::Scroll, true) if self.show_scroll => {
Some((&scroll, icons.scroll_on.as_str()))
}
(Key::Scroll, false) if self.show_scroll => {
Some((&scroll, icons.scroll_off.as_str()))
}
_ => None,
};
if let Some((label, input)) = parts {
label.set_label(Some(input));
if ev.state {
label.add_class("enabled");
} else {
label.remove_class("enabled");
}
}
}
KeyboardUpdate::Layout(KeyboardLayoutUpdate(language)) => {
let text = icons.layout_map.get(&language).unwrap_or(&language);
layout_button.set_label(text);
}
});
Ok(ModuleParts::new(container, None))
}
}

View file

@ -1,9 +1,10 @@
use crate::config::CommonConfig;
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::config::{CommonConfig, LayoutConfig, TruncateMode};
use crate::dynamic_value::dynamic_string;
use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext};
use crate::{glib_recv, module_impl, try_send};
use crate::gtk_helpers::IronbarLabelExt;
use crate::module_impl;
use crate::modules::{Module, ModuleInfo, ModuleParts, WidgetContext};
use color_eyre::Result;
use gtk::prelude::*;
use gtk::Label;
use serde::Deserialize;
use tokio::sync::mpsc;
@ -17,6 +18,16 @@ pub struct LabelModule {
/// **Required**
label: String,
// -- Common --
/// See [truncate options](module-level-options#truncate-mode).
///
/// **Default**: `null`
truncate: Option<TruncateMode>,
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
@ -26,6 +37,8 @@ impl LabelModule {
pub(crate) fn new(label: String) -> Self {
Self {
label,
truncate: None,
layout: LayoutConfig::default(),
common: Some(CommonConfig::default()),
}
}
@ -43,9 +56,8 @@ impl Module<Label> for LabelModule {
context: &WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let tx = context.tx.clone();
dynamic_string(&self.label, move |string| {
try_send!(tx, ModuleUpdateEvent::Update(string));
dynamic_string(&self.label, &context.tx, move |tx, string| {
tx.send_update_spawn(string);
});
Ok(())
@ -54,16 +66,22 @@ impl Module<Label> for LabelModule {
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_info: &ModuleInfo,
info: &ModuleInfo,
) -> Result<ModuleParts<Label>> {
let label = Label::new(None);
label.set_use_markup(true);
let label = Label::builder()
.use_markup(true)
.angle(self.layout.angle(info))
.justify(self.layout.justify.into())
.build();
{
let label = label.clone();
glib_recv!(context.subscribe(), string => label.set_markup(&string));
if let Some(truncate) = self.truncate {
label.truncate(truncate);
}
context.subscribe().recv_glib(&label, move |label, string| {
label.set_label_escaped(&string)
});
Ok(ModuleParts {
widget: label,
popup: None,

View file

@ -1,19 +1,20 @@
use super::open_state::OpenState;
use crate::channels::AsyncSenderExt;
use crate::clients::wayland::ToplevelInfo;
use crate::config::BarPosition;
use crate::gtk_helpers::IronbarGtkExt;
use crate::image::ImageProvider;
use crate::modules::launcher::{ItemEvent, LauncherUpdate};
use crate::config::{BarPosition, TruncateMode};
use crate::gtk_helpers::{IronbarGtkExt, IronbarLabelExt};
use crate::modules::ModuleUpdateEvent;
use crate::{read_lock, try_send};
use crate::modules::launcher::{ItemEvent, LauncherUpdate};
use crate::{image, read_lock};
use glib::Propagation;
use gtk::gdk::{BUTTON_MIDDLE, BUTTON_PRIMARY};
use gtk::prelude::*;
use gtk::{Button, IconTheme};
use gtk::{Align, Button, Image, Justification, Label, Orientation};
use indexmap::IndexMap;
use std::ops::Deref;
use std::rc::Rc;
use std::sync::RwLock;
use tokio::sync::mpsc::Sender;
use tracing::error;
#[derive(Debug, Clone)]
pub struct Item {
@ -134,7 +135,7 @@ pub struct MenuState {
}
pub struct ItemButton {
pub button: Button,
pub button: ImageTextButton,
pub persistent: bool,
pub show_names: bool,
pub menu_state: Rc<RwLock<MenuState>>,
@ -145,74 +146,89 @@ pub struct AppearanceOptions {
pub show_names: bool,
pub show_icons: bool,
pub icon_size: i32,
pub truncate: TruncateMode,
pub orientation: Orientation,
pub angle: f64,
pub justify: Justification,
}
impl ItemButton {
pub fn new(
item: &Item,
appearance: AppearanceOptions,
icon_theme: &IconTheme,
image_provider: image::Provider,
bar_position: BarPosition,
tx: &Sender<ModuleUpdateEvent<LauncherUpdate>>,
controller_tx: &Sender<ItemEvent>,
) -> Self {
let mut button = Button::builder();
let button = ImageTextButton::new(appearance.orientation);
if appearance.show_names {
button = button.label(&item.name);
button.label.set_label(&item.name);
button.label.truncate(appearance.truncate);
button.label.set_angle(appearance.angle);
button.label.set_justify(appearance.justify);
}
let button = button.build();
if appearance.show_icons {
let gtk_image = gtk::Image::new();
let input = if item.app_id.is_empty() {
item.name.clone()
} else {
item.app_id.clone()
};
let image = ImageProvider::parse(&input, icon_theme, true, appearance.icon_size);
if let Some(image) = image {
button.set_image(Some(&gtk_image));
button.set_always_show_image(true);
if let Err(err) = image.load_into_image(gtk_image) {
error!("{err:?}");
}
};
let button = button.clone();
glib::spawn_future_local(async move {
image_provider
.load_into_image_silent(&input, appearance.icon_size, true, &button.image)
.await;
});
}
let style_context = button.style_context();
style_context.add_class("item");
button.add_class("item");
if item.favorite {
style_context.add_class("favorite");
button.add_class("favorite");
}
if item.open_state.is_open() {
style_context.add_class("open");
button.add_class("open");
}
if item.open_state.is_focused() {
style_context.add_class("focused");
}
{
let app_id = item.app_id.clone();
let tx = controller_tx.clone();
button.connect_clicked(move |button| {
// lazy check :| TODO: Improve this
let style_context = button.style_context();
if style_context.has_class("open") {
try_send!(tx, ItemEvent::FocusItem(app_id.clone()));
} else {
try_send!(tx, ItemEvent::OpenItem(app_id.clone()));
}
});
button.add_class("focused");
}
let menu_state = Rc::new(RwLock::new(MenuState {
num_windows: item.windows.len(),
}));
{
let app_id = item.app_id.clone();
let tx = controller_tx.clone();
let menu_state = menu_state.clone();
button.connect_button_release_event(move |button, event| {
if event.button() == BUTTON_PRIMARY {
// lazy check :| TODO: Improve this
let style_context = button.style_context();
if style_context.has_class("open") {
let menu_state = read_lock!(menu_state);
if style_context.has_class("focused") && menu_state.num_windows == 1 {
tx.send_spawn(ItemEvent::MinimizeItem(app_id.clone()));
} else {
tx.send_spawn(ItemEvent::FocusItem(app_id.clone()));
}
} else {
tx.send_spawn(ItemEvent::OpenItem(app_id.clone()));
}
} else if event.button() == BUTTON_MIDDLE {
tx.send_spawn(ItemEvent::OpenItem(app_id.clone()));
}
Propagation::Proceed
});
}
{
let app_id = item.app_id.clone();
let tx = tx.clone();
@ -222,17 +238,12 @@ impl ItemButton {
let menu_state = read_lock!(menu_state);
if menu_state.num_windows > 1 {
try_send!(
tx,
ModuleUpdateEvent::Update(LauncherUpdate::Hover(app_id.clone(),))
);
try_send!(
tx,
ModuleUpdateEvent::OpenPopupAt(button.geometry(bar_position.orientation()))
);
tx.send_update_spawn(LauncherUpdate::Hover(app_id.clone()));
tx.send_spawn(ModuleUpdateEvent::OpenPopupAt(
button.geometry(bar_position.orientation()),
));
} else {
try_send!(tx, ModuleUpdateEvent::ClosePopup);
tx.send_spawn(ModuleUpdateEvent::ClosePopup);
}
Propagation::Proceed
@ -257,7 +268,7 @@ impl ItemButton {
};
if close {
try_send!(tx, ModuleUpdateEvent::ClosePopup);
tx.send_spawn(ModuleUpdateEvent::ClosePopup);
}
Propagation::Proceed
@ -297,3 +308,41 @@ impl ItemButton {
}
}
}
#[derive(Debug, Clone)]
pub struct ImageTextButton {
pub(crate) button: Button,
pub(crate) label: Label,
image: Image,
}
impl ImageTextButton {
pub(crate) fn new(orientation: Orientation) -> Self {
let button = Button::new();
let container = gtk::Box::new(orientation, 0);
let label = Label::new(None);
let image = Image::new();
container.add(&image);
container.add(&label);
button.add(&container);
container.set_halign(Align::Center);
container.set_valign(Align::Center);
ImageTextButton {
button,
label,
image,
}
}
}
impl Deref for ImageTextButton {
type Target = Button;
fn deref(&self) -> &Self::Target {
&self.button
}
}

View file

@ -1,22 +1,28 @@
mod item;
mod open_state;
mod pagination;
use self::item::{AppearanceOptions, Item, ItemButton, Window};
use self::open_state::OpenState;
use super::{Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, WidgetContext};
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::clients::wayland::{self, ToplevelEvent};
use crate::config::CommonConfig;
use crate::desktop_file::find_desktop_file;
use crate::{arc_mut, glib_recv, lock, module_impl, send_async, spawn, try_send, write_lock};
use color_eyre::{Help, Report};
use crate::config::{
CommonConfig, EllipsizeMode, LayoutConfig, TruncateMode, default_launch_command,
};
use crate::desktop_file::open_program;
use crate::gtk_helpers::{IronbarGtkExt, IronbarLabelExt};
use crate::modules::launcher::item::ImageTextButton;
use crate::modules::launcher::pagination::{IconContext, Pagination};
use crate::{arc_mut, lock, module_impl, spawn, write_lock};
use color_eyre::Report;
use gtk::prelude::*;
use gtk::{Button, Orientation};
use indexmap::IndexMap;
use serde::Deserialize;
use std::process::{Command, Stdio};
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
use tracing::{debug, error, trace};
use tokio::sync::mpsc;
use tracing::{debug, error, trace, warn};
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
@ -54,15 +60,120 @@ pub struct LauncherModule {
#[serde(default = "crate::config::default_false")]
reversed: bool,
/// Whether to minimize a window if it is focused when clicked.
///
/// **Default**: `true`
#[serde(default = "crate::config::default_true")]
minimize_focused: bool,
/// The number of items to show on a page.
///
/// When the number of items reaches the page size,
/// pagination controls appear at the start of the widget
/// which can be used to move forward/back through the list of items.
///
/// If there are too many to fit, the overflow will be truncated
/// by the next widget.
///
/// **Default**: `1000`.
#[serde(default = "default_page_size")]
page_size: usize,
/// Module UI icons (separate from app icons shown for items).
///
/// See [icons](#icons).
#[serde(default)]
icons: Icons,
/// Size in pixels to render pagination icons at (image icons only).
///
/// **Default**: `16`
#[serde(default = "default_icon_size_pagination")]
pagination_icon_size: i32,
// -- common --
/// Truncate application names on the bar if they get too long.
/// See [truncate options](module-level-options#truncate-mode).
///
/// **Default**: `Auto (end)`
#[serde(default)]
truncate: TruncateMode,
/// Truncate application names in popups if they get too long.
/// See [truncate options](module-level-options#truncate-mode).
///
/// **Default**: `{ mode = "middle" max_length = 25 }`
#[serde(default = "default_truncate_popup")]
truncate_popup: TruncateMode,
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// Command used to launch applications.
///
/// **Default**: `gtk-launch`
#[serde(default = "default_launch_command")]
launch_command: String,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
struct Icons {
/// Icon to show for page back button.
///
/// **Default**: `󰅁`
#[serde(default = "default_icon_page_back")]
page_back: String,
/// Icon to show for page back button.
///
/// **Default**: `>`
#[serde(default = "default_icon_page_forward")]
page_forward: String,
}
impl Default for Icons {
fn default() -> Self {
Self {
page_back: default_icon_page_back(),
page_forward: default_icon_page_forward(),
}
}
}
const fn default_icon_size() -> i32 {
32
}
const fn default_icon_size_pagination() -> i32 {
default_icon_size() / 2
}
const fn default_page_size() -> usize {
1000
}
fn default_icon_page_back() -> String {
String::from("󰅁")
}
fn default_icon_page_forward() -> String {
String::from("󰅂")
}
const fn default_truncate_popup() -> TruncateMode {
TruncateMode::Length {
mode: EllipsizeMode::Middle,
length: None,
max_length: Some(25),
}
}
#[derive(Debug, Clone)]
pub enum LauncherUpdate {
/// Adds item
@ -86,6 +197,7 @@ pub enum ItemEvent {
FocusItem(String),
FocusWindow(usize),
OpenItem(String),
MinimizeItem(String),
}
enum ItemOrWindow {
@ -126,7 +238,6 @@ impl Module<gtk::Box> for LauncherModule {
});
let items = arc_mut!(items);
let items2 = Arc::clone(&items);
let tx = context.tx.clone();
@ -143,24 +254,26 @@ impl Module<gtk::Box> for LauncherModule {
for info in handles {
let mut items = lock!(items);
let item = items.get_mut(&info.app_id);
match item {
Some(item) => {
item.merge_toplevel(info.clone());
}
None => {
items.insert(info.app_id.clone(), Item::from(info.clone()));
}
if let Some(item) = item {
item.merge_toplevel(info.clone());
} else {
let item = Item::from(info.clone());
items.insert(info.app_id.clone(), item);
}
}
{
let items = lock!(items);
let items = items.iter();
for (_, item) in items {
try_send!(
tx,
ModuleUpdateEvent::Update(LauncherUpdate::AddItem(item.clone()))
);
let items = {
let items = lock!(items);
items
.iter()
.map(|(_, item)| item.clone())
.collect::<Vec<_>>() // need to collect to be able to drop lock
};
for item in items {
tx.send_update(LauncherUpdate::AddItem(item)).await;
}
}
@ -179,7 +292,6 @@ impl Module<gtk::Box> for LauncherModule {
match item {
None => {
let item: Item = info.into();
items.insert(app_id.clone(), item.clone());
ItemOrWindow::Item(item)
@ -256,7 +368,7 @@ impl Module<gtk::Box> for LauncherModule {
.await?;
}
None => {}
};
}
}
}
}
@ -265,36 +377,29 @@ impl Module<gtk::Box> for LauncherModule {
});
// listen to ui events
let minimize_focused = self.minimize_focused;
let wl = context.client::<wayland::Client>();
let desktop_files = context.ironbar.desktop_files();
let launch_command_str: String = self.launch_command.clone();
spawn(async move {
while let Some(event) = rx.recv().await {
if let ItemEvent::OpenItem(app_id) = event {
find_desktop_file(&app_id).map_or_else(
|| error!("Could not find desktop file for {}", app_id),
|file| {
if let Err(err) = Command::new("gtk-launch")
.arg(
file.file_name()
.expect("File segment missing from path to desktop file"),
)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
error!(
"{:?}",
Report::new(err)
.wrap_err("Failed to run gtk-launch command.")
.suggestion("Perhaps the desktop file is invalid?")
);
}
},
);
match desktop_files.find(&app_id).await {
Ok(Some(file)) => {
open_program(&file.file_name, &launch_command_str);
}
Ok(None) => warn!("Could not find applications file for {}", app_id),
Err(err) => error!("Failed to find parse file for {}: {}", app_id, err),
}
} else {
send_async!(tx, ModuleUpdateEvent::ClosePopup);
tx.send_expect(ModuleUpdateEvent::ClosePopup).await;
let minimize_window = matches!(event, ItemEvent::MinimizeItem(_));
let id = match event {
ItemEvent::FocusItem(app_id) => {
ItemEvent::FocusItem(app_id) | ItemEvent::MinimizeItem(app_id) => {
lock!(items).get(&app_id).and_then(|item| {
item.windows
.iter()
@ -313,7 +418,11 @@ impl Module<gtk::Box> for LauncherModule {
.find_map(|(_, item)| item.windows.get(&id))
{
debug!("Focusing window {id}: {}", window.name);
wl.toplevel_focus(window.id);
if minimize_window && minimize_focused {
wl.toplevel_minimize(window.id);
} else {
wl.toplevel_focus(window.id);
}
}
}
}
@ -328,20 +437,32 @@ impl Module<gtk::Box> for LauncherModule {
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> crate::Result<ModuleParts<gtk::Box>> {
let icon_theme = info.icon_theme;
let container = gtk::Box::new(self.layout.orientation(info), 0);
let page_size = self.page_size;
let container = gtk::Box::new(info.bar_position.orientation(), 0);
let image_provider = context.ironbar.image_provider();
let pagination = Pagination::new(
&container,
self.page_size,
self.layout.orientation(info),
&IconContext {
back: &self.icons.page_back,
fwd: &self.icons.page_forward,
size: self.pagination_icon_size,
},
&image_provider,
);
{
let container = container.clone();
let icon_theme = icon_theme.clone();
let controller_tx = context.controller_tx.clone();
let appearance_options = AppearanceOptions {
show_names: self.show_names,
show_icons: self.show_icons,
icon_size: self.icon_size,
truncate: self.truncate,
orientation: self.layout.orientation(info),
angle: self.layout.angle(info),
justify: self.layout.justify.into(),
};
let show_names = self.show_names;
@ -349,93 +470,117 @@ impl Module<gtk::Box> for LauncherModule {
let mut buttons = IndexMap::<String, ItemButton>::new();
let tx = context.tx.clone();
let rx = context.subscribe();
glib_recv!(rx, event => {
match event {
LauncherUpdate::AddItem(item) => {
debug!("Adding item with id '{}' to the bar: {item:?}", item.app_id);
if let Some(button) = buttons.get(&item.app_id) {
button.set_open(true);
button.set_focused(item.open_state.is_focused());
} else {
let button = ItemButton::new(
&item,
appearance_options,
&icon_theme,
bar_position,
&tx,
&controller_tx,
);
rx.recv_glib(
(&container, &context.controller_tx, &context.tx),
move |(container, controller_tx, tx), event: LauncherUpdate| {
// all widgets show by default
// so check if pagination should be shown
// to ensure correct state on init.
if buttons.len() <= page_size {
pagination.hide();
}
if self.reversed {
container.pack_end(&button.button, false, false, 0);
match event {
LauncherUpdate::AddItem(item) => {
debug!("Adding item with id '{}' to the bar: {item:?}", item.app_id);
if let Some(button) = buttons.get(&item.app_id) {
button.set_open(true);
button.set_focused(item.open_state.is_focused());
} else {
container.add(&button.button);
}
let button = ItemButton::new(
&item,
appearance_options,
image_provider.clone(),
bar_position,
tx,
controller_tx,
);
buttons.insert(item.app_id, button);
}
}
LauncherUpdate::AddWindow(app_id, win) => {
if let Some(button) = buttons.get(&app_id) {
button.set_open(true);
button.set_focused(win.open_state.is_focused());
write_lock!(button.menu_state).num_windows += 1;
}
}
LauncherUpdate::RemoveItem(app_id) => {
debug!("Removing item with id {}", app_id);
if let Some(button) = buttons.get(&app_id) {
if button.persistent {
button.set_open(false);
if button.show_names {
button.button.set_label(&app_id);
if self.reversed {
container.pack_end(&*button.button, false, false, 0);
} else {
container.add(&*button.button);
}
} else {
container.remove(&button.button);
buttons.shift_remove(&app_id);
if buttons.len() + 1 >= pagination.offset() + page_size {
button.button.set_visible(false);
pagination.set_sensitive_fwd(true);
}
if buttons.len() + 1 > page_size {
pagination.show_all();
}
buttons.insert(item.app_id, button);
}
}
}
LauncherUpdate::RemoveWindow(app_id, win_id) => {
debug!("Removing window {win_id} with id {app_id}");
if let Some(button) = buttons.get(&app_id) {
button.set_focused(false);
let mut menu_state = write_lock!(button.menu_state);
menu_state.num_windows -= 1;
}
}
LauncherUpdate::Focus(app_id, focus) => {
debug!("Changing focus to {} on item with id {}", focus, app_id);
if let Some(button) = buttons.get(&app_id) {
button.set_focused(focus);
}
}
LauncherUpdate::Title(app_id, _, name) => {
debug!("Updating title for item with id {}: {:?}", app_id, name);
if show_names {
LauncherUpdate::AddWindow(app_id, win) => {
if let Some(button) = buttons.get(&app_id) {
button.button.set_label(&name);
button.set_open(true);
button.set_focused(win.open_state.is_focused());
write_lock!(button.menu_state).num_windows += 1;
}
}
LauncherUpdate::RemoveItem(app_id) => {
debug!("Removing item with id {}", app_id);
if let Some(button) = buttons.get(&app_id) {
if button.persistent {
button.set_open(false);
if button.show_names {
button.button.label.set_label(&app_id);
}
} else {
container.remove(&button.button.button);
buttons.shift_remove(&app_id);
}
}
if buttons.len() < pagination.offset() + page_size {
pagination.set_sensitive_fwd(false);
}
if buttons.len() <= page_size {
pagination.hide();
}
}
LauncherUpdate::RemoveWindow(app_id, win_id) => {
debug!("Removing window {win_id} with id {app_id}");
if let Some(button) = buttons.get(&app_id) {
button.set_focused(false);
let mut menu_state = write_lock!(button.menu_state);
menu_state.num_windows -= 1;
}
}
LauncherUpdate::Focus(app_id, focus) => {
debug!("Changing focus to {} on item with id {}", focus, app_id);
if let Some(button) = buttons.get(&app_id) {
button.set_focused(focus);
}
}
LauncherUpdate::Title(app_id, _, name) => {
debug!("Updating title for item with id {}: {:?}", app_id, name);
if show_names {
if let Some(button) = buttons.get(&app_id) {
button.button.label.set_label(&name);
}
}
}
LauncherUpdate::Hover(_) => {}
}
LauncherUpdate::Hover(_) => {}
};
});
},
);
}
let rx = context.subscribe();
let popup = self
.into_popup(context.controller_tx.clone(), rx, context, info)
.into_popup_parts(vec![]); // since item buttons are dynamic, they pass their geometry directly
let popup = self.into_popup(context, info).into_popup_parts(vec![]); // since item buttons are dynamic, they pass their geometry directly
Ok(ModuleParts {
widget: container,
@ -445,9 +590,7 @@ impl Module<gtk::Box> for LauncherModule {
fn into_popup(
self,
controller_tx: mpsc::Sender<Self::ReceiveMessage>,
rx: broadcast::Receiver<Self::SendMessage>,
_context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box> {
const MAX_WIDTH: i32 = 250;
@ -459,11 +602,11 @@ impl Module<gtk::Box> for LauncherModule {
placeholder.set_width_request(MAX_WIDTH);
container.add(&placeholder);
let mut buttons = IndexMap::<String, IndexMap<usize, Button>>::new();
let mut buttons = IndexMap::<String, IndexMap<usize, ImageTextButton>>::new();
{
let container = container.clone();
glib_recv!(rx, event => {
context
.subscribe()
.recv_glib(&container, move |container, event| {
match event {
LauncherUpdate::AddItem(item) => {
let app_id = item.app_id.clone();
@ -473,15 +616,16 @@ impl Module<gtk::Box> for LauncherModule {
.windows
.into_iter()
.map(|(_, win)| {
let button = Button::builder()
.label(clamp(&win.name))
.height_request(40)
.build();
// TODO: Currently has a useless image
let button = ImageTextButton::new(Orientation::Horizontal);
button.set_height_request(40);
button.label.set_label(&win.name);
button.label.truncate(self.truncate_popup);
{
let tx = controller_tx.clone();
let tx = context.controller_tx.clone();
button.connect_clicked(move |_| {
try_send!(tx, ItemEvent::FocusWindow(win.id));
tx.send_spawn(ItemEvent::FocusWindow(win.id));
});
}
@ -498,15 +642,16 @@ impl Module<gtk::Box> for LauncherModule {
);
if let Some(buttons) = buttons.get_mut(&app_id) {
let button = Button::builder()
.height_request(40)
.label(clamp(&win.name))
.build();
// TODO: Currently has a useless image
let button = ImageTextButton::new(Orientation::Horizontal);
button.set_height_request(40);
button.label.set_label(&win.name);
button.label.truncate(self.truncate_popup);
{
let tx = controller_tx.clone();
let tx = context.controller_tx.clone();
button.connect_clicked(move |_button| {
try_send!(tx, ItemEvent::FocusWindow(win.id));
tx.send_spawn(ItemEvent::FocusWindow(win.id));
});
}
@ -527,7 +672,7 @@ impl Module<gtk::Box> for LauncherModule {
if let Some(buttons) = buttons.get_mut(&app_id) {
if let Some(button) = buttons.get(&win_id) {
button.set_label(&title);
button.label.set_label(&title);
}
}
}
@ -540,8 +685,8 @@ impl Module<gtk::Box> for LauncherModule {
// add app's buttons
if let Some(buttons) = buttons.get(&app_id) {
for (_, button) in buttons {
button.style_context().add_class("popup-item");
container.add(button);
button.add_class("popup-item");
container.add(&button.button);
}
container.show_all();
@ -551,26 +696,7 @@ impl Module<gtk::Box> for LauncherModule {
_ => {}
}
});
}
Some(container)
}
}
/// Clamps a string at 24 characters.
///
/// This is a hacky number derived from
/// "what fits inside the 250px popup"
/// and probably won't hold up with wide fonts.
///
/// TODO: Migrate this to truncate system
///
fn clamp(str: &str) -> String {
const MAX_CHARS: usize = 24;
if str.len() > MAX_CHARS {
str.chars().take(MAX_CHARS - 3).collect::<String>() + "..."
} else {
str.to_string()
}
}

View file

@ -0,0 +1,135 @@
use crate::gtk_helpers::IronbarGtkExt;
use crate::image;
use crate::image::IconButton;
use gtk::prelude::*;
use gtk::{Button, Orientation};
use std::cell::RefCell;
use std::ops::Deref;
use std::rc::Rc;
pub struct Pagination {
offset: Rc<RefCell<usize>>,
controls_container: gtk::Box,
btn_fwd: Button,
}
pub struct IconContext<'a> {
pub back: &'a str,
pub fwd: &'a str,
pub size: i32,
}
impl Pagination {
pub fn new(
container: &gtk::Box,
page_size: usize,
orientation: Orientation,
icon_context: &IconContext,
image_provider: &image::Provider,
) -> Self {
let scroll_box = gtk::Box::new(orientation, 0);
let scroll_back =
IconButton::new(icon_context.back, icon_context.size, image_provider.clone());
let scroll_fwd =
IconButton::new(icon_context.fwd, icon_context.size, image_provider.clone());
scroll_back.set_sensitive(false);
scroll_fwd.set_sensitive(false);
scroll_box.add_class("pagination");
scroll_back.add_class("btn-back");
scroll_fwd.add_class("btn-forward");
scroll_box.add(&*scroll_back);
scroll_box.add(&*scroll_fwd);
container.add(&scroll_box);
let offset = Rc::new(RefCell::new(1));
{
let offset = offset.clone();
let container = container.clone();
let scroll_back = scroll_back.clone();
scroll_fwd.connect_clicked(move |btn| {
let mut offset = offset.borrow_mut();
let child_count = container.children().len();
*offset = std::cmp::min(child_count - 1, *offset + page_size);
Self::update_page(&container, *offset, page_size);
if *offset + page_size >= child_count {
btn.set_sensitive(false);
}
scroll_back.set_sensitive(true);
});
}
{
let offset = offset.clone();
let container = container.clone();
let scroll_fwd = scroll_fwd.clone();
scroll_back.connect_clicked(move |btn| {
let mut offset = offset.borrow_mut();
// avoid using std::cmp::max due to possible overflow
if page_size < *offset {
*offset -= page_size;
} else {
*offset = 1;
}
Self::update_page(&container, *offset, page_size);
if *offset == 1 || *offset - page_size < 1 {
btn.set_sensitive(false);
}
scroll_fwd.set_sensitive(true);
});
}
Self {
offset,
controls_container: scroll_box,
btn_fwd: scroll_fwd.deref().clone(),
}
}
fn update_page(container: &gtk::Box, offset: usize, page_size: usize) {
for (i, btn) in container.children().iter().enumerate() {
// skip offset buttons
if i == 0 {
continue;
}
if i >= offset && i < offset + page_size {
btn.show();
} else {
btn.hide();
}
}
}
pub fn set_sensitive_fwd(&self, sensitive: bool) {
self.btn_fwd.set_sensitive(sensitive);
}
pub fn offset(&self) -> usize {
*self.offset.borrow()
}
}
impl Deref for Pagination {
type Target = gtk::Box;
fn deref(&self) -> &Self::Target {
&self.controls_container
}
}

279
src/modules/menu/config.rs Normal file
View file

@ -0,0 +1,279 @@
use crate::config::default_launch_command;
use crate::config::{CommonConfig, TruncateMode};
use crate::modules::menu::{MenuEntry, XdgSection};
use indexmap::IndexMap;
use serde::Deserialize;
/// An individual entry in the main menu section.
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MenuConfig {
/// Contains all applications matching the configured `categories`.
XdgEntry(XdgEntry),
/// Contains all applications not covered by `xdg_entry` categories.
XdgOther,
/// Individual shell command entry.
Custom(CustomEntry),
}
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct XdgEntry {
/// Text to display on the button.
#[serde(default)]
pub label: String,
/// Name of the image icon to show next to the label.
#[serde(default)]
pub icon: Option<String>,
/// XDG categories the associated submenu should contain.
#[serde(default)]
pub categories: Vec<String>,
}
/// Individual shell command entry.
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CustomEntry {
/// Text to display on the button.
#[serde(default)]
pub label: String,
/// Name of the image icon to show next to the label.
///
/// **Default**: `null`
pub icon: Option<String>,
/// Shell command to execute when the button is clicked.
/// This is run using `sh -c`.
#[serde(default)]
pub on_click: String,
}
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct MenuModule {
/// Items to add to the start of the main menu.
///
/// **Default**: `[]`
#[serde(default)]
pub(super) start: Vec<MenuConfig>,
/// Items to add to the start of the main menu.
///
/// By default, this shows a number of XDG entries
/// that should cover all common applications.
///
/// **Default**: See `examples/menu/default`
#[serde(default = "default_menu")]
pub(super) center: Vec<MenuConfig>,
/// Items to add to the end of the main menu.
///
/// **Default**: `[]`
#[serde(default)]
pub(super) end: Vec<MenuConfig>,
/// Fixed height of the menu.
///
/// When set, if the number of (sub)menu entries exceeds this value,
/// a scrollbar will be shown.
///
/// Leave null to resize dynamically.
///
/// **Default**: `null`
#[serde(default)]
pub(super) height: Option<i32>,
/// Fixed width of the menu.
///
/// Can be used with `truncate` options
/// to customise how item labels are truncated.
///
/// **Default**: `null`
#[serde(default)]
pub(super) width: Option<i32>,
/// Label to show on the menu button on the bar.
///
/// **Default**: `≡`
#[serde(default = "default_menu_popup_label")]
pub(super) label: Option<String>,
/// Icon to show on the menu button on the bar.
///
/// **Default**: `null`
#[serde(default)]
pub(super) label_icon: Option<String>,
/// Size of the `label_icon` image.
#[serde(default = "default_menu_popup_icon_size")]
pub(super) label_icon_size: i32,
// -- common --
/// Truncate options to apply to (sub)menu item labels.
///
/// See [truncate options](module-level-options#truncate-mode).
///
/// **Default**: `Auto (end)`
#[serde(default)]
pub(super) truncate: TruncateMode,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
/// Command used to launch applications.
///
/// **Default**: `gtk-launch`
#[serde(default = "default_launch_command")]
pub launch_command: String,
}
impl Default for MenuModule {
fn default() -> Self {
MenuModule {
start: vec![],
center: default_menu(),
end: vec![],
height: None,
width: None,
truncate: TruncateMode::default(),
// max_label_length: default_length(),
label: default_menu_popup_label(),
label_icon: None,
label_icon_size: default_menu_popup_icon_size(),
common: Some(CommonConfig::default()),
launch_command: default_launch_command(),
}
}
}
fn default_menu() -> Vec<MenuConfig> {
vec![
MenuConfig::XdgEntry(XdgEntry {
label: "Accessories".to_string(),
icon: Some("accessories".to_string()),
categories: vec![
"Accessibility".to_string(),
"Core".to_string(),
"Legacy".to_string(),
"Utility".to_string(),
],
}),
MenuConfig::XdgEntry(XdgEntry {
label: "Development".to_string(),
icon: Some("applications-development".to_string()),
categories: vec!["Development".to_string()],
}),
MenuConfig::XdgEntry(XdgEntry {
label: "Education".to_string(),
icon: Some("applications-education".to_string()),
categories: vec!["Education".to_string()],
}),
MenuConfig::XdgEntry(XdgEntry {
label: "Games".to_string(),
icon: Some("applications-games".to_string()),
categories: vec!["Game".to_string()],
}),
MenuConfig::XdgEntry(XdgEntry {
label: "Graphics".to_string(),
icon: Some("applications-graphics".to_string()),
categories: vec!["Graphics".to_string()],
}),
MenuConfig::XdgEntry(XdgEntry {
label: "Multimedia".to_string(),
icon: Some("applications-multimedia".to_string()),
categories: vec![
"Audio".to_string(),
"Video".to_string(),
"AudioVideo".to_string(),
],
}),
MenuConfig::XdgEntry(XdgEntry {
label: "Network".to_string(),
icon: Some("applications-internet".to_string()),
categories: vec!["Network".to_string()],
}),
MenuConfig::XdgEntry(XdgEntry {
label: "Office".to_string(),
icon: Some("applications-office".to_string()),
categories: vec!["Office".to_string()],
}),
MenuConfig::XdgEntry(XdgEntry {
label: "Science".to_string(),
icon: Some("applications-science".to_string()),
categories: vec!["Science".to_string()],
}),
MenuConfig::XdgEntry(XdgEntry {
label: "System".to_string(),
icon: Some("applications-system".to_string()),
categories: vec!["Emulator".to_string(), "System".to_string()],
}),
MenuConfig::XdgOther,
MenuConfig::XdgEntry(XdgEntry {
label: "Settings".to_string(),
icon: Some("preferences-system".to_string()),
categories: vec!["Settings".to_string(), "Screensaver".to_string()],
}),
]
}
fn default_menu_popup_label() -> Option<String> {
Some("".to_string())
}
const fn default_menu_popup_icon_size() -> i32 {
16
}
pub const OTHER_LABEL: &str = "Other";
pub fn parse_config(
section_config: Vec<MenuConfig>,
sections_by_cat: &mut IndexMap<String, Vec<String>>,
) -> IndexMap<String, MenuEntry> {
section_config
.into_iter()
.map(|entry_config| match entry_config {
MenuConfig::XdgEntry(entry) => {
entry.categories.into_iter().for_each(|cat| {
let existing = sections_by_cat.get_mut(&cat);
if let Some(existing) = existing {
existing.push(entry.label.clone());
} else {
sections_by_cat.insert(cat, vec![entry.label.clone()]);
}
});
(
entry.label.clone(),
MenuEntry::Xdg(XdgSection {
label: entry.label,
icon: entry.icon,
applications: IndexMap::new(),
}),
)
}
MenuConfig::XdgOther => (
OTHER_LABEL.to_string(),
MenuEntry::Xdg(XdgSection {
label: OTHER_LABEL.to_string(),
icon: Some("applications-other".to_string()),
applications: IndexMap::new(),
}),
),
MenuConfig::Custom(entry) => (
entry.label.clone(),
MenuEntry::Custom(CustomEntry {
icon: entry.icon,
label: entry.label,
on_click: entry.on_click,
}),
),
})
.collect()
}

326
src/modules/menu/mod.rs Normal file
View file

@ -0,0 +1,326 @@
mod config;
mod ui;
use color_eyre::Result;
use color_eyre::eyre::Report;
use config::*;
use gtk::prelude::*;
use gtk::{Align, Button, Orientation};
use indexmap::IndexMap;
use serde::Deserialize;
use tokio::sync::mpsc;
use super::{ModuleLocation, PopupButton};
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::config::BarPosition;
use crate::gtk_helpers::IronbarGtkExt;
use crate::modules::{
Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, WidgetContext,
};
use crate::{module_impl, spawn};
pub use config::MenuModule;
/// XDG button and menu from parsed config.
#[derive(Debug, Clone)]
pub struct XdgSection {
pub label: String,
pub icon: Option<String>,
pub applications: IndexMap<String, MenuApplication>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct MenuApplication {
pub label: String,
pub file_name: String,
pub categories: Vec<String>,
}
#[derive(Debug)]
pub enum MenuEntry {
Xdg(XdgSection),
Custom(CustomEntry),
}
impl MenuEntry {
pub fn label(&self) -> String {
match self {
Self::Xdg(entry) => entry.label.clone(),
Self::Custom(entry) => entry.label.clone(),
}
}
pub fn icon(&self) -> Option<String> {
match self {
Self::Xdg(entry) => entry.icon.clone(),
Self::Custom(entry) => entry.icon.clone(),
}
}
}
impl Module<Button> for MenuModule {
type SendMessage = Vec<MenuApplication>;
type ReceiveMessage = ();
module_impl!("menu");
fn spawn_controller(
&self,
_info: &ModuleInfo,
context: &WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
mut rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let tx = context.tx.clone();
// let max_label_length = self.max_label_length;
let desktop_files = context.ironbar.desktop_files();
spawn(async move {
// parsing all desktop files is heavy so wait until the popup is first opened before loading
rx.recv().await;
let apps = desktop_files
.get_all()
.await?
.into_iter()
.filter(|file| {
file.no_display != Some(true)
&& file.app_type.as_deref().is_some_and(|v| v == "Application")
})
.map(|file| MenuApplication {
label: file.name.unwrap_or_default(),
file_name: file.file_name,
categories: file.categories,
})
.collect::<Vec<_>>();
tx.send_update_spawn(apps);
Ok::<(), Report>(())
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleParts<Button>> {
let button = Button::new();
if let Some(ref label) = self.label {
button.set_label(label);
}
if let Some(ref label_icon) = self.label_icon {
let image_provider = context.ironbar.image_provider();
let gtk_image = gtk::Image::new();
button.set_image(Some(&gtk_image));
button.set_always_show_image(true);
let label_icon = label_icon.clone();
glib::spawn_future_local(async move {
image_provider
.load_into_image_silent(&label_icon, self.label_icon_size, true, &gtk_image)
.await;
});
}
let tx = context.tx.clone();
let controller_tx = context.controller_tx.clone();
button.connect_clicked(move |button| {
tx.send_spawn(ModuleUpdateEvent::TogglePopup(button.popup_id()));
// channel will close after init event
if !controller_tx.is_closed() {
controller_tx.send_spawn(());
}
});
let popup = self
.into_popup(context, info)
.into_popup_parts(vec![&button]);
Ok(ModuleParts::new(button, popup))
}
fn into_popup(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Option<gtk::Box> {
let image_provider = context.ironbar.image_provider();
let alignment = {
match info.bar_position {
// For fixed height menus always align to the top
_ if self.height.is_some() => Align::Start,
// Otherwise alignment is based on menu position
BarPosition::Top => Align::Start,
BarPosition::Bottom => Align::End,
_ => match &info.location {
&ModuleLocation::Left | &ModuleLocation::Center => Align::Start,
&ModuleLocation::Right => Align::End,
},
}
};
let mut sections_by_cat = IndexMap::<String, Vec<String>>::new();
let container = gtk::Box::new(Orientation::Horizontal, 4);
let main_menu = gtk::Box::new(Orientation::Vertical, 0);
main_menu.set_valign(alignment);
main_menu.set_vexpand(false);
main_menu.add_class("main");
if let Some(width) = self.width {
main_menu.set_width_request(width / 2);
}
if let Some(max_height) = self.height {
container.set_height_request(max_height);
let scrolled = gtk::ScrolledWindow::builder()
.max_content_height(max_height)
.hscrollbar_policy(gtk::PolicyType::Never)
.build();
scrolled.add(&main_menu);
container.add(&scrolled);
} else {
container.add(&main_menu);
}
container.show_all();
let mut start_entries = parse_config(self.start, &mut sections_by_cat);
let mut center_entries = parse_config(self.center, &mut sections_by_cat);
let mut end_entries = parse_config(self.end, &mut sections_by_cat);
let start_section = gtk::Box::new(Orientation::Vertical, 0);
let center_section = gtk::Box::new(Orientation::Vertical, 0);
let end_section = gtk::Box::new(Orientation::Vertical, 0);
start_section.add_class("main-start");
center_section.add_class("main-center");
end_section.add_class("main-end");
let truncate_mode = self.truncate;
context.subscribe().recv_glib(
(
&main_menu,
&container,
&start_section,
&center_section,
&end_section,
),
move |(main_menu, container, start_section, center_section, end_section),
applications| {
for application in applications.iter() {
let mut inserted = false;
for category in application.categories.iter() {
if let Some(section_names) = sections_by_cat.get(category) {
for section_name in section_names.iter() {
[&mut start_entries, &mut center_entries, &mut end_entries]
.into_iter()
.for_each(|entries| {
let existing = entries.get_mut(section_name);
if let Some(MenuEntry::Xdg(existing)) = existing {
existing.applications.insert_sorted(
application.label.clone(),
application.clone(),
);
}
});
}
inserted = true;
}
}
if !inserted {
let other = center_entries.get_mut(OTHER_LABEL);
if let Some(MenuEntry::Xdg(other)) = other {
let _ = other
.applications
.insert_sorted(application.label.clone(), application.clone());
}
}
}
main_menu.foreach(|child| {
main_menu.remove(child);
});
macro_rules! add_entries {
($entries:expr, $section:expr) => {
for entry in $entries.values() {
let container1 = container.clone();
let tx = context.tx.clone();
let (button, sub_menu) = ui::make_entry(
entry,
tx,
&image_provider,
truncate_mode,
&self.launch_command,
);
if let Some(sub_menu) = sub_menu.clone() {
sub_menu.set_valign(alignment);
sub_menu.add_class("sub-menu");
if let Some(width) = self.width {
sub_menu.set_width_request(width / 2);
}
}
ui::add_entries(
entry,
button,
sub_menu.as_ref(),
$section,
&container1,
self.height,
);
}
};
}
add_entries!(&start_entries, &start_section);
add_entries!(&center_entries, &center_section);
add_entries!(&end_entries, &end_section);
main_menu.add(start_section);
main_menu.add(center_section);
main_menu.add(end_section);
},
);
{
let container = container.clone();
context.popup.window.connect_hide(move |_| {
start_section.foreach(|child| {
child.remove_class("open");
});
center_section.foreach(|child| {
child.remove_class("open");
});
end_section.foreach(|child| {
child.remove_class("open");
});
container.children().iter().skip(1).for_each(|sub_menu| {
sub_menu.hide();
});
});
}
Some(container)
}
}

210
src/modules/menu/ui.rs Normal file
View file

@ -0,0 +1,210 @@
use super::MenuEntry;
use crate::channels::AsyncSenderExt;
use crate::config::TruncateMode;
use crate::desktop_file::open_program;
use crate::gtk_helpers::{IronbarGtkExt, IronbarLabelExt};
use crate::modules::ModuleUpdateEvent;
use crate::script::Script;
use crate::{image, spawn};
use gtk::prelude::*;
use gtk::{Align, Button, Label, Orientation};
use tokio::sync::mpsc;
use tracing::{debug, error};
pub fn make_entry<R>(
entry: &MenuEntry,
tx: mpsc::Sender<ModuleUpdateEvent<R>>,
image_provider: &image::Provider,
truncate_mode: TruncateMode,
launch_command_str: &str,
) -> (Button, Option<gtk::Box>)
where
R: Send + Clone + 'static,
{
let button = Button::new();
button.add_class("category");
let button_container = gtk::Box::new(Orientation::Horizontal, 4);
button.add(&button_container);
let label = Label::new(Some(&entry.label()));
label.set_halign(Align::Start);
label.truncate(truncate_mode);
if let Some(icon_name) = entry.icon() {
let gtk_image = gtk::Image::new();
gtk_image.set_halign(Align::Start);
button_container.add(&gtk_image);
let image_provider = image_provider.clone();
glib::spawn_future_local(async move {
image_provider
.load_into_image_silent(&icon_name, 16, true, &gtk_image)
.await;
});
}
button_container.add(&label);
button_container.foreach(|child| {
child.set_halign(Align::Start);
});
if let MenuEntry::Xdg(_) = entry {
let right_arrow = Label::new(Some("🢒"));
right_arrow.set_halign(Align::End);
button_container.pack_end(&right_arrow, false, false, 0);
}
button.show_all();
let sub_menu = match entry {
MenuEntry::Xdg(entry) => {
let sub_menu = gtk::Box::new(Orientation::Vertical, 0);
entry.applications.values().for_each(|sub_entry| {
let button = Button::new();
button.add_class("application");
let button_container = gtk::Box::new(Orientation::Horizontal, 4);
button.add(&button_container);
let label = Label::new(Some(&sub_entry.label));
label.set_halign(Align::Start);
label.truncate(truncate_mode);
let icon_name = sub_entry.file_name.trim_end_matches(".desktop").to_string();
let gtk_image = gtk::Image::new();
gtk_image.set_halign(Align::Start);
button_container.add(&gtk_image);
button_container.add(&label);
let image_provider = image_provider.clone();
glib::spawn_future_local(async move {
image_provider
.load_into_image_silent(&icon_name, 16, true, &gtk_image)
.await;
});
button.foreach(|child| {
child.set_halign(Align::Start);
});
sub_menu.add(&button);
{
let sub_menu = sub_menu.clone();
let file_name = sub_entry.file_name.clone();
let command = launch_command_str.to_string();
let tx = tx.clone();
button.connect_clicked(move |_button| {
open_program(&file_name, &command);
sub_menu.hide();
tx.send_spawn(ModuleUpdateEvent::ClosePopup);
});
}
button.show_all();
});
Some(sub_menu)
}
MenuEntry::Custom(_) => None,
};
(button, sub_menu)
}
pub fn add_entries(
entry: &MenuEntry,
button: Button,
sub_menu: Option<&gtk::Box>,
main_menu: &gtk::Box,
container: &gtk::Box,
height: Option<i32>,
) {
let container1 = container.clone();
main_menu.add(&button);
if let Some(sub_menu) = sub_menu {
if let Some(height) = height {
container.set_height_request(height);
let scrolled = gtk::ScrolledWindow::builder()
.max_content_height(height)
.hscrollbar_policy(gtk::PolicyType::Never)
.build();
sub_menu.show();
scrolled.add(sub_menu);
container.add(&scrolled);
let sub_menu1 = scrolled.clone();
let sub_menu_popup_container = sub_menu.clone();
button.connect_clicked(move |button| {
container1.children().iter().skip(1).for_each(|sub_menu| {
if sub_menu.get_visible() {
sub_menu.hide();
}
});
button
.parent()
.expect("button parent should exist")
.downcast::<gtk::Box>()
.expect("button container should be gtk::Box")
.children()
.iter()
.for_each(|child| child.remove_class("open"));
sub_menu1.show_all();
button.add_class("open");
// Reset scroll to top.
if let Some(w) = sub_menu_popup_container.children().first() {
w.set_has_focus(true)
}
});
} else {
container.add(sub_menu);
let sub_menu1 = sub_menu.clone();
button.connect_clicked(move |_button| {
container1.children().iter().skip(1).for_each(|sub_menu| {
if sub_menu.get_visible() {
sub_menu.hide();
}
});
sub_menu1.show();
});
}
}
if let MenuEntry::Custom(entry) = entry {
let label = entry.on_click.clone();
let container = container.clone();
button.connect_clicked(move |_button| {
container.children().iter().skip(1).for_each(|sub_menu| {
sub_menu.hide();
});
let script = Script::from(label.as_str());
debug!("executing command: '{}'", script.cmd);
let args = Vec::new();
spawn(async move {
if let Err(err) = script.get_output(Some(&args)).await {
error!("{err:?}");
}
});
});
}
main_menu.show_all();
}

View file

@ -6,16 +6,19 @@ use color_eyre::Result;
use glib::IsA;
use gtk::gdk::{EventMask, Monitor};
use gtk::prelude::*;
use gtk::{Application, Button, EventBox, IconTheme, Orientation, Revealer, Widget};
use gtk::{Application, Button, EventBox, Orientation, Revealer, Widget};
use tokio::sync::{broadcast, mpsc};
use tracing::debug;
use crate::Ironbar;
use crate::channels::{MpscReceiverExt, SyncSenderExt};
use crate::clients::{ClientResult, ProvidesClient, ProvidesFallibleClient};
use crate::config::{BarPosition, CommonConfig, TransitionType};
use crate::gtk_helpers::{IronbarGtkExt, WidgetGeometry};
use crate::popup::Popup;
use crate::{glib_recv_mpsc, send, Ironbar};
#[cfg(feature = "bindmode")]
pub mod bindmode;
#[cfg(feature = "cairo")]
pub mod cairo;
#[cfg(feature = "clipboard")]
@ -28,18 +31,28 @@ pub mod clipboard;
/// with second-level precision and a calendar.
#[cfg(feature = "clock")]
pub mod clock;
#[cfg(feature = "custom")]
pub mod custom;
#[cfg(feature = "focused")]
pub mod focused;
#[cfg(feature = "keyboard")]
pub mod keyboard;
#[cfg(feature = "label")]
pub mod label;
#[cfg(feature = "launcher")]
pub mod launcher;
#[cfg(feature = "menu")]
pub mod menu;
#[cfg(feature = "music")]
pub mod music;
#[cfg(feature = "network_manager")]
pub mod networkmanager;
#[cfg(feature = "notifications")]
pub mod notifications;
#[cfg(feature = "script")]
pub mod script;
#[cfg(feature = "sys_info")]
pub mod sysinfo;
@ -66,7 +79,6 @@ pub struct ModuleInfo<'a> {
pub bar_position: BarPosition,
pub monitor: &'a Monitor,
pub output_name: &'a str,
pub icon_theme: &'a IconTheme,
}
#[derive(Debug, Clone)]
@ -257,8 +269,6 @@ where
fn into_popup(
self,
_tx: mpsc::Sender<Self::ReceiveMessage>,
_rx: broadcast::Receiver<Self::SendMessage>,
_context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box>
@ -378,38 +388,41 @@ impl ModuleFactory for BarModuleFactory {
) where
TSend: Debug + Clone + Send + 'static,
{
let popup = self.popup.clone();
glib_recv_mpsc!(rx, ev => {
match ev {
ModuleUpdateEvent::Update(update) => {
send!(tx, update);
}
ModuleUpdateEvent::TogglePopup(button_id) if !disable_popup => {
debug!("Toggling popup for {} [#{}] (button id: {button_id})", name, id);
if popup.visible() && popup.current_widget().unwrap_or_default() == id {
popup.hide();
} else {
popup.show(id, button_id);
}
}
ModuleUpdateEvent::OpenPopup(button_id) if !disable_popup => {
debug!("Opening popup for {} [#{}] (button id: {button_id})", name, id);
rx.recv_glib(&self.popup, move |popup, ev| match ev {
ModuleUpdateEvent::Update(update) => {
tx.send_expect(update);
}
ModuleUpdateEvent::TogglePopup(button_id) if !disable_popup => {
debug!(
"Toggling popup for {} [#{}] (button id: {button_id})",
name, id
);
if popup.visible() && popup.current_widget().unwrap_or_default() == id {
popup.hide();
} else {
popup.show(id, button_id);
}
#[cfg(feature = "launcher")]
ModuleUpdateEvent::OpenPopupAt(geometry) if !disable_popup => {
debug!("Opening popup for {} [#{}]", name, id);
popup.hide();
popup.show_at(id, geometry);
}
ModuleUpdateEvent::ClosePopup if !disable_popup => {
debug!("Closing popup for {} [#{}]", name, id);
popup.hide();
},
_ => {}
}
ModuleUpdateEvent::OpenPopup(button_id) if !disable_popup => {
debug!(
"Opening popup for {} [#{}] (button id: {button_id})",
name, id
);
popup.hide();
popup.show(id, button_id);
}
#[cfg(feature = "launcher")]
ModuleUpdateEvent::OpenPopupAt(geometry) if !disable_popup => {
debug!("Opening popup for {} [#{}]", name, id);
popup.hide();
popup.show_at(id, geometry);
}
ModuleUpdateEvent::ClosePopup if !disable_popup => {
debug!("Closing popup for {} [#{}]", name, id);
popup.hide();
}
_ => {}
});
}
@ -450,39 +463,43 @@ impl ModuleFactory for PopupModuleFactory {
) where
TSend: Debug + Clone + Send + 'static,
{
let popup = self.popup.clone();
let button_id = self.button_id;
glib_recv_mpsc!(rx, ev => {
match ev {
ModuleUpdateEvent::Update(update) => {
send!(tx, update);
}
ModuleUpdateEvent::TogglePopup(_) if !disable_popup => {
debug!("Toggling popup for {} [#{}] (button id: {button_id})", name, id);
if popup.visible() && popup.current_widget().unwrap_or_default() == id {
popup.hide();
} else {
popup.show(id, button_id);
}
}
ModuleUpdateEvent::OpenPopup(_) if !disable_popup => {
debug!("Opening popup for {} [#{}] (button id: {button_id})", name, id);
rx.recv_glib(&self.popup, move |popup, ev| match ev {
ModuleUpdateEvent::Update(update) => {
tx.send_expect(update);
}
ModuleUpdateEvent::TogglePopup(_) if !disable_popup => {
debug!(
"Toggling popup for {} [#{}] (button id: {button_id})",
name, id
);
if popup.visible() && popup.current_widget().unwrap_or_default() == id {
popup.hide();
} else {
popup.show(id, button_id);
}
#[cfg(feature = "launcher")]
ModuleUpdateEvent::OpenPopupAt(geometry) if !disable_popup => {
debug!("Opening popup for {} [#{}]", name, id);
popup.hide();
popup.show_at(id, geometry);
}
ModuleUpdateEvent::ClosePopup if !disable_popup => {
debug!("Closing popup for {} [#{}]", name, id);
popup.hide();
},
_ => {}
}
ModuleUpdateEvent::OpenPopup(_) if !disable_popup => {
debug!(
"Opening popup for {} [#{}] (button id: {button_id})",
name, id
);
popup.hide();
popup.show(id, button_id);
}
#[cfg(feature = "launcher")]
ModuleUpdateEvent::OpenPopupAt(geometry) if !disable_popup => {
debug!("Opening popup for {} [#{}]", name, id);
popup.hide();
popup.show_at(id, geometry);
}
ModuleUpdateEvent::ClosePopup if !disable_popup => {
debug!("Closing popup for {} [#{}]", name, id);
popup.hide();
}
_ => {}
});
}
@ -569,7 +586,7 @@ pub fn wrap_widget<W: IsA<Widget>>(
let container = EventBox::new();
container.add_class("widget-container");
container.add_events(EventMask::SCROLL_MASK);
container.add_events(EventMask::SCROLL_MASK | EventMask::SMOOTH_SCROLL_MASK);
container.add(&revealer);
common.install_events(&container, &revealer);

View file

@ -1,4 +1,4 @@
use crate::config::{CommonConfig, TruncateMode};
use crate::config::{CommonConfig, LayoutConfig, TruncateMode};
use dirs::{audio_dir, home_dir};
use serde::Deserialize;
use std::path::PathBuf;
@ -74,13 +74,23 @@ impl Default for Icons {
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum PlayerType {
#[cfg(feature = "music+mpd")]
Mpd,
#[cfg(feature = "music+mpris")]
Mpris,
}
impl Default for PlayerType {
fn default() -> Self {
Self::Mpris
cfg_if::cfg_if! {
if #[cfg(feature = "music+mpris")] {
Self::Mpris
} else if #[cfg(feature = "music+mpd")] {
Self::Mpd
} else {
compile_error!("No player type feature enabled")
}
}
}
}
@ -147,6 +157,25 @@ pub struct MusicModule {
/// **Default**: `null`
pub(crate) truncate: Option<TruncateMode>,
/// See [truncate options](module-level-options#truncate-mode).
///
/// **Default**: `null`
pub(crate) truncate_popup_artist: Option<TruncateMode>,
/// See [truncate options](module-level-options#truncate-mode).
///
/// **Default**: `null`
pub(crate) truncate_popup_album: Option<TruncateMode>,
/// See [truncate options](module-level-options#truncate-mode).
///
/// **Default**: `null`
pub(crate) truncate_popup_title: Option<TruncateMode>,
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
pub(crate) layout: LayoutConfig,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,

View file

@ -1,31 +1,31 @@
use std::cell::RefMut;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use color_eyre::Result;
use glib::{markup_escape_text, Propagation, PropertySet};
use glib::{Propagation, PropertySet};
use gtk::prelude::*;
use gtk::{Button, IconTheme, Label, Orientation, Scale};
use gtk::{Button, Label, Orientation, Scale};
use regex::Regex;
use tokio::sync::{broadcast, mpsc};
use tracing::error;
use tokio::sync::mpsc;
use tracing::{error, warn};
pub use self::config::MusicModule;
use self::config::PlayerType;
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::clients::Clients;
use crate::clients::music::{
self, MusicClient, PlayerState, PlayerUpdate, ProgressTick, Status, Track,
};
use crate::clients::Clients;
use crate::gtk_helpers::IronbarGtkExt;
use crate::image::{new_icon_button, new_icon_label, ImageProvider};
use crate::gtk_helpers::{IronbarGtkExt, IronbarLabelExt};
use crate::image::{IconButton, IconLabel};
use crate::modules::PopupButton;
use crate::modules::{
Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, WidgetContext,
};
use crate::{glib_recv, module_impl, send_async, spawn, try_send};
pub use self::config::MusicModule;
use self::config::PlayerType;
use crate::{image, module_impl, spawn};
mod config;
@ -40,13 +40,18 @@ pub enum PlayerCommand {
}
/// Formats a duration given in seconds
/// in hh:mm format
/// in hh:mm:ss format
fn format_time(duration: Duration) -> String {
let time = duration.as_secs();
let hours = time / (60 * 60);
let minutes = (time / 60) % 60;
let seconds = time % 60;
format!("{minutes:0>2}:{seconds:0>2}")
if hours > 0 {
format!("{hours}:{minutes:0>2}:{seconds:0>2}")
} else {
format!("{minutes:0>2}:{seconds:0>2}")
}
}
/// Extracts the formatting tokens from a formatting string
@ -76,7 +81,9 @@ fn get_client(
music_dir: PathBuf,
) -> Arc<dyn MusicClient> {
let client_type = match player_type {
#[cfg(feature = "music+mpd")]
PlayerType::Mpd => music::ClientType::Mpd { host, music_dir },
#[cfg(feature = "music+mpris")]
PlayerType::Mpris => music::ClientType::Mpris,
};
@ -129,24 +136,14 @@ impl Module<Button> for MusicModule {
display_string,
};
send_async!(
tx,
ModuleUpdateEvent::Update(ControllerEvent::Update(Some(
update
)))
);
tx.send_update(ControllerEvent::Update(Some(update))).await;
}
None => send_async!(
tx,
ModuleUpdateEvent::Update(ControllerEvent::Update(None))
),
None => tx.send_update(ControllerEvent::Update(None)).await,
},
PlayerUpdate::ProgressTick(progress_tick) => send_async!(
tx,
ModuleUpdateEvent::Update(ControllerEvent::UpdateProgress(
progress_tick
))
),
PlayerUpdate::ProgressTick(progress_tick) => {
tx.send_update(ControllerEvent::UpdateProgress(progress_tick))
.await;
}
}
}
}
@ -182,79 +179,83 @@ impl Module<Button> for MusicModule {
info: &ModuleInfo,
) -> Result<ModuleParts<Button>> {
let button = Button::new();
let button_contents = gtk::Box::new(Orientation::Horizontal, 5);
let button_contents = gtk::Box::new(self.layout.orientation(info), 5);
button_contents.add_class("contents");
button.add(&button_contents);
let icon_play = new_icon_label(&self.icons.play, info.icon_theme, self.icon_size);
let icon_pause = new_icon_label(&self.icons.pause, info.icon_theme, self.icon_size);
let label = Label::new(None);
let image_provider = context.ironbar.image_provider();
label.set_use_markup(true);
label.set_angle(info.bar_position.get_angle());
let icon_play = IconLabel::new(&self.icons.play, self.icon_size, &image_provider);
let icon_pause = IconLabel::new(&self.icons.pause, self.icon_size, &image_provider);
icon_play.label().set_angle(self.layout.angle(info));
icon_play.label().set_justify(self.layout.justify.into());
icon_pause.label().set_angle(self.layout.angle(info));
icon_pause.label().set_justify(self.layout.justify.into());
let label = Label::builder()
.use_markup(true)
.angle(self.layout.angle(info))
.justify(self.layout.justify.into())
.build();
if let Some(truncate) = self.truncate {
truncate.truncate_label(&label);
label.truncate(truncate);
}
button_contents.add(&icon_pause);
button_contents.add(&icon_play);
button_contents.add(&*icon_pause);
button_contents.add(&*icon_play);
button_contents.add(&label);
{
let tx = context.tx.clone();
button.connect_clicked(move |button| {
try_send!(tx, ModuleUpdateEvent::TogglePopup(button.popup_id()));
tx.send_spawn(ModuleUpdateEvent::TogglePopup(button.popup_id()));
});
}
{
let button = button.clone();
let rx = context.subscribe();
let tx = context.tx.clone();
let rx = context.subscribe();
rx.recv_glib((&button, &context.tx), move |(button, tx), event| {
let ControllerEvent::Update(mut event) = event else {
return;
};
glib_recv!(rx, event => {
let ControllerEvent::Update(mut event) = event else {
continue;
};
if let Some(event) = event.take() {
label.set_label_escaped(&event.display_string);
if let Some(event) = event.take() {
label.set_label(&event.display_string);
button.show();
button.show();
match event.status.state {
PlayerState::Playing if self.show_status_icon => {
icon_play.show();
icon_pause.hide();
}
PlayerState::Paused if self.show_status_icon => {
icon_pause.show();
icon_play.hide();
}
PlayerState::Stopped => {
button.hide();
}
_ => {}
}
if !self.show_status_icon {
match event.status.state {
PlayerState::Playing if self.show_status_icon => {
icon_play.show();
icon_pause.hide();
}
PlayerState::Paused if self.show_status_icon => {
icon_pause.show();
icon_play.hide();
}
} else {
button.hide();
try_send!(tx, ModuleUpdateEvent::ClosePopup);
PlayerState::Stopped => {
button.hide();
}
_ => {}
}
});
};
let rx = context.subscribe();
if !self.show_status_icon {
icon_pause.hide();
icon_play.hide();
}
} else {
button.hide();
tx.send_spawn(ModuleUpdateEvent::ClosePopup);
}
});
let popup = self
.into_popup(context.controller_tx.clone(), rx, context, info)
.into_popup(context, info)
.into_popup_parts(vec![&button]);
Ok(ModuleParts::new(button, popup))
@ -262,12 +263,10 @@ impl Module<Button> for MusicModule {
fn into_popup(
self,
tx: mpsc::Sender<Self::ReceiveMessage>,
rx: broadcast::Receiver<Self::SendMessage>,
_context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box> {
let icon_theme = info.icon_theme;
let image_provider = context.ironbar.image_provider();
let container = gtk::Box::new(Orientation::Vertical, 10);
let main_container = gtk::Box::new(Orientation::Horizontal, 10);
@ -281,9 +280,21 @@ impl Module<Button> for MusicModule {
let icons = self.icons;
let info_box = gtk::Box::new(Orientation::Vertical, 10);
let title_label = IconLabel::new(&icons.track, None, icon_theme);
let album_label = IconLabel::new(&icons.album, None, icon_theme);
let artist_label = IconLabel::new(&icons.artist, None, icon_theme);
let title_label = IconPrefixedLabel::new(&icons.track, None, &image_provider);
if let Some(truncate) = self.truncate_popup_title {
title_label.label.truncate(truncate);
}
let album_label = IconPrefixedLabel::new(&icons.album, None, &image_provider);
if let Some(truncate) = self.truncate_popup_album {
album_label.label.truncate(truncate);
}
let artist_label = IconPrefixedLabel::new(&icons.artist, None, &image_provider);
if let Some(truncate) = self.truncate_popup_artist {
artist_label.label.truncate(truncate);
}
title_label.container.add_class("title");
album_label.container.add_class("album");
@ -296,22 +307,22 @@ impl Module<Button> for MusicModule {
let controls_box = gtk::Box::new(Orientation::Horizontal, 0);
controls_box.add_class("controls");
let btn_prev = new_icon_button(&icons.prev, icon_theme, self.icon_size);
let btn_prev = IconButton::new(&icons.prev, self.icon_size, image_provider.clone());
btn_prev.add_class("btn-prev");
let btn_play = new_icon_button(&icons.play, icon_theme, self.icon_size);
let btn_play = IconButton::new(&icons.play, self.icon_size, image_provider.clone());
btn_play.add_class("btn-play");
let btn_pause = new_icon_button(&icons.pause, icon_theme, self.icon_size);
let btn_pause = IconButton::new(&icons.pause, self.icon_size, image_provider.clone());
btn_pause.add_class("btn-pause");
let btn_next = new_icon_button(&icons.next, icon_theme, self.icon_size);
let btn_next = IconButton::new(&icons.next, self.icon_size, image_provider.clone());
btn_next.add_class("btn-next");
controls_box.add(&btn_prev);
controls_box.add(&btn_play);
controls_box.add(&btn_pause);
controls_box.add(&btn_next);
controls_box.add(&*btn_prev);
controls_box.add(&*btn_play);
controls_box.add(&*btn_pause);
controls_box.add(&*btn_next);
info_box.add(&controls_box);
@ -322,40 +333,40 @@ impl Module<Button> for MusicModule {
volume_slider.set_inverted(true);
volume_slider.add_class("slider");
let volume_icon = new_icon_label(&icons.volume, icon_theme, self.icon_size);
let volume_icon = IconLabel::new(&icons.volume, self.icon_size, &image_provider);
volume_icon.add_class("icon");
volume_box.pack_start(&volume_slider, true, true, 0);
volume_box.pack_end(&volume_icon, false, false, 0);
volume_box.pack_end(&*volume_icon, false, false, 0);
main_container.add(&album_image);
main_container.add(&info_box);
main_container.add(&volume_box);
container.add(&main_container);
let tx_prev = tx.clone();
let tx_prev = context.controller_tx.clone();
btn_prev.connect_clicked(move |_| {
try_send!(tx_prev, PlayerCommand::Previous);
tx_prev.send_spawn(PlayerCommand::Previous);
});
let tx_play = tx.clone();
let tx_play = context.controller_tx.clone();
btn_play.connect_clicked(move |_| {
try_send!(tx_play, PlayerCommand::Play);
tx_play.send_spawn(PlayerCommand::Play);
});
let tx_pause = tx.clone();
let tx_pause = context.controller_tx.clone();
btn_pause.connect_clicked(move |_| {
try_send!(tx_pause, PlayerCommand::Pause);
tx_pause.send_spawn(PlayerCommand::Pause);
});
let tx_next = tx.clone();
let tx_next = context.controller_tx.clone();
btn_next.connect_clicked(move |_| {
try_send!(tx_next, PlayerCommand::Next);
tx_next.send_spawn(PlayerCommand::Next);
});
let tx_vol = tx.clone();
let tx_vol = context.controller_tx.clone();
volume_slider.connect_change_value(move |_, _, val| {
try_send!(tx_vol, PlayerCommand::Volume(val as u8));
tx_vol.send_spawn(PlayerCommand::Volume(val as u8));
Propagation::Proceed
});
@ -387,9 +398,10 @@ impl Module<Button> for MusicModule {
{
let drag_lock = drag_lock.clone();
let tx = context.controller_tx.clone();
progress.connect_button_release_event(move |scale, _| {
let value = scale.value();
try_send!(tx, PlayerCommand::Seek(Duration::from_secs_f64(value)));
tx.send_spawn(PlayerCommand::Seek(Duration::from_secs_f64(value)));
drag_lock.set(false);
Propagation::Proceed
@ -398,107 +410,123 @@ impl Module<Button> for MusicModule {
container.show_all();
{
let icon_theme = icon_theme.clone();
let image_size = self.cover_image_size;
let image_size = self.cover_image_size;
let mut prev_cover = None;
glib_recv!(rx, event => {
match event {
ControllerEvent::Update(Some(update)) => {
// only update art when album changes
let new_cover = update.song.cover_path;
if prev_cover != new_cover {
prev_cover.clone_from(&new_cover);
let res = if let Some(image) = new_cover.and_then(|cover_path| {
ImageProvider::parse(&cover_path, &icon_theme, false, image_size)
}) {
album_image.show();
image.load_into_image(album_image.clone())
} else {
album_image.set_from_pixbuf(None);
album_image.hide();
Ok(())
};
let mut prev_cover = None;
context.subscribe().recv_glib((), move |(), event| {
match event {
ControllerEvent::Update(Some(update)) => {
// only update art when album changes
let new_cover = update.song.cover_path;
if prev_cover != new_cover {
prev_cover.clone_from(&new_cover);
if let Err(err) = res {
error!("{err:?}");
}
}
if let Some(cover_path) = new_cover {
let image_provider = image_provider.clone();
let album_image = album_image.clone();
update_popup_metadata_label(update.song.title, &title_label);
update_popup_metadata_label(update.song.album, &album_label);
update_popup_metadata_label(update.song.artist, &artist_label);
glib::spawn_future_local(async move {
let success = match image_provider
.load_into_image(&cover_path, image_size, false, &album_image)
.await
{
Ok(true) => {
album_image.show();
true
}
Ok(false) => {
warn!("failed to parse image: {}", cover_path);
false
}
Err(err) => {
error!("failed to load image: {}", err);
false
}
};
match update.status.state {
PlayerState::Stopped => {
btn_pause.hide();
btn_play.show();
btn_play.set_sensitive(false);
}
PlayerState::Playing => {
btn_play.set_sensitive(false);
btn_play.hide();
btn_pause.set_sensitive(true);
btn_pause.show();
}
PlayerState::Paused => {
btn_pause.set_sensitive(false);
btn_pause.hide();
btn_play.set_sensitive(true);
btn_play.show();
}
}
let enable_prev = update.status.playlist_position > 0;
let enable_next =
update.status.playlist_position < update.status.playlist_length;
btn_prev.set_sensitive(enable_prev);
btn_next.set_sensitive(enable_next);
if let Some(volume) = update.status.volume_percent {
volume_slider.set_value(f64::from(volume));
volume_box.show();
if !success {
album_image.set_from_pixbuf(None);
album_image.hide();
}
});
} else {
volume_box.hide();
album_image.set_from_pixbuf(None);
album_image.hide();
}
}
ControllerEvent::UpdateProgress(progress_tick)
if !drag_lock.load(Ordering::Relaxed) =>
update_popup_metadata_label(update.song.title, &title_label);
update_popup_metadata_label(update.song.album, &album_label);
update_popup_metadata_label(update.song.artist, &artist_label);
match update.status.state {
PlayerState::Stopped => {
btn_pause.hide();
btn_play.show();
btn_play.set_sensitive(false);
}
PlayerState::Playing => {
btn_play.set_sensitive(false);
btn_play.hide();
btn_pause.set_sensitive(true);
btn_pause.show();
}
PlayerState::Paused => {
btn_pause.set_sensitive(false);
btn_pause.hide();
btn_play.set_sensitive(true);
btn_play.show();
}
}
let enable_prev = update.status.playlist_position > 0;
let enable_next =
update.status.playlist_position < update.status.playlist_length;
btn_prev.set_sensitive(enable_prev);
btn_next.set_sensitive(enable_next);
if let Some(volume) = update.status.volume_percent {
volume_slider.set_value(f64::from(volume));
volume_box.show();
} else {
volume_box.hide();
}
}
ControllerEvent::UpdateProgress(progress_tick)
if !drag_lock.load(Ordering::Relaxed) =>
{
if let (Some(elapsed), Some(duration)) =
(progress_tick.elapsed, progress_tick.duration)
{
if let (Some(elapsed), Some(duration)) =
(progress_tick.elapsed, progress_tick.duration)
{
progress_label.set_label(&format!(
"{}/{}",
format_time(elapsed),
format_time(duration)
));
progress_label.set_label_escaped(&format!(
"{}/{}",
format_time(elapsed),
format_time(duration)
));
progress.set_value(elapsed.as_secs_f64());
progress.set_range(0.0, duration.as_secs_f64());
progress_box.show_all();
} else {
progress_box.hide();
}
progress.set_value(elapsed.as_secs_f64());
progress.set_range(0.0, duration.as_secs_f64());
progress_box.show_all();
} else {
progress_box.hide();
}
_ => {}
};
});
}
}
_ => {}
}
});
Some(container)
}
}
fn update_popup_metadata_label(text: Option<String>, label: &IconLabel) {
fn update_popup_metadata_label(text: Option<String>, label: &IconPrefixedLabel) {
match text {
Some(value) => {
label.label.set_text(&value);
label.label.set_label_escaped(&value);
label.container.show_all();
}
None => {
@ -531,21 +559,20 @@ fn get_token_value(song: &Track, token: &str) -> String {
"track" => song.track.map(|x| x.to_string()),
_ => Some(token.to_string()),
}
.map(|str| markup_escape_text(str.as_str()).to_string())
.unwrap_or_default()
}
#[derive(Clone, Debug)]
struct IconLabel {
struct IconPrefixedLabel {
label: Label,
container: gtk::Box,
}
impl IconLabel {
fn new(icon_input: &str, label: Option<&str>, icon_theme: &IconTheme) -> Self {
impl IconPrefixedLabel {
fn new(icon_input: &str, label: Option<&str>, image_provider: &image::Provider) -> Self {
let container = gtk::Box::new(Orientation::Horizontal, 5);
let icon = new_icon_label(icon_input, icon_theme, 24);
let icon = IconLabel::new(icon_input, 24, image_provider);
let mut builder = Label::builder().use_markup(true);
@ -558,7 +585,7 @@ impl IconLabel {
icon.add_class("icon-box");
label.add_class("label");
container.add(&icon);
container.add(&*icon);
container.add(&label);
Self { label, container }

View file

@ -1,8 +1,9 @@
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::clients::swaync;
use crate::config::CommonConfig;
use crate::gtk_helpers::IronbarGtkExt;
use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext};
use crate::{glib_recv, module_impl, send_async, spawn, try_send};
use crate::modules::{Module, ModuleInfo, ModuleParts, WidgetContext};
use crate::{module_impl, spawn};
use gtk::prelude::*;
use gtk::{Align, Button, Label, Overlay};
use serde::Deserialize;
@ -153,12 +154,12 @@ impl Module<Overlay> for NotificationsModule {
let initial_state = client.state().await;
match initial_state {
Ok(ev) => send_async!(tx, ModuleUpdateEvent::Update(ev)),
Ok(ev) => tx.send_update(ev).await,
Err(err) => error!("{err:?}"),
};
}
while let Ok(ev) = rx.recv().await {
send_async!(tx, ModuleUpdateEvent::Update(ev));
tx.send_update(ev).await;
}
});
}
@ -200,20 +201,16 @@ impl Module<Overlay> for NotificationsModule {
let ctx = context.controller_tx.clone();
button.connect_clicked(move |_| {
try_send!(ctx, UiEvent::ToggleVisibility);
ctx.send_spawn(UiEvent::ToggleVisibility);
});
{
let button = button.clone();
context.subscribe().recv_glib(&button, move |button, ev| {
let icon = self.icons.icon(ev);
button.set_label(icon);
glib_recv!(context.subscribe(), ev => {
let icon = self.icons.icon(ev);
button.set_label(icon);
label.set_label(&ev.count.to_string());
label.set_visible(self.show_count && ev.count > 0);
});
}
label.set_label(&ev.count.to_string());
label.set_visible(self.show_count && ev.count > 0);
});
Ok(ModuleParts {
widget: overlay,

View file

@ -1,9 +1,10 @@
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext};
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::config::{CommonConfig, LayoutConfig};
use crate::gtk_helpers::IronbarLabelExt;
use crate::modules::{Module, ModuleInfo, ModuleParts, WidgetContext};
use crate::script::{OutputStream, Script, ScriptMode};
use crate::{glib_recv, module_impl, spawn, try_send};
use crate::{module_impl, spawn};
use color_eyre::{Help, Report, Result};
use gtk::prelude::*;
use gtk::Label;
use serde::Deserialize;
use tokio::sync::mpsc;
@ -35,6 +36,11 @@ pub struct ScriptModule {
#[serde(default = "default_interval")]
interval: u64,
// -- Common --
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
@ -78,7 +84,7 @@ impl Module<Label> for ScriptModule {
spawn(async move {
script.run(None, move |out, _| match out {
OutputStream::Stdout(stdout) => {
try_send!(tx, ModuleUpdateEvent::Update(stdout));
tx.send_update_spawn(stdout);
},
OutputStream::Stderr(stderr) => {
error!("{:?}", Report::msg(stderr)
@ -98,13 +104,15 @@ impl Module<Label> for ScriptModule {
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleParts<Label>> {
let label = Label::builder().use_markup(true).build();
label.set_angle(info.bar_position.get_angle());
let label = Label::builder()
.use_markup(true)
.angle(self.layout.angle(info))
.justify(self.layout.justify.into())
.build();
{
let label = label.clone();
glib_recv!(context.subscribe(), s => label.set_markup(s.as_str()));
}
context
.subscribe()
.recv_glib(&label, |label, s| label.set_label_escaped(&s));
Ok(ModuleParts {
widget: label,

View file

@ -1,451 +0,0 @@
use crate::config::{CommonConfig, ModuleOrientation};
use crate::gtk_helpers::IronbarGtkExt;
use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext};
use crate::{glib_recv, module_impl, send_async, spawn};
use color_eyre::Result;
use gtk::prelude::*;
use gtk::Label;
use regex::{Captures, Regex};
use serde::Deserialize;
use std::collections::HashMap;
use std::time::Duration;
use sysinfo::{ComponentExt, CpuExt, DiskExt, NetworkExt, RefreshKind, System, SystemExt};
use tokio::sync::mpsc;
use tokio::time::sleep;
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SysInfoModule {
/// List of strings including formatting tokens.
/// For available tokens, see [below](#formatting-tokens).
///
/// **Required**
format: Vec<String>,
/// Number of seconds between refresh.
///
/// This can be set as a global interval,
/// or passed as an object to customize the interval per-system.
///
/// **Default**: `5`
#[serde(default = "Interval::default")]
interval: Interval,
/// The orientation of text for the labels.
///
/// **Valid options**: `horizontal`, `vertical, `h`, `v`
/// <br>
/// **Default** : `horizontal`
#[serde(default)]
orientation: ModuleOrientation,
/// The orientation by which the labels are laid out.
///
/// **Valid options**: `horizontal`, `vertical, `h`, `v`
/// <br>
/// **Default** : `horizontal`
direction: Option<ModuleOrientation>,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
#[derive(Debug, Deserialize, Copy, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Intervals {
/// The number of seconds between refreshing memory data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
memory: u64,
/// The number of seconds between refreshing CPU data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
cpu: u64,
/// The number of seconds between refreshing temperature data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
temps: u64,
/// The number of seconds between refreshing disk data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
disks: u64,
/// The number of seconds between refreshing network data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
networks: u64,
/// The number of seconds between refreshing system data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
system: u64,
}
#[derive(Debug, Deserialize, Copy, Clone)]
#[serde(untagged)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum Interval {
All(u64),
Individual(Intervals),
}
impl Default for Interval {
fn default() -> Self {
Self::All(default_interval())
}
}
impl Interval {
const fn memory(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.memory,
}
}
const fn cpu(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.cpu,
}
}
const fn temps(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.temps,
}
}
const fn disks(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.disks,
}
}
const fn networks(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.networks,
}
}
const fn system(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.system,
}
}
}
const fn default_interval() -> u64 {
5
}
#[derive(Debug)]
enum RefreshType {
Memory,
Cpu,
Temps,
Disks,
Network,
System,
}
impl Module<gtk::Box> for SysInfoModule {
type SendMessage = HashMap<String, String>;
type ReceiveMessage = ();
module_impl!("sysinfo");
fn spawn_controller(
&self,
_info: &ModuleInfo,
context: &WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let interval = self.interval;
let refresh_kind = RefreshKind::everything()
.without_processes()
.without_users_list();
let mut sys = System::new_with_specifics(refresh_kind);
sys.refresh_components_list();
sys.refresh_disks_list();
sys.refresh_networks_list();
let (refresh_tx, mut refresh_rx) = mpsc::channel(16);
macro_rules! spawn_refresh {
($refresh_type:expr, $func:ident) => {{
let tx = refresh_tx.clone();
spawn(async move {
loop {
send_async!(tx, $refresh_type);
sleep(Duration::from_secs(interval.$func())).await;
}
});
}};
}
spawn_refresh!(RefreshType::Memory, memory);
spawn_refresh!(RefreshType::Cpu, cpu);
spawn_refresh!(RefreshType::Temps, temps);
spawn_refresh!(RefreshType::Disks, disks);
spawn_refresh!(RefreshType::Network, networks);
spawn_refresh!(RefreshType::System, system);
let tx = context.tx.clone();
spawn(async move {
let mut format_info = HashMap::new();
while let Some(refresh) = refresh_rx.recv().await {
match refresh {
RefreshType::Memory => refresh_memory_tokens(&mut format_info, &mut sys),
RefreshType::Cpu => refresh_cpu_tokens(&mut format_info, &mut sys),
RefreshType::Temps => refresh_temp_tokens(&mut format_info, &mut sys),
RefreshType::Disks => refresh_disk_tokens(&mut format_info, &mut sys),
RefreshType::Network => {
refresh_network_tokens(&mut format_info, &mut sys, interval.networks());
}
RefreshType::System => refresh_system_tokens(&mut format_info, &sys),
};
send_async!(tx, ModuleUpdateEvent::Update(format_info.clone()));
}
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_info: &ModuleInfo,
) -> Result<ModuleParts<gtk::Box>> {
let re = Regex::new(r"\{([^}]+)}")?;
let layout = match self.direction {
Some(orientation) => orientation,
None => self.orientation,
};
let container = gtk::Box::new(layout.into(), 10);
let mut labels = Vec::new();
for format in &self.format {
let label = Label::builder().label(format).use_markup(true).build();
label.add_class("item");
label.set_angle(self.orientation.to_angle());
container.add(&label);
labels.push(label);
}
{
let formats = self.format;
glib_recv!(context.subscribe(), info => {
for (format, label) in formats.iter().zip(labels.clone()) {
let format_compiled = re.replace_all(format, |caps: &Captures| {
info.get(&caps[1])
.unwrap_or(&caps[0].to_string())
.to_string()
});
label.set_markup(format_compiled.as_ref());
}
});
}
Ok(ModuleParts {
widget: container,
popup: None,
})
}
}
fn refresh_memory_tokens(format_info: &mut HashMap<String, String>, sys: &mut System) {
sys.refresh_memory();
let total_memory = sys.total_memory();
let available_memory = sys.available_memory();
let actual_used_memory = total_memory - available_memory;
let memory_percent = actual_used_memory as f64 / total_memory as f64 * 100.0;
format_info.insert(
String::from("memory_free"),
(bytes_to_gigabytes(available_memory)).to_string(),
);
format_info.insert(
String::from("memory_used"),
(bytes_to_gigabytes(actual_used_memory)).to_string(),
);
format_info.insert(
String::from("memory_total"),
(bytes_to_gigabytes(total_memory)).to_string(),
);
format_info.insert(
String::from("memory_percent"),
format!("{memory_percent:0>2.0}"),
);
let used_swap = sys.used_swap();
let total_swap = sys.total_swap();
format_info.insert(
String::from("swap_free"),
(bytes_to_gigabytes(sys.free_swap())).to_string(),
);
format_info.insert(
String::from("swap_used"),
(bytes_to_gigabytes(used_swap)).to_string(),
);
format_info.insert(
String::from("swap_total"),
(bytes_to_gigabytes(total_swap)).to_string(),
);
format_info.insert(
String::from("swap_percent"),
format!("{:0>2.0}", used_swap as f64 / total_swap as f64 * 100.0),
);
}
fn refresh_cpu_tokens(format_info: &mut HashMap<String, String>, sys: &mut System) {
sys.refresh_cpu();
let cpu_info = sys.global_cpu_info();
let cpu_percent = cpu_info.cpu_usage();
format_info.insert(String::from("cpu_percent"), format!("{cpu_percent:0>2.0}"));
}
fn refresh_temp_tokens(format_info: &mut HashMap<String, String>, sys: &mut System) {
sys.refresh_components();
let components = sys.components();
for component in components {
let key = component.label().replace(' ', "-");
let temp = component.temperature();
format_info.insert(format!("temp_c:{key}"), format!("{temp:.0}"));
format_info.insert(format!("temp_f:{key}"), format!("{:.0}", c_to_f(temp)));
}
}
fn refresh_disk_tokens(format_info: &mut HashMap<String, String>, sys: &mut System) {
sys.refresh_disks();
for disk in sys.disks() {
// replace braces to avoid conflict with regex
let key = disk
.mount_point()
.to_str()
.map(|s| s.replace(['{', '}'], ""));
if let Some(key) = key {
let total = disk.total_space();
let available = disk.available_space();
let used = total - available;
format_info.insert(
format!("disk_free:{key}"),
bytes_to_gigabytes(available).to_string(),
);
format_info.insert(
format!("disk_used:{key}"),
bytes_to_gigabytes(used).to_string(),
);
format_info.insert(
format!("disk_total:{key}"),
bytes_to_gigabytes(total).to_string(),
);
format_info.insert(
format!("disk_percent:{key}"),
format!("{:0>2.0}", used as f64 / total as f64 * 100.0),
);
}
}
}
fn refresh_network_tokens(
format_info: &mut HashMap<String, String>,
sys: &mut System,
interval: u64,
) {
sys.refresh_networks();
for (iface, network) in sys.networks() {
format_info.insert(
format!("net_down:{iface}"),
format!("{:0>2.0}", bytes_to_megabits(network.received()) / interval),
);
format_info.insert(
format!("net_up:{iface}"),
format!(
"{:0>2.0}",
bytes_to_megabits(network.transmitted()) / interval
),
);
}
}
fn refresh_system_tokens(format_info: &mut HashMap<String, String>, sys: &System) {
// no refresh required for these tokens
let load_average = sys.load_average();
format_info.insert(
String::from("load_average:1"),
format!("{:.2}", load_average.one),
);
format_info.insert(
String::from("load_average:5"),
format!("{:.2}", load_average.five),
);
format_info.insert(
String::from("load_average:15"),
format!("{:.2}", load_average.fifteen),
);
let uptime = Duration::from_secs(sys.uptime()).as_secs();
let hours = uptime / 3600;
format_info.insert(
String::from("uptime"),
format!("{:0>2}:{:0>2}", hours, (uptime % 3600) / 60),
);
}
/// Converts celsius to fahrenheit.
fn c_to_f(c: f32) -> f32 {
c * 9.0 / 5.0 + 32.0
}
const fn bytes_to_gigabytes(b: u64) -> u64 {
const BYTES_IN_GIGABYTE: u64 = 1_000_000_000;
b / BYTES_IN_GIGABYTE
}
const fn bytes_to_megabits(b: u64) -> u64 {
const BYTES_IN_MEGABIT: u64 = 125_000;
b / BYTES_IN_MEGABIT
}

316
src/modules/sysinfo/mod.rs Normal file
View file

@ -0,0 +1,316 @@
mod parser;
mod renderer;
mod token;
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::clients::sysinfo::TokenType;
use crate::config::{CommonConfig, LayoutConfig, ModuleOrientation};
use crate::gtk_helpers::{IronbarGtkExt, IronbarLabelExt};
use crate::modules::sysinfo::token::Part;
use crate::modules::{Module, ModuleInfo, ModuleParts, WidgetContext};
use crate::{clients, module_impl, spawn};
use color_eyre::Result;
use gtk::Label;
use gtk::prelude::*;
use serde::Deserialize;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::sleep;
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SysInfoModule {
/// List of strings including formatting tokens.
/// For available tokens, see [below](#formatting-tokens).
///
/// **Required**
format: Vec<String>,
/// Number of seconds between refresh.
///
/// This can be set as a global interval,
/// or passed as an object to customize the interval per-system.
///
/// **Default**: `5`
#[serde(default = "Interval::default")]
interval: Interval,
/// The orientation by which the labels are laid out.
///
/// **Valid options**: `horizontal`, `vertical`, `h`, `v`
/// <br>
/// **Default** : `horizontal`
direction: Option<ModuleOrientation>,
// -- common --
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
#[derive(Debug, Deserialize, Copy, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Intervals {
/// The number of seconds between refreshing memory data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
memory: u64,
/// The number of seconds between refreshing CPU data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
cpu: u64,
/// The number of seconds between refreshing temperature data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
temps: u64,
/// The number of seconds between refreshing disk data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
disks: u64,
/// The number of seconds between refreshing network data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
networks: u64,
/// The number of seconds between refreshing system data.
///
/// **Default**: `5`
#[serde(default = "default_interval")]
system: u64,
}
#[derive(Debug, Deserialize, Copy, Clone)]
#[serde(untagged)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum Interval {
All(u64),
Individual(Intervals),
}
impl Default for Interval {
fn default() -> Self {
Self::All(default_interval())
}
}
impl Interval {
const fn memory(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.memory,
}
}
const fn cpu(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.cpu,
}
}
const fn temps(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.temps,
}
}
pub const fn disks(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.disks,
}
}
pub const fn networks(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.networks,
}
}
const fn system(self) -> u64 {
match self {
Self::All(n) => n,
Self::Individual(intervals) => intervals.system,
}
}
}
const fn default_interval() -> u64 {
5
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum RefreshType {
Memory,
Cpu,
Temps,
Disks,
Network,
System,
}
impl TokenType {
fn is_affected_by(self, refresh_type: RefreshType) -> bool {
match self {
Self::CpuFrequency | Self::CpuPercent => refresh_type == RefreshType::Cpu,
Self::MemoryFree
| Self::MemoryAvailable
| Self::MemoryTotal
| Self::MemoryUsed
| Self::MemoryPercent
| Self::SwapFree
| Self::SwapTotal
| Self::SwapUsed
| Self::SwapPercent => refresh_type == RefreshType::Memory,
Self::TempC | Self::TempF => refresh_type == RefreshType::Temps,
Self::DiskFree
| Self::DiskTotal
| Self::DiskUsed
| Self::DiskPercent
| Self::DiskRead
| Self::DiskWrite => refresh_type == RefreshType::Disks,
Self::NetDown | Self::NetUp => refresh_type == RefreshType::Network,
Self::LoadAverage1 | Self::LoadAverage5 | Self::LoadAverage15 => {
refresh_type == RefreshType::System
}
Self::Uptime => refresh_type == RefreshType::System,
}
}
}
impl Module<gtk::Box> for SysInfoModule {
type SendMessage = (usize, String);
type ReceiveMessage = ();
module_impl!("sysinfo");
fn spawn_controller(
&self,
_info: &ModuleInfo,
context: &WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let interval = self.interval;
let client = context.client::<clients::sysinfo::Client>();
let format_tokens = self
.format
.iter()
.map(|format| parser::parse_input(format.as_str()))
.collect::<Result<Vec<_>>>()?;
for (i, token_set) in format_tokens.iter().enumerate() {
let rendered = Part::render_all(token_set, &client, interval);
context.tx.send_update_spawn((i, rendered));
}
let (refresh_tx, mut refresh_rx) = mpsc::channel(16);
macro_rules! spawn_refresh {
($refresh_type:expr, $func:ident) => {{
let tx = refresh_tx.clone();
spawn(async move {
loop {
tx.send_expect($refresh_type).await;
sleep(Duration::from_secs(interval.$func())).await;
}
});
}};
}
spawn_refresh!(RefreshType::Memory, memory);
spawn_refresh!(RefreshType::Cpu, cpu);
spawn_refresh!(RefreshType::Temps, temps);
spawn_refresh!(RefreshType::Disks, disks);
spawn_refresh!(RefreshType::Network, networks);
spawn_refresh!(RefreshType::System, system);
let tx = context.tx.clone();
spawn(async move {
while let Some(refresh) = refresh_rx.recv().await {
match refresh {
RefreshType::Memory => client.refresh_memory(),
RefreshType::Cpu => client.refresh_cpu(),
RefreshType::Temps => client.refresh_temps(),
RefreshType::Disks => client.refresh_disks(),
RefreshType::Network => client.refresh_network(),
RefreshType::System => client.refresh_load_average(),
}
for (i, token_set) in format_tokens.iter().enumerate() {
let is_affected = token_set
.iter()
.filter_map(|part| {
if let Part::Token(token) = part {
Some(token)
} else {
None
}
})
.any(|t| t.token.is_affected_by(refresh));
if is_affected {
let rendered = Part::render_all(token_set, &client, interval);
tx.send_update((i, rendered)).await;
}
}
}
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleParts<gtk::Box>> {
let layout = match self.direction {
Some(orientation) => orientation.into(),
None => self.layout.orientation(info),
};
let container = gtk::Box::new(layout, 10);
let mut labels = Vec::new();
for _ in &self.format {
let label = Label::builder()
.use_markup(true)
.angle(self.layout.angle(info))
.justify(self.layout.justify.into())
.build();
label.add_class("item");
container.add(&label);
labels.push(label);
}
context.subscribe().recv_glib((), move |(), data| {
let label = &labels[data.0];
label.set_label_escaped(&data.1);
});
Ok(ModuleParts {
widget: container,
popup: None,
})
}
}

View file

@ -0,0 +1,404 @@
use crate::clients::sysinfo::{Function, Prefix, TokenType};
use crate::modules::sysinfo::token::{Alignment, Formatting, Part, Token};
use color_eyre::{Report, Result};
use std::iter::Peekable;
use std::str::{Chars, FromStr};
impl Function {
pub(crate) fn default_for(token_type: TokenType) -> Self {
match token_type {
TokenType::CpuFrequency
| TokenType::CpuPercent
| TokenType::TempC
| TokenType::DiskPercent => Self::Mean,
TokenType::DiskFree
| TokenType::DiskTotal
| TokenType::DiskUsed
| TokenType::DiskRead
| TokenType::DiskWrite
| TokenType::NetDown
| TokenType::NetUp => Self::Sum,
_ => Self::None,
}
}
}
impl Prefix {
pub(crate) fn default_for(token_type: TokenType) -> Self {
match token_type {
TokenType::CpuFrequency
| TokenType::MemoryFree
| TokenType::MemoryAvailable
| TokenType::MemoryTotal
| TokenType::MemoryUsed
| TokenType::SwapFree
| TokenType::SwapTotal
| TokenType::SwapUsed
| TokenType::DiskFree
| TokenType::DiskTotal
| TokenType::DiskUsed => Self::Giga,
TokenType::DiskRead | TokenType::DiskWrite => Self::Mega,
TokenType::NetDown | TokenType::NetUp => Self::MegaBit,
_ => Self::None,
}
}
}
impl FromStr for Prefix {
type Err = Report;
fn from_str(s: &str) -> Result<Self> {
match s {
"k" => Ok(Prefix::Kilo),
"M" => Ok(Prefix::Mega),
"G" => Ok(Prefix::Giga),
"T" => Ok(Prefix::Tera),
"P" => Ok(Prefix::Peta),
"ki" => Ok(Prefix::Kibi),
"Mi" => Ok(Prefix::Mebi),
"Gi" => Ok(Prefix::Gibi),
"Ti" => Ok(Prefix::Tebi),
"Pi" => Ok(Prefix::Pebi),
"kb" => Ok(Prefix::KiloBit),
"Mb" => Ok(Prefix::MegaBit),
"Gb" => Ok(Prefix::GigaBit),
_ => Err(Report::msg(format!("invalid prefix: {s}"))),
}
}
}
impl TryFrom<char> for Alignment {
type Error = Report;
fn try_from(value: char) -> Result<Self> {
match value {
'<' => Ok(Self::Left),
'^' => Ok(Self::Center),
'>' => Ok(Self::Right),
_ => Err(Report::msg(format!("Unknown alignment: {value}"))),
}
}
}
impl Formatting {
fn default_for(token_type: TokenType) -> Self {
match token_type {
TokenType::CpuFrequency
| TokenType::LoadAverage1
| TokenType::LoadAverage5
| TokenType::LoadAverage15 => Self {
width: 0,
fill: '0',
align: Alignment::default(),
precision: 2,
},
TokenType::CpuPercent => Self {
width: 2,
fill: '0',
align: Alignment::default(),
precision: 0,
},
TokenType::MemoryFree
| TokenType::MemoryAvailable
| TokenType::MemoryTotal
| TokenType::MemoryUsed
| TokenType::MemoryPercent
| TokenType::SwapFree
| TokenType::SwapTotal
| TokenType::SwapUsed
| TokenType::SwapPercent => Self {
width: 4,
fill: '0',
align: Alignment::default(),
precision: 1,
},
_ => Self {
width: 0,
fill: '0',
align: Alignment::default(),
precision: 0,
},
}
}
}
pub fn parse_input(input: &str) -> Result<Vec<Part>> {
let mut tokens = vec![];
let mut chars = input.chars().peekable();
let mut next_char = chars.peek().copied();
while let Some(char) = next_char {
let token = if char == '{' {
chars.next();
parse_dynamic(&mut chars)?
} else {
parse_static(&mut chars)
};
tokens.push(token);
next_char = chars.peek().copied();
}
Ok(tokens)
}
fn parse_static(chars: &mut Peekable<Chars>) -> Part {
let mut str = String::new();
let mut next_char = chars.next_if(|&c| c != '{');
while let Some(char) = next_char {
if char == '{' {
break;
}
str.push(char);
next_char = chars.next_if(|&c| c != '{');
}
Part::Static(str)
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
enum DynamicMode {
Token,
Name,
Prefix,
}
fn parse_dynamic(chars: &mut Peekable<Chars>) -> Result<Part> {
let mut mode = DynamicMode::Token;
let mut token_str = String::new();
let mut func_str = String::new();
let mut prefix_str = String::new();
// we don't want to peek here as that would be the same char as the outer loop
let mut next_char = chars.next();
while let Some(char) = next_char {
match char {
'}' | ':' => break,
'@' => mode = DynamicMode::Name,
'#' => mode = DynamicMode::Prefix,
_ => match mode {
DynamicMode::Token => token_str.push(char),
DynamicMode::Name => func_str.push(char),
DynamicMode::Prefix => prefix_str.push(char),
},
}
next_char = chars.next();
}
let token_type = token_str.parse()?;
let mut formatting = Formatting::default_for(token_type);
if next_char == Some(':') {
formatting = parse_formatting(chars, formatting)?;
}
let token = Token {
token: token_type,
function: func_str
.parse()
.unwrap_or_else(|()| Function::default_for(token_type)),
prefix: prefix_str
.parse()
.unwrap_or_else(|_| Prefix::default_for(token_type)),
formatting,
};
Ok(Part::Token(token))
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
enum FormattingMode {
WidthFillAlign,
Precision,
}
fn parse_formatting(chars: &mut Peekable<Chars>, mut formatting: Formatting) -> Result<Formatting> {
let mut width_string = String::new();
let mut precision_string = String::new();
let mut mode = FormattingMode::WidthFillAlign;
let mut next_char = chars.next();
while let Some(char) = next_char {
match (char, mode) {
('}', _) => break,
('.', _) => mode = FormattingMode::Precision,
(_, FormattingMode::Precision) => precision_string.push(char),
('1'..='9', FormattingMode::WidthFillAlign) => width_string.push(char),
('<' | '^' | '>', FormattingMode::WidthFillAlign) => {
formatting.align = Alignment::try_from(char)?;
}
(_, FormattingMode::WidthFillAlign) => formatting.fill = char,
}
next_char = chars.next();
}
if !width_string.is_empty() {
formatting.width = width_string.parse()?;
}
if !precision_string.is_empty() {
formatting.precision = precision_string.parse()?;
}
Ok(formatting)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_only() {
let tokens = parse_input("hello world").unwrap();
println!("{tokens:?}");
assert_eq!(tokens.len(), 1);
assert!(matches!(&tokens[0], Part::Static(str) if str == "hello world"));
}
#[test]
fn basic() {
let tokens = parse_input("{cpu_frequency}").unwrap();
println!("{tokens:?}");
assert_eq!(tokens.len(), 1);
assert!(matches!(&tokens[0], Part::Token(_)));
let Part::Token(token) = tokens.get(0).unwrap() else {
return;
};
assert_eq!(token.token, TokenType::CpuFrequency);
}
#[test]
fn named() {
let tokens = parse_input("{cpu_frequency@cpu0}").unwrap();
println!("{tokens:?}");
assert_eq!(tokens.len(), 1);
assert!(matches!(&tokens[0], Part::Token(_)));
let Part::Token(token) = tokens.get(0).unwrap() else {
return;
};
assert_eq!(token.token, TokenType::CpuFrequency);
assert!(matches!(&token.function, Function::Name(n) if n == "cpu0"));
}
#[test]
fn conversion() {
let tokens = parse_input("{cpu_frequency#G}").unwrap();
println!("{tokens:?}");
assert_eq!(tokens.len(), 1);
assert!(matches!(&tokens[0], Part::Token(_)));
let Part::Token(token) = tokens.get(0).unwrap() else {
return;
};
assert_eq!(token.token, TokenType::CpuFrequency);
assert_eq!(token.prefix, Prefix::Giga);
}
#[test]
fn formatting_basic() {
let tokens = parse_input("{cpu_frequency:.2}").unwrap();
println!("{tokens:?}");
assert_eq!(tokens.len(), 1);
assert!(matches!(&tokens[0], Part::Token(_)));
let Part::Token(token) = tokens.get(0).unwrap() else {
return;
};
assert_eq!(token.token, TokenType::CpuFrequency);
assert_eq!(token.formatting.precision, 2);
}
#[test]
fn formatting_complex() {
let tokens = parse_input("{cpu_frequency:0<5.2}").unwrap();
println!("{tokens:?}");
assert_eq!(tokens.len(), 1);
assert!(matches!(&tokens[0], Part::Token(_)));
let Part::Token(token) = tokens.get(0).unwrap() else {
return;
};
assert_eq!(token.token, TokenType::CpuFrequency);
assert_eq!(token.formatting.fill, '0');
assert_eq!(token.formatting.align, Alignment::Left);
assert_eq!(token.formatting.width, 5);
assert_eq!(token.formatting.precision, 2);
}
#[test]
fn complex() {
let tokens = parse_input("{cpu_frequency@cpu0#G:.2}").unwrap();
println!("{tokens:?}");
assert_eq!(tokens.len(), 1);
assert!(matches!(&tokens[0], Part::Token(_)));
let Part::Token(token) = tokens.get(0).unwrap() else {
return;
};
assert_eq!(token.token, TokenType::CpuFrequency);
assert!(matches!(&token.function, Function::Name(n) if n == "cpu0"));
assert_eq!(token.prefix, Prefix::Giga);
assert_eq!(token.formatting.precision, 2);
}
#[test]
fn static_then_token() {
let tokens = parse_input("Freq: {cpu_frequency#G:.2}").unwrap();
println!("{tokens:?}");
assert_eq!(tokens.len(), 2);
assert!(matches!(&tokens[0], Part::Static(str) if str == "Freq: "));
assert!(matches!(&tokens[1], Part::Token(_)));
let Part::Token(token) = tokens.get(1).unwrap() else {
return;
};
assert_eq!(token.token, TokenType::CpuFrequency);
assert_eq!(token.formatting.precision, 2);
}
#[test]
fn token_then_static() {
let tokens = parse_input("{cpu_frequency#G:.2} GHz").unwrap();
println!("{tokens:?}");
assert_eq!(tokens.len(), 2);
assert!(matches!(&tokens[0], Part::Token(_)));
let Part::Token(token) = tokens.get(0).unwrap() else {
return;
};
assert_eq!(token.token, TokenType::CpuFrequency);
assert_eq!(token.formatting.precision, 2);
assert!(matches!(&tokens[1], Part::Static(str) if str == " GHz"));
}
}

View file

@ -0,0 +1,91 @@
use super::Interval;
use super::token::{Alignment, Part, Token};
use crate::clients;
use crate::clients::sysinfo::{TokenType, Value, ValueSet};
pub enum TokenValue {
Number(f64),
String(String),
}
impl Part {
pub fn render_all(
tokens: &[Self],
client: &clients::sysinfo::Client,
interval: Interval,
) -> String {
tokens
.iter()
.map(|part| part.render(client, interval))
.collect()
}
fn render(&self, client: &clients::sysinfo::Client, interval: Interval) -> String {
match self {
Part::Static(str) => str.clone(),
Part::Token(token) => {
match token.get(client, interval) {
TokenValue::Number(value) => {
let fmt = token.formatting;
let mut str = format!("{value:.precision$}", precision = fmt.precision);
// fill/align doesn't support parameterization so we need our own impl
let mut add_to_end = fmt.align == Alignment::Right;
while str.len() < fmt.width {
if add_to_end {
str.push(fmt.fill);
} else {
str.insert(0, fmt.fill);
}
if fmt.align == Alignment::Center {
add_to_end = !add_to_end;
}
}
str
}
TokenValue::String(value) => value,
}
}
}
}
}
impl Token {
pub fn get(&self, client: &clients::sysinfo::Client, interval: Interval) -> TokenValue {
let get = |value: Value| TokenValue::Number(value.get(self.prefix));
let apply = |set: ValueSet| TokenValue::Number(set.apply(&self.function, self.prefix));
match self.token {
// Number tokens
TokenType::CpuFrequency => apply(client.cpu_frequency()),
TokenType::CpuPercent => apply(client.cpu_percent()),
TokenType::MemoryFree => get(client.memory_free()),
TokenType::MemoryAvailable => get(client.memory_available()),
TokenType::MemoryTotal => get(client.memory_total()),
TokenType::MemoryUsed => get(client.memory_used()),
TokenType::MemoryPercent => get(client.memory_percent()),
TokenType::SwapFree => get(client.swap_free()),
TokenType::SwapTotal => get(client.swap_total()),
TokenType::SwapUsed => get(client.swap_used()),
TokenType::SwapPercent => get(client.swap_percent()),
TokenType::TempC => apply(client.temp_c()),
TokenType::TempF => apply(client.temp_f()),
TokenType::DiskFree => apply(client.disk_free()),
TokenType::DiskTotal => apply(client.disk_total()),
TokenType::DiskUsed => apply(client.disk_used()),
TokenType::DiskPercent => apply(client.disk_percent()),
TokenType::DiskRead => apply(client.disk_read(interval)),
TokenType::DiskWrite => apply(client.disk_write(interval)),
TokenType::NetDown => apply(client.net_down(interval)),
TokenType::NetUp => apply(client.net_up(interval)),
TokenType::LoadAverage1 => get(client.load_average_1()),
TokenType::LoadAverage5 => get(client.load_average_5()),
TokenType::LoadAverage15 => get(client.load_average_15()),
// String tokens
TokenType::Uptime => TokenValue::String(clients::sysinfo::Client::uptime()),
}
}
}

View file

@ -0,0 +1,31 @@
use crate::clients::sysinfo::{Function, Prefix, TokenType};
#[derive(Debug, Clone)]
pub struct Token {
pub token: TokenType,
pub function: Function,
pub prefix: Prefix,
pub formatting: Formatting,
}
#[derive(Debug, Clone)]
pub enum Part {
Static(String),
Token(Token),
}
#[derive(Debug, Clone, Copy)]
pub struct Formatting {
pub width: usize,
pub fill: char,
pub align: Alignment,
pub precision: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Alignment {
#[default]
Left,
Center,
Right,
}

View file

@ -1,104 +0,0 @@
use system_tray::menu::{MenuItem, ToggleState};
/// Diff change type and associated info.
#[derive(Debug, Clone)]
pub enum Diff {
Add(MenuItem),
Update(i32, MenuItemDiff),
Remove(i32),
}
/// Diff info to be applied to an existing menu item as an update.
#[derive(Debug, Clone)]
pub struct MenuItemDiff {
/// Text of the item,
pub label: Option<Option<String>>,
/// Whether the item can be activated or not.
pub enabled: Option<bool>,
/// True if the item is visible in the menu.
pub visible: Option<bool>,
/// Icon name of the item, following the freedesktop.org icon spec.
// pub icon_name: Option<Option<String>>,
/// Describe the current state of a "togglable" item. Can be one of:
/// - Some(true): on
/// - Some(false): off
/// - None: indeterminate
pub toggle_state: Option<ToggleState>,
/// A submenu for this item, typically this would ve revealed to the user by hovering the current item
pub submenu: Vec<Diff>,
}
impl MenuItemDiff {
fn new(old: &MenuItem, new: &MenuItem) -> Self {
macro_rules! diff {
($field:ident) => {
if old.$field == new.$field {
None
} else {
Some(new.$field)
}
};
(&$field:ident) => {
if &old.$field == &new.$field {
None
} else {
Some(new.$field.clone())
}
};
}
Self {
label: diff!(&label),
enabled: diff!(enabled),
visible: diff!(visible),
// icon_name: diff!(&icon_name),
toggle_state: diff!(toggle_state),
submenu: get_diffs(&old.submenu, &new.submenu),
}
}
/// Whether this diff contains any changes
fn has_diff(&self) -> bool {
self.label.is_some()
|| self.enabled.is_some()
|| self.visible.is_some()
// || self.icon_name.is_some()
|| self.toggle_state.is_some()
|| !self.submenu.is_empty()
}
}
/// Gets a diff set between old and new state.
pub fn get_diffs(old: &[MenuItem], new: &[MenuItem]) -> Vec<Diff> {
let mut diffs = vec![];
for new_item in new {
let old_item = old.iter().find(|&item| item.id == new_item.id);
let diff = match old_item {
Some(old_item) => {
let item_diff = MenuItemDiff::new(old_item, new_item);
if item_diff.has_diff() {
Some(Diff::Update(old_item.id, item_diff))
} else {
None
}
}
None => Some(Diff::Add(new_item.clone())),
};
if let Some(diff) = diff {
diffs.push(diff);
}
}
for old_item in old {
let new_item = new.iter().find(|&item| item.id == old_item.id);
if new_item.is_none() {
diffs.push(Diff::Remove(old_item.id));
}
}
diffs
}

View file

@ -1,4 +1,4 @@
use crate::image::ImageProvider;
use crate::image::create_and_load_surface;
use crate::modules::tray::interface::TrayMenu;
use color_eyre::{Report, Result};
use glib::ffi::g_strfreev;
@ -40,21 +40,21 @@ fn get_icon_theme_search_paths(icon_theme: &IconTheme) -> HashSet<String> {
pub fn get_image(
item: &TrayMenu,
icon_theme: &IconTheme,
size: u32,
prefer_icons: bool,
icon_theme: &IconTheme,
) -> Result<Image> {
if !prefer_icons && item.icon_pixmap.is_some() {
get_image_from_pixmap(item, size)
} else {
get_image_from_icon_name(item, icon_theme, size)
get_image_from_icon_name(item, size, icon_theme)
.or_else(|_| get_image_from_pixmap(item, size))
}
}
/// Attempts to get a GTK `Image` component
/// for the status notifier item's icon.
fn get_image_from_icon_name(item: &TrayMenu, icon_theme: &IconTheme, size: u32) -> Result<Image> {
fn get_image_from_icon_name(item: &TrayMenu, size: u32, icon_theme: &IconTheme) -> Result<Image> {
if let Some(path) = item.icon_theme_path.as_ref() {
if !path.is_empty() && !get_icon_theme_search_paths(icon_theme).contains(path) {
icon_theme.append_search_path(path);
@ -68,7 +68,7 @@ fn get_image_from_icon_name(item: &TrayMenu, icon_theme: &IconTheme, size: u32)
if let Some(icon_info) = icon_info {
let pixbuf = icon_info.load_icon()?;
let image = Image::new();
ImageProvider::create_and_load_surface(&pixbuf, &image)?;
create_and_load_surface(&pixbuf, &image)?;
Ok(image)
} else {
Err(Report::msg("could not find icon"))
@ -90,7 +90,11 @@ fn get_image_from_pixmap(item: &TrayMenu, size: u32) -> Result<Image> {
.and_then(|pixmap| pixmap.first())
.ok_or_else(|| Report::msg("Failed to get pixmap from tray icon"))?;
let mut pixels = pixmap.pixels.to_vec();
if pixmap.width == 0 || pixmap.height == 0 {
return Err(Report::msg("empty pixmap"));
}
let mut pixels = pixmap.pixels.clone();
for i in (0..pixels.len()).step_by(4) {
let alpha = pixels[i];
@ -118,6 +122,6 @@ fn get_image_from_pixmap(item: &TrayMenu, size: u32) -> Result<Image> {
.unwrap_or(pixbuf);
let image = Image::new();
ImageProvider::create_and_load_surface(&pixbuf, &image)?;
create_and_load_surface(&pixbuf, &image)?;
Ok(image)
}

View file

@ -1,114 +1,51 @@
use super::diff::{Diff, MenuItemDiff};
use crate::{spawn, try_send};
use glib::Propagation;
use glib::{Propagation, SignalHandlerId};
use gtk::gdk::Gravity;
use gtk::prelude::*;
use gtk::{CheckMenuItem, Image, Label, Menu, MenuItem, SeparatorMenuItem};
use std::collections::HashMap;
use system_tray::client::ActivateRequest;
use system_tray::item::{IconPixmap, StatusNotifierItem};
use system_tray::menu::{MenuItem as MenuItemInfo, MenuType, ToggleState, ToggleType};
use tokio::sync::mpsc;
/// Calls a method on the underlying widget,
/// passing in a single argument.
///
/// This is useful to avoid matching on
/// `TrayMenuWidget` constantly.
///
/// # Example
/// ```rust
/// call!(container, add, my_widget)
/// ```
/// is the same as:
/// ```
/// match &my_widget {
/// TrayMenuWidget::Separator(w) => {
/// container.add(w);
/// }
/// TrayMenuWidget::Standard(w) => {
/// container.add(w);
/// }
/// TrayMenuWidget::Checkbox(w) => {
/// container.add(w);
/// }
/// }
/// ```
macro_rules! call {
($parent:expr, $method:ident, $child:expr) => {
match &$child {
TrayMenuWidget::Separator(w) => {
$parent.$method(w);
}
TrayMenuWidget::Standard(w) => {
$parent.$method(w);
}
TrayMenuWidget::Checkbox(w) => {
$parent.$method(w);
}
}
};
}
use gtk::{EventBox, Image, Label, MenuItem};
use system_tray::item::{IconPixmap, StatusNotifierItem, Tooltip};
/// Main tray icon to show on the bar
pub(crate) struct TrayMenu {
pub widget: MenuItem,
menu_widget: Menu,
pub event_box: EventBox,
button_handler: Option<SignalHandlerId>,
widget: MenuItem,
image_widget: Option<Image>,
label_widget: Option<Label>,
menu: HashMap<i32, TrayMenuItem>,
state: Vec<MenuItemInfo>,
pub title: Option<String>,
pub icon_name: Option<String>,
pub icon_theme_path: Option<String>,
pub icon_pixmap: Option<Vec<IconPixmap>>,
tx: mpsc::Sender<i32>,
}
impl TrayMenu {
pub fn new(
tx: mpsc::Sender<ActivateRequest>,
address: String,
item: StatusNotifierItem,
) -> Self {
pub fn new(address: &str, item: StatusNotifierItem) -> Self {
let event_box = EventBox::new();
let widget = MenuItem::new();
widget.style_context().add_class("item");
event_box.add(&widget);
let (item_tx, mut item_rx) = mpsc::channel(8);
event_box.show_all();
if let Some(menu) = item.menu {
spawn(async move {
while let Some(id) = item_rx.recv().await {
try_send!(
tx,
ActivateRequest {
submenu_id: id,
menu_path: menu.clone(),
address: address.clone(),
}
);
}
});
}
let menu = Menu::new();
widget.set_submenu(Some(&menu));
Self {
let mut slf = Self {
event_box,
button_handler: None,
widget,
menu_widget: menu,
image_widget: None,
label_widget: None,
state: vec![],
title: item.title,
icon_name: item.icon_name,
icon_theme_path: item.icon_theme_path,
icon_pixmap: item.icon_pixmap,
menu: HashMap::new(),
tx: item_tx,
};
if let Some(menu) = item.menu {
let menu = system_tray::gtk_menu::Menu::new(address, &menu);
slf.set_menu_widget(menu);
}
slf
}
/// Updates the label text, and shows it in favour of the image.
@ -153,42 +90,10 @@ impl TrayMenu {
image.show();
}
/// Applies a diff set to the submenu.
pub fn apply_diffs(&mut self, diffs: Vec<Diff>) {
for diff in diffs {
match diff {
Diff::Add(info) => {
let item = TrayMenuItem::new(&info, self.tx.clone());
call!(self.menu_widget, add, item.widget);
self.menu.insert(item.id, item);
// self.widget.show_all();
}
Diff::Update(id, info) => {
if let Some(item) = self.menu.get_mut(&id) {
item.apply_diff(info);
}
}
Diff::Remove(id) => {
if let Some(item) = self.menu.remove(&id) {
call!(self.menu_widget, remove, item.widget);
}
}
}
}
}
pub fn label_widget(&self) -> Option<&Label> {
self.label_widget.as_ref()
}
pub fn state(&self) -> &[MenuItemInfo] {
&self.state
}
pub fn set_state(&mut self, state: Vec<MenuItemInfo>) {
self.state = state;
}
pub fn icon_name(&self) -> Option<&String> {
self.icon_name.as_ref()
}
@ -196,173 +101,28 @@ impl TrayMenu {
pub fn set_icon_name(&mut self, icon_name: Option<String>) {
self.icon_name = icon_name;
}
}
#[derive(Debug)]
struct TrayMenuItem {
id: i32,
widget: TrayMenuWidget,
menu_widget: Menu,
submenu: HashMap<i32, TrayMenuItem>,
tx: mpsc::Sender<i32>,
}
pub fn set_tooltip(&self, tooltip: Option<Tooltip>) {
let title = tooltip.map(|t| t.title);
#[derive(Debug)]
enum TrayMenuWidget {
Separator(SeparatorMenuItem),
Standard(MenuItem),
Checkbox(CheckMenuItem),
}
impl TrayMenuItem {
fn new(info: &MenuItemInfo, tx: mpsc::Sender<i32>) -> Self {
let mut submenu = HashMap::new();
let menu = Menu::new();
macro_rules! add_submenu {
($menu:expr, $widget:expr) => {
if !info.submenu.is_empty() {
for sub_item in &info.submenu {
let sub_item = TrayMenuItem::new(sub_item, tx.clone());
call!($menu, add, sub_item.widget);
submenu.insert(sub_item.id, sub_item);
}
$widget.set_submenu(Some(&menu));
}
};
if let Some(widget) = &self.image_widget {
widget.set_tooltip_text(title.as_deref());
}
let widget = match (info.menu_type, info.toggle_type) {
(MenuType::Separator, _) => TrayMenuWidget::Separator(SeparatorMenuItem::new()),
(MenuType::Standard, ToggleType::Checkmark) => {
let widget = CheckMenuItem::builder()
.visible(info.visible)
.sensitive(info.enabled)
.active(info.toggle_state == ToggleState::On)
.build();
if let Some(label) = &info.label {
widget.set_label(label);
}
add_submenu!(menu, widget);
{
let tx = tx.clone();
let id = info.id;
widget.connect_button_press_event(move |_item, _button| {
try_send!(tx, id);
Propagation::Proceed
});
}
TrayMenuWidget::Checkbox(widget)
}
(MenuType::Standard, _) => {
let widget = MenuItem::builder()
.visible(info.visible)
.sensitive(info.enabled)
.build();
if let Some(label) = &info.label {
widget.set_label(label);
}
add_submenu!(menu, widget);
{
let tx = tx.clone();
let id = info.id;
widget.connect_activate(move |_item| {
try_send!(tx, id);
});
}
TrayMenuWidget::Standard(widget)
}
};
Self {
id: info.id,
widget,
menu_widget: menu,
submenu,
tx,
if let Some(widget) = &self.label_widget {
widget.set_tooltip_text(title.as_deref());
}
}
/// Applies a diff to this submenu item.
///
/// This is called recursively,
/// applying the submenu diffs to any further submenu items.
fn apply_diff(&mut self, diff: MenuItemDiff) {
if let Some(label) = diff.label {
let label = label.unwrap_or_default();
match &self.widget {
TrayMenuWidget::Separator(widget) => widget.set_label(&label),
TrayMenuWidget::Standard(widget) => widget.set_label(&label),
TrayMenuWidget::Checkbox(widget) => widget.set_label(&label),
}
}
// TODO: Image support
// if let Some(icon_name) = diff.icon_name {
//
// }
if let Some(enabled) = diff.enabled {
match &self.widget {
TrayMenuWidget::Separator(widget) => widget.set_sensitive(enabled),
TrayMenuWidget::Standard(widget) => widget.set_sensitive(enabled),
TrayMenuWidget::Checkbox(widget) => widget.set_sensitive(enabled),
}
}
if let Some(visible) = diff.visible {
match &self.widget {
TrayMenuWidget::Separator(widget) => widget.set_visible(visible),
TrayMenuWidget::Standard(widget) => widget.set_visible(visible),
TrayMenuWidget::Checkbox(widget) => widget.set_visible(visible),
}
}
if let Some(toggle_state) = diff.toggle_state {
if let TrayMenuWidget::Checkbox(widget) = &self.widget {
widget.set_active(toggle_state == ToggleState::On);
}
}
for sub_diff in diff.submenu {
match sub_diff {
Diff::Add(info) => {
let menu_item = TrayMenuItem::new(&info, self.tx.clone());
call!(self.menu_widget, add, menu_item.widget);
if let TrayMenuWidget::Standard(widget) = &self.widget {
widget.set_submenu(Some(&self.menu_widget));
}
self.submenu.insert(menu_item.id, menu_item);
}
Diff::Update(id, diff) => {
if let Some(sub) = self.submenu.get_mut(&id) {
sub.apply_diff(diff);
}
}
Diff::Remove(id) => {
if let Some(sub) = self.submenu.remove(&id) {
call!(self.menu_widget, remove, sub.widget);
}
if let TrayMenuWidget::Standard(widget) = &self.widget {
if self.submenu.is_empty() {
widget.set_submenu(None::<&Menu>);
}
}
}
}
pub fn set_menu_widget(&mut self, menu: system_tray::gtk_menu::Menu) {
let button_handler = self
.event_box
.connect_button_press_event(move |event_box, _event| {
menu.popup_at_widget(event_box, Gravity::North, Gravity::South, None);
Propagation::Proceed
});
if let Some(handler) = self.button_handler.replace(button_handler) {
self.event_box.disconnect(handler);
}
}
}

View file

@ -1,22 +1,21 @@
mod diff;
mod icon;
mod interface;
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::clients::tray;
use crate::config::CommonConfig;
use crate::modules::tray::diff::get_diffs;
use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext};
use crate::{glib_recv, lock, module_impl, send_async, spawn};
use crate::config::{CommonConfig, ModuleOrientation};
use crate::modules::{Module, ModuleInfo, ModuleParts, WidgetContext};
use crate::{lock, module_impl, spawn};
use color_eyre::{Report, Result};
use gtk::{prelude::*, PackDirection};
use gtk::{IconTheme, MenuBar};
use gtk::prelude::*;
use gtk::{IconTheme, Orientation};
use interface::TrayMenu;
use serde::Deserialize;
use std::collections::HashMap;
use system_tray::client::Event;
use system_tray::client::{ActivateRequest, UpdateEvent};
use tokio::sync::mpsc;
use tracing::{debug, error, warn};
use tracing::{debug, error, trace, warn};
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
@ -34,14 +33,13 @@ pub struct TrayModule {
#[serde(default = "default_icon_size")]
icon_size: u32,
/// Direction to display the tray items.
/// The direction in which to pack tray icons.
///
/// **Valid options**: `top_to_bottom`, `bottom_to_top`, `left_to_right`, `right_to_left`
/// **Valid options**: `horizontal`, `vertical`
/// <br>
/// **Default**: `left_to_right` if bar is horizontal, `top_to_bottom` if bar is vertical
#[serde(default, deserialize_with = "deserialize_pack_direction")]
#[cfg_attr(feature = "schema", schemars(schema_with = "schema_pack_direction"))]
direction: Option<PackDirection>,
/// **Default**: `horizontal` for horizontal bars, `vertical` for vertical bars
#[serde(default)]
direction: Option<ModuleOrientation>,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
@ -52,36 +50,7 @@ const fn default_icon_size() -> u32 {
16
}
fn deserialize_pack_direction<'de, D>(deserializer: D) -> Result<Option<PackDirection>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
value
.map(|v| match v.as_str() {
"left_to_right" => Ok(PackDirection::Ltr),
"right_to_left" => Ok(PackDirection::Rtl),
"top_to_bottom" => Ok(PackDirection::Ttb),
"bottom_to_top" => Ok(PackDirection::Btt),
_ => Err(serde::de::Error::custom("invalid value for direction")),
})
.transpose()
}
#[cfg(feature = "schema")]
fn schema_pack_direction(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::JsonSchema;
let mut schema: schemars::schema::SchemaObject = <String>::json_schema(gen).into();
schema.enum_values = Some(vec![
"top_to_bottom".into(),
"bottom_to_top".into(),
"left_to_right".into(),
"right_to_left".into(),
]);
schema.into()
}
impl Module<MenuBar> for TrayModule {
impl Module<gtk::Box> for TrayModule {
type SendMessage = Event;
type ReceiveMessage = ActivateRequest;
@ -98,33 +67,33 @@ impl Module<MenuBar> for TrayModule {
let client = context.try_client::<tray::Client>()?;
let mut tray_rx = client.subscribe();
let initial_items = lock!(client.items()).clone();
let initial_items = {
let items = client.items();
lock!(items).clone()
};
// listen to tray updates
spawn(async move {
for (key, (item, menu)) in initial_items {
send_async!(
tx,
ModuleUpdateEvent::Update(Event::Add(key.clone(), item.into()))
);
tx.send_update(Event::Add(key.clone(), item.into())).await;
if let Some(menu) = menu.clone() {
send_async!(
tx,
ModuleUpdateEvent::Update(Event::Update(key, UpdateEvent::Menu(menu)))
);
tx.send_update(Event::Update(key, UpdateEvent::Menu(menu)))
.await;
}
}
while let Ok(message) = tray_rx.recv().await {
send_async!(tx, ModuleUpdateEvent::Update(message));
tx.send_update(message).await;
}
});
// send tray commands
spawn(async move {
while let Some(cmd) = rx.recv().await {
client.activate(cmd).await?;
if let Err(err) = client.activate(cmd).await {
error!("{err:?}");
}
}
Ok::<_, Report>(())
@ -137,30 +106,33 @@ impl Module<MenuBar> for TrayModule {
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleParts<MenuBar>> {
let container = MenuBar::new();
) -> Result<ModuleParts<gtk::Box>> {
let orientation = self
.direction
.map_or(info.bar_position.orientation(), Orientation::from);
let direction = self.direction.unwrap_or(
if info.bar_position.orientation() == gtk::Orientation::Vertical {
PackDirection::Ttb
} else {
PackDirection::Ltr
},
);
// We use a `Box` here instead of the (supposedly correct) `MenuBar`
// as the latter has issues on Sway with menus focus-stealing from the bar.
//
// Each widget is wrapped in an EventBox, copying what Waybar does here.
let container = gtk::Box::new(orientation, 10);
container.set_pack_direction(direction);
container.set_child_pack_direction(direction);
let mut menus = HashMap::new();
let icon_theme = context.ironbar.image_provider().icon_theme();
{
let container = container.clone();
let mut menus = HashMap::new();
let icon_theme = info.icon_theme.clone();
// listen for UI updates
glib_recv!(context.subscribe(), update =>
on_update(update, &container, &mut menus, &icon_theme, self.icon_size, self.prefer_theme_icons, &context.controller_tx)
);
};
// listen for UI updates
context
.subscribe()
.recv_glib(&container, move |container, update| {
on_update(
update,
container,
&mut menus,
&icon_theme,
self.icon_size,
self.prefer_theme_icons,
);
});
Ok(ModuleParts {
widget: container,
@ -173,32 +145,32 @@ impl Module<MenuBar> for TrayModule {
/// getting the diff since the previous update and applying it to the menu.
fn on_update(
update: Event,
container: &MenuBar,
container: &gtk::Box,
menus: &mut HashMap<Box<str>, TrayMenu>,
icon_theme: &IconTheme,
icon_size: u32,
prefer_icons: bool,
tx: &mpsc::Sender<ActivateRequest>,
) {
match update {
Event::Add(address, item) => {
debug!("Received new tray item at '{address}': {item:?}");
let mut menu_item = TrayMenu::new(tx.clone(), address.clone(), *item);
container.add(&menu_item.widget);
let mut menu_item = TrayMenu::new(&address, *item);
container.pack_start(&menu_item.event_box, true, true, 0);
if let Ok(image) = icon::get_image(&menu_item, icon_theme, icon_size, prefer_icons) {
if let Ok(image) = icon::get_image(&menu_item, icon_size, prefer_icons, icon_theme) {
menu_item.set_image(&image);
} else {
let label = menu_item.title.clone().unwrap_or(address.clone());
menu_item.set_label(&label);
};
}
menu_item.widget.show();
menu_item.event_box.show();
menus.insert(address.into(), menu_item);
}
Event::Update(address, update) => {
debug!("Received tray update for '{address}': {update:?}");
debug!("Received tray update for '{address}'");
trace!("Tray update for '{address}: {update:?}'");
let Some(menu_item) = menus.get_mut(address.as_str()) else {
error!("Attempted to update menu at '{address}' but could not find it");
@ -211,13 +183,12 @@ fn on_update(
}
UpdateEvent::Icon(icon) => {
if icon.as_ref() != menu_item.icon_name() {
match icon::get_image(menu_item, icon_theme, icon_size, prefer_icons) {
menu_item.set_icon_name(icon);
match icon::get_image(menu_item, icon_size, prefer_icons, icon_theme) {
Ok(image) => menu_item.set_image(&image),
Err(_) => menu_item.show_label(),
};
}
}
menu_item.set_icon_name(icon);
}
UpdateEvent::OverlayIcon(_icon) => {
warn!("received unimplemented NewOverlayIcon event");
@ -230,25 +201,22 @@ fn on_update(
label_widget.set_label(&title.unwrap_or_default());
}
}
// UpdateEvent::Tooltip(_tooltip) => {
// warn!("received unimplemented NewAttentionIcon event");
// }
UpdateEvent::Menu(menu) => {
debug!("received new menu for '{}'", address);
let diffs = get_diffs(menu_item.state(), &menu.submenus);
menu_item.apply_diffs(diffs);
menu_item.set_state(menu.submenus);
UpdateEvent::Tooltip(tooltip) => {
menu_item.set_tooltip(tooltip);
}
UpdateEvent::MenuConnect(menu) => {
let menu = system_tray::gtk_menu::Menu::new(&address, &menu);
menu_item.set_menu_widget(menu);
}
UpdateEvent::Menu(_) | UpdateEvent::MenuDiff(_) => {}
}
}
Event::Remove(address) => {
debug!("Removing tray item at '{address}'");
if let Some(menu) = menus.get(address.as_str()) {
container.remove(&menu.widget);
container.remove(&menu.event_box);
}
}
};
}
}

View file

@ -1,21 +1,22 @@
use color_eyre::Result;
use futures_lite::stream::StreamExt;
use gtk::{prelude::*, Button};
use gtk::{Button, prelude::*};
use gtk::{Label, Orientation};
use serde::Deserialize;
use tokio::sync::{broadcast, mpsc};
use upower_dbus::BatteryState;
use std::fmt::Write;
use tokio::sync::mpsc;
use zbus;
use zbus::fdo::PropertiesProxy;
use crate::config::CommonConfig;
use crate::gtk_helpers::IronbarGtkExt;
use crate::image::ImageProvider;
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::clients::upower::BatteryState;
use crate::config::{CommonConfig, LayoutConfig};
use crate::gtk_helpers::{IronbarGtkExt, IronbarLabelExt};
use crate::modules::PopupButton;
use crate::modules::{
Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, WidgetContext,
};
use crate::{glib_recv, module_impl, send_async, spawn, try_send};
use crate::{module_impl, spawn};
const DAY: i64 = 24 * 60 * 60;
const HOUR: i64 = 60 * 60;
@ -37,6 +38,11 @@ pub struct UpowerModule {
#[serde(default = "default_icon_size")]
icon_size: i32,
// -- Common --
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
@ -59,7 +65,7 @@ pub struct UpowerProperties {
time_to_empty: i64,
}
impl Module<gtk::Button> for UpowerModule {
impl Module<Button> for UpowerModule {
type SendMessage = UpowerProperties;
type ReceiveMessage = ();
@ -73,7 +79,7 @@ impl Module<gtk::Button> for UpowerModule {
) -> Result<()> {
let tx = context.tx.clone();
let display_proxy = context.client::<PropertiesProxy>();
let display_proxy = context.try_client::<PropertiesProxy>()?;
spawn(async move {
let mut prop_changed_stream = display_proxy.receive_properties_changed().await?;
@ -84,23 +90,23 @@ impl Module<gtk::Button> for UpowerModule {
let properties = display_proxy.get_all(device_interface_name.clone()).await?;
let percentage = *properties["Percentage"]
let percentage = properties["Percentage"]
.downcast_ref::<f64>()
.expect("expected percentage: f64 in HashMap of all properties");
let icon_name = properties["IconName"]
.downcast_ref::<str>()
.downcast_ref::<&str>()
.expect("expected IconName: str in HashMap of all properties")
.to_string();
let state = u32_to_battery_state(
*properties["State"]
properties["State"]
.downcast_ref::<u32>()
.expect("expected State: u32 in HashMap of all properties"),
)
.unwrap_or(BatteryState::Unknown);
let time_to_full = *properties["TimeToFull"]
let time_to_full = properties["TimeToFull"]
.downcast_ref::<i64>()
.expect("expected TimeToFull: i64 in HashMap of all properties");
let time_to_empty = *properties["TimeToEmpty"]
let time_to_empty = properties["TimeToEmpty"]
.downcast_ref::<i64>()
.expect("expected TimeToEmpty: i64 in HashMap of all properties");
let mut properties = UpowerProperties {
@ -111,7 +117,7 @@ impl Module<gtk::Button> for UpowerModule {
time_to_empty,
};
send_async!(tx, ModuleUpdateEvent::Update(properties.clone()));
tx.send_update(properties.clone()).await;
while let Some(signal) = prop_changed_stream.next().await {
let args = signal.args().expect("Invalid signal arguments");
@ -128,7 +134,7 @@ impl Module<gtk::Button> for UpowerModule {
}
"IconName" => {
properties.icon_name = changed_value
.downcast_ref::<str>()
.downcast_ref::<&str>()
.expect("expected IconName to be str")
.to_string();
}
@ -151,7 +157,7 @@ impl Module<gtk::Button> for UpowerModule {
}
}
send_async!(tx, ModuleUpdateEvent::Update(properties.clone()));
tx.send_update(properties.clone()).await;
}
Result::<()>::Ok(())
@ -165,17 +171,19 @@ impl Module<gtk::Button> for UpowerModule {
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleParts<Button>> {
let icon_theme = info.icon_theme.clone();
let icon = gtk::Image::new();
icon.add_class("icon");
let label = Label::builder()
.label(&self.format)
.use_markup(true)
.angle(self.layout.angle(info))
.justify(self.layout.justify.into())
.build();
label.add_class("label");
let container = gtk::Box::new(Orientation::Horizontal, 5);
let container = gtk::Box::new(self.layout.orientation(info), 5);
container.add_class("contents");
let button = Button::new();
@ -187,37 +195,48 @@ impl Module<gtk::Button> for UpowerModule {
let tx = context.tx.clone();
button.connect_clicked(move |button| {
try_send!(tx, ModuleUpdateEvent::TogglePopup(button.popup_id()));
tx.send_spawn(ModuleUpdateEvent::TogglePopup(button.popup_id()));
});
let format = self.format.clone();
let rx = context.subscribe();
glib_recv!(rx, properties => {
let provider = context.ironbar.image_provider();
rx.recv_glib_async((), move |(), properties| {
let state = properties.state;
let is_charging = state == BatteryState::Charging || state == BatteryState::PendingCharge;
let is_charging =
state == BatteryState::Charging || state == BatteryState::PendingCharge;
let time_remaining = if is_charging {
seconds_to_string(properties.time_to_full)
}
else {
} else {
seconds_to_string(properties.time_to_empty)
};
let format = format.replace("{percentage}", &properties.percentage.to_string())
}
.unwrap_or_default();
let format = format
.replace("{percentage}", &properties.percentage.round().to_string())
.replace("{time_remaining}", &time_remaining)
.replace("{state}", battery_state_to_string(state));
let mut icon_name = String::from("icon:");
icon_name.push_str(&properties.icon_name);
ImageProvider::parse(&icon_name, &icon_theme, false, self.icon_size)
.map(|provider| provider.load_into_image(icon.clone()));
let provider = provider.clone();
let icon = icon.clone();
label.set_markup(format.as_ref());
label.set_label_escaped(&format);
async move {
provider
.load_into_image_silent(&icon_name, self.icon_size, false, &icon)
.await;
}
});
let rx = context.subscribe();
let popup = self
.into_popup(context.controller_tx.clone(), rx, context, info)
.into_popup(context, info)
.into_popup_parts(vec![&button]);
Ok(ModuleParts::new(button, popup))
@ -225,9 +244,7 @@ impl Module<gtk::Button> for UpowerModule {
fn into_popup(
self,
_tx: mpsc::Sender<Self::ReceiveMessage>,
rx: broadcast::Receiver<Self::SendMessage>,
_context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box>
where
@ -237,17 +254,17 @@ impl Module<gtk::Button> for UpowerModule {
.orientation(Orientation::Horizontal)
.build();
let label = Label::new(None);
let label = Label::builder().use_markup(true).build();
label.add_class("upower-details");
container.add(&label);
glib_recv!(rx, properties => {
context.subscribe().recv_glib((), move |(), properties| {
let state = properties.state;
let format = match state {
BatteryState::Charging | BatteryState::PendingCharge => {
let ttf = properties.time_to_full;
if ttf > 0 {
format!("Full in {}", seconds_to_string(ttf))
format!("Full in {}", seconds_to_string(ttf).unwrap_or_default())
} else {
String::new()
}
@ -255,7 +272,7 @@ impl Module<gtk::Button> for UpowerModule {
BatteryState::Discharging | BatteryState::PendingDischarge => {
let tte = properties.time_to_empty;
if tte > 0 {
format!("Empty in {}", seconds_to_string(tte))
format!("Empty in {}", seconds_to_string(tte).unwrap_or_default())
} else {
String::new()
}
@ -263,7 +280,7 @@ impl Module<gtk::Button> for UpowerModule {
_ => String::new(),
};
label.set_markup(&format);
label.set_label_escaped(&format);
});
container.show_all();
@ -272,21 +289,22 @@ impl Module<gtk::Button> for UpowerModule {
}
}
fn seconds_to_string(seconds: i64) -> String {
fn seconds_to_string(seconds: i64) -> Result<String> {
let mut time_string = String::new();
let days = seconds / (DAY);
if days > 0 {
time_string += &format!("{days}d");
write!(time_string, "{days}d")?;
}
let hours = (seconds % DAY) / HOUR;
if hours > 0 {
time_string += &format!(" {hours}h");
write!(time_string, " {hours}h")?;
}
let minutes = (seconds % HOUR) / MINUTE;
if minutes > 0 {
time_string += &format!(" {minutes}m");
write!(time_string, " {minutes}m")?;
}
time_string.trim_start().to_string()
Ok(time_string.trim_start().to_string())
}
const fn u32_to_battery_state(number: u32) -> Result<BatteryState, u32> {

View file

@ -1,426 +0,0 @@
use crate::clients::compositor::{Visibility, Workspace, WorkspaceClient, WorkspaceUpdate};
use crate::config::CommonConfig;
use crate::gtk_helpers::IronbarGtkExt;
use crate::image::new_icon_button;
use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext};
use crate::{glib_recv, module_impl, send_async, spawn, try_send, Ironbar};
use color_eyre::{Report, Result};
use gtk::prelude::*;
use gtk::{Button, IconTheme};
use serde::Deserialize;
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use tokio::sync::mpsc::{Receiver, Sender};
use tracing::{debug, trace, warn};
#[derive(Debug, Deserialize, Clone, Copy, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum SortOrder {
/// Shows workspaces in the order they're added
Added,
/// Shows workspaces in numeric order.
/// Named workspaces are added to the end in alphabetical order.
Alphanumeric,
}
impl Default for SortOrder {
fn default() -> Self {
Self::Alphanumeric
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum Favorites {
ByMonitor(HashMap<String, Vec<String>>),
Global(Vec<String>),
}
impl Default for Favorites {
fn default() -> Self {
Self::Global(vec![])
}
}
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct WorkspacesModule {
/// Map of actual workspace names to custom names.
///
/// Custom names can be [images](images).
///
/// If a workspace is not present in the map,
/// it will fall back to using its actual name.
name_map: Option<HashMap<String, String>>,
/// Workspaces which should always be shown.
/// This can either be an array of workspace names,
/// or a map of monitor names to arrays of workspace names.
///
/// **Default**: `{}`
///
/// # Example
///
/// ```corn
/// // array format
/// {
/// type = "workspaces"
/// favorites = ["1", "2", "3"]
/// }
///
/// // map format
/// {
/// type = "workspaces"
/// favorites.DP-1 = ["1", "2", "3"]
/// favorites.DP-2 = ["4", "5", "6"]
/// }
/// ```
#[serde(default)]
favorites: Favorites,
/// A list of workspace names to never show.
///
/// This may be useful for scratchpad/special workspaces, for example.
///
/// **Default**: `[]`
#[serde(default)]
hidden: Vec<String>,
/// Whether to display workspaces from all monitors.
/// When false, only shows workspaces on the current monitor.
///
/// **Default**: `false`
#[serde(default = "crate::config::default_false")]
all_monitors: bool,
/// The method used for sorting workspaces.
/// `added` always appends to the end, `alphanumeric` sorts by number/name.
///
/// **Valid options**: `added`, `alphanumeric`
/// <br>
/// **Default**: `alphanumeric`
#[serde(default)]
sort: SortOrder,
/// The size to render icons at (image icons only).
///
/// **Default**: `32`
#[serde(default = "default_icon_size")]
icon_size: i32,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
const fn default_icon_size() -> i32 {
32
}
/// Creates a button from a workspace
fn create_button(
name: &str,
visibility: Visibility,
name_map: &HashMap<String, String>,
icon_theme: &IconTheme,
icon_size: i32,
tx: &Sender<String>,
) -> Button {
let label = name_map.get(name).map_or(name, String::as_str);
let button = new_icon_button(label, icon_theme, icon_size);
button.set_widget_name(name);
let style_context = button.style_context();
style_context.add_class("item");
if visibility.is_visible() {
style_context.add_class("visible");
}
if visibility.is_focused() {
style_context.add_class("focused");
}
if !visibility.is_visible() {
style_context.add_class("inactive");
}
{
let tx = tx.clone();
let name = name.to_string();
button.connect_clicked(move |_item| {
try_send!(tx, name.clone());
});
}
button
}
fn reorder_workspaces(container: &gtk::Box) {
let mut buttons = container
.children()
.into_iter()
.map(|child| (child.widget_name().to_string(), child))
.collect::<Vec<_>>();
buttons.sort_by(|(label_a, _), (label_b, _a)| {
match (label_a.parse::<i32>(), label_b.parse::<i32>()) {
(Ok(a), Ok(b)) => a.cmp(&b),
(Ok(_), Err(_)) => Ordering::Less,
(Err(_), Ok(_)) => Ordering::Greater,
(Err(_), Err(_)) => label_a.cmp(label_b),
}
});
for (i, (_, button)) in buttons.into_iter().enumerate() {
container.reorder_child(&button, i as i32);
}
}
fn find_btn(map: &HashMap<i64, Button>, workspace: &Workspace) -> Option<Button> {
map.get(&workspace.id)
.or_else(|| {
map.values()
.find(|btn| btn.label().unwrap_or_default() == workspace.name)
})
.cloned()
}
impl WorkspacesModule {
fn show_workspace_check(&self, output: &String, work: &Workspace) -> bool {
(work.visibility.is_focused() || !self.hidden.contains(&work.name))
&& (self.all_monitors || output == &work.monitor)
}
}
impl Module<gtk::Box> for WorkspacesModule {
type SendMessage = WorkspaceUpdate;
type ReceiveMessage = String;
module_impl!("workspaces");
fn spawn_controller(
&self,
_info: &ModuleInfo,
context: &WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
mut rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let tx = context.tx.clone();
let client = context.ironbar.clients.borrow_mut().workspaces()?;
// Subscribe & send events
spawn(async move {
let mut srx = client.subscribe_workspace_change();
trace!("Set up workspace subscription");
while let Ok(payload) = srx.recv().await {
debug!("Received update: {payload:?}");
send_async!(tx, ModuleUpdateEvent::Update(payload));
}
});
let client = context.try_client::<dyn WorkspaceClient>()?;
// Change workspace focus
spawn(async move {
trace!("Setting up UI event handler");
while let Some(name) = rx.recv().await {
client.focus(name)?;
}
Ok::<(), Report>(())
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleParts<gtk::Box>> {
let container = gtk::Box::new(info.bar_position.orientation(), 0);
let name_map = self.name_map.clone().unwrap_or_default();
let favs = self.favorites.clone();
let mut fav_names: Vec<String> = vec![];
let mut button_map: HashMap<i64, Button> = HashMap::new();
{
let container = container.clone();
let output_name = info.output_name.to_string();
let icon_theme = info.icon_theme.clone();
let icon_size = self.icon_size;
// keep track of whether init event has fired previously
// since it fires for every workspace subscriber
let mut has_initialized = false;
glib_recv!(context.subscribe(), event => {
match event {
WorkspaceUpdate::Init(workspaces) => {
if !has_initialized {
trace!("Creating workspace buttons");
let mut added = HashSet::new();
let mut add_workspace = |id: i64, name: &str, visibility: Visibility| {
let item = create_button(
name,
visibility,
&name_map,
&icon_theme,
icon_size,
&context.controller_tx,
);
container.add(&item);
button_map.insert(id, item);
};
// add workspaces from client
for workspace in &workspaces {
if self.show_workspace_check(&output_name, workspace) {
add_workspace(workspace.id, &workspace.name, workspace.visibility);
added.insert(workspace.name.to_string());
}
}
let mut add_favourites = |names: &Vec<String>| {
for name in names {
fav_names.push(name.to_string());
if !added.contains(name) {
// Favourites are added with the same name and ID
// as Hyprland will initialize them this way.
// Since existing workspaces are added above,
// this means there shouldn't be any issues with renaming.
add_workspace(-(Ironbar::unique_id() as i64), name, Visibility::Hidden);
added.insert(name.to_string());
}
}
};
// add workspaces from favourites
match &favs {
Favorites::Global(names) => add_favourites(names),
Favorites::ByMonitor(map) => {
if let Some(to_add) = map.get(&output_name) {
add_favourites(to_add);
}
}
}
if self.sort == SortOrder::Alphanumeric {
reorder_workspaces(&container);
}
container.show_all();
has_initialized = true;
}
}
WorkspaceUpdate::Focus { old, new } => {
if let Some(btn) = old.as_ref().and_then(|w| find_btn(&button_map, w)) {
if Some(new.monitor.as_str()) == old.as_ref().map(|w| w.monitor.as_str()) {
btn.style_context().remove_class("visible");
}
btn.style_context().remove_class("focused");
}
if let Some(btn) = find_btn(&button_map, &new) {
btn.add_class("visible");
btn.add_class("focused");
}
}
WorkspaceUpdate::Rename { id, name } => {
if let Some(btn) = button_map.get(&id) {
let name = name_map.get(&name).unwrap_or(&name);
btn.set_label(name);
}
}
WorkspaceUpdate::Add(workspace) => {
if fav_names.contains(&workspace.name) {
let btn = button_map.get(&workspace.id);
if let Some(btn) = btn {
btn.style_context().remove_class("inactive");
}
} else if self.show_workspace_check(&output_name, &workspace) {
let name = workspace.name;
let item = create_button(
&name,
workspace.visibility,
&name_map,
&icon_theme,
icon_size,
&context.controller_tx,
);
container.add(&item);
if self.sort == SortOrder::Alphanumeric {
reorder_workspaces(&container);
}
item.show();
if !name.is_empty() {
button_map.insert(workspace.id, item);
}
}
}
WorkspaceUpdate::Move(workspace) => {
if !self.hidden.contains(&workspace.name) && !self.all_monitors {
if workspace.monitor == output_name {
let name = workspace.name;
let item = create_button(
&name,
workspace.visibility,
&name_map,
&icon_theme,
icon_size,
&context.controller_tx,
);
container.add(&item);
if self.sort == SortOrder::Alphanumeric {
reorder_workspaces(&container);
}
item.show();
if !name.is_empty() {
button_map.insert(workspace.id, item);
}
} else if let Some(item) = button_map.get(&workspace.id) {
container.remove(item);
}
}
}
WorkspaceUpdate::Remove(workspace) => {
let button = button_map.get(&workspace);
if let Some(item) = button {
if workspace < 0 {
// if fav_names.contains(&workspace) {
item.style_context().add_class("inactive");
} else {
container.remove(item);
}
}
}
WorkspaceUpdate::Unknown => warn!("Received unknown type workspace event")
};
});
}
Ok(ModuleParts {
widget: container,
popup: None,
})
}
}

View file

@ -0,0 +1,77 @@
use super::open_state::OpenState;
use crate::channels::AsyncSenderExt;
use crate::gtk_helpers::IronbarGtkExt;
use crate::image::IconButton;
use crate::modules::workspaces::WorkspaceItemContext;
use gtk::Button as GtkButton;
use gtk::prelude::*;
#[derive(Debug, Clone)]
pub struct Button {
button: IconButton,
workspace_id: i64,
}
impl Button {
pub fn new(id: i64, name: &str, open_state: OpenState, context: &WorkspaceItemContext) -> Self {
let label = context.name_map.get(name).map_or(name, String::as_str);
let button = IconButton::new(label, context.icon_size, context.image_provider.clone());
button.set_widget_name(name);
button.add_class("item");
let tx = context.tx.clone();
button.connect_clicked(move |_item| {
tx.send_spawn(id);
});
let btn = Self {
button,
workspace_id: id,
};
btn.set_open_state(open_state);
btn
}
pub fn button(&self) -> &GtkButton {
&self.button
}
pub fn set_open_state(&self, open_state: OpenState) {
if open_state.is_visible() {
self.button.add_class("visible");
} else {
self.button.remove_class("visible");
}
if open_state == OpenState::Focused {
self.button.add_class("focused");
} else {
self.button.remove_class("focused");
}
if open_state == OpenState::Closed {
self.button.add_class("inactive");
} else {
self.button.remove_class("inactive");
}
}
pub fn set_urgent(&self, urgent: bool) {
if urgent {
self.button.add_class("urgent");
} else {
self.button.remove_class("urgent");
}
}
pub fn workspace_id(&self) -> i64 {
self.workspace_id
}
pub fn set_workspace_id(&mut self, id: i64) {
self.workspace_id = id;
}
}

View file

@ -0,0 +1,78 @@
use super::button::Button;
use crate::clients::compositor::Workspace;
use std::collections::HashMap;
use std::ops::{Deref, DerefMut};
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum Identifier {
Id(i64),
Name(String),
}
/// Wrapper around a hashmap of workspace buttons,
/// which can be found using the workspace ID,
/// or their name for favourites.
#[derive(Debug)]
pub struct ButtonMap {
map: HashMap<Identifier, Button>,
}
impl ButtonMap {
pub fn new() -> Self {
Self {
map: HashMap::new(),
}
}
/// Gets the button for a workspace,
/// checking the map for both its ID and name.
pub fn find_button_mut(&mut self, workspace: &Workspace) -> Option<&mut Button> {
let id = Identifier::Id(workspace.id);
if self.map.contains_key(&id) {
self.map.get_mut(&id)
} else {
self.map.get_mut(&Identifier::Name(workspace.name.clone()))
}
}
/// Gets the button for a workspace,
/// performing a search of all keys for the button
/// with the associated workspace ID.
pub fn find_button_by_id(&self, id: i64) -> Option<&Button> {
self.map.iter().find_map(|(_, button)| {
if button.workspace_id() == id {
Some(button)
} else {
None
}
})
}
/// Gets the button for a workspace,
/// performing a search of all keys for the button
/// with the associated workspace ID.
pub fn find_button_by_id_mut(&mut self, id: i64) -> Option<&mut Button> {
self.map.iter_mut().find_map(|(_, button)| {
if button.workspace_id() == id {
Some(button)
} else {
None
}
})
}
}
impl Deref for ButtonMap {
type Target = HashMap<Identifier, Button>;
fn deref(&self) -> &Self::Target {
&self.map
}
}
impl DerefMut for ButtonMap {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.map
}
}

View file

@ -0,0 +1,416 @@
mod button;
mod button_map;
mod open_state;
use self::button::Button;
use crate::channels::{AsyncSenderExt, BroadcastReceiverExt};
use crate::clients::compositor::{Workspace, WorkspaceClient, WorkspaceUpdate};
use crate::config::{CommonConfig, LayoutConfig};
use crate::modules::workspaces::button_map::{ButtonMap, Identifier};
use crate::modules::workspaces::open_state::OpenState;
use crate::modules::{Module, ModuleInfo, ModuleParts, WidgetContext};
use crate::{image, module_impl, spawn};
use color_eyre::{Report, Result};
use gtk::prelude::*;
use serde::Deserialize;
use std::cmp::Ordering;
use std::collections::HashMap;
use tokio::sync::mpsc;
use tracing::{debug, trace, warn};
#[derive(Debug, Deserialize, Default, Clone, Copy, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum SortOrder {
/// Shows workspaces in the order they're added
Added,
/// Shows workspaces in the order of their displayed labels,
/// accounting for any mappings supplied in `name_map`.
/// In most cases, this is likely their number.
///
/// Workspaces are sorted numerically first,
/// and named workspaces are added to the end in alphabetical order.
#[default]
Label,
/// Shows workspaces in the order of their real names,
/// as supplied by the compositor.
/// In most cases, this is likely their number.
///
/// Workspaces are sorted numerically first,
/// and named workspaces are added to the end in alphabetical order.
Name,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum Favorites {
ByMonitor(HashMap<String, Vec<String>>),
Global(Vec<String>),
}
impl Default for Favorites {
fn default() -> Self {
Self::Global(vec![])
}
}
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct WorkspacesModule {
/// Map of actual workspace names to custom names.
///
/// Custom names can be [images](images).
///
/// If a workspace is not present in the map,
/// it will fall back to using its actual name.
#[serde(default)]
name_map: HashMap<String, String>,
/// Workspaces which should always be shown.
/// This can either be an array of workspace names,
/// or a map of monitor names to arrays of workspace names.
///
/// **Default**: `{}`
///
/// # Example
///
/// ```corn
/// // array format
/// {
/// type = "workspaces"
/// favorites = ["1", "2", "3"]
/// }
///
/// // map format
/// {
/// type = "workspaces"
/// favorites.DP-1 = ["1", "2", "3"]
/// favorites.DP-2 = ["4", "5", "6"]
/// }
/// ```
#[serde(default)]
favorites: Favorites,
/// A list of workspace names to never show.
///
/// This may be useful for scratchpad/special workspaces, for example.
///
/// **Default**: `[]`
#[serde(default)]
hidden: Vec<String>,
/// Whether to display workspaces from all monitors.
/// When false, only shows workspaces on the current monitor.
///
/// **Default**: `false`
#[serde(default = "crate::config::default_false")]
all_monitors: bool,
/// The method used for sorting workspaces.
///
/// - `added` always appends to the end.
/// - `label` sorts by displayed value.
/// - `name` sorts by workspace name.
///
/// **Valid options**: `added`, `label`, `name`.
/// <br>
/// **Default**: `label`
#[serde(default)]
sort: SortOrder,
/// The size to render icons at (image icons only).
///
/// **Default**: `32`
#[serde(default = "default_icon_size")]
icon_size: i32,
// -- Common --
/// See [layout options](module-level-options#layout)
#[serde(default, flatten)]
layout: LayoutConfig,
/// See [common options](module-level-options#common-options).
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
const fn default_icon_size() -> i32 {
32
}
#[derive(Debug, Clone)]
pub struct WorkspaceItemContext {
name_map: HashMap<String, String>,
icon_size: i32,
image_provider: image::Provider,
tx: mpsc::Sender<i64>,
}
/// Re-orders the container children alphabetically,
/// using their widget names.
///
/// Named workspaces are always sorted before numbered ones.
fn reorder_workspaces(container: &gtk::Box, sort_order: SortOrder) {
let mut buttons = container
.children()
.into_iter()
.map(|child| {
let label = if sort_order == SortOrder::Label {
child
.downcast_ref::<gtk::Button>()
.and_then(ButtonExt::label)
.unwrap_or_else(|| child.widget_name())
} else {
child.widget_name()
}
.to_string();
(label, child)
})
.collect::<Vec<_>>();
buttons.sort_by(|(label_a, _), (label_b, _a)| {
match (label_a.parse::<i32>(), label_b.parse::<i32>()) {
(Ok(a), Ok(b)) => a.cmp(&b),
(Ok(_), Err(_)) => Ordering::Less,
(Err(_), Ok(_)) => Ordering::Greater,
(Err(_), Err(_)) => label_a.cmp(label_b),
}
});
for (i, (_, button)) in buttons.into_iter().enumerate() {
container.reorder_child(&button, i as i32);
}
}
impl Module<gtk::Box> for WorkspacesModule {
type SendMessage = WorkspaceUpdate;
type ReceiveMessage = i64;
module_impl!("workspaces");
fn spawn_controller(
&self,
_info: &ModuleInfo,
context: &WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
mut rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let tx = context.tx.clone();
let client = context.ironbar.clients.borrow_mut().workspaces()?;
// Subscribe & send events
spawn(async move {
let mut srx = client.subscribe();
trace!("Set up workspace subscription");
while let Ok(payload) = srx.recv().await {
debug!("Received update: {payload:?}");
tx.send_update(payload).await;
}
});
let client = context.try_client::<dyn WorkspaceClient>()?;
// Change workspace focus
spawn(async move {
trace!("Setting up UI event handler");
while let Some(id) = rx.recv().await {
client.focus(id);
}
Ok::<(), Report>(())
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleParts<gtk::Box>> {
let container = gtk::Box::new(self.layout.orientation(info), 0);
let mut button_map = ButtonMap::new();
let item_context = WorkspaceItemContext {
name_map: self.name_map.clone(),
icon_size: self.icon_size,
image_provider: context.ironbar.image_provider(),
tx: context.controller_tx.clone(),
};
// setup favorites
let favorites = match self.favorites {
Favorites::ByMonitor(map) => map.get(info.output_name).cloned(),
Favorites::Global(vec) => Some(vec),
}
.unwrap_or_default();
for favorite in &favorites {
let btn = Button::new(-1, favorite, OpenState::Closed, &item_context);
container.add(btn.button());
button_map.insert(Identifier::Name(favorite.clone()), btn);
}
{
let container = container.clone();
let output_name = info.output_name.to_string();
// keep track of whether init event has fired previously
// since it fires for every workspace subscriber
let mut has_initialized = false;
let add_workspace = {
let container = container.clone();
move |workspace: Workspace, button_map: &mut ButtonMap| {
if favorites.contains(&workspace.name) {
let btn = button_map
.get_mut(&Identifier::Name(workspace.name))
.expect("favorite to exist");
// set an ID to track the open workspace for the favourite
btn.set_workspace_id(workspace.id);
btn.set_open_state(workspace.visibility.into());
} else {
let btn = Button::new(
workspace.id,
&workspace.name,
workspace.visibility.into(),
&item_context,
);
container.add(btn.button());
btn.button().show();
button_map.insert(Identifier::Id(workspace.id), btn);
}
}
};
let remove_workspace = {
let container = container.clone();
move |id: i64, button_map: &mut ButtonMap| {
// since favourites use name identifiers,
// we can safely remove using ID here and favourites will remain
if let Some(button) = button_map.remove(&Identifier::Id(id)) {
container.remove(button.button());
} else {
// otherwise we do a deep search and use the button's cached ID
if let Some(button) = button_map.find_button_by_id_mut(id) {
button.set_workspace_id(-1);
button.set_open_state(OpenState::Closed);
}
}
}
};
macro_rules! reorder {
() => {
if self.sort != SortOrder::Added {
reorder_workspaces(&container, self.sort);
}
};
}
let name_map = self.name_map;
context
.subscribe()
.recv_glib((), move |(), event| match event {
WorkspaceUpdate::Init(workspaces) => {
if has_initialized {
return;
}
trace!("Creating workspace buttons");
for workspace in workspaces
.into_iter()
.filter(|w| self.all_monitors || w.monitor == output_name)
.filter(|w| !self.hidden.contains(&w.name))
{
add_workspace(workspace, &mut button_map);
}
reorder!();
has_initialized = true;
}
WorkspaceUpdate::Add(workspace) => {
if !self.hidden.contains(&workspace.name)
&& (self.all_monitors || workspace.monitor == output_name)
{
add_workspace(workspace, &mut button_map);
}
reorder!();
}
WorkspaceUpdate::Remove(id) => remove_workspace(id, &mut button_map),
WorkspaceUpdate::Move(workspace) => {
if self.all_monitors {
return;
}
if workspace.monitor == output_name
&& !self.hidden.contains(&workspace.name)
{
add_workspace(workspace, &mut button_map);
reorder!();
} else {
remove_workspace(workspace.id, &mut button_map);
}
}
WorkspaceUpdate::Focus { old, new } => {
// Open states are calculated here rather than using the workspace visibility
// as that seems to come back wrong, at least on Hyprland.
// Likely a deeper issue that needs exploring.
if let Some(old) = old {
if let Some(button) = button_map.find_button_mut(&old) {
let open_state = if new.monitor == old.monitor {
OpenState::Hidden
} else {
OpenState::Visible
};
button.set_open_state(open_state);
}
}
if let Some(button) = button_map.find_button_mut(&new) {
button.set_open_state(OpenState::Focused);
}
}
WorkspaceUpdate::Rename { id, name } => {
if let Some(button) = button_map
.get(&Identifier::Id(id))
.or_else(|| button_map.get(&Identifier::Name(name.clone())))
.map(Button::button)
{
let display_name = name_map.get(&name).unwrap_or(&name);
button.set_label(display_name);
button.set_widget_name(&name);
}
}
WorkspaceUpdate::Urgent { id, urgent } => {
if let Some(button) = button_map
.get(&Identifier::Id(id))
.or_else(|| button_map.find_button_by_id(id))
{
button.set_urgent(urgent);
}
}
WorkspaceUpdate::Unknown => warn!("received unknown type workspace event"),
});
}
Ok(ModuleParts {
widget: container,
popup: None,
})
}
}

View file

@ -0,0 +1,30 @@
use crate::clients::compositor::Visibility;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum OpenState {
/// A favourite workspace, which is not currently open
Closed,
/// A workspace which is open but not visible on any monitors.
Hidden,
/// A workspace which is visible, but not focused.
Visible,
/// The currently active workspace.
Focused,
}
impl From<Visibility> for OpenState {
fn from(value: Visibility) -> Self {
match value {
Visibility::Visible { focused: true } => Self::Focused,
Visibility::Visible { focused: false } => Self::Visible,
Visibility::Hidden => Self::Hidden,
}
}
}
impl OpenState {
/// Whether the workspace is visible, including focused state.
pub fn is_visible(self) -> bool {
matches!(self, OpenState::Visible | OpenState::Focused)
}
}

View file

@ -3,17 +3,17 @@ use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use gtk::gdk::Monitor;
use crate::channels::BroadcastReceiverExt;
use crate::clients::wayland::{OutputEvent, OutputEventType};
use crate::config::BarPosition;
use crate::gtk_helpers::{IronbarGtkExt, WidgetGeometry};
use crate::modules::{ModuleInfo, ModulePopupParts, PopupButton};
use crate::{Ironbar, rc_mut};
use gtk::prelude::*;
use gtk::{ApplicationWindow, Button, Orientation};
use gtk_layer_shell::LayerShell;
use tracing::{debug, trace};
use crate::config::BarPosition;
use crate::gtk_helpers::{IronbarGtkExt, WidgetGeometry};
use crate::modules::{ModuleInfo, ModulePopupParts, PopupButton};
use crate::rc_mut;
#[derive(Debug, Clone)]
pub struct PopupCacheValue {
pub name: String,
@ -25,16 +25,21 @@ pub struct Popup {
pub window: ApplicationWindow,
pub container_cache: Rc<RefCell<HashMap<usize, PopupCacheValue>>>,
pub button_cache: Rc<RefCell<Vec<Button>>>,
monitor: Monitor,
pos: BarPosition,
current_widget: Rc<RefCell<Option<(usize, usize)>>>,
output_size: Rc<RefCell<(i32, i32)>>,
}
impl Popup {
/// Creates a new popup window.
/// This includes setting up gtk-layer-shell
/// and an empty `gtk::Box` container.
pub fn new(module_info: &ModuleInfo, gap: i32) -> Self {
pub fn new(
ironbar: &Ironbar,
module_info: &ModuleInfo,
output_size: (i32, i32),
gap: i32,
) -> Self {
let pos = module_info.bar_position;
let orientation = pos.orientation();
@ -105,13 +110,26 @@ impl Popup {
Propagation::Proceed
});
let output_size = rc_mut!(output_size);
// respond to resolution changes
let rx = ironbar.clients.borrow_mut().wayland().subscribe_outputs();
let output_name = module_info.output_name.to_string();
rx.recv_glib(&output_size, move |output_size, event: OutputEvent| {
if event.event_type == OutputEventType::Update
&& event.output.name.unwrap_or_default() == output_name
{
*output_size.borrow_mut() = event.output.logical_size.unwrap_or_default();
}
});
Self {
window: win,
container_cache: rc_mut!(HashMap::new()),
button_cache: rc_mut!(vec![]),
monitor: module_info.monitor.clone(),
pos,
current_widget: rc_mut!(None),
output_size,
}
}
@ -123,13 +141,14 @@ impl Popup {
}
let orientation = self.pos.orientation();
let monitor = self.monitor.clone();
let window = self.window.clone();
let current_widget = self.current_widget.clone();
let cache = self.container_cache.clone();
let button_cache = self.button_cache.clone();
let output_size = self.output_size.clone();
content
.container
.connect_size_allocate(move |container, rect| {
@ -142,8 +161,8 @@ impl Popup {
&button_cache.borrow(),
button_id,
orientation,
&monitor,
&window,
&output_size,
);
}
}
@ -175,8 +194,8 @@ impl Popup {
&self.button_cache.borrow(),
button_id,
self.pos.orientation(),
&self.monitor,
&self.window,
&self.output_size,
);
}
}
@ -193,8 +212,8 @@ impl Popup {
Self::set_pos(
geometry,
self.pos.orientation(),
&self.monitor,
&self.window,
*self.output_size.borrow(),
);
}
}
@ -203,8 +222,8 @@ impl Popup {
buttons: &[Button],
button_id: usize,
orientation: Orientation,
monitor: &Monitor,
window: &ApplicationWindow,
output_size: &Rc<RefCell<(i32, i32)>>,
) {
let button = buttons
.iter()
@ -212,7 +231,7 @@ impl Popup {
.expect("to find valid button");
let geometry = button.geometry(orientation);
Self::set_pos(geometry, orientation, monitor, window);
Self::set_pos(geometry, orientation, window, *output_size.borrow());
}
fn clear_window(&self) {
@ -242,14 +261,13 @@ impl Popup {
fn set_pos(
geometry: WidgetGeometry,
orientation: Orientation,
monitor: &Monitor,
window: &ApplicationWindow,
output_size: (i32, i32),
) {
let mon_workarea = monitor.workarea();
let screen_size = if orientation == Orientation::Horizontal {
mon_workarea.width()
output_size.0
} else {
mon_workarea.height()
output_size.1
};
let (popup_width, popup_height) = window.size();

View file

@ -1,4 +1,5 @@
use crate::{send_async, spawn};
use crate::channels::AsyncSenderExt;
use crate::spawn;
use color_eyre::eyre::WrapErr;
use color_eyre::{Report, Result};
use serde::Deserialize;
@ -216,7 +217,7 @@ impl Script {
}
Err(err) => error!("{err:?}"),
},
};
}
sleep(tokio::time::Duration::from_millis(self.interval)).await;
}
@ -302,11 +303,11 @@ impl Script {
_ = handle.wait() => break,
Ok(Some(line)) = stdout_lines.next_line() => {
debug!("sending stdout line: '{line}'");
send_async!(tx, OutputStream::Stdout(line));
tx.send_expect(OutputStream::Stdout(line)).await;
}
Ok(Some(line)) = stderr_lines.next_line() => {
debug!("sending stderr line: '{line}'");
send_async!(tx, OutputStream::Stderr(line));
tx.send_expect(OutputStream::Stderr(line)).await;
}
}
}

View file

@ -1,10 +1,11 @@
use crate::{glib_recv_mpsc, spawn, try_send};
use crate::channels::{AsyncSenderExt, MpscReceiverExt};
use crate::spawn;
use color_eyre::{Help, Report};
use gtk::ffi::GTK_STYLE_PROVIDER_PRIORITY_USER;
use gtk::prelude::CssProviderExt;
use gtk::{gdk, gio, CssProvider, StyleContext};
use gtk::prelude::*;
use gtk::{Application, CssProvider, StyleContext, gdk, gio};
use notify::event::ModifyKind;
use notify::{recommended_watcher, Event, EventKind, RecursiveMode, Result, Watcher};
use notify::{Event, EventKind, RecursiveMode, Result, Watcher, recommended_watcher};
use std::env;
use std::path::PathBuf;
use std::time::Duration;
@ -17,7 +18,7 @@ use tracing::{debug, error, info};
///
/// Installs a file watcher and reloads CSS when
/// write changes are detected on the file.
pub fn load_css(style_path: PathBuf) {
pub fn load_css(style_path: PathBuf, application: Application) {
// file watcher requires absolute path
let style_path = if style_path.is_absolute() {
style_path
@ -34,7 +35,7 @@ pub fn load_css(style_path: PathBuf) {
.suggestion("Check the CSS file for errors")
.suggestion("GTK CSS uses a subset of the full CSS spec and many properties are not available. Ensure you are not using any unsupported property.")
)
};
}
let screen = gdk::Screen::default().expect("Failed to get default GTK screen");
StyleContext::add_provider_for_screen(
@ -51,7 +52,7 @@ pub fn load_css(style_path: PathBuf) {
Ok(event) if matches!(event.kind, EventKind::Modify(ModifyKind::Data(_))) => {
debug!("{event:?}");
if event.paths.first().is_some_and(|p| p == &style_path2) {
try_send!(tx, style_path2.clone());
tx.send_spawn(style_path2.clone());
}
}
Err(e) => error!("Error occurred when watching stylesheet: {:?}", e),
@ -72,7 +73,7 @@ pub fn load_css(style_path: PathBuf) {
}
});
glib_recv_mpsc!(rx, path => {
rx.recv_glib((), move |(), path| {
info!("Reloading CSS");
if let Err(err) = provider.load_from_file(&gio::File::for_path(path)) {
error!("{:?}", Report::new(err)
@ -80,6 +81,10 @@ pub fn load_css(style_path: PathBuf) {
.suggestion("Check the CSS file for errors")
.suggestion("GTK CSS uses a subset of the full CSS spec and many properties are not available. Ensure you are not using any unsupported property.")
);
} else {
for win in application.windows() {
win.queue_draw();
}
}
});
}