Compare commits
38
Commits
6e82d0a175
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e37d0662a
|
||
|
|
cf7683ebfd
|
||
|
|
5f338c4278
|
||
|
|
489e7ff326
|
||
|
|
99d24a7a09
|
||
|
|
c1a67c9335
|
||
|
|
fe07022015
|
||
|
|
6a62d14755
|
||
|
|
7bf6f989dc
|
||
|
|
4d586d5137
|
||
|
|
2bb7b31b76
|
||
|
|
63e1c0ee4c
|
||
|
|
120eaf5855
|
||
|
|
cd3cf4d65f
|
||
|
|
ac70a9133a
|
||
|
|
fb6b03b369
|
||
|
|
e8c0e79bae
|
||
|
|
ec43d7b57a
|
||
|
|
14c3bb0495
|
||
|
|
aba6af03b4
|
||
|
|
cc67cd25e9
|
||
|
|
a14b6f4967
|
||
|
|
57201bc0ff
|
||
|
|
74f59be3f8
|
||
|
|
b81eb967ee
|
||
|
|
b20e03def7
|
||
|
|
27ec2b90a6
|
||
|
|
539c80cda9
|
||
|
|
bdec970b62
|
||
|
|
8696425590
|
||
|
|
b9c7e73824
|
||
|
|
3490af52e4
|
||
|
|
bc5a26b728
|
||
|
|
c82b470b9e
|
||
|
|
61ed2ab85c
|
||
|
|
4db39f718a
|
||
|
|
0ee71a0618
|
||
|
|
7aa1550f41
|
@@ -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
+2164
-130
File diff suppressed because it is too large
Load Diff
+24
-4
@@ -1,9 +1,29 @@
|
||||
[package]
|
||||
name = "barbar"
|
||||
[workspace]
|
||||
members = ["crates/core", "crates/common", "crates/modules", "crates/services"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
[workspace.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"] }
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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 }
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
]
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Workspaces module: renders workspaces 1–10 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()]
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod hyprland;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ®istry, id, type_, version, name.to_string());
|
||||
}
|
||||
}
|
||||
types::interface::METADATA if props.get("metadata.name") == Some(DEFAULTS) => {
|
||||
bind_defaults(track, ®istry, 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -22,7 +22,7 @@
|
||||
flake-utils,
|
||||
...
|
||||
}@inputs:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
(flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import inputs.nixpkgs {
|
||||
@@ -59,6 +59,8 @@
|
||||
# Common arguments can be set here to avoid repeating them later
|
||||
# Note: changes here will rebuild all dependency crates
|
||||
commonArgs = {
|
||||
pname = "barbar";
|
||||
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
|
||||
# Exclude build outputs regardless of git state (this repo is not
|
||||
# git-initialized, so cleanCargoSource would otherwise copy target/).
|
||||
src = pkgs.lib.cleanSourceWith {
|
||||
@@ -72,9 +74,18 @@
|
||||
};
|
||||
strictDeps = true;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkgs.pkg-config
|
||||
];
|
||||
nativeBuildInputs =
|
||||
[
|
||||
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 =
|
||||
guiLibs
|
||||
@@ -87,23 +98,42 @@
|
||||
my-crate = craneLib.buildPackage (
|
||||
commonArgs
|
||||
// {
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
cargoArtifacts = craneLib.buildDepsOnly (commonArgs // {
|
||||
src = craneLib.cleanCargoSource ./.;
|
||||
});
|
||||
|
||||
# Additional environment variables or build phases/hooks can be set
|
||||
# here *without* rebuilding all dependency crates
|
||||
# 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
|
||||
{
|
||||
checks = {
|
||||
inherit my-crate;
|
||||
};
|
||||
|
||||
packages.default = my-crate;
|
||||
packages.default = barbar;
|
||||
|
||||
apps.default = flake-utils.lib.mkApp {
|
||||
drv = my-crate;
|
||||
drv = barbar;
|
||||
exePath = "/bin/core";
|
||||
};
|
||||
|
||||
devShells.default = craneLib.devShell {
|
||||
@@ -112,13 +142,24 @@
|
||||
|
||||
# Put the GUI shared libraries on the runtime library path so winit
|
||||
# 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 = ''
|
||||
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.
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 ];
|
||||
};
|
||||
};
|
||||
}
|
||||
-342
@@ -1,342 +0,0 @@
|
||||
//! barbar — a Wayland layer-shell status bar built on `iced` + `iced_layershell`.
|
||||
//!
|
||||
//! Renders a full-width bar pinned to the top of the screen via the
|
||||
//! `wlr-layer-shell` protocol. Optionally target a specific output by
|
||||
//! passing its name as the first CLI argument.
|
||||
//!
|
||||
//! Clicking any button spawns a separate LayerShell popup surface anchored
|
||||
//! to that button's position on the bar.
|
||||
|
||||
use iced::advanced::widget as advanced_widget;
|
||||
use iced::widget::{column, container, mouse_area, text};
|
||||
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, StartMode, Settings};
|
||||
use iced_layershell::to_layer_message;
|
||||
|
||||
/// Height of the bar in logical pixels.
|
||||
const BAR_HEIGHT: u32 = 36;
|
||||
|
||||
/// Popup dimensions.
|
||||
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;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Identifies which bar module triggered an action.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Module {
|
||||
Workspace,
|
||||
Clock,
|
||||
System,
|
||||
}
|
||||
|
||||
impl Module {
|
||||
const ALL: &'static [Self] = &[Self::Workspace, Self::Clock, Self::System];
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => "workspaces",
|
||||
Self::Clock => "clock",
|
||||
Self::System => "system",
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable widget-tree id used to look up the laid-out bounds of the
|
||||
/// module's clickable area.
|
||||
fn id(&self) -> iced::widget::Id {
|
||||
iced::widget::Id::new(self.label())
|
||||
}
|
||||
}
|
||||
|
||||
/// Application state. Tracks which popup is open and shared module data.
|
||||
#[derive(Debug, Default)]
|
||||
struct Bar {
|
||||
/// Which button triggered the open popup? None = closed.
|
||||
active_popup: Option<Module>,
|
||||
/// Surface ID of the currently open popup (for removal).
|
||||
popup_id: Option<window::Id>,
|
||||
/// Example workspace list populated when workspace menu opens.
|
||||
workspaces: Vec<String>,
|
||||
}
|
||||
|
||||
/// Messages produced by user input, events and subscriptions.
|
||||
#[to_layer_message(multi)]
|
||||
#[derive(Debug, Clone)]
|
||||
enum Message {
|
||||
Noop,
|
||||
/// A bar module was clicked; opens or toggles a popup.
|
||||
ModuleClicked(Module),
|
||||
/// Internal: the widget-tree layout pass reported the laid-out bounds of
|
||||
/// a module's clickable area; the popup is anchored there.
|
||||
BoundsFound(Module, Rectangle),
|
||||
/// Closes the currently open popup.
|
||||
ClosePopup(window::Id),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Application wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
.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()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
|
||||
match msg {
|
||||
Message::ModuleClicked(module) => {
|
||||
// Toggle: close if already open, else open.
|
||||
if bar.active_popup == Some(module) {
|
||||
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.
|
||||
capture_bounds(module)
|
||||
}
|
||||
|
||||
Message::BoundsFound(module, bounds) => {
|
||||
bar.active_popup = Some(module);
|
||||
|
||||
// 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);
|
||||
|
||||
// Seed example data for the workspace menu.
|
||||
if module == Module::Workspace {
|
||||
bar.workspaces = vec!["1".into(), "2".into(), "3".into(), "4".into()];
|
||||
}
|
||||
|
||||
task
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: closes the popup if one is open.
|
||||
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()
|
||||
}
|
||||
|
||||
/// Runs a widget-tree [`Operation`] that captures the laid-out bounds of the
|
||||
/// given module's clickable area, then reports them via `BoundsFound`.
|
||||
fn capture_bounds(module: Module) -> Task<Message> {
|
||||
let target = module.id();
|
||||
|
||||
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>),
|
||||
) {
|
||||
// Delegate traversal so widgets can visit their children; the
|
||||
// container id is matched in `container` above.
|
||||
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::BoundsFound(module, bounds))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View — routed per-surface by window ID
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Dispatch view by which surface is being rendered.
|
||||
fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> {
|
||||
// The bar is always created first. Any subsequently opened popup has a
|
||||
// different IcedId we can distinguish. We compare against the bar's
|
||||
// known ID (stored implicitly as "first window").
|
||||
if bar.popup_id == Some(id) {
|
||||
// This is the popup surface.
|
||||
popup_view(bar)
|
||||
} else {
|
||||
// This is the main bar surface.
|
||||
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 Module::ALL.iter().enumerate() {
|
||||
children.push(button(*module));
|
||||
if i < Module::ALL.len() - 1 {
|
||||
children.push(text("|").size(14).into());
|
||||
}
|
||||
}
|
||||
|
||||
container(
|
||||
column(children)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.padding(8)
|
||||
.align_x(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Standalone button wrapped in MouseArea for click capture. The container
|
||||
/// carries the module's id so a widget-tree [`Operation`] can find its
|
||||
/// laid-out bounds for popup anchoring.
|
||||
fn button(module: Module) -> Element<'static, Message> {
|
||||
container(mouse_area(text(module.label()).size(14)).on_press(Message::ModuleClicked(module)))
|
||||
.id(module.id())
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Popup widget tree. Rendered on its own floating LayerShell surface.
|
||||
fn popup_view<'a>(bar: &'a Bar) -> Element<'a, Message> {
|
||||
let module = bar.active_popup.unwrap();
|
||||
let popup_id = bar.popup_id.unwrap();
|
||||
let label = module.label();
|
||||
|
||||
// Build list items based on which module was clicked.
|
||||
let items: Vec<Element<Message>> = match module {
|
||||
Module::Workspace => bar
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|ws| popup_item(ws.clone(), popup_id))
|
||||
.collect(),
|
||||
Module::Clock => vec![
|
||||
popup_item("Time format", popup_id),
|
||||
popup_item("Date display", popup_id),
|
||||
],
|
||||
Module::System => vec![
|
||||
popup_item("Brightness", popup_id),
|
||||
popup_item("Volume", popup_id),
|
||||
popup_item("Power", popup_id),
|
||||
],
|
||||
};
|
||||
|
||||
column![
|
||||
text(format!("{} menu", label))
|
||||
.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()
|
||||
}
|
||||
|
||||
/// Generic clickable item inside a popup.
|
||||
fn popup_item(label: impl Into<String>, popup_id: window::Id) -> Element<'static, Message> {
|
||||
mouse_area(text(label.into()).size(13))
|
||||
.on_press(Message::ClosePopup(popup_id))
|
||||
.into()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style {
|
||||
use iced::theme::Style;
|
||||
Style {
|
||||
background_color: theme.palette().background,
|
||||
text_color: theme.palette().text,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user