Compare commits

...
40 Commits
Author SHA1 Message Date
doloro 9e37d0662a clippy fix 2026-09-17 12:38:12 +01:00
doloro cf7683ebfd cargo fmt 2026-09-17 12:37:44 +01:00
doloro 5f338c4278 battery eta smoothing 2026-09-16 14:22:28 +01:00
doloro 489e7ff326 bleh 2026-09-16 12:26:04 +01:00
doloro 99d24a7a09 cat 2026-09-16 12:15:10 +01:00
doloro c1a67c9335 bleh 2026-09-16 12:12:57 +01:00
doloro fe07022015 popup 2026-09-16 12:07:30 +01:00
doloro 6a62d14755 battery 2026-09-16 11:36:34 +01:00
doloro 7bf6f989dc meow 2026-09-16 11:32:50 +01:00
doloro 4d586d5137 bap 2026-09-16 11:29:30 +01:00
doloro 2bb7b31b76 battery module test 2026-09-16 11:26:05 +01:00
doloro 63e1c0ee4c basic battery module/service 2026-09-16 11:06:12 +01:00
doloro 120eaf5855 removed useless popup buttons 2026-09-15 15:38:51 +01:00
doloro cd3cf4d65f meow 2026-09-15 15:09:36 +01:00
doloro ac70a9133a popup fixes and clock gets nicer popup 2026-09-15 11:46:16 +01:00
doloro fb6b03b369 random fmt 2026-09-15 09:05:19 +01:00
doloro e8c0e79bae meow 2026-09-14 00:55:25 +01:00
doloro ec43d7b57a things 2026-09-13 19:47:01 +01:00
doloro 14c3bb0495 bwa 2026-09-13 18:45:32 +01:00
doloro aba6af03b4 hm test 2026-09-13 18:40:55 +01:00
doloro cc67cd25e9 configurations things 2026-09-13 18:32:37 +01:00
doloro a14b6f4967 hyprland workspace, service 2026-09-13 18:15:22 +01:00
doloro 57201bc0ff tracing and changes 2026-09-13 16:25:47 +01:00
doloro 74f59be3f8 fmstl 2026-09-13 14:37:59 +01:00
doloro b81eb967ee changes 2026-09-12 21:50:09 +01:00
doloro b20e03def7 cargo fmt -all 2026-09-12 17:45:43 +01:00
doloro 27ec2b90a6 msg type -> wire::push() -> update handle wire into module/service/ect 2026-09-12 15:04:06 +01:00
doloro 539c80cda9 fucking something i forgot 2026-09-12 12:24:36 +01:00
doloro bdec970b62 reorginise 2026-09-10 02:08:45 +01:00
doloro 8696425590 a 2026-09-09 16:56:02 +01:00
doloro b9c7e73824 asked the clanker to change the arcidecture a bit 2026-09-09 15:50:07 +01:00
doloro 3490af52e4 refec 2026-09-07 11:41:59 +01:00
doloro bc5a26b728 refac: workspace 2026-09-07 00:24:19 +01:00
doloro c82b470b9e rename our subscriptions to services 2026-09-05 14:10:29 +01:00
doloro 61ed2ab85c cargo fmt 2026-09-05 14:06:06 +01:00
doloro 4db39f718a more framework perfecting 2026-09-05 14:04:11 +01:00
doloro 0ee71a0618 change 2026-09-01 16:43:05 +01:00
doloro 7aa1550f41 bullshit 2026-09-01 16:34:58 +01:00
doloro 6e82d0a175 pop up 2026-08-31 23:03:24 +01:00
doloro cc5db9089a fresh-start 2026-08-31 22:51:49 +01:00
62 changed files with 5799 additions and 664 deletions
+7
View File
@@ -0,0 +1,7 @@
# Link with mold from the repo itself, so `cargo build`/`cargo run` do not
# depend on a machine-local ~/.cargo/config.toml. -fuse-ld is understood by
# both the clang and gcc drivers, so this works with whatever `linker` the
# caller has configured (mold is in the devShell PATH; the nix build gets it
# via flake.nix).
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
Generated
+2185 -128
View File
File diff suppressed because it is too large Load Diff
+25 -4
View File
@@ -1,8 +1,29 @@
[package] [workspace]
name = "barbar" members = ["crates/core", "crates/common", "crates/modules", "crates/services"]
resolver = "2"
[workspace.package]
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [workspace.dependencies]
iced = { version = "0.14", default-features = false, features = ["wgpu", "wayland", "crisp", "web-colors", "thread-pool", "advanced"] } iced = { version = "0.14", default-features = false, features = ["wgpu", "wayland", "crisp", "web-colors", "thread-pool", "advanced", "tokio"] }
iced_layershell = { version = "0.19", default-features = false } iced_layershell = { version = "0.19", default-features = false }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = "0.4"
toml = "1.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
thiserror = "2"
clap = { version = "4.6.6", features = ["derive", "help"] }
# Dependencies get light optimization in dev so wgpu/iced stay usable at
# runtime without paying full O3 codegen on every cold build; workspace
# members keep dev defaults (opt-level 0). Line-tables-only debuginfo keeps
# the dev binary and target/ from ballooning.
[profile.dev.package."*"]
opt-level = 1
debug = "line-tables-only"
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "barbar"
version = "0.1.0"
edition = "2021"
[dependencies]
iced = { version = "0.14", default-features = false, features = ["wgpu", "wayland", "crisp", "web-colors", "thread-pool", "advanced", "tokio"] }
iced_layershell = { version = "0.19", default-features = false }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
chrono = "0.4"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "common"
version.workspace = true
edition.workspace = true
[lib]
name = "common"
path = "src/lib.rs"
[dependencies]
iced = { workspace = true }
iced_layershell = { workspace = true }
toml = { workspace = true }
serde = { workspace = true }
chrono = { workspace = true }
serde_json = { workspace = true }
+43
View File
@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
use toml::Table;
use crate::display::Display;
#[derive(Serialize, Deserialize)]
pub struct BarbarConfig {
#[serde(default)]
pub display: Display,
pub ups: Option<i32>, // Updates per second
pub order: ModuleOrder,
pub modules: Option<Table>, // 'clock': [clock settings]
pub services: Option<Table>, // 'clockticker': [clockticker settings]
}
#[derive(serde::Deserialize, Serialize, Clone, Copy, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Modules {
Audio,
Battery,
Clock,
Weather,
Workspaces,
}
impl Modules {
pub fn to_string(&self) -> &str {
match self {
Modules::Audio => "audio",
Modules::Battery => "battery",
Modules::Clock => "clock",
Modules::Weather => "weather",
Modules::Workspaces => "workspaces",
}
}
}
#[derive(Deserialize, Serialize, Clone)]
pub struct ModuleOrder {
pub left: Vec<Modules>,
pub middle: Vec<Modules>,
pub right: Vec<Modules>,
}
+91
View File
@@ -0,0 +1,91 @@
//! `[display]` config section, converted into `iced_layershell` settings.
//!
//! Keeps the bar surface's geometry and renderer flags in one place, so `core`
//! only reads config and hands the result straight to the daemon.
use iced::{Font, Pixels};
use iced_layershell::reexport::{Anchor, KeyboardInteractivity};
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Bar text font, matching quickshell's `CaskaydiaCove NFM`.
const DEFAULT_FONT: &str = "CaskaydiaCove NFM";
/// Bar height in logical px when `[display].height` is omitted.
const DEFAULT_HEIGHT: u32 = 36;
/// Default text size in logical px.
const DEFAULT_TEXT_SIZE: f32 = 16.0;
/// Default horizontal padding between the bar edge and its content, logical px.
const DEFAULT_PADDING: f32 = 8.0;
/// Default gap between modules in the bar, logical px.
const DEFAULT_SPACING: f32 = 8.0;
/// Parsed `[display]` table.
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(default)]
pub struct Display {
/// Output to pin the bar to; `None` follows the active output.
pub monitor: Option<String>,
/// Bar height in logical px; the same strip is reserved exclusively.
pub height: u32,
/// MSAA for triangle primitives (Canvas/meshes). Quads — borders,
/// rounded rectangles — are already anti-aliased analytically, so this
/// does not change them.
pub antialiasing: bool,
/// Default text size in logical px.
pub default_text_size: f32,
/// Left/right padding between the bar edge and its content, logical px.
pub padding: f32,
/// Gap between modules in the bar, logical px.
pub spacing: f32,
/// Path to a base16 YAML scheme to theme the bar with; system theme if
/// absent.
pub theme: Option<PathBuf>,
}
impl Default for Display {
fn default() -> Self {
Self {
monitor: None,
height: DEFAULT_HEIGHT,
antialiasing: false,
default_text_size: DEFAULT_TEXT_SIZE,
padding: DEFAULT_PADDING,
spacing: DEFAULT_SPACING,
theme: None,
}
}
}
impl Display {
/// Output the bar starts on.
pub fn start_mode(&self) -> StartMode {
match &self.monitor {
Some(name) => StartMode::TargetScreen(name.clone()),
None => StartMode::Active,
}
}
/// Bar surface placement: full width, top, reserved strip of `height`.
pub fn layer_settings(&self) -> LayerShellSettings {
LayerShellSettings {
size: Some((0, self.height)),
exclusive_zone: self.height as i32,
anchor: Anchor::Top | Anchor::Left | Anchor::Right,
start_mode: self.start_mode(),
keyboard_interactivity: KeyboardInteractivity::None,
..Default::default()
}
}
/// Full daemon settings: layer-shell placement plus iced renderer flags.
pub fn settings(&self) -> Settings {
Settings {
layer_settings: self.layer_settings(),
default_font: Font::with_name(DEFAULT_FONT),
default_text_size: Pixels(self.default_text_size),
antialiasing: self.antialiasing,
..Default::default()
}
}
}
+27
View File
@@ -0,0 +1,27 @@
use iced::{window, Rectangle};
use crate::Wire;
#[derive(Clone, Debug)]
pub struct PopupSettings {
// pub name: String,
pub module_id: String,
pub element_id: String,
pub gap: i32,
}
/// What a module asks the app to do — the single effect vocabulary shared by
/// modules and app, so mapping module output into a `Message` is a plain
/// `.map(Message::Effect)` with no mirror enum.
#[derive(Debug, Clone)]
pub enum ModuleEffect {
/// Toggle a popup for the given module id (element id anchors it).
RequestPopup(PopupSettings),
/// A widget-tree pass reported a module's laid-out bounds; anchor the
/// popup there. App-internal, but one effect type keeps routing trivial.
BoundsFound(Rectangle, PopupSettings),
/// Request removal of the popup surface.
ClosePopup(window::Id),
/// Route a wire to its target (a module, a service inbox, or a topic).
Send(Wire),
}
+23
View File
@@ -0,0 +1,23 @@
//! Barbar's protocol crate: the types that cross crate boundaries.
//!
//! Nothing here may reference app-level types (`Message`, `Msg`,
//! layer-shell effects). `common` is the hub every other crate depends on,
//! so it must stay acyclic and pure.
mod config;
mod display;
mod effect;
mod messages;
mod module;
mod reusable_elements;
mod service;
mod wire;
pub use config::{BarbarConfig, Modules};
pub use display::Display;
pub use effect::{ModuleEffect, PopupSettings};
pub use messages::*;
pub use module::BarModule;
pub use reusable_elements::pill;
pub use service::Service;
pub use wire::{Endpoint, Inbox, Namespace, Target, Wire};
+13
View File
@@ -0,0 +1,13 @@
//! Wire vocabulary: every message type that crosses a crate boundary.
//!
//! Laid out to mirror the crate that owns the behavior: `modules/*` are
//! messages a bar module sends or receives, `services/*` are messages a
//! service produces or accepts. Types live here so either side can name a
//! payload without depending on the other's crate; only the types are
//! shared, the behavior stays in the owning crate.
pub mod modules;
pub mod services;
pub use modules::*;
pub use services::*;
@@ -0,0 +1,8 @@
//! Battery module messages.
/// A module-local message for the battery tile (bar/popup interactions).
#[derive(Clone)]
pub enum BatteryAction {
/// Open the battery info popup.
OpenPopup,
}
@@ -0,0 +1,10 @@
//! Clock module messages.
/// A module-local message for the clock (bar/popup interactions).
#[derive(Clone)]
pub enum ClockMsg {
/// Toggle the seconds suffix.
ToggleSeconds,
/// Open the big-time popup.
OpenPopup,
}
@@ -0,0 +1,9 @@
//! Messages owned by bar modules.
mod battery;
mod clock;
mod weather;
pub use battery::*;
pub use clock::*;
pub use weather::*;
@@ -0,0 +1,7 @@
//! Weather module messages.
/// A module-local message for the weather tile (bar/popup interactions).
#[derive(Clone)]
pub enum WeatherKind {
Poke,
}
@@ -0,0 +1,44 @@
//! Battery service messages.
use crate::Service;
/// Route key a module subscribes under to receive battery events. One
/// stream carries every event; the [`BatteryEvent`] payload tags which one,
/// so modules filter on it.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum BatteryMsg {
Events,
}
impl BatteryMsg {
/// Route key a module subscribes under to receive this service's events.
pub fn key(self) -> Service {
Service(match self {
Self::Events => "battery.events",
})
}
}
/// Charge state of the first battery, without depending on D-Bus types
/// outside the service itself.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
pub enum BatteryStatus {
#[default]
Unknown,
Charging,
Discharging,
Empty,
Full,
}
/// A battery event, published to subscribers of the events key.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum BatteryEvent {
/// Charge percentage changed (0..=100).
PercentageChanged(i32),
/// Charging state changed.
StatusChanged(BatteryStatus),
/// Seconds until full (charging) or empty (discharging) changed;
/// `None` when unknown or not applicable.
EtaChanged(Option<u64>),
}
@@ -0,0 +1,27 @@
//! Clock service messages.
use crate::Service;
/// What a clock subscription wants: the tick interval and payload selector.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum ClockKind {
Mins,
Seconds,
}
impl ClockKind {
/// Route key a module subscribes under to receive this kind's ticks.
pub fn key(self) -> Service {
Service(match self {
Self::Mins => "clock.mins",
Self::Seconds => "clock.seconds",
})
}
}
/// Clock tick: the kind is the variant, the rendered time the parameter.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum ClockPayload {
Mins(String),
Seconds(String),
}
@@ -0,0 +1,33 @@
//! Hyprland compositor messages.
use crate::Service;
/// Route key a module subscribes under to receive compositor events. One
/// stream carries every event; the [`HyprlandEvent`] payload tags which one,
/// so modules filter on it.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum HyprlandMsg {
Events,
}
impl HyprlandMsg {
/// Route key a module subscribes under to receive this service's events.
pub fn key(self) -> Service {
Service("hyprland.events")
}
}
/// A hyprland compositor event, published to subscribers of the events key.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum HyprlandEvent {
/// Focused workspace changed.
WorkspaceChanged { id: i32, name: String },
/// A workspace was created.
WorkspaceAdded { id: i32, name: String },
/// A workspace was destroyed.
WorkspaceRemoved { id: i32, name: String },
/// Focused window changed; empty class/title mean no window is focused.
ActiveWindow { class: String, title: String },
/// Focused monitor changed.
Monitor { name: String },
}
@@ -0,0 +1,13 @@
//! Messages owned by services.
mod battery;
mod datetime;
mod hyprland;
mod pipewire;
mod weather;
pub use battery::*;
pub use datetime::*;
pub use hyprland::*;
pub use pipewire::*;
pub use weather::*;
@@ -0,0 +1,36 @@
//! PipeWire audio messages.
use crate::Service;
/// Route key a module subscribes under to receive the audio state.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum PipewireMsg {
State,
}
impl PipewireMsg {
/// Route key a module subscribes under to receive this service's state.
pub fn key(self) -> Service {
Service(match self {
Self::State => "pipewire.state",
})
}
}
/// One audio node's level, as PipeWire reports it.
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub struct AudioNode {
/// Linear volume fraction: 1.0 is 100%, above 1.0 is boost.
pub volume: f32,
/// Node is muted; `volume` is still the level it will unmute to.
pub muted: bool,
}
/// Default sink and default source state, published whenever either changes.
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub struct PipewireState {
/// Default playback device.
pub sink: AudioNode,
/// Default capture device.
pub source: AudioNode,
}
@@ -0,0 +1,77 @@
use std::{any::Any, sync::Arc};
use serde::Deserialize;
use crate::Service;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum WeatherMsg {
State,
}
impl WeatherMsg {
pub fn key(self) -> Service {
Service(match self {
Self::State => "weather.state",
})
}
}
#[derive(Clone, Debug)]
pub struct WeatherPayload {
pub kind: WeatherMsg,
pub payload: Arc<dyn Any + Send + Sync>,
}
/// Open-Meteo forecast response.
#[derive(Debug, Deserialize)]
pub struct WeatherResponse {
pub latitude: f64,
pub longitude: f64,
pub generationtime_ms: f64,
pub utc_offset_seconds: i64,
pub timezone: String,
pub timezone_abbreviation: String,
pub elevation: f64,
pub hourly_units: HourlyUnits,
pub hourly: Hourly,
}
#[derive(Debug, Deserialize)]
pub struct HourlyUnits {
pub time: String,
pub temperature_2m: String,
pub rain: String,
pub showers: String,
pub precipitation: String,
pub relative_humidity_2m: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Hourly {
#[serde(deserialize_with = "naive_dt::deserialize")]
pub time: Vec<chrono::NaiveDateTime>,
pub temperature_2m: Vec<f64>,
pub rain: Vec<f64>,
pub showers: Vec<f64>,
pub precipitation: Vec<f64>,
pub relative_humidity_2m: Vec<f64>,
}
/// Open-Meteo emits local timestamps as `"%Y-%m-%dT%H:%M"` (no seconds).
mod naive_dt {
use chrono::NaiveDateTime;
use serde::{de::Error, Deserialize, Deserializer};
const FMT: &str = "%Y-%m-%dT%H:%M";
pub fn deserialize<'de, D>(d: D) -> Result<Vec<NaiveDateTime>, D::Error>
where
D: Deserializer<'de>,
{
Vec::<String>::deserialize(d)?
.iter()
.map(|s| NaiveDateTime::parse_from_str(s, FMT).map_err(D::Error::custom))
.collect()
}
}
+35
View File
@@ -0,0 +1,35 @@
use std::error::Error;
use iced::window;
use iced::{Element, Task};
use crate::{ModuleEffect, Wire};
/// A bar module. Object-safe; modules live in a `BTreeMap` keyed by id. The
/// `Wire` boundary keeps the app ignorant of each module's message enum —
/// the module downcasts whatever arrives.
pub trait BarModule: Send {
fn id(&self) -> &'static str;
/// Bar contents. When `window_id` is `Some`, this is the module's popup
/// surface and the module returns its popup contents instead.
fn view(&self, window_id: Option<window::Id>) -> Element<'_, Wire>;
/// Handles a routed message; may return app-level tasks (e.g. popups).
fn update(&mut self, msg: Wire) -> Task<ModuleEffect>;
/// What services this module wants.
fn services(&self) -> Vec<crate::Service>;
/// Requested popup size when it's not the generic small menu.
fn popup_size(&self) -> Option<(u32, u32)> {
None
}
/// Digest config from `module.{module-id}`. Error is boxed because this
/// runs through `dyn BarModule`; each impl keeps its own concrete error
/// internally and may surface it pre-box via its constructor instead.
fn config(&mut self, _config: toml::Table) -> Result<(), Box<dyn Error>> {
Ok(())
}
}
+15
View File
@@ -0,0 +1,15 @@
use iced::{border, widget::container, Theme};
const REF_RADIUS: f32 = 3.5;
/// The quickshell pill: rounded, filled with the theme background (the
/// darkest colour the palette offers) so it reads as a solid block over the
/// transparent bar.
pub fn pill(theme: &Theme) -> container::Style {
container::Style {
background: Some(theme.palette().background.into()),
text_color: Some(theme.palette().text),
border: border::rounded(REF_RADIUS),
..container::Style::default()
}
}
+5
View File
@@ -0,0 +1,5 @@
/// Opaque route key identifying a service subscription. Services name their
/// own keys, so `common` never enumerates them and adding a service never
/// edits this crate.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct Service(pub &'static str);
+151
View File
@@ -0,0 +1,151 @@
use std::any::Any;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use iced::futures::channel::mpsc;
/// Which namespace an endpoint lives in. Module ids and service route keys
/// are separate namespaces, so a module and a service may share a name.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Namespace {
Module,
Service,
}
/// A wire's sender: a module id or a service route key.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct Endpoint {
pub ns: Namespace,
pub name: &'static str,
}
impl Endpoint {
pub const fn module(name: &'static str) -> Self {
Self {
ns: Namespace::Module,
name,
}
}
pub const fn service(name: &'static str) -> Self {
Self {
ns: Namespace::Service,
name,
}
}
}
/// Where a wire goes. Core owns the routing rule for each variant.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Target {
/// One module, by id.
Module(&'static str),
/// One running service's inbox, by route key.
Service(&'static str),
/// Every module subscribed to a route key (a service's fan-out).
Topic(&'static str),
}
/// One typed message on the wire. Core routes by `to`; the receiver
/// downcasts `payload` to one of *its own* types. Sender and receiver keep
/// their own concrete types — nothing here enumerates them.
#[derive(Clone)]
pub struct Wire {
pub from: Endpoint,
pub to: Target,
pub payload: Arc<dyn Any + Send + Sync>,
}
impl Wire {
/// A module-local message: addressed straight back to one module.
pub fn module<T: Any + Send + Sync>(from: Endpoint, id: &'static str, msg: T) -> Self {
Self {
from,
to: Target::Module(id),
payload: Arc::new(msg),
}
}
/// Sent into a service's inbox.
pub fn service<T: Any + Send + Sync>(from: Endpoint, key: &'static str, msg: T) -> Self {
Self {
from,
to: Target::Service(key),
payload: Arc::new(msg),
}
}
/// Published by a service to every module subscribed to `key`.
pub fn topic<T: Any + Send + Sync>(from: Endpoint, key: &'static str, msg: T) -> Self {
Self {
from,
to: Target::Topic(key),
payload: Arc::new(msg),
}
}
/// Downcasts to the receiver's own message type.
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&T> {
self.payload.downcast_ref()
}
}
impl fmt::Debug for Wire {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Wire")
.field("from", &self.from)
.field("to", &self.to)
.field("payload", &self.payload.type_id())
.finish()
}
}
/// A service's inbound mailbox: modules send `Wire`s here, the service's
/// subscription task reads them. Cloneable; the receiver is taken once.
///
/// The `Arc<Mutex<Option<..>>>` lets `Bar` keep a clone and hand the same
/// value to `Subscription::run_with` on every rebuild without moving the
/// single-consumer receiver out of its reach.
#[derive(Clone)]
pub struct Inbox {
key: &'static str,
tx: mpsc::UnboundedSender<Wire>,
rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<Wire>>>>,
}
impl Inbox {
/// Creates the channel backing a service's inbox.
pub fn new(key: &'static str) -> Self {
let (tx, rx) = mpsc::unbounded();
Self {
key,
tx,
rx: Arc::new(Mutex::new(Some(rx))),
}
}
/// The service's route key, used as the `Topic` it publishes under.
pub fn key(&self) -> &'static str {
self.key
}
/// Sends a wire into the service; `false` if the service is gone.
pub fn send(&self, wire: Wire) -> bool {
self.tx.unbounded_send(wire).is_ok()
}
/// Takes the receiver; the service stream calls this once, when iced
/// first starts it.
pub fn take(&self) -> Option<mpsc::UnboundedReceiver<Wire>> {
self.rx.lock().unwrap().take()
}
}
impl Hash for Inbox {
/// Identity for `Subscription::run_with`: the route key, so returning
/// the same service subscription each pass does not restart its stream.
fn hash<H: Hasher>(&self, state: &mut H) {
self.key.hash(state);
}
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "core"
version.workspace = true
edition.workspace = true
[[bin]]
name = "core"
[dependencies]
common = { path = "../common" }
iced = { workspace = true }
iced_layershell = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
chrono = { workspace = true }
clap = {workspace = true}
toml = {workspace =true}
serde = { workspace = true }
serde_yaml = { workspace = true }
# Modules + services
modules = { path = "../modules" }
services = { path = "../services" }
+69
View File
@@ -0,0 +1,69 @@
use std::collections::{BTreeMap, BTreeSet};
use iced::window;
use common::{BarModule, Inbox, Modules, Service};
use toml::Table;
/// Application state: module registry, routing, popup bookkeeping.
pub(crate) struct Bar {
/// Module whose popup is open; None = closed.
pub(crate) active_popup: Option<String>,
/// Module registry keyed by stable module id.
pub(crate) modules: BTreeMap<&'static str, Box<dyn BarModule>>,
/// Left, Middle, Right ; Module Order
pub(crate) order: (Vec<Modules>, Vec<Modules>, Vec<Modules>),
/// Left/right padding between the bar edge and its content.
pub(crate) padding: f32,
/// Gap between modules in the bar.
pub(crate) spacing: f32,
/// Surface id of the open popup.
pub(crate) popup_id: Option<window::Id>,
/// Fan-out routing table: module id -> services it wants.
pub(crate) routes: BTreeMap<&'static str, Vec<Service>>,
/// One inbound mailbox per running service, keyed by route key. Modules
/// send here (`Target::Service`) to talk to a running service.
pub(crate) inputs: BTreeMap<&'static str, Inbox>,
}
impl Bar {
pub(crate) fn new(
modules_config_table: &Table,
modules_order: (Vec<Modules>, Vec<Modules>, Vec<Modules>),
padding: f32,
spacing: f32,
) -> Self {
// Registry owns construction: adding a module never edits this file.
let modules: BTreeMap<&'static str, Box<dyn BarModule>> =
modules::all(modules_config_table.clone())
.into_iter()
.map(|m| (m.id(), m))
.collect();
let routes: BTreeMap<&'static str, Vec<Service>> = modules
.iter()
.map(|(id, module)| (*id, module.services()))
.collect();
// One inbox per distinct service any module wants.
let wanted: BTreeSet<&'static str> = routes.values().flatten().map(|s| s.0).collect();
tracing::debug!(?routes, "module routes: wanted msg types per module");
let inputs: BTreeMap<&'static str, Inbox> = wanted
.into_iter()
.map(|key| (key, Inbox::new(key)))
.collect();
tracing::debug!(
modules = modules.len(),
services = ?inputs.keys().copied().collect::<Vec<_>>(),
"spawning wanted services"
);
Self {
active_popup: None,
modules,
order: modules_order,
padding,
spacing,
popup_id: None,
routes,
inputs,
}
}
}
+10
View File
@@ -0,0 +1,10 @@
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
pub struct Args {
#[arg(short, long)]
pub config: PathBuf,
// #[arg(long)]
// demo: bool,
}
+87
View File
@@ -0,0 +1,87 @@
//! barbar — a Wayland layer-shell status bar (iced + iced_layershell).
//!
//! Renders a full-width bar pinned to the top of the screen via
//! `wlr-layer-shell`. Optional first CLI arg = target output name.
//! Clicking a module's button spawns a LayerShell popup anchored to it.
use std::fs::File;
use std::io::Read;
use clap::Parser;
use common::BarbarConfig;
use iced_layershell::daemon;
mod app;
mod args;
mod msg;
mod popup;
mod subscription;
mod theme;
mod update;
mod view;
pub(crate) use app::Bar;
pub(crate) use msg::{Message, Msg};
/// Default log filter per build profile. Debug builds log barbar's own crates
/// at `debug` while dependency noise (iced_layershell/sctk/cosmic_text/wgpu…)
/// stays at `warn`; release builds log `warn` and above. `RUST_LOG` overrides.
const DEFAULT_FILTER: &str = if cfg!(debug_assertions) {
"warn,core=debug,common=debug,modules=debug,services=debug"
} else {
"warn"
};
fn main() -> Result<(), iced_layershell::Error> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_FILTER));
tracing_subscriber::fmt().with_env_filter(filter).init();
let args = args::Args::parse();
tracing::debug!(config = %args.config.display(), "parsed args");
let config_handle = File::open(&args.config);
let config: BarbarConfig;
if let Ok(mut config_file) = config_handle {
let mut string = String::new();
config_file.read_to_string(&mut string).unwrap();
config = toml::from_str(&string).unwrap();
tracing::info!(config = %args.config.display(), "config loaded");
} else {
tracing::warn!(config = %args.config.display(), "cannot open config file");
return Ok(());
}
let modules = config.modules.clone().unwrap_or_default();
let order = config.order;
let disp = config.display;
tracing::info!(monitor = ?disp.monitor, "starting barbar daemon");
let (padding, spacing) = (disp.padding, disp.spacing);
let settings = disp.settings();
// Optional base16 theme; `None` lets iced follow the system colour scheme.
let theme = disp.theme.as_deref().map(theme::load);
daemon(
move || {
Bar::new(
&modules,
(
order.left.clone(),
order.middle.clone(),
order.right.clone(),
),
padding,
spacing,
)
},
subscription::namespace,
update::update,
view::view,
)
.style(view::style)
.theme(move |_: &Bar, _: iced::window::Id| theme.clone())
.subscription(subscription::gather_subscriptions)
.settings(settings)
.run()
}
+35
View File
@@ -0,0 +1,35 @@
use iced::window;
use iced_layershell::to_layer_message;
use common::{ModuleEffect, Wire};
/// Synchronous input: wires plus window events.
#[derive(Debug, Clone)]
pub(crate) enum Msg {
/// One wire; core dispatches by its `to` target.
Wire(Wire),
/// The compositor confirmed a window closed. The popup is really gone.
WindowClosed(window::Id),
}
/// iced needs one `Message` type. `to_layer_message` must sit here: it
/// injects the layer-shell effect variants + their `TryInto` impl.
#[to_layer_message(multi)]
#[derive(Debug, Clone)]
pub(crate) enum Message {
Event(Msg),
/// Effects. `common::ModuleEffect` is the one effect vocabulary, so
/// mapping module output is a plain `.map(Message::Effect)`.
Effect(ModuleEffect),
}
/// `to_layer_message` generates `popup_open` as a fn private to this module;
/// re-expose it for the popup surface code, which lives elsewhere.
pub(crate) fn popup_open(
settings: iced_layershell::actions::IcedNewPopupSettings,
) -> (
iced_layershell::reexport::IcedId,
iced_layershell::reexport::Task<Message>,
) {
Message::popup_open(settings)
}
+138
View File
@@ -0,0 +1,138 @@
//! Popup surfaces: bounds capture, open/close, and rendering.
//!
//! A popup is a separate LayerShell surface anchored to its module's button.
//! The app captures the button's laid-out bounds via a widget-tree operation,
//! then opens the popup there; the active module renders its content.
use iced::advanced::widget as advanced_widget;
use iced::widget::{column, container, text};
use iced::{Border, Element, Padding, Rectangle, Task};
use iced_layershell::actions::IcedNewPopupSettings;
use iced_layershell::reexport::{PopupAnchor, PopupGravity};
use common::{ModuleEffect, PopupSettings};
use crate::msg::popup_open;
use crate::{Bar, Message, Msg};
/// Default (small menu) popup size.
const POPUP_W: u32 = 150;
const POPUP_H: u32 = 100;
// Gap (logical px) between the bar's bottom edge and the popup.
// const POPUP_GAP: i32 = 32;
/// Opens a popup for `module_id` below its laid-out `bounds`. Size comes
/// from the module's `popup_size()` (default: small menu).
pub fn open_popup(bar: &mut Bar, bounds: Rectangle, settings: PopupSettings) -> Task<Message> {
bar.active_popup = Some(settings.module_id.clone());
let (w, h) = bar
.modules
.get(settings.module_id.as_str())
.and_then(|m| m.popup_size())
.unwrap_or((POPUP_W, POPUP_H));
// Anchor at the button's bottom edge + POPUP_GAP so the popup grows
// downward from just below the bar.
let (bx, by, bw, bh) = (
bounds.x.round() as i32,
bounds.y.round() as i32,
bounds.width.round() as i32,
bounds.height.round() as i32,
);
let anchor_rect = (bx, by, bw, bh + settings.gap);
let settings_popup = IcedNewPopupSettings::on_current_surface((w, h), anchor_rect)
.anchor(PopupAnchor::Bottom)
.gravity(PopupGravity::Bottom);
let (id, task) = popup_open(settings_popup);
bar.popup_id = Some(id);
tracing::debug!(module = %settings.module_id, ?id, size = ?(w, h), "popup opened");
task
}
/// Requests closing the popup. Does not clear popup state — that happens on
/// `Msg::WindowClosed`, so popup content keeps rendering until then.
pub fn close_popup(bar: &mut Bar) -> Task<Message> {
if let Some(id) = bar.popup_id {
tracing::debug!(?id, "requesting popup close");
return Task::done(Message::Effect(ModuleEffect::ClosePopup(id)));
}
Task::none()
}
/// Runs a widget-tree [`Operation`] that captures the laid-out bounds of the
/// given element, then reports them via `BoundsFound` for the module.
pub fn capture_bounds(settings: PopupSettings) -> Task<Message> {
let target = iced::widget::Id::from(settings.element_id.clone());
struct FindBounds {
target: iced::widget::Id,
found: Option<Rectangle>,
}
impl advanced_widget::Operation<Rectangle> for FindBounds {
fn traverse(
&mut self,
operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation<Rectangle>),
) {
operate(self);
}
fn container(&mut self, id: Option<&iced::widget::Id>, bounds: Rectangle) {
if self.found.is_none() && id == Some(&self.target) {
self.found = Some(bounds);
}
}
fn finish(&self) -> advanced_widget::operation::Outcome<Rectangle> {
match self.found {
Some(bounds) => advanced_widget::operation::Outcome::Some(bounds),
None => advanced_widget::operation::Outcome::None,
}
}
}
advanced_widget::operate(FindBounds {
target,
found: None,
})
.map(move |bounds| -> Message {
Message::Effect(ModuleEffect::BoundsFound(bounds, settings.clone()))
})
}
/// Popup widget tree on its own LayerShell surface; the active module
/// renders its content via `view(Some(id))`.
pub fn view(bar: &Bar) -> Element<'_, Message> {
let module_id = bar.active_popup.as_deref().unwrap();
let popup_id = bar.popup_id.unwrap();
let content = bar
.modules
.get(module_id)
.map(|module| {
module
.view(Some(popup_id))
.map(|w| Message::Event(Msg::Wire(w)))
})
.unwrap_or_else(|| text("unknown module").into());
container(column![content,].spacing(1))
.padding(Padding::from(4))
.style(|theme| container::Style {
text_color: None,
background: Some(
iced::Background::Color(theme.extended_palette().background.base.color)
.scale_alpha(0.4),
),
border: Border {
width: 3.0,
radius: 10.into(),
color: theme.extended_palette().secondary.strong.color,
},
snap: true,
..Default::default()
})
.into()
}
+28
View File
@@ -0,0 +1,28 @@
use iced::{window, Subscription};
use common::Service;
use services::IntoSubscription;
use crate::app::Bar;
use crate::msg::{Message, Msg};
pub(crate) fn namespace() -> String {
String::from("barbar")
}
/// Route subscriptions + window-close events (popup really destroyed).
/// One subscription per service inbox, so adding a service never edits this.
pub(crate) fn gather_subscriptions(bar: &Bar) -> Subscription<Message> {
let route_sub = Subscription::batch(
bar.inputs
.iter()
.map(|(&key, inbox)| {
Service(key)
.into_subscription(inbox.clone())
.map(|wire| Message::Event(Msg::Wire(wire)))
})
.collect::<Vec<_>>(),
);
let close_events = window::close_events().map(|id| Message::Event(Msg::WindowClosed(id)));
Subscription::batch(vec![route_sub, close_events])
}
+60
View File
@@ -0,0 +1,60 @@
//! Base16 (`base00`..`base0F`) scheme loaded from YAML into an iced [`Theme`].
//!
//! The sixteen slots map onto iced's six-colour [`Palette`]: `base00` is the
//! background, `base05` the foreground, and the blue/green/yellow/red accents
//! become primary/success/warning/danger. The remaining slots are unused.
use std::path::Path;
use iced::theme::Palette;
use iced::{Color, Theme};
use serde::Deserialize;
/// The base16 slots the palette needs; other keys (author, base01..) are
/// ignored by serde.
#[derive(Deserialize)]
struct Base16 {
scheme: Option<String>,
base00: String,
base05: String,
base08: String,
#[serde(rename = "base0A")]
base0a: String,
#[serde(rename = "base0B")]
base0b: String,
#[serde(rename = "base0D")]
base0d: String,
}
impl Base16 {
fn into_theme(self) -> Theme {
Theme::custom(
self.scheme.unwrap_or_else(|| "base16".to_string()),
Palette {
background: hex(&self.base00),
text: hex(&self.base05),
primary: hex(&self.base0d),
success: hex(&self.base0b),
warning: hex(&self.base0a),
danger: hex(&self.base08),
},
)
}
}
/// `"RRGGBB"` (optionally `#`-prefixed) to a [`Color`].
fn hex(s: &str) -> Color {
let v = u32::from_str_radix(s.trim_start_matches('#'), 16)
.unwrap_or_else(|_| panic!("base16 colour {s:?} is not RRGGBB hex"));
Color::from_rgb8((v >> 16) as u8, (v >> 8) as u8, v as u8)
}
/// Loads a base16 YAML scheme; panics with the offending path on a read or
/// parse error, matching how the TOML config is loaded.
pub fn load(path: &Path) -> Theme {
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("cannot read theme {}: {e}", path.display()));
let scheme: Base16 = serde_yaml::from_str(&text)
.unwrap_or_else(|e| panic!("cannot parse base16 theme {}: {e}", path.display()));
scheme.into_theme()
}
+102
View File
@@ -0,0 +1,102 @@
use iced::Task;
use common::{ModuleEffect, Service, Target, Wire};
use crate::app::Bar;
use crate::msg::{Message, Msg};
use crate::popup;
pub(crate) fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
match msg {
Message::Event(event) => handle_event(bar, event),
Message::Effect(effect) => handle_effect(bar, effect),
// Layer-shell variants injected by `to_layer_message`.
_ => Task::none(),
}
}
/// Applies an input event to state; effects come back as `Task`s.
fn handle_event(bar: &mut Bar, event: Msg) -> Task<Message> {
match event {
Msg::Wire(wire) => route(bar, wire),
Msg::WindowClosed(id) => {
// Popup gone; forget it. `popup_id` stays set until this event
// so the popup content (not the bar) renders during teardown.
if bar.popup_id == Some(id) {
bar.popup_id = None;
bar.active_popup = None;
tracing::debug!(?id, "popup window closed");
}
Task::none()
}
}
}
/// Delivers a wire by its target — the only place routing lives.
fn route(bar: &mut Bar, wire: Wire) -> Task<Message> {
match wire.to {
Target::Module(id) => match bar.modules.get_mut(id) {
Some(module) => module.update(wire).map(Message::Effect),
None => {
tracing::warn!(module = id, "wire routed to unknown module");
Task::none()
}
},
Target::Service(key) => {
if let Some(inbox) = bar.inputs.get(key) {
if !inbox.send(wire) {
tracing::warn!(service = key, "service inbox closed");
}
} else {
tracing::warn!(service = key, "wire routed to unknown service");
}
Task::none()
}
Target::Topic(key) => {
// Fan out by route key; the payload is opaque here, each module
// downcasts it. New services never touch this file.
let topic = Service(key);
let tasks: Vec<_> = bar
.modules
.iter_mut()
.filter(|(id, _)| {
bar.routes
.get(*id)
.is_some_and(|keys| keys.contains(&topic))
})
.map(|(_, module)| module.update(wire.clone()).map(Message::Effect))
.collect();
tracing::debug!(topic = key, subscribers = tasks.len(), "fan-out");
tasks.into_iter().fold(Task::none(), |acc, t| acc.chain(t))
}
}
}
/// Runs an effect; may set up bar state for it (e.g. which popup is open).
fn handle_effect(bar: &mut Bar, effect: ModuleEffect) -> Task<Message> {
match effect {
ModuleEffect::RequestPopup(settings) => {
// Toggle: close if already open for this module, else open.
if bar.active_popup.as_deref() == Some(settings.module_id.as_str()) {
tracing::debug!(module = settings.module_id, "closing popup");
popup::close_popup(bar)
} else {
tracing::debug!(module = settings.module_id, "opening popup");
popup::capture_bounds(settings)
}
}
ModuleEffect::BoundsFound(bounds, settings) => popup::open_popup(bar, bounds, settings),
ModuleEffect::ClosePopup(id) => {
// Request removal only; keep popup state so the popup content
// renders until `WindowClosed` confirms it's gone.
Task::done(Message::RemoveWindow(id))
}
ModuleEffect::Send(wire) => route(bar, wire),
}
}
+95
View File
@@ -0,0 +1,95 @@
use iced::widget::{container, row, space};
use iced::{window, Alignment, Element, Length, Theme};
use crate::app::Bar;
use crate::msg::{Message, Msg};
use crate::popup;
pub(crate) fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> {
if bar.popup_id == Some(id) {
popup::view(bar) // popup surface
} else {
bar_view(bar) // main bar surface
}
}
/// Each module renders itself; the bar lays them out in a row.
fn bar_view(bar: &Bar) -> Element<'_, Message> {
// for (i, (_, module)) in bar.modules.iter().enumerate() {
// children.push(module.view(None).map(|w| Message::Event(Msg::Wire(w))));
// if i < bar.modules.len() - 1 {
// children.push(text("|").size(14).into());
// }
// }
let left = row(bar
.order
.0
.iter()
.map(|x| {
bar.modules
.get(x.to_string())
.unwrap()
.view(None)
.map(|w| Message::Event(Msg::Wire(w)))
})
.collect::<Vec<Element<Message>>>())
.spacing(bar.spacing)
.width(Length::Shrink)
.align_y(Alignment::Center);
let middle = row(bar
.order
.1
.iter()
.map(|x| {
bar.modules
.get(x.to_string())
.unwrap()
.view(None)
.map(|w| Message::Event(Msg::Wire(w)))
})
.collect::<Vec<Element<Message>>>())
.spacing(bar.spacing)
.width(Length::Shrink)
.align_y(Alignment::Center);
let right = row(bar
.order
.2
.iter()
.map(|x| {
bar.modules
.get(x.to_string())
.unwrap()
.view(None)
.map(|w| Message::Event(Msg::Wire(w)))
})
.collect::<Vec<Element<Message>>>())
.spacing(bar.spacing)
.width(Length::Shrink)
.align_y(Alignment::Center);
let row = row![
left,
space::horizontal(),
middle,
space::horizontal(),
right,
]
.width(Length::Fill)
.align_y(Alignment::Center);
container(row)
.padding([0.0, bar.padding])
.width(Length::Fill)
.height(Length::Fill)
.align_y(Alignment::Center)
.into()
}
pub(crate) fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style {
use iced::theme::Style;
Style {
background_color: iced::Color::TRANSPARENT,
text_color: theme.palette().text,
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "modules"
version.workspace = true
edition.workspace = true
[lib]
name = "modules"
path = "src/lib.rs"
[dependencies]
common = { path = "../common" }
iced = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
toml = { workspace = true }
serde = {workspace = true}
+110
View File
@@ -0,0 +1,110 @@
# modules
Bar modules. Each module renders a piece of the bar (or its popup) and is
object-safe behind `common::BarModule`, so core holds them as
`Box<dyn BarModule>` and never knows their message types — everything crosses
the `common::Wire` boundary.
Message types are shared, so they live in the `common` crate, under
`common/src/messages/modules/`. That is what lets a module and core name the
same payload without either depending on the other.
## Getting started: add a module
1. Declare the module's messages in `crates/common/src/messages/modules/greeter.rs`:
```rust
//! Greeter module messages.
#[derive(Clone)]
pub enum GreeterMsg {
Poke,
}
```
2. Re-export them from `crates/common/src/messages/modules/mod.rs`:
```rust
mod clock;
mod greeter;
pub use clock::ClockMsg;
pub use greeter::GreeterMsg;
```
3. Create `crates/modules/src/greeter.rs`:
```rust
use std::error::Error;
use iced::widget::text;
use iced::{Element, Task};
use common::{BarModule, Endpoint, GreeterMsg, ModuleEffect, Service, Wire};
pub struct Greeter {
text: String,
}
impl Default for Greeter {
fn default() -> Self {
Self { text: "hi".into() }
}
}
impl BarModule for Greeter {
fn id(&self) -> &'static str {
"greeter"
}
fn view(&self, _window_id: Option<iced::window::Id>) -> Element<'_, Wire> {
text(&self.text).into()
}
fn update(&mut self, msg: Wire) -> Task<ModuleEffect> {
match msg.downcast::<GreeterMsg>() {
Some(GreeterMsg::Poke) => {
self.text = "poked".into();
Task::none()
}
None => Task::none(),
}
}
/// Route keys this module subscribes to.
fn services(&self) -> Vec<Service> {
Vec::new(
}
/// Optional: read `module.greeter` from the config table.
fn config(&mut self, _config: toml::Table) -> Result<(), Box<dyn Error>> {
Ok(())
}
}
```
4. Register it in `crates/modules/src/lib.rs`'s `all()` and re-export it:
```rust
mod greeter;
pub use greeter::Greeter;
pub fn all(module_config: toml::Table) -> Vec<Box<dyn BarModule>> {
vec![
Box::new(clock::Clock::new(Some(module_config.clone()))),
Box::new(greeter::Greeter::default()),
]
}
```
That's it — core picks it up by `id()`. Use `wire` targeting:
```rust
// Module-local message back to yourself:
Wire::module(Endpoint::module(self.id()), self.id(), GreeterMsg::Poke)
```
To talk to a service, add it to `services()` and send via
`Wire::service(...)`; service ticks arrive in `update()` and you `downcast`
to the payload type (e.g. `common::ClockPayload`). See `src/clock.rs` for a
full example.
+93
View File
@@ -0,0 +1,93 @@
//! Audio module: the default sink and source as icons, drawn only while
//! something is muted — the signal the quickshell Audio widget gives.
use iced::widget::{container, row, space, text};
use iced::{Alignment, Element, Length, Task};
use common::{pill, AudioNode, BarModule, ModuleEffect, PipewireMsg, PipewireState, Service, Wire};
/// Glyphs exactly as the Quickshell Audio widget draws them: the speaker is a
/// Material `md-volume` icon, the mic a Font Awesome one.
const SINK_ON: &str = "\u{f057e}"; // md-volume_high
const SINK_MUTED: &str = "\u{f0581}"; // md-volume_off
const MIC_ON: &str = "\u{f130}"; // fa-microphone
const MIC_MUTED: &str = "\u{f131}"; // fa-microphone_slash
/// Quickshell's geometry at its 20 px reference bar: a 24 px slot per icon
/// and 4 px between them. Heights and the pill fill the bar through layout,
/// but font size is a fixed px value iced cannot derive from the space it is
/// given — if the bar ever leaves 20 px, scale the two sizes here.
const REF_SLOT_W: f32 = 24.0;
const REF_SPACING: f32 = 4.0;
/// The speaker is re-drawn in the mono face, whose ink is 1168/1496 the size
/// of the proportional face quickshell uses, so it is pre-compensated.
const REF_SINK_SIZE: f32 = 20.0 * 1496.0 / 1168.0;
const REF_MIC_SIZE: f32 = 30.0;
pub struct Audio {
state: PipewireState,
}
impl Audio {
pub fn new() -> Self {
Self {
state: PipewireState::default(),
}
}
}
impl BarModule for Audio {
fn id(&self) -> &'static str {
"audio"
}
fn view(&self, _window_id: Option<iced::window::Id>) -> Element<'_, Wire> {
let (sink, source) = (self.state.sink, self.state.source);
if !sink.muted && !source.muted {
// Nothing is muted, so there is nothing to show.
return space::horizontal().into();
}
let icons = row![
icon(glyph(sink, SINK_ON, SINK_MUTED), REF_SINK_SIZE),
icon(glyph(source, MIC_ON, MIC_MUTED), REF_MIC_SIZE),
]
.height(Length::Fill)
.spacing(REF_SPACING);
container(icons).height(Length::Fill).style(pill).into()
}
fn update(&mut self, msg: Wire) -> Task<ModuleEffect> {
if let Some(state) = msg.downcast::<PipewireState>() {
self.state = *state;
}
Task::none()
}
fn services(&self) -> Vec<Service> {
vec![PipewireMsg::State.key()]
}
}
/// One glyph centred in a fixed-width slot that fills the bar's height.
fn icon<'a>(glyph: &'static str, size: f32) -> Element<'a, Wire> {
container(
text(glyph)
.size(size)
.height(Length::Fill)
.align_y(Alignment::Center),
)
.center_x(Length::Fixed(REF_SLOT_W))
.height(Length::Fill)
.into()
}
/// The muted glyph when the node is muted, its normal glyph otherwise.
fn glyph(node: AudioNode, on: &'static str, muted: &'static str) -> &'static str {
if node.muted {
muted
} else {
on
}
}
+274
View File
@@ -0,0 +1,274 @@
//! Battery module: Android-style battery icon fed by the battery service.
use iced::widget::{column, container, mouse_area, row, stack, text};
use iced::{alignment, Border, Color, Element, Length, Task, Theme};
use common::{
pill, BarModule, BatteryAction, BatteryEvent, BatteryMsg, BatteryStatus, Endpoint,
ModuleEffect, PopupSettings, Service, Wire,
};
use serde::Deserialize;
use toml::Table;
/// Icon shell size (logical px); fill scales inside it.
const SHELL_W: f32 = 28.0;
const SHELL_H: f32 = 14.0;
const BORDER_W: f32 = 1.5;
const PAD: f32 = 2.0;
/// Charge at or below this shows the warning color while discharging.
const LOW_PCT: i32 = 20;
pub struct Battery {
percent: Option<i32>,
status: BatteryStatus,
eta_secs: Option<u64>,
numbers_in_icon: bool,
}
/// Battery info popup size (logical px).
const POPUP_W: u32 = 280;
const POPUP_H: u32 = 160;
/// `module.battery` table; everything optional.
#[derive(Deserialize, Default)]
struct BatteryConfig {
/// Render the percentage as a number centered inside the icon shell.
#[serde(default)]
numbers_in_icon: bool,
}
impl Battery {
pub fn new(config: Option<Table>) -> Self {
Self {
percent: None,
status: BatteryStatus::default(),
eta_secs: None,
numbers_in_icon: config
.and_then(|table| table.get("battery").cloned())
.and_then(|battery| battery.try_into().ok())
.map(|cfg: BatteryConfig| cfg.numbers_in_icon)
.unwrap_or_default(),
}
}
/// Fill width for the level, leaving a sliver visible below 100%.
fn fill_w(&self) -> f32 {
match self.percent {
Some(p) => ((SHELL_W - 2.0 * (BORDER_W + PAD)) * p as f32 / 100.0)
.clamp(0.0, SHELL_W)
.max(if p > 0 { 2.0 } else { 0.0 }),
None => 0.0,
}
}
}
/// Human form of a duration: `1 h 5 min`, `42 min`, or `30 s`.
fn fmt_duration(secs: u64) -> String {
let (hours, mins) = (secs / 3600, secs % 3600 / 60);
if hours > 0 {
format!("{hours} h {mins} min")
} else if mins > 0 {
format!("{mins} min")
} else {
format!("{secs} s")
}
}
/// Icon ink: success green while charging, danger red when low and
/// discharging, bar text otherwise.
fn ink(status: BatteryStatus, percent: Option<i32>, theme: &Theme) -> Color {
let palette = theme.palette();
match status {
BatteryStatus::Charging => palette.success,
BatteryStatus::Discharging if percent.is_some_and(|p| p <= LOW_PCT) => palette.danger,
_ => palette.text,
}
}
/// Transparent shell with a rounded outline tinted by charge state.
fn shell_style(status: BatteryStatus, percent: Option<i32>) -> impl Fn(&Theme) -> container::Style {
move |theme: &Theme| container::Style {
border: Border {
color: ink(status, percent, theme),
width: BORDER_W,
radius: 4.0.into(),
},
..container::Style::default()
}
}
/// Solid fill (and nub cap) tinted by charge state. `dim` renders the
/// Android-style muted fill that sits behind the in-icon number.
fn solid_style(
status: BatteryStatus,
percent: Option<i32>,
dim: bool,
) -> impl Fn(&Theme) -> container::Style {
move |theme: &Theme| {
let palette = theme.palette();
let background = if dim {
Color {
a: 0.45,
..palette.text
}
} else {
ink(status, percent, theme)
};
container::Style {
background: Some(background.into()),
border: Border {
radius: 2.0.into(),
..Default::default()
},
..container::Style::default()
}
}
}
/// Bold face of the bar font, matching Android's bold in-icon digits.
fn bold() -> iced::Font {
iced::Font {
weight: iced::font::Weight::Bold,
..iced::Font::default()
}
}
impl Battery {
/// Bar tile: icon (plus `%` label unless numbers live in the icon),
/// left-click opens the info popup.
fn bar(&self) -> Element<'_, Wire> {
let status = self.status;
let shell = container(
container(text(""))
.width(Length::Fixed(self.fill_w()))
.height(Length::Fill)
.style(solid_style(status, self.percent, self.numbers_in_icon)),
)
.width(Length::Fixed(SHELL_W))
.height(Length::Fixed(SHELL_H))
.padding(PAD)
.style(shell_style(status, self.percent));
// Optional percentage readout centered over the fill, in the frame's
// light color — legible on the dimmed fill, like Android.
let shell: Element<'_, Wire> = match (self.numbers_in_icon, self.percent) {
(true, Some(percent)) => stack![
shell,
container(text(format!("{percent}")).size(10).font(bold()).style(
|theme: &Theme| text::Style {
color: Some(theme.palette().text),
}
),)
.width(Length::Fill)
.height(Length::Fill)
.align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center),
]
.into(),
_ => shell.into(),
};
let icon = row![
shell,
// Nub cap on the right edge.
container(text(""))
.width(Length::Fixed(2.5))
.height(Length::Fixed(6.0))
.style(solid_style(status, self.percent, false)),
]
.spacing(2)
.align_y(iced::Alignment::Center);
let bar: Element<'_, Wire> = if self.numbers_in_icon {
icon.into()
} else {
let label = match self.percent {
Some(percent) => text(format!("{percent}%")),
None => text("--"),
};
row![icon, label]
.spacing(6)
.align_y(iced::Alignment::Center)
.into()
};
// Container carries the module id so the popup can anchor to it.
container(mouse_area(bar).on_press(Wire::module(
Endpoint::module(self.id()),
self.id(),
BatteryAction::OpenPopup,
)))
.style(pill)
.height(Length::Fill)
.align_y(alignment::Vertical::Center)
.id(iced::widget::Id::from(self.id()))
.into()
}
/// Popup surface: charge, status, and time to full/empty.
fn popup(&self) -> Element<'_, Wire> {
let (status, percent) = (self.status, self.percent);
let warn = move |theme: &Theme| text::Style {
color: Some(ink(status, percent, theme)),
};
let charge = match percent {
Some(percent) => text(format!("{percent}%")).size(44).style(warn),
None => text("--").size(44).style(warn),
};
let status = text(match self.status {
BatteryStatus::Charging => "Charging".to_string(),
BatteryStatus::Discharging => "Discharging".to_string(),
BatteryStatus::Empty => "Empty".to_string(),
BatteryStatus::Full => "Fully charged".to_string(),
BatteryStatus::Unknown => "Unknown".to_string(),
});
let mut lines = column![charge, status].spacing(4);
if let Some(secs) = self.eta_secs {
let eta = match self.status {
BatteryStatus::Charging => format!("Full in {}", fmt_duration(secs)),
BatteryStatus::Discharging => format!("{} remaining", fmt_duration(secs)),
_ => fmt_duration(secs),
};
lines = lines.push(text(eta));
}
container(lines).padding(16).width(Length::Fill).into()
}
}
impl BarModule for Battery {
fn id(&self) -> &'static str {
"battery"
}
fn view(&self, window_id: Option<iced::window::Id>) -> Element<'_, Wire> {
match window_id {
Some(_) => self.popup(),
None => self.bar(),
}
}
fn update(&mut self, msg: Wire) -> Task<ModuleEffect> {
if let Some(event) = msg.downcast::<BatteryEvent>() {
match event {
BatteryEvent::PercentageChanged(percent) => self.percent = Some(*percent),
BatteryEvent::StatusChanged(status) => self.status = *status,
BatteryEvent::EtaChanged(eta) => self.eta_secs = *eta,
}
return Task::none();
}
match msg.downcast::<BatteryAction>() {
Some(BatteryAction::OpenPopup) => {
Task::done(ModuleEffect::RequestPopup(PopupSettings {
module_id: self.id().into(),
element_id: self.id().into(),
gap: 8,
}))
}
None => Task::none(),
}
}
fn services(&self) -> Vec<Service> {
vec![BatteryMsg::Events.key()]
}
fn popup_size(&self) -> Option<(u32, u32)> {
Some((POPUP_W, POPUP_H))
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Clock module: subscribes to the clock service, renders the time in the
//! bar. Left-click toggles the seconds suffix — and pokes the running clock
//! service to switch which kind it publishes; right-click opens a popup.
use iced::widget::{container, mouse_area, text};
use iced::Length::Fill;
use iced::{alignment, Element, Padding, Task};
use common::{
pill, BarModule, ClockKind, ClockMsg, ClockPayload, Endpoint, ModuleEffect, PopupSettings,
Service, Wire,
};
use serde::Deserialize;
use toml::Table;
/// Big-text popup size (logical px).
const POPUP_W: u32 = 360;
const POPUP_H: u32 = 160;
/// Errors digesting the `module.clock` config table.
#[derive(Debug, thiserror::Error)]
pub enum ClockError {
/// Config value was present but not a bool.
#[error("module.clock.{0} must be a bool")]
NotBool(&'static str),
}
pub struct Clock {
value: String,
show_seconds: bool,
}
#[derive(Deserialize, Default)]
struct ClockConfig {
#[serde(default)]
show_seconds: bool,
}
impl Clock {
pub fn new(config: Option<Table>) -> Self {
match config_clock(config) {
Some(x) => Self {
value: "--:--:--".to_string(),
show_seconds: x.show_seconds,
},
None => Self::default(),
}
}
/// Display string: HH:MM:SS, or HH:MM when seconds are hidden.
fn shown(&self) -> String {
if self.show_seconds {
self.value.clone()
} else {
self.value
.split(':') // [hh, mm, ss]
.take(2)
.collect::<Vec<_>>()
.join(":")
}
}
}
impl Default for Clock {
fn default() -> Self {
Self {
value: "--:--:--".to_string(),
show_seconds: false,
}
}
}
fn config_clock(table: Option<Table>) -> Option<ClockConfig> {
let clock = table?.get("clock")?.clone();
clock.try_into().ok()
}
impl BarModule for Clock {
fn id(&self) -> &'static str {
"clock"
}
fn view(&self, window_id: Option<iced::window::Id>) -> Element<'_, Wire> {
let me = Endpoint::module(self.id());
match window_id {
// Popup surface: current time in large text.
Some(_) => container(text(&self.value).size(56))
.align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center)
.padding(20)
.width(Fill)
.into(),
// Bar surface: clickable time. Container carries the module id
// so the popup can anchor to these bounds.
None => container(
mouse_area(text(self.shown()).size(16))
.on_press(Wire::module(me, self.id(), ClockMsg::ToggleSeconds))
.on_right_press(Wire::module(me, self.id(), ClockMsg::OpenPopup)),
)
.style(pill)
.padding(Padding {
right: 3.0,
left: 3.0,
..Default::default()
})
.id(iced::widget::Id::from(self.id()))
.into(),
}
}
fn update(&mut self, msg: Wire) -> Task<ModuleEffect> {
// The service publishes a raw `ClockPayload`; UI sends `ClockMsg`.
if let Some(payload) = msg.downcast::<ClockPayload>() {
self.value = match payload {
ClockPayload::Mins(v) | ClockPayload::Seconds(v) => v.clone(),
};
return Task::none();
}
match msg.downcast::<ClockMsg>() {
Some(ClockMsg::ToggleSeconds) => {
self.show_seconds = !self.show_seconds;
tracing::debug!(show_seconds = self.show_seconds, "clock toggle");
let want = if self.show_seconds {
ClockKind::Seconds
} else {
ClockKind::Mins
};
// Poke the running clock service: tell it which kind to publish.
Task::done(ModuleEffect::Send(Wire::service(
Endpoint::module(self.id()),
ClockKind::Seconds.key().0,
want,
)))
}
Some(ClockMsg::OpenPopup) => Task::done(ModuleEffect::RequestPopup(PopupSettings {
// name: "barbar".into(),
module_id: self.id().into(),
element_id: "clock".into(),
gap: 8,
})),
None => Task::none(),
}
}
fn services(&self) -> Vec<Service> {
vec![ClockKind::Seconds.key()]
}
fn popup_size(&self) -> Option<(u32, u32)> {
Some((POPUP_W, POPUP_H))
}
}
+24
View File
@@ -0,0 +1,24 @@
//! Bar module registry: constructs every enabled module. Adding a module is
//! a new file under `src/` plus one entry in `all()` — core never changes.
mod audio;
mod battery;
mod clock;
mod weather;
mod workspaces;
pub use clock::{Clock, ClockError};
use common::BarModule;
use toml::Table;
pub use weather::*;
/// One instance of every enabled module, ready to insert by `id()`.
pub fn all(module_config: Table) -> Vec<Box<dyn BarModule>> {
vec![
Box::new(audio::Audio::new()),
Box::new(battery::Battery::new(Some(module_config.clone()))),
Box::new(clock::Clock::new(Some(module_config.clone()))),
Box::new(weather::WeatherModule::default()),
Box::new(workspaces::Workspaces::new(Some(module_config))),
]
}
+104
View File
@@ -0,0 +1,104 @@
use std::error::Error;
use chrono::Timelike;
use common::{
BarModule, ClockKind, ClockPayload, Endpoint, Hourly, ModuleEffect, Service, WeatherKind,
WeatherMsg, WeatherPayload, WeatherResponse, Wire,
};
use iced::{
widget::{container, mouse_area, text},
Element, Task,
};
pub struct WeatherModule {
text: String,
/// Cached hourly forecast from the last weather update.
hourly: Option<Hourly>,
/// Current hour, mirrored from the datetime service's clock ticks.
hour: Option<u32>,
}
impl WeatherModule {
/// Shows the forecast temperature for the tracked current hour.
fn refresh(&mut self) {
let (Some(hourly), Some(hour)) = (&self.hourly, self.hour) else {
return;
};
if let Some(i) = hourly.time.iter().position(|t| t.hour() == hour) {
if let Some(&temp) = hourly.temperature_2m.get(i) {
let value = format!("{}°", temp as i32);
if self.text != value {
self.text = value;
tracing::info!(hour, temp, "weather: current hour");
}
}
}
}
}
impl Default for WeatherModule {
fn default() -> Self {
Self {
text: "waiting ".into(),
hourly: None,
hour: None,
}
}
}
impl BarModule for WeatherModule {
fn id(&self) -> &'static str {
"weather"
}
fn view(&self, _window_id: Option<iced::window::Id>) -> Element<'_, Wire> {
let me = Endpoint::module(self.id());
container(mouse_area(text(&self.text).size(16)).on_press(Wire::module(
me,
self.id(),
WeatherKind::Poke,
)))
.into()
}
fn update(&mut self, msg: Wire) -> Task<ModuleEffect> {
// Weather state: cache the hourly forecast, then show the current hour.
if let Some(env) = msg.downcast::<WeatherPayload>() {
match env.kind {
WeatherMsg::State => match env.payload.downcast_ref::<WeatherResponse>() {
Some(resp) => {
self.hourly = Some(resp.hourly.clone());
self.refresh();
}
None => tracing::warn!("weather payload was not a WeatherResponse"),
},
}
}
// Clock tick from the datetime service: track the current hour.
if let Some(payload) = msg.downcast::<ClockPayload>() {
let value = match payload {
ClockPayload::Mins(v) | ClockPayload::Seconds(v) => v,
};
self.hour = value.split(':').next().and_then(|h| h.parse().ok());
self.refresh();
}
match msg.downcast::<WeatherKind>() {
Some(WeatherKind::Poke) => {
tracing::debug!("weather poked");
self.text = "poked".into();
}
None => (),
}
Task::none()
}
/// Route keys this module subscribes to.
fn services(&self) -> Vec<Service> {
vec![WeatherMsg::State.key(), ClockKind::Seconds.key()]
}
/// Optional: read `module.weather` from the config table.
fn config(&mut self, _config: toml::Table) -> Result<(), Box<dyn Error>> {
Ok(())
}
}
+144
View File
@@ -0,0 +1,144 @@
//! Workspaces module: renders workspaces 110 as cubes, coloured and scaled by
//! state — focused largest, existing mid, non-existent smallest. State arrives
//! as `HyprlandEvent`s from the hyprland service.
//!
//! Ported from the Quickshell `Workspaces.qml`: constant 10×15 slot per
//! workspace, corner radius 2.5, and the inactive cubes scaled about their
//! centre (0.85 existing / 0.75 absent) so the shrink never shifts the row.
use std::collections::BTreeSet;
use common::{BarModule, HyprlandEvent, HyprlandMsg, ModuleEffect, Service, Wire};
use iced::border;
use iced::theme::palette;
use iced::widget::{container, row, space};
use iced::{window, Element, Length, Task, Theme};
use serde::Deserialize;
use toml::Table;
/// How far each state's cube fades from the theme text colour toward the
/// theme background. Opaque (mixed, not alpha) so cubes stay solid over the
/// transparent bar instead of washing out.
const FADE_ACTIVE: f32 = 0.0;
const FADE_EXISTS: f32 = 0.55;
const FADE_ABSENT: f32 = 0.8;
/// Look of the workspace row, from `[modules.workspaces]`; every field
/// defaults to the value Quickshell's `Workspaces.qml` hardcodes, so an
/// absent table (or absent key) renders identically to before.
#[derive(Deserialize)]
#[serde(default)]
struct WorkspacesConfig {
/// Number of slots to render (workspaces `1..=count`).
count: i32,
/// Horizontal gap between slots in logical px.
gap: f32,
/// Slot width in logical px — every workspace keeps this slot, so the
/// unfocused shrink never moves the row.
slot_w: f32,
/// Slot height in logical px.
slot_h: f32,
/// Corner radius.
radius: f32,
/// Visual scale per state.
scale_active: f32,
scale_exists: f32,
scale_absent: f32,
}
impl Default for WorkspacesConfig {
fn default() -> Self {
Self {
count: 10,
gap: 6.0,
slot_w: 10.0,
slot_h: 15.0,
radius: 2.5,
scale_active: 1.0,
scale_exists: 0.85,
scale_absent: 0.75,
}
}
}
fn config_workspaces(table: Option<Table>) -> Option<WorkspacesConfig> {
let ws = table?.get("workspaces")?.clone();
ws.try_into().ok()
}
pub struct Workspaces {
cfg: WorkspacesConfig,
active: Option<i32>,
existing: BTreeSet<i32>,
}
impl Workspaces {
pub fn new(config: Option<Table>) -> Self {
Self {
cfg: config_workspaces(config).unwrap_or_default(),
active: None,
existing: BTreeSet::new(),
}
}
fn cube(&self, id: i32) -> Element<'_, Wire> {
let (fade, scale) = if self.active == Some(id) {
(FADE_ACTIVE, self.cfg.scale_active)
} else if self.existing.contains(&id) {
(FADE_EXISTS, self.cfg.scale_exists)
} else {
(FADE_ABSENT, self.cfg.scale_absent)
};
let radius = self.cfg.radius;
let bar = container(space())
.width(Length::Fixed(self.cfg.slot_w * scale))
.height(Length::Fixed(self.cfg.slot_h * scale))
.style(move |theme: &Theme| {
let p = theme.palette();
container::Style {
background: Some(palette::mix(p.text, p.background, fade).into()),
border: border::rounded(radius),
..Default::default()
}
});
container(bar)
.center_x(Length::Fixed(self.cfg.slot_w))
.center_y(Length::Fixed(self.cfg.slot_h))
.into()
}
}
impl BarModule for Workspaces {
fn id(&self) -> &'static str {
"workspaces"
}
fn view(&self, _window_id: Option<window::Id>) -> Element<'_, Wire> {
row((1..=self.cfg.count).map(|id| self.cube(id)))
.spacing(self.cfg.gap)
.into()
}
fn update(&mut self, msg: Wire) -> Task<ModuleEffect> {
if let Some(event) = msg.downcast::<HyprlandEvent>() {
match event {
HyprlandEvent::WorkspaceChanged { id, .. } => self.active = Some(*id),
HyprlandEvent::WorkspaceAdded { id, .. } => {
self.existing.insert(*id);
}
HyprlandEvent::WorkspaceRemoved { id, .. } => {
self.existing.remove(id);
if self.active == Some(*id) {
self.active = None;
}
}
HyprlandEvent::ActiveWindow { .. } | HyprlandEvent::Monitor { .. } => {}
}
}
Task::none()
}
fn services(&self) -> Vec<Service> {
vec![HyprlandMsg::Events.key()]
}
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "services"
version.workspace = true
edition.workspace = true
[lib]
name = "services"
path = "src/lib.rs"
[dependencies]
common = { path = "../common" }
zbus = "5"
iced = { workspace = true }
tokio = { workspace = true, features = ["net", "io-util"] }
tracing = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { version = "0.13.5", features = ["json"] }
pipewire-native = "0.1.4"
pipewire-native-spa = "0.1.4"
hyprland = { git = "https://github.com/hyprland-community/hyprland-rs", branch = "master", default-features = false, features = ["listener", "tokio"] }
+113
View File
@@ -0,0 +1,113 @@
# services
Background services. A service owns a route key, runs as an iced
`Subscription`, receives `common::Wire`s on its `Inbox`, and publishes
payloads back to every module subscribed to its key. Core maps a `Service`
route key to a subscription through `IntoSubscription`.
Message types are shared, so they live in the `common` crate, under
`common/src/messages/services/`. That is what lets a service and the modules
that consume it name the same payload without either depending on the other.
## Getting started: add a service
1. Declare the service's route key and payload in
`crates/common/src/messages/services/greeter.rs`:
```rust
//! Greeter service messages.
use crate::Service;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum GreeterKind {
Hello,
}
impl GreeterKind {
/// Route key a module subscribes under to receive this kind's ticks.
pub fn key(self) -> Service {
Service(match self {
Self::Hello => "greeter.hello",
})
}
}
#[derive(Clone)]
pub struct GreeterPayload {
pub value: String,
}
```
2. Re-export them from `crates/common/src/messages/services/mod.rs`:
```rust
mod datetime;
mod greeter;
mod weather;
pub use datetime::{ClockKind, ClockPayload};
pub use greeter::{GreeterKind, GreeterPayload};
pub use weather::*;
```
3. Create `crates/services/src/greeter.rs`:
```rust
use std::time::Duration;
use common::{Endpoint, Inbox, Wire, GreeterPayload};
use iced::{futures::channel::mpsc, Subscription};
/// Route-key namespace owned by this service; keys look like `"greeter.hello"`.
pub const NAMESPACE: &str = "greeter.";
pub struct GreeterService;
impl GreeterService {
pub fn run(inbox: Inbox) -> Subscription<Wire> {
Subscription::run_with(inbox, |inbox| {
let key = inbox.key();
let mut _rx = inbox.take().expect("inbox receiver is taken once");
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
let mut interval = tokio::time::interval(Duration::from_secs(5));
loop {
interval.tick().await;
// Fan-out: publish to every module subscribed to `key`.
let wire = Wire::topic(Endpoint::service(key), key, GreeterPayload {
value: "hello".into(),
});
if sender.send(wire).await.is_err() {
return;
}
}
})
})
}
}
```
4. Add one arm to the `IntoSubscription` match in `crates/services/src/lib.rs`:
```rust
pub mod greeter;
impl IntoSubscription for Service {
fn into_subscription(self, inbox: Inbox) -> Subscription<Wire> {
match self.0 {
key if key.starts_with(datetime::NAMESPACE) => datetime::ClockTicker::run(inbox),
key if key.starts_with(weather::NAMESPACE) => weather::WeatherService::run(inbox),
key if key.starts_with(greeter::NAMESPACE) => greeter::GreeterService::run(inbox),
_ => Subscription::none(),
}
}
}
```
Then a module subscribes by returning `GreeterKind::Hello.key()` from its
`services()` and downcasts `GreeterPayload` in `update()`.
`run_with(inbox, ..)` keys the subscription by the inbox route key, so the
same subscription returned on every rebuild is not restarted. The receiver is
taken once inside the closure — don't call `take()` anywhere else.
+289
View File
@@ -0,0 +1,289 @@
//! Battery service: follows UPower's display device over D-Bus and
//! republishes each aspect whenever UPower pushes a change — no polling.
//!
//! Machines without a battery (or without UPower) simply never publish;
//! the service keeps retrying in case UPower shows up later.
use std::time::Duration;
use common::{BatteryEvent, BatteryStatus, Endpoint, Inbox, Wire};
use iced::futures::{channel::mpsc, SinkExt, StreamExt};
use iced::Subscription;
/// Route-key namespace owned by this service; keys are `"battery.<kind>"`.
pub const NAMESPACE: &str = "battery.";
/// How long to wait before retrying when UPower is unreachable.
const RETRY: Duration = Duration::from_secs(30);
/// EMA weight for raw UPower estimates; lower = smoother but laggier.
// ponytail: fixed alpha, make configurable if laggy/jittery on real hardware.
const ETA_ALPHA: f64 = 0.3;
/// Minimum change before republishing ETA; matches the minute-resolution display.
const ETA_DEADBAND_SECS: u64 = 60;
/// UPower device states (`org.freedesktop.UPower.Device.State`).
const CHARGING: u32 = 1;
const DISCHARGING: u32 = 2;
const EMPTY: u32 = 3;
const FULLY_CHARGED: u32 = 4;
pub struct BatteryService;
impl BatteryService {
pub fn run(inbox: Inbox) -> Subscription<Wire> {
Subscription::run_with(inbox, |inbox| {
let key = inbox.key();
// We dont need to rx any msgs from modules
let mut _rx = inbox.take().expect("inbox receiver is taken once");
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
tracing::info!(key, "battery service started");
let mut last: Option<(i32, BatteryStatus, i64, i64)> = None;
let mut eta_filter = EtaFilter::default();
loop {
match serve(&mut sender, key, &mut last, &mut eta_filter).await {
// Subscriber gone: stop the service.
Ok(false) => return,
Ok(true) => tracing::debug!(key, "upower stream ended; reconnecting"),
Err(err) => tracing::debug!(key, %err, "upower unavailable; retrying"),
}
tokio::time::sleep(RETRY).await;
}
})
})
}
}
/// Follows the display device until UPower goes away. Returns whether the
/// subscriber is still alive (`false` stops the service, `true` reconnects).
async fn serve(
sender: &mut mpsc::Sender<Wire>,
key: &'static str,
last: &mut Option<(i32, BatteryStatus, i64, i64)>,
eta_filter: &mut EtaFilter,
) -> Result<bool, zbus::Error> {
let conn = zbus::Connection::system().await?;
let display = UPowerProxy::new(&conn).await?.get_display_device().await?;
let device = DeviceProxy::builder(&conn).path(display)?.build().await?;
if !emit(sender, key, last, eta_filter, read(&device).await?).await {
return Ok(false);
}
let mut percentage = device.receive_percentage_changed().await;
let mut state = device.receive_state_changed().await;
let mut time_to_empty = device.receive_time_to_empty_changed().await;
let mut time_to_full = device.receive_time_to_full_changed().await;
loop {
// `last` is always `Some` here: the initial read published first.
let (percent, status, to_full, to_empty) = last.unwrap_or_default();
let current = tokio::select! {
Some(change) = percentage.next() => (change.get().await?.round() as i32, status, to_full, to_empty),
Some(change) = state.next() => (percent, map_state(change.get().await?), to_full, to_empty),
Some(change) = time_to_empty.next() => (percent, status, to_full, change.get().await?),
Some(change) = time_to_full.next() => (percent, status, change.get().await?, to_empty),
else => return Ok(true),
};
if !emit(sender, key, last, eta_filter, current).await {
return Ok(false);
}
}
}
/// Sends the aspects that differ from `last`; `false` when the subscriber
/// is gone and the service should stop.
async fn emit(
sender: &mut mpsc::Sender<Wire>,
key: &'static str,
last: &mut Option<(i32, BatteryStatus, i64, i64)>,
eta_filter: &mut EtaFilter,
(percent, status, to_full, to_empty): (i32, BatteryStatus, i64, i64),
) -> bool {
let mut events = Vec::new();
if last.map(|(p, _, _, _)| p) != Some(percent) {
tracing::debug!(key, percent, "publishing battery percentage");
events.push(BatteryEvent::PercentageChanged(percent));
}
if last.map(|(_, s, _, _)| s) != Some(status) {
tracing::debug!(key, ?status, "publishing battery status");
events.push(BatteryEvent::StatusChanged(status));
}
if let Some(smoothed) = eta_filter.update(status, to_full, to_empty) {
tracing::debug!(key, ?smoothed, "publishing battery eta");
events.push(BatteryEvent::EtaChanged(smoothed));
}
*last = Some((percent, status, to_full, to_empty));
for event in events {
let wire = Wire::topic(Endpoint::service(key), key, event);
if sender.send(wire).await.is_err() {
tracing::warn!(key, "battery send failed; stopping service");
return false;
}
}
true
}
/// Current percentage, status, and raw UPower time estimates of the display device.
async fn read(device: &DeviceProxy<'_>) -> Result<(i32, BatteryStatus, i64, i64), zbus::Error> {
Ok((
device.percentage().await?.round() as i32,
map_state(device.state().await?),
device.time_to_full().await?,
device.time_to_empty().await?,
))
}
/// Low-pass + deadband filter for UPower's jittery time estimates.
/// `update` returns `Some` only when the module should be notified.
#[derive(Default)]
struct EtaFilter {
smooth: Option<f64>,
status: Option<BatteryStatus>,
published: Option<Option<u64>>,
}
impl EtaFilter {
fn update(
&mut self,
status: BatteryStatus,
to_full: i64,
to_empty: i64,
) -> Option<Option<u64>> {
let raw = eta(status, to_full, to_empty);
let Some(raw) = raw else {
self.smooth = None;
self.status = Some(status);
// First run with no estimate: nothing to report (matches unfiltered behavior).
let changed = self.published.is_some_and(|p| p.is_some());
self.published = Some(None);
return changed.then_some(None);
};
// Estimate source switches with charge state, so past smoothing is stale.
let reset = self.status != Some(status) || self.smooth.is_none();
self.status = Some(status);
let smooth = if reset {
raw as f64
} else {
ETA_ALPHA * raw as f64 + (1.0 - ETA_ALPHA) * self.smooth.unwrap_or(raw as f64)
};
self.smooth = Some(smooth);
let candidate = smooth.round() as u64;
match self.published {
// First sample: publish immediately.
None => {
self.published = Some(Some(candidate));
Some(Some(candidate))
}
Some(published) => {
let drift = published.map_or(u64::MAX, |p| p.abs_diff(candidate));
if reset || drift >= ETA_DEADBAND_SECS {
self.published = Some(Some(candidate));
Some(Some(candidate))
} else {
None
}
}
}
}
}
/// Seconds until full while charging, or until empty while discharging.
/// UPower reports 0 when there is no estimate, and neither state applies
/// once full, empty, or unknown — all of those surface as `None`.
fn eta(status: BatteryStatus, to_full: i64, to_empty: i64) -> Option<u64> {
let secs = match status {
BatteryStatus::Charging => to_full,
BatteryStatus::Discharging => to_empty,
_ => return None,
};
(secs > 0).then_some(secs as u64)
}
/// UPower reports pending/unknown states our protocol has no variant for;
/// those surface as [`BatteryStatus::Unknown`].
fn map_state(state: u32) -> BatteryStatus {
match state {
CHARGING => BatteryStatus::Charging,
DISCHARGING => BatteryStatus::Discharging,
EMPTY => BatteryStatus::Empty,
FULLY_CHARGED => BatteryStatus::Full,
_ => BatteryStatus::Unknown,
}
}
#[zbus::proxy(
interface = "org.freedesktop.UPower",
default_service = "org.freedesktop.UPower",
default_path = "/org/freedesktop/UPower"
)]
trait UPower {
/// Path of the composite display device aggregating all batteries.
fn get_display_device(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>;
}
#[zbus::proxy(
interface = "org.freedesktop.UPower.Device",
default_service = "org.freedesktop.UPower"
)]
trait Device {
/// Charge percentage (0..=100).
#[zbus(property)]
fn percentage(&self) -> zbus::Result<f64>;
/// Device state (1 = charging, 2 = discharging, 3 = empty, 4 = full).
#[zbus(property)]
fn state(&self) -> zbus::Result<u32>;
/// Seconds until empty (0 when unknown).
#[zbus(property)]
fn time_to_empty(&self) -> zbus::Result<i64>;
/// Seconds until full (0 when unknown).
#[zbus(property)]
fn time_to_full(&self) -> zbus::Result<i64>;
}
#[cfg(test)]
mod tests {
use super::*;
const DIS: BatteryStatus = BatteryStatus::Discharging;
#[test]
fn jitter_within_deadband_is_suppressed() {
let mut f = EtaFilter::default();
assert_eq!(f.update(DIS, 0, 3600), Some(Some(3600)));
assert_eq!(f.update(DIS, 0, 3610), None);
assert_eq!(f.update(DIS, 0, 3590), None);
}
#[test]
fn large_step_publishes_smoothed_value() {
let mut f = EtaFilter::default();
assert_eq!(f.update(DIS, 0, 3600), Some(Some(3600)));
// EMA: 0.3*1800 + 0.7*3600 = 3060, drift 540 >= 60.
assert_eq!(f.update(DIS, 0, 1800), Some(Some(3060)));
}
#[test]
fn status_flip_resets_and_publishes() {
let mut f = EtaFilter::default();
assert_eq!(f.update(DIS, 0, 3600), Some(Some(3600)));
assert_eq!(f.update(BatteryStatus::Charging, 1200, 0), Some(Some(1200)));
}
#[test]
fn loss_and_return_of_estimate_publishes() {
let mut f = EtaFilter::default();
assert_eq!(f.update(DIS, 0, 3600), Some(Some(3600)));
assert_eq!(f.update(DIS, 0, 0), Some(None));
assert_eq!(f.update(DIS, 0, 0), None);
assert_eq!(f.update(DIS, 0, 3500), Some(Some(3500)));
}
#[test]
fn first_run_without_estimate_stays_silent() {
let mut f = EtaFilter::default();
assert_eq!(f.update(BatteryStatus::Full, 0, 0), None);
}
}
+205
View File
@@ -0,0 +1,205 @@
//! Hyprland compositor service: listens on the compositor's event socket and
//! republishes focus changes (`HyprlandEvent`) to modules subscribed to
//! `hyprland.events`.
//!
//! Seeds the workspace list and current focus on startup so subscribers have
//! state before the first event — the event socket only reports changes.
//!
//! The startup snapshot is queried over Hyprland's command socket directly:
//! the `hyprland` crate's `data` types cannot deserialize 0.56 responses
//! (`Workspace.id` was dropped), though its event stream still works.
use std::path::PathBuf;
use common::{Endpoint, HyprlandEvent, Inbox, Wire};
use hyprland::event_listener::{Event, EventStream};
use iced::futures::{channel::mpsc, SinkExt, StreamExt};
use iced::Subscription;
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
/// Route-key namespace owned by this service; the key is `"hyprland.events"`.
pub const NAMESPACE: &str = "hyprland.";
/// A workspace as far as the bar cares — only the name, which for numbered
/// workspaces is also the id.
#[derive(Deserialize)]
struct WorkspaceState {
name: String,
}
#[derive(Deserialize)]
struct ClientState {
class: String,
title: String,
}
#[derive(Deserialize)]
struct MonitorState {
name: String,
focused: bool,
}
pub struct HyprlandService;
impl HyprlandService {
pub fn run(inbox: Inbox) -> Subscription<Wire> {
Subscription::run_with(inbox, |inbox| {
let key = inbox.key();
let mut _rx = inbox.take().expect("inbox receiver is taken once");
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
tracing::info!(key, "hyprland service started");
if !seed(&mut sender, key).await {
return;
}
let mut stream = EventStream::new();
while let Some(event) = stream.next().await {
let event = match event {
Ok(event) => event,
Err(e) => {
tracing::warn!(error = %e, "hyprland event stream error");
continue;
}
};
tracing::debug!(?event, "hyprland raw event");
for event in translate(event) {
if !publish(&mut sender, key, event).await {
return;
}
}
}
tracing::warn!(key, "hyprland event stream ended");
})
})
}
}
/// Publishes the current compositor state before live events start; `false`
/// if the subscriber channel is gone.
async fn seed(sender: &mut mpsc::Sender<Wire>, key: &'static str) -> bool {
if let Some(list) = query::<Vec<WorkspaceState>>("workspaces").await {
for ws in list {
let event = HyprlandEvent::WorkspaceAdded {
id: workspace_id(&ws.name, -1),
name: ws.name,
};
if !publish(sender, key, event).await {
return false;
}
}
}
if let Some(ws) = query::<WorkspaceState>("activeworkspace").await {
let event = HyprlandEvent::WorkspaceChanged {
id: workspace_id(&ws.name, -1),
name: ws.name,
};
if !publish(sender, key, event).await {
return false;
}
}
if let Some(m) = query::<Vec<MonitorState>>("monitors")
.await
.and_then(|list| list.into_iter().find(|m| m.focused))
{
if !publish(sender, key, HyprlandEvent::Monitor { name: m.name }).await {
return false;
}
}
if let Some(c) = query::<ClientState>("activewindow").await {
let event = HyprlandEvent::ActiveWindow {
class: c.class,
title: c.title,
};
if !publish(sender, key, event).await {
return false;
}
}
true
}
/// Runs `j/<cmd>` against Hyprland's command socket and parses the reply.
/// `None` if Hyprland isn't running, the query fails, or the shape is unknown.
async fn query<T: for<'de> Deserialize<'de>>(cmd: &str) -> Option<T> {
let runtime = std::env::var_os("XDG_RUNTIME_DIR")?;
let signature = std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE")?;
let path = PathBuf::from(runtime)
.join("hypr")
.join(signature)
.join(".socket.sock");
let mut stream = UnixStream::connect(path).await.ok()?;
stream.write_all(format!("j/{cmd}").as_bytes()).await.ok()?;
let mut reply = String::new();
stream.read_to_string(&mut reply).await.ok()?;
serde_json::from_str(&reply).ok()
}
/// Workspace id for the bar: the numeric name when it is one (numbered
/// workspaces), else `fallback` (the event's own id, `-1` when unknown).
fn workspace_id(name: &str, fallback: i32) -> i32 {
name.parse().unwrap_or(fallback)
}
/// Publishes one event to subscribers; `false` if the channel is gone.
async fn publish(sender: &mut mpsc::Sender<Wire>, key: &'static str, event: HyprlandEvent) -> bool {
tracing::debug!(?event, "hyprland event");
let wire = Wire::topic(Endpoint::service(key), key, event);
if sender.send(wire).await.is_err() {
tracing::warn!(key, "hyprland send failed; stopping service");
return false;
}
true
}
/// Maps one crate event onto the protocol payload(s); empty for events the
/// bar does not publish. `focusedmon` yields two: the monitor change *and* the
/// workspace that monitor just focused.
fn translate(event: Event) -> Vec<HyprlandEvent> {
match event {
Event::WorkspaceChanged(w) => {
let name = w.name.to_string();
vec![HyprlandEvent::WorkspaceChanged {
id: workspace_id(&name, w.id),
name,
}]
}
Event::WorkspaceAdded(w) => {
let name = w.name.to_string();
vec![HyprlandEvent::WorkspaceAdded {
id: workspace_id(&name, w.id),
name,
}]
}
Event::WorkspaceDeleted(w) => {
let name = w.name.to_string();
vec![HyprlandEvent::WorkspaceRemoved {
id: workspace_id(&name, w.id),
name,
}]
}
Event::ActiveWindowChanged(w) => vec![HyprlandEvent::ActiveWindow {
class: w.as_ref().map(|w| w.class.clone()).unwrap_or_default(),
title: w.map(|w| w.title).unwrap_or_default(),
}],
Event::ActiveMonitorChanged(m) => {
let mut events = vec![HyprlandEvent::Monitor {
name: m.monitor_name,
}];
// Focusing a monitor also focuses its workspace; switching to a
// workspace on another monitor fires only this event, so without
// it the bar's active workspace never updates.
if let Some(ws) = m.workspace_name {
let name = ws.to_string();
events.push(HyprlandEvent::WorkspaceChanged {
id: workspace_id(&name, -1),
name,
});
}
events
}
_ => Vec::new(),
}
}
+1
View File
@@ -0,0 +1 @@
pub mod hyprland;
+72
View File
@@ -0,0 +1,72 @@
//! Clock service: publishes a `Wire` on every change of the displayed
//! value, and accepts `ClockKind` wires from modules that want it to switch
//! which kind it publishes.
//!
//! The ticker knows nothing about the app's `Message` type; it produces
//! protocol wires from `common` that core routes to subscribers.
use chrono::{DateTime, Local, Timelike};
use iced::futures::{channel::mpsc, SinkExt};
use iced::Subscription;
use common::{ClockPayload, Endpoint, Inbox, Wire};
/// Route-key namespace owned by this service; keys are `"clock.<kind>"`.
pub const NAMESPACE: &str = "clock.";
/// Drives the clock, publishing only the kind modules last asked for.
#[derive(Default)]
pub struct ClockTicker {
last: DateTime<Local>,
// want: ClockKind,
}
impl ClockTicker {
pub fn new() -> Self {
Self::default()
}
/// Subscription that reads inbound wires from `inbox` and publishes
/// changes to every module subscribed to this service's route key.
pub fn run(inbox: Inbox) -> Subscription<Wire> {
Subscription::run_with(inbox, |inbox| {
let key = inbox.key();
// We dont need to rx any msgs from modules
let mut _rx = inbox.take().expect("inbox receiver is taken once");
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
let mut clock = ClockTicker::new();
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
tracing::info!(key, "clock service started");
loop {
tokio::select! {
_ = interval.tick() => {
if let Some(payload) = clock.tick() {
tracing::debug!(key, ?payload, "publishing clock");
let wire = Wire::topic(Endpoint::service(key), key, payload);
if sender.send(wire).await.is_err() {
tracing::warn!(key, "clock send failed; stopping service");
return;
}
}
}
}
}
})
})
}
/// Latest value for the wanted kind, or `None` if unchanged.
pub fn tick(&mut self) -> Option<ClockPayload> {
let value = chrono::Local::now();
let meow = self.last;
if meow.minute() != value.minute() {
return Some(ClockPayload::Mins(value.format("%H:%M:%S").to_string()));
}
if meow.second() != value.second() {
return Some(ClockPayload::Seconds(value.format("%H:%M:%S").to_string()));
}
None
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Service registry: turns a route key into the subscription that backs it.
//! Adding a service is a new file under `src/` plus one arm in the impl below
//! — core never changes.
use iced::Subscription;
use common::{Inbox, Service, Wire};
pub mod battery;
pub mod compositors;
pub mod datetime;
pub mod pipewire;
pub mod weather;
/// Conversion from a route key + its inbox to the subscription backing it.
///
/// An extension trait (not `impl From<Service> for Subscription<...>`) because
/// both `Service` and `Subscription` are foreign to this crate — the orphan
/// rule blocks a foreign trait (`From`) on a foreign type (`Subscription`).
pub trait IntoSubscription {
fn into_subscription(self, inbox: Inbox) -> Subscription<Wire>;
}
impl IntoSubscription for Service {
fn into_subscription(self, inbox: Inbox) -> Subscription<Wire> {
let key = self.0;
match key {
k if k.starts_with(battery::NAMESPACE) => battery::BatteryService::run(inbox),
k if k.starts_with(datetime::NAMESPACE) => datetime::ClockTicker::run(inbox),
k if k.starts_with(compositors::hyprland::NAMESPACE) => {
compositors::hyprland::HyprlandService::run(inbox)
}
k if k.starts_with(weather::NAMESPACE) => weather::WeatherService::run(inbox),
k if k.starts_with(pipewire::NAMESPACE) => pipewire::PipewireService::run(inbox),
_ => {
tracing::warn!(service = key, "no service impl for route key");
Subscription::none()
}
}
}
}
+321
View File
@@ -0,0 +1,321 @@
//! PipeWire service: follows the default sink and source — WirePlumber names
//! them in its `default` metadata — and republishes their volume/mute
//! whenever PipeWire reports a change.
//!
//! PipeWire drives its own main loop, so the connection lives on a dedicated
//! thread; that thread hands state to the async side over a channel.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use common::{AudioNode, Endpoint, Inbox, PipewireState, Wire};
use iced::futures::{channel::mpsc, SinkExt};
use iced::Subscription;
use pipewire_native as pw;
use pipewire_native::context::Context;
use pipewire_native::core::Core;
use pipewire_native::main_loop::MainLoop;
use pipewire_native::properties::Properties;
use pipewire_native::proxy::metadata::{Metadata, MetadataEvents};
use pipewire_native::proxy::node::{Node, NodeEvents};
use pipewire_native::proxy::registry::{Registry, RegistryEvents};
use pipewire_native::proxy::{HasProxy, ProxyEvents};
use pipewire_native::some_closure;
use pipewire_native::types;
use pipewire_native_spa as spa;
use tokio::sync::mpsc as tokio_mpsc;
/// Route-key namespace owned by this service; keys are `"pipewire.<kind>"`.
pub const NAMESPACE: &str = "pipewire.";
/// Node classes the service follows.
const SINK: &str = "Audio/Sink";
const SOURCE: &str = "Audio/Source";
/// WirePlumber's metadata object holding the default nodes.
const DEFAULTS: &str = "default";
const DEFAULT_SINK: &str = "default.audio.sink";
const DEFAULT_SOURCE: &str = "default.audio.source";
pub struct PipewireService;
impl PipewireService {
pub fn run(inbox: Inbox) -> Subscription<Wire> {
Subscription::run_with(inbox, |inbox| {
let key = inbox.key();
// We dont need to rx any msgs from modules
let mut _rx = inbox.take().expect("inbox receiver is taken once");
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
// The PipeWire thread owns the connection and drops it once
// this channel goes away, which stops its loop.
let (tx, mut rx) = tokio_mpsc::unbounded_channel();
std::thread::spawn(move || connect(tx));
while let Some(state) = rx.recv().await {
tracing::debug!(?state, "pipewire state changed");
let wire = Wire::topic(Endpoint::service(key), key, state);
if sender.send(wire).await.is_err() {
tracing::warn!(key, "pipewire send failed; stopping service");
return;
}
}
})
})
}
}
/// Connects, follows the default nodes, and runs PipeWire's loop until the
/// subscriber (or PipeWire itself) goes away.
fn connect(tx: tokio_mpsc::UnboundedSender<PipewireState>) {
pw::init();
let conn = match Connection::open() {
Ok(conn) => conn,
Err(err) => {
tracing::warn!(%err, "pipewire unavailable; audio state stays unknown");
return;
}
};
let track = Arc::new(Mutex::new(Track::new(conn, tx)));
watch(&track);
let main_loop = track.lock().unwrap().conn.main_loop.clone();
tracing::info!("pipewire connected");
main_loop.run();
tracing::info!("pipewire disconnected");
}
/// The connection chain: PipeWire tears the session down if any link of it
/// goes away, so all of it lives for as long as the loop runs.
struct Connection {
main_loop: MainLoop,
_context: Context,
_core: Core,
registry: Registry,
}
impl Connection {
fn open() -> std::io::Result<Self> {
let main_loop = MainLoop::new(&Properties::new())
.ok_or_else(|| std::io::Error::other("pipewire main loop unavailable"))?;
let context = Context::new(&main_loop, Properties::new())?;
let core = context.connect(None)?;
let registry = core.registry()?;
Ok(Self {
main_loop,
_context: context,
_core: core,
registry,
})
}
}
/// Subscribes to every sink/source node and to the `default` metadata, so
/// PipeWire pushes volume/mute changes instead of us polling for them.
fn watch(track: &Arc<Mutex<Track>>) {
let registry = track.lock().unwrap().conn.registry.clone();
registry.add_listener(RegistryEvents {
global: some_closure!([registry ^(track)] id, _perms, type_, version, props, {
match type_ {
types::interface::NODE => {
let class = props.get("media.class").unwrap_or_default();
if class != SINK && class != SOURCE {
return;
}
if let Some(name) = props.get("node.name") {
bind_node(track, &registry, id, type_, version, name.to_string());
}
}
types::interface::METADATA if props.get("metadata.name") == Some(DEFAULTS) => {
bind_defaults(track, &registry, id, type_, version);
}
_ => {}
}
}),
global_remove: some_closure!([^(track)] id, {
let mut track = track.lock().unwrap();
if let Some(name) = track.ids.remove(&id) {
track.nodes.remove(&name);
track.publish();
}
}),
});
}
/// Binds a sink/source node and follows its `Props` param (volume + mute).
fn bind_node(
track: &Arc<Mutex<Track>>,
registry: &Registry,
id: pw::Id,
type_: &str,
version: u32,
name: String,
) {
let Ok(object) = registry.bind(id, type_, version) else {
return;
};
let Some(proxy) = object.downcast_proxy::<Node>() else {
return;
};
let Some(node) = proxy.object() else {
return;
};
node.add_listener(NodeEvents {
param: some_closure!([^(track, name)] _seq, id, _index, _next, pod, {
if id != spa::param::ParamType::Props {
return;
}
let mut track = track.lock().unwrap();
merge(track.nodes.entry(name.clone()).or_default(), pod);
track.publish();
}),
..Default::default()
});
// A local proxy is only usable once the server has bound it, and asking
// for params is a method call.
proxy.add_listener(ProxyEvents {
bound_props: some_closure!([^(node)] _id, _props, {
let _ = node.subscribe_params(&[spa::param::ParamType::Props]);
let _ = node.enum_params(0, Some(spa::param::ParamType::Props), 0, u32::MAX, None);
}),
..Default::default()
});
tracing::debug!(name, "following pipewire node");
let mut track = track.lock().unwrap();
track.ids.insert(id, name);
track.keep.push(object);
}
/// Binds WirePlumber's `default` metadata and follows which nodes are the
/// current sink and source.
fn bind_defaults(
track: &Arc<Mutex<Track>>,
registry: &Registry,
id: pw::Id,
type_: &str,
version: u32,
) {
let Ok(object) = registry.bind(id, type_, version) else {
return;
};
let Some(proxy) = object.downcast_proxy::<Metadata>() else {
return;
};
let Some(metadata) = proxy.object() else {
return;
};
metadata.add_listener(MetadataEvents {
property: some_closure!([^(track)] _subject, key, _type, value, {
let (Some(key), Some(value)) = (key, value) else {
return;
};
let Some(name) = default_node(value) else {
return;
};
let mut track = track.lock().unwrap();
match key {
DEFAULT_SINK => track.sink = Some(name),
DEFAULT_SOURCE => track.source = Some(name),
_ => return,
}
track.publish();
}),
});
tracing::debug!("following pipewire default metadata");
track.lock().unwrap().keep.push(object);
}
/// Merges the volume/mute carried by a `SPA_PARAM_Props` pod into `state`;
/// anything the pod leaves out keeps its previous value.
fn merge(state: &mut AudioNode, pod: &spa::pod::RawPodOwned) {
let mut parser = spa::pod::parser::Parser::new(pod.data());
let _ = parser.pop_object::<spa::param::props::Prop, spa::param::ParamType, _>(|props, _id| {
for (key, _flags, value) in props {
match key {
spa::param::props::Prop::Volume => {
if let Ok(volume) = value.decode::<f32>() {
state.volume = volume;
}
}
spa::param::props::Prop::Mute => {
if let Ok(muted) = value.decode::<bool>() {
state.muted = muted;
}
}
_ => {}
}
}
Ok(())
});
}
/// WirePlumber stores a default as `{"name":"<node.name>"}`.
fn default_node(value: &str) -> Option<String> {
#[derive(serde::Deserialize)]
struct Default {
name: String,
}
serde_json::from_str::<Default>(value).ok().map(|d| d.name)
}
/// What the PipeWire callbacks share: the connection, the audio state they
/// observe, and the channel home.
struct Track {
conn: Connection,
/// Volume/mute of every sink and source, keyed by `node.name`.
nodes: HashMap<String, AudioNode>,
/// Registry id -> `node.name`, so a removed node can be forgotten.
ids: HashMap<pw::Id, String>,
/// `node.name` of the default sink/source, per the metadata.
sink: Option<String>,
source: Option<String>,
/// Bound proxies, kept alive so their listeners stay registered.
keep: Vec<Box<dyn HasProxy>>,
tx: tokio_mpsc::UnboundedSender<PipewireState>,
/// Last state published, so redundant updates are dropped.
sent: Option<PipewireState>,
}
impl Track {
fn new(conn: Connection, tx: tokio_mpsc::UnboundedSender<PipewireState>) -> Self {
Self {
conn,
nodes: HashMap::new(),
ids: HashMap::new(),
sink: None,
source: None,
keep: Vec::new(),
tx,
sent: None,
}
}
/// Publishes the default sink/source, if the state actually changed.
fn publish(&mut self) {
let state = PipewireState {
sink: self.node(&self.sink),
source: self.node(&self.source),
};
if self.sent == Some(state) {
return;
}
self.sent = Some(state);
if self.tx.send(state).is_err() {
tracing::info!("pipewire subscriber gone; disconnecting");
self.conn.main_loop.quit();
}
}
/// State of the named node; zeroes while the default is unknown.
fn node(&self, name: &Option<String>) -> AudioNode {
name.as_ref()
.and_then(|name| self.nodes.get(name))
.copied()
.unwrap_or_default()
}
}
+74
View File
@@ -0,0 +1,74 @@
use std::{sync::Arc, time::Duration};
use common::{Endpoint, Inbox, WeatherMsg, WeatherPayload, WeatherResponse, Wire};
use iced::{
futures::{channel::mpsc, SinkExt},
Subscription,
};
use tokio::time::interval;
pub const NAMESPACE: &str = "weather.";
const FORECAST_URL: &str = "https://api.open-meteo.com/v1/forecast\
?latitude=51.3714&longitude=-0.2302\
&hourly=temperature_2m,rain,showers,precipitation,relative_humidity_2m";
pub struct WeatherService {
client: reqwest::Client,
}
impl WeatherService {
fn new() -> Self {
Self {
client: reqwest::Client::default(),
}
}
pub fn run(inbox: Inbox) -> Subscription<Wire> {
Subscription::run_with(inbox, |inbox| {
let key = inbox.key();
let mut _rx = inbox.take().expect("cant take");
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
let weather = WeatherService::new();
let mut interval = interval(Duration::from_mins(30));
tracing::info!(key = key, "weather service started");
loop {
tokio::select! {
_ = interval.tick() => {
if let Some(resp) = weather.get_weather().await {
let env = WeatherPayload {
kind: WeatherMsg::State,
payload: Arc::new(resp),
};
let wire = Wire::topic(Endpoint::service(key), key, env);
if sender.send(wire).await.is_err() {
tracing::warn!(key, "weather send failed; stopping service");
return;
}
}
}
}
}
})
})
}
async fn get_weather(&self) -> Option<WeatherResponse> {
tracing::debug!(url = FORECAST_URL, "fetching weather");
match self.client.get(FORECAST_URL).send().await {
Ok(resp) => match resp.json::<WeatherResponse>().await {
Ok(parsed) => {
tracing::debug!("weather fetched");
Some(parsed)
}
Err(e) => {
tracing::warn!(error = %e, "weather response decode failed");
None
}
},
Err(e) => {
tracing::warn!(error = %e, "weather request failed");
None
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
scheme: "Gruvbox dark, medium"
author: "https://github.com/morhetz/gruvbox"
base00: "282828" # bg
base01: "3c3836" # bg1
base02: "504945" # bg2
base03: "665c54" # bg3
base04: "bdae93" # fg4
base05: "d5c4a1" # fg
base06: "ebdbb2" # fg2
base07: "fbf1c7" # fg0
base08: "fb4934" # red
base09: "fe8019" # orange
base0A: "fabd2f" # yellow
base0B: "b8bb26" # green
base0C: "8ec07c" # aqua
base0D: "83a598" # blue
base0E: "d3869b" # purple
base0F: "d65d0e" # brown
+48 -7
View File
@@ -22,7 +22,7 @@
flake-utils, flake-utils,
... ...
}@inputs: }@inputs:
flake-utils.lib.eachDefaultSystem ( (flake-utils.lib.eachDefaultSystem (
system: system:
let let
pkgs = import inputs.nixpkgs { pkgs = import inputs.nixpkgs {
@@ -59,6 +59,8 @@
# Common arguments can be set here to avoid repeating them later # Common arguments can be set here to avoid repeating them later
# Note: changes here will rebuild all dependency crates # Note: changes here will rebuild all dependency crates
commonArgs = { commonArgs = {
pname = "barbar";
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
# Exclude build outputs regardless of git state (this repo is not # Exclude build outputs regardless of git state (this repo is not
# git-initialized, so cleanCargoSource would otherwise copy target/). # git-initialized, so cleanCargoSource would otherwise copy target/).
src = pkgs.lib.cleanSourceWith { src = pkgs.lib.cleanSourceWith {
@@ -72,8 +74,17 @@
}; };
strictDeps = true; strictDeps = true;
nativeBuildInputs = [ nativeBuildInputs =
[
pkgs.pkg-config pkgs.pkg-config
# libspa-0.2.pc: pipewire-native bakes the SPA plugin directory
# from it, and loads libspa-support from there at runtime.
pkgs.pipewire.dev
]
# mold for the final link; the flag itself comes from the
# repo-wide .cargo/config.toml so dev and nix builds share one.
++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [
pkgs.mold
]; ];
buildInputs = buildInputs =
@@ -87,23 +98,42 @@
my-crate = craneLib.buildPackage ( my-crate = craneLib.buildPackage (
commonArgs commonArgs
// { // {
cargoArtifacts = craneLib.buildDepsOnly commonArgs; cargoArtifacts = craneLib.buildDepsOnly (commonArgs // {
src = craneLib.cleanCargoSource ./.;
});
# Additional environment variables or build phases/hooks can be set # Additional environment variables or build phases/hooks can be set
# here *without* rebuilding all dependency crates # here *without* rebuilding all dependency crates
# MY_CUSTOM_VAR = "some value"; # MY_CUSTOM_VAR = "some value";
} }
); );
# winit dlopens libwayland and wgpu the vulkan loader at runtime, which
# rpath alone does not cover; wrap the binary with LD_LIBRARY_PATH so
# the package also runs outside the devShell (e.g. from systemd).
barbar = pkgs.symlinkJoin {
name = "barbar-${my-crate.version}";
paths = [ my-crate ];
nativeBuildInputs = [ pkgs.makeWrapper ];
postBuild = ''
wrapProgram $out/bin/core \
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath guiLibs} \
--set SPA_PLUGIN_DIR ${pkgs.pipewire}/lib/spa-0.2 \
--set PIPEWIRE_CONFIG_DIR ${pkgs.pipewire}/share/pipewire
'';
meta.mainProgram = "core";
};
in in
{ {
checks = { checks = {
inherit my-crate; inherit my-crate;
}; };
packages.default = my-crate; packages.default = barbar;
apps.default = flake-utils.lib.mkApp { apps.default = flake-utils.lib.mkApp {
drv = my-crate; drv = barbar;
exePath = "/bin/core";
}; };
devShells.default = craneLib.devShell { devShells.default = craneLib.devShell {
@@ -112,13 +142,24 @@
# Put the GUI shared libraries on the runtime library path so winit # Put the GUI shared libraries on the runtime library path so winit
# can dlopen libwayland and wgpu can find the vulkan loader. # can dlopen libwayland and wgpu can find the vulkan loader.
# No RUSTFLAGS here: an env RUSTFLAGS would override (not merge
# with) .cargo/config.toml's rustflags, giving devShell and
# non-devShell builds different fingerprints and force-rebuilding
# every dependency on each switch.
shellHook = '' shellHook = ''
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath guiLibs}" export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath guiLibs}"
# pipewire-native dlopens SPA plugins and reads client.conf.
export SPA_PLUGIN_DIR="${pkgs.pipewire}/lib/spa-0.2"
export PIPEWIRE_CONFIG_DIR="${pkgs.pipewire}/share/pipewire"
''; '';
# Extra inputs can be added here; cargo and rustc are provided by default. # Extra inputs can be added here; cargo and rustc are provided by default.
packages = guiLibs; packages = guiLibs ++ [ pkgs.pipewire.dev ] ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.mold ];
}; };
} }
); ))
// {
# System-independent, so it lives outside eachDefaultSystem.
homeManagerModules.default = import ./nix/home-manager.nix self;
};
} }
+86
View File
@@ -0,0 +1,86 @@
# Home Manager module for barbar.
#
# imports = [ inputs.barbar.homeManagerModules.default ];
#
# Generates barbar.toml from `programs.barbar.settings` and runs the bar as a
# systemd user service bound to the graphical session.
self:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.programs.barbar;
toml = pkgs.formats.toml { };
configFile = toml.generate "barbar.toml" cfg.settings;
in
{
options.programs.barbar = {
enable = lib.mkEnableOption "barbar, a Wayland layer-shell status bar";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
defaultText = lib.literalExpression "barbar.packages.\${system}.default";
description = "The barbar package to run.";
};
settings = lib.mkOption {
type = toml.type;
default.order = {
left = [ ];
middle = [ ];
right = [ ];
};
defaultText = lib.literalExpression ''{ order = { left = [ ]; middle = [ ]; right = [ ]; }; }'';
description = ''
Contents of barbar.toml, written verbatim. Only `order` is required by
barbar; `[display]`, `[modules.*]` and `[services.*]` are optional.
See test.toml in the barbar repo for the full schema.
'';
example = lib.literalExpression ''
{
display = {
monitor = "DP-2";
height = 24;
};
order = {
left = [ "workspaces" "weather" ];
middle = [ ];
right = [ "clock" ];
};
modules.clock.show_seconds = false;
}
'';
};
systemd.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Run barbar as a systemd user service.";
};
systemd.target = lib.mkOption {
type = lib.types.str;
default = "graphical-session.target";
description = "Systemd target the bar is bound to.";
};
};
config = lib.mkIf cfg.enable {
systemd.user.services.barbar = lib.mkIf cfg.systemd.enable {
Unit = {
Description = "barbar status bar";
After = [ cfg.systemd.target ];
PartOf = [ cfg.systemd.target ];
};
Service = {
ExecStart = "${lib.getExe' cfg.package "core"} --config ${configFile}";
Restart = "on-failure";
};
Install.WantedBy = [ cfg.systemd.target ];
};
};
}
-45
View File
@@ -1,45 +0,0 @@
//! Widget-tree operation that captures the laid-out bounds of a clickable
//! bar module so a popup can be anchored to it.
use iced::advanced::widget as advanced_widget;
use iced::widget::Id;
use iced::{Rectangle, Task};
/// Captures the laid-out bounds of the module with the given id, reported via
/// `BoundsFound`.
pub fn capture_bounds(label: String) -> Task<crate::Message> {
let target = Id::from(label.clone());
struct FindBounds {
target: Id,
found: Option<Rectangle>,
}
impl advanced_widget::Operation<Rectangle> for FindBounds {
fn traverse(
&mut self,
operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation<Rectangle>),
) {
operate(self);
}
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
if self.found.is_none() && id == Some(&self.target) {
self.found = Some(bounds);
}
}
fn finish(&self) -> advanced_widget::operation::Outcome<Rectangle> {
match self.found {
Some(bounds) => advanced_widget::operation::Outcome::Some(bounds),
None => advanced_widget::operation::Outcome::None,
}
}
}
advanced_widget::operate(FindBounds {
target,
found: None,
})
.map(move |bounds| crate::Message::BoundsFound(label.clone(), bounds))
}
-225
View File
@@ -1,225 +0,0 @@
//! Wayland layer-shell status bar built on `iced` + `iced_layershell`.
mod bounds;
mod module;
mod modules;
use iced::widget::{container, text, Row};
use iced::{window, Alignment, Element, Length, Rectangle, Task, Theme};
use iced_layershell::daemon;
use iced_layershell::reexport::{Anchor, PopupAnchor, PopupGravity};
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode};
use iced_layershell::to_layer_message;
use crate::module::{ModuleMsg, ModuleState, MODULES};
/// Height of the bar in logical pixels.
const BAR_HEIGHT: u32 = 36;
const POPUP_W: u32 = 150;
const POPUP_H: u32 = 100;
/// Vertical gap (logical px) between the bar's bottom edge and the popup.
const POPUP_GAP: i32 = 32;
struct Bar {
/// Which button triggered the open popup? None = closed. Keyed by module label.
active_popup: Option<String>,
/// Surface ID of the currently open popup (for removal).
popup_id: Option<window::Id>,
/// Live module states, in registry order.
modules: Vec<Box<dyn ModuleState>>,
}
impl Default for Bar {
fn default() -> Self {
Self {
active_popup: None,
popup_id: None,
modules: MODULES.iter().map(|m| m.new_state()).collect(),
}
}
}
#[to_layer_message(multi)]
#[derive(Debug, Clone)]
enum Message {
Noop,
ModuleClicked(String),
BoundsFound(String, Rectangle),
ClosePopup(window::Id),
Module(String, Box<dyn ModuleMsg>),
}
fn namespace() -> String {
String::from("barbar")
}
fn main() -> Result<(), iced_layershell::Error> {
let start_mode = match std::env::args().nth(1) {
Some(output) => StartMode::TargetScreen(output),
None => StartMode::Active,
};
daemon(Bar::default, namespace, update, view)
.style(style)
.subscription(subscription)
.settings(Settings {
layer_settings: LayerShellSettings {
size: Some((0, BAR_HEIGHT)),
exclusive_zone: BAR_HEIGHT as i32,
anchor: Anchor::Top | Anchor::Left | Anchor::Right,
start_mode,
..Default::default()
},
..Default::default()
})
.run()
}
/// Folds module subscriptions into one daemon subscription.
fn subscription(bar: &Bar) -> iced::Subscription<Message> {
iced::Subscription::batch(bar.modules.iter().flat_map(|m| m.subscriptions()))
}
fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
match msg {
Message::ModuleClicked(label) => {
// Toggle: close if already open, else open.
if bar.active_popup.as_deref() == Some(label.as_str()) {
return maybe_close_popup(bar);
}
// Query the widget tree for the module's actual laid-out bounds.
// The resulting `BoundsFound` message opens the popup there.
bounds::capture_bounds(label)
}
Message::BoundsFound(label, bounds) => {
bar.active_popup = Some(label.clone());
// Anchor rect = the button's bounds, extended below the bar so the
// `Bottom` anchor point (bottom-center) sits POPUP_GAP below the
// button's bottom edge. With `Bottom` gravity the popup grows
// downward from there, leaving a gap under the bar.
let (x, y, w, h) = (
bounds.x.round() as i32,
bounds.y.round() as i32,
bounds.width.round() as i32,
bounds.height.round() as i32,
);
let anchor_rect = (x, y, w, h + POPUP_GAP);
let settings = iced_layershell::actions::IcedNewPopupSettings::on_current_surface(
(POPUP_W, POPUP_H),
anchor_rect,
)
.anchor(PopupAnchor::Bottom)
.gravity(PopupGravity::Bottom);
let (id, task) = Message::popup_open(settings);
bar.popup_id = Some(id);
task
}
Message::Module(label, msg) => {
let state = bar
.modules
.iter_mut()
.find(|m| m.label() == label)
.expect("unknown module label");
state.handle(msg)
}
Message::ClosePopup(id) => {
if bar.popup_id == Some(id) {
bar.popup_id = None;
bar.active_popup = None;
}
Task::done(Message::RemoveWindow(id))
}
Message::Noop => Task::none(),
// Forward multi-window mutations to their internal handlers.
Message::AnchorChange { .. }
| Message::SetInputRegion { .. }
| Message::AnchorSizeChange { .. }
| Message::LayerChange { .. }
| Message::MarginChange { .. }
| Message::SizeChange { .. }
| Message::ExclusiveZoneChange { .. }
| Message::KeyboardInteractivityChange { .. }
| Message::NewBaseWindow { .. }
| Message::NewInputPanel { .. }
| Message::NewLayerShell { .. }
| Message::NewMenu { .. }
| Message::NewPopUp { .. }
| Message::RemoveWindow(_)
| Message::ForgetLastOutput
| Message::VirtualKeyboardPressed { .. } => Task::none(),
}
}
fn maybe_close_popup(bar: &mut Bar) -> Task<Message> {
if let Some(id) = bar.popup_id.take() {
bar.active_popup = None;
return Task::done(Message::RemoveWindow(id));
}
Task::none()
}
/// Dispatch view by which surface is being rendered.
fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> {
if bar.popup_id == Some(id) {
popup_view(bar)
} else {
bar_view(bar)
}
}
/// Main bar widget tree.
fn bar_view(bar: &Bar) -> Element<'static, Message> {
let mut children: Vec<Element<Message>> = Vec::new();
for (i, module) in bar.modules.iter().enumerate() {
children.push(module.button());
if i < bar.modules.len() - 1 {
children.push(text("|").size(14).into());
}
}
container(
Row::from_vec(children)
.width(Length::Fill)
.align_y(Alignment::Center),
)
.padding(8)
.align_x(Alignment::Center)
.into()
}
/// Popup widget tree. Rendered on its own floating LayerShell surface.
fn popup_view(bar: &Bar) -> Element<'static, Message> {
let label = bar
.active_popup
.as_ref()
.expect("popup open without active module");
let popup_id = bar.popup_id.expect("popup open without surface id");
let module = bar
.modules
.iter()
.find(|m| m.label() == label)
.expect("unknown module label");
module.popup(popup_id)
}
fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style {
use iced::theme::Style;
Style {
background_color: theme.palette().background,
text_color: theme.palette().text,
}
}
-84
View File
@@ -1,84 +0,0 @@
use std::any::Any;
use std::fmt::Debug;
use iced::widget::{container, mouse_area, text, Id};
use iced::{window, Alignment, Element, Length, Subscription, Task};
use crate::Message;
/// A module's own message type, boxed for transport through [`Message::Module`].
pub trait ModuleMsg: Any + Debug + Send + 'static {
fn clone_box(&self) -> Box<dyn ModuleMsg>;
}
impl Clone for Box<dyn ModuleMsg> {
fn clone(&self) -> Self {
self.clone_box()
}
}
impl<T: Any + Debug + Clone + Send + 'static> ModuleMsg for T {
fn clone_box(&self) -> Box<dyn ModuleMsg> {
Box::new((*self).clone())
}
}
/// Downcast a boxed module message to a concrete type.
pub fn downcast<T: Any>(boxed: Box<dyn ModuleMsg>) -> Result<Box<T>, ()> {
let erased: Box<dyn Any> = boxed;
match erased.downcast::<T>() {
Ok(t) => Ok(t),
Err(_) => Err(()),
}
}
/// Static module definition: how to construct the live state.
pub trait ModuleDef: Send + Sync {
fn new_state(&self) -> Box<dyn ModuleState>;
}
/// Live, object-safe module state held by the bar.
pub trait ModuleState: Send + 'static {
/// Stable label used as widget-tree id and message routing key.
fn label(&self) -> &'static str;
/// Stable widget-tree id used for popup bounds capture.
fn id(&self) -> Id {
Id::from(self.label().to_string())
}
/// Render the module's clickable bar segment.
fn button(&self) -> Element<'static, Message> {
container(
mouse_area(text(self.label()).size(14))
.on_press(Message::ModuleClicked(self.label().to_string())),
)
.id(self.id())
.into()
}
/// Handle a message routed to this module.
fn handle(&mut self, msg: Box<dyn ModuleMsg>) -> Task<Message>;
/// Render the popup body when this module's popup is open.
fn popup(&self, popup_id: window::Id) -> Element<'static, Message> {
let _ = popup_id;
text(format!("{} menu", self.label()))
.size(14)
.width(Length::Fill)
.align_x(Alignment::Center)
.into()
}
/// Subscriptions this module wants to run.
fn subscriptions(&self) -> Vec<Subscription<Message>> {
Vec::new()
}
}
/// Registry of all bar modules, in display order.
pub static MODULES: &[&'static dyn ModuleDef] = &[
&crate::modules::workspaces::Workspaces,
&crate::modules::clock::Clock,
&crate::modules::system::System,
];
-50
View File
@@ -1,50 +0,0 @@
use iced::widget::{column, mouse_area, text};
use iced::{window, Alignment, Element, Length};
use crate::module::{ModuleDef, ModuleMsg, ModuleState};
use crate::Message;
pub struct Clock;
pub struct State;
impl ModuleDef for Clock {
fn new_state(&self) -> Box<dyn ModuleState> {
Box::new(State)
}
}
impl ModuleState for State {
fn label(&self) -> &'static str {
"clock"
}
// ponytail: clock has no internal messages yet; the tick subscription
// that gives it live time is the upgrade path (add `Msg` + `update`
// here when needed).
fn handle(&mut self, _msg: Box<dyn ModuleMsg>) -> iced::Task<Message> {
iced::Task::none()
}
fn popup(&self, popup_id: window::Id) -> Element<'static, Message> {
let items: Vec<Element<'static, Message>> = ["Time format", "Date display"]
.into_iter()
.map(|label| {
mouse_area(text(label).size(13))
.on_press(Message::ClosePopup(popup_id))
.into()
})
.collect();
column![
text("clock menu")
.size(14)
.width(Length::Fill)
.align_x(Alignment::Center),
column(items).spacing(1),
mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)),
]
.spacing(1)
.into()
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod clock;
pub mod system;
pub mod workspaces;
-49
View File
@@ -1,49 +0,0 @@
use iced::widget::{column, mouse_area, text};
use iced::{window, Alignment, Element, Length};
use crate::module::{ModuleDef, ModuleMsg, ModuleState};
use crate::Message;
pub struct System;
pub struct State;
impl ModuleDef for System {
fn new_state(&self) -> Box<dyn ModuleState> {
Box::new(State)
}
}
impl ModuleState for State {
fn label(&self) -> &'static str {
"system"
}
// ponytail: no internal messages yet; real brightness/volume/power
// actions (each with its own `Msg` variant) are the upgrade path.
fn handle(&mut self, _msg: Box<dyn ModuleMsg>) -> iced::Task<Message> {
iced::Task::none()
}
fn popup(&self, popup_id: window::Id) -> Element<'static, Message> {
let items: Vec<Element<'static, Message>> = ["Brightness", "Volume", "Power"]
.into_iter()
.map(|label| {
mouse_area(text(label).size(13))
.on_press(Message::ClosePopup(popup_id))
.into()
})
.collect();
column![
text("system menu")
.size(14)
.width(Length::Fill)
.align_x(Alignment::Center),
column(items).spacing(1),
mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)),
]
.spacing(1)
.into()
}
}
-66
View File
@@ -1,66 +0,0 @@
use iced::widget::{column, mouse_area, text, Row};
use iced::{window, Alignment, Element, Length, Task};
use crate::module::{downcast, ModuleDef, ModuleMsg, ModuleState};
use crate::Message;
pub struct Workspaces;
pub struct State {
workspaces: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum Msg {
Select(String),
}
impl ModuleDef for Workspaces {
fn new_state(&self) -> Box<dyn ModuleState> {
Box::new(State {
workspaces: vec!["1".into(), "2".into(), "3".into(), "4".into()],
})
}
}
impl ModuleState for State {
fn label(&self) -> &'static str {
"workspaces"
}
fn handle(&mut self, msg: Box<dyn ModuleMsg>) -> Task<Message> {
let msg = *downcast::<Msg>(msg).expect("workspaces module received foreign message");
match msg {
Msg::Select(ws) => {
self.workspaces.sort_by_key(|w| w != &ws);
Task::none()
}
}
}
fn popup(&self, popup_id: window::Id) -> Element<'static, Message> {
let items: Vec<Element<'static, Message>> = self
.workspaces
.iter()
.map(|ws| {
mouse_area(text(ws.clone()).size(13))
.on_press(Message::Module(
"workspaces".into(),
Box::new(Msg::Select(ws.clone())),
))
.into()
})
.collect();
column![
text("workspaces menu")
.size(14)
.width(Length::Fill)
.align_x(Alignment::Center),
Row::from_vec(items).spacing(1),
mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)),
]
.spacing(1)
.into()
}
}
+31
View File
@@ -0,0 +1,31 @@
[display]
monitor = "DP-2"
height = 20
antialiasing = true
default_text_size = 14.0
padding = 4.0
spacing = 8.0
theme = 'example_base16.yaml'
[order]
left = ["workspaces", "weather"]
middle = []
right = ["battery", "audio", "clock"]
[modules.clock]
show_seconds = false
[modules.workspaces]
# Slots rendered, left to right (workspaces 1..=count).
count = 10
# Horizontal gap between slots, logical px.
gap = 3.0
# Slot size, logical px.
slot_w = 10.0
slot_h = 17.0
# Corner radius.
radius = 2.5
# Visual scale per state (focused / exists / absent).
scale_active = 1.0
scale_exists = 0.85
scale_absent = 0.65