Compare commits
40
Commits
81f242419e
...
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
|
||
|
|
6e82d0a175
|
||
|
|
cc5db9089a
|
@@ -0,0 +1,7 @@
|
|||||||
|
# Link with mold from the repo itself, so `cargo build`/`cargo run` do not
|
||||||
|
# depend on a machine-local ~/.cargo/config.toml. -fuse-ld is understood by
|
||||||
|
# both the clang and gcc drivers, so this works with whatever `linker` the
|
||||||
|
# caller has configured (mold is in the devShell PATH; the nix build gets it
|
||||||
|
# via flake.nix).
|
||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||||
Generated
+2185
-128
File diff suppressed because it is too large
Load Diff
+26
-5
@@ -1,8 +1,29 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "barbar"
|
members = ["crates/core", "crates/common", "crates/modules", "crates/services"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[workspace.dependencies]
|
||||||
iced = { version = "0.14", default-features = false, features = ["wgpu", "wayland", "crisp", "web-colors", "thread-pool", "advanced"] }
|
iced = { version = "0.14", default-features = false, features = ["wgpu", "wayland", "crisp", "web-colors", "thread-pool", "advanced", "tokio"] }
|
||||||
iced_layershell = { version = "0.19", default-features = false }
|
iced_layershell = { version = "0.19", default-features = false }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
chrono = "0.4"
|
||||||
|
toml = "1.1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
thiserror = "2"
|
||||||
|
clap = { version = "4.6.6", features = ["derive", "help"] }
|
||||||
|
|
||||||
|
# Dependencies get light optimization in dev so wgpu/iced stay usable at
|
||||||
|
# runtime without paying full O3 codegen on every cold build; workspace
|
||||||
|
# members keep dev defaults (opt-level 0). Line-tables-only debuginfo keeps
|
||||||
|
# the dev binary and target/ from ballooning.
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
opt-level = 1
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|||||||
@@ -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,
|
flake-utils,
|
||||||
...
|
...
|
||||||
}@inputs:
|
}@inputs:
|
||||||
flake-utils.lib.eachDefaultSystem (
|
(flake-utils.lib.eachDefaultSystem (
|
||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
pkgs = import inputs.nixpkgs {
|
pkgs = import inputs.nixpkgs {
|
||||||
@@ -59,6 +59,8 @@
|
|||||||
# Common arguments can be set here to avoid repeating them later
|
# Common arguments can be set here to avoid repeating them later
|
||||||
# Note: changes here will rebuild all dependency crates
|
# Note: changes here will rebuild all dependency crates
|
||||||
commonArgs = {
|
commonArgs = {
|
||||||
|
pname = "barbar";
|
||||||
|
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
|
||||||
# Exclude build outputs regardless of git state (this repo is not
|
# Exclude build outputs regardless of git state (this repo is not
|
||||||
# git-initialized, so cleanCargoSource would otherwise copy target/).
|
# git-initialized, so cleanCargoSource would otherwise copy target/).
|
||||||
src = pkgs.lib.cleanSourceWith {
|
src = pkgs.lib.cleanSourceWith {
|
||||||
@@ -72,9 +74,18 @@
|
|||||||
};
|
};
|
||||||
strictDeps = true;
|
strictDeps = true;
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs =
|
||||||
pkgs.pkg-config
|
[
|
||||||
];
|
pkgs.pkg-config
|
||||||
|
# libspa-0.2.pc: pipewire-native bakes the SPA plugin directory
|
||||||
|
# from it, and loads libspa-support from there at runtime.
|
||||||
|
pkgs.pipewire.dev
|
||||||
|
]
|
||||||
|
# mold for the final link; the flag itself comes from the
|
||||||
|
# repo-wide .cargo/config.toml so dev and nix builds share one.
|
||||||
|
++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [
|
||||||
|
pkgs.mold
|
||||||
|
];
|
||||||
|
|
||||||
buildInputs =
|
buildInputs =
|
||||||
guiLibs
|
guiLibs
|
||||||
@@ -87,23 +98,42 @@
|
|||||||
my-crate = craneLib.buildPackage (
|
my-crate = craneLib.buildPackage (
|
||||||
commonArgs
|
commonArgs
|
||||||
// {
|
// {
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
cargoArtifacts = craneLib.buildDepsOnly (commonArgs // {
|
||||||
|
src = craneLib.cleanCargoSource ./.;
|
||||||
|
});
|
||||||
|
|
||||||
# Additional environment variables or build phases/hooks can be set
|
# Additional environment variables or build phases/hooks can be set
|
||||||
# here *without* rebuilding all dependency crates
|
# here *without* rebuilding all dependency crates
|
||||||
# MY_CUSTOM_VAR = "some value";
|
# MY_CUSTOM_VAR = "some value";
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
# winit dlopens libwayland and wgpu the vulkan loader at runtime, which
|
||||||
|
# rpath alone does not cover; wrap the binary with LD_LIBRARY_PATH so
|
||||||
|
# the package also runs outside the devShell (e.g. from systemd).
|
||||||
|
barbar = pkgs.symlinkJoin {
|
||||||
|
name = "barbar-${my-crate.version}";
|
||||||
|
paths = [ my-crate ];
|
||||||
|
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||||
|
postBuild = ''
|
||||||
|
wrapProgram $out/bin/core \
|
||||||
|
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath guiLibs} \
|
||||||
|
--set SPA_PLUGIN_DIR ${pkgs.pipewire}/lib/spa-0.2 \
|
||||||
|
--set PIPEWIRE_CONFIG_DIR ${pkgs.pipewire}/share/pipewire
|
||||||
|
'';
|
||||||
|
meta.mainProgram = "core";
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
checks = {
|
checks = {
|
||||||
inherit my-crate;
|
inherit my-crate;
|
||||||
};
|
};
|
||||||
|
|
||||||
packages.default = my-crate;
|
packages.default = barbar;
|
||||||
|
|
||||||
apps.default = flake-utils.lib.mkApp {
|
apps.default = flake-utils.lib.mkApp {
|
||||||
drv = my-crate;
|
drv = barbar;
|
||||||
|
exePath = "/bin/core";
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = craneLib.devShell {
|
devShells.default = craneLib.devShell {
|
||||||
@@ -112,13 +142,24 @@
|
|||||||
|
|
||||||
# Put the GUI shared libraries on the runtime library path so winit
|
# Put the GUI shared libraries on the runtime library path so winit
|
||||||
# can dlopen libwayland and wgpu can find the vulkan loader.
|
# can dlopen libwayland and wgpu can find the vulkan loader.
|
||||||
|
# No RUSTFLAGS here: an env RUSTFLAGS would override (not merge
|
||||||
|
# with) .cargo/config.toml's rustflags, giving devShell and
|
||||||
|
# non-devShell builds different fingerprints and force-rebuilding
|
||||||
|
# every dependency on each switch.
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath guiLibs}"
|
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath guiLibs}"
|
||||||
|
# pipewire-native dlopens SPA plugins and reads client.conf.
|
||||||
|
export SPA_PLUGIN_DIR="${pkgs.pipewire}/lib/spa-0.2"
|
||||||
|
export PIPEWIRE_CONFIG_DIR="${pkgs.pipewire}/share/pipewire"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Extra inputs can be added here; cargo and rustc are provided by default.
|
# Extra inputs can be added here; cargo and rustc are provided by default.
|
||||||
packages = guiLibs;
|
packages = guiLibs ++ [ pkgs.pipewire.dev ] ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.mold ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
))
|
||||||
|
// {
|
||||||
|
# System-independent, so it lives outside eachDefaultSystem.
|
||||||
|
homeManagerModules.default = import ./nix/home-manager.nix self;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
//! Widget-tree operation that captures the laid-out bounds of a clickable
|
|
||||||
//! bar module so a popup can be anchored to it.
|
|
||||||
|
|
||||||
use iced::advanced::widget as advanced_widget;
|
|
||||||
use iced::widget::Id;
|
|
||||||
use iced::{Rectangle, Task};
|
|
||||||
|
|
||||||
/// Captures the laid-out bounds of the module with the given id, reported via
|
|
||||||
/// `BoundsFound`.
|
|
||||||
pub fn capture_bounds(label: String) -> Task<crate::Message> {
|
|
||||||
let target = Id::from(label.clone());
|
|
||||||
|
|
||||||
struct FindBounds {
|
|
||||||
target: Id,
|
|
||||||
found: Option<Rectangle>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl advanced_widget::Operation<Rectangle> for FindBounds {
|
|
||||||
fn traverse(
|
|
||||||
&mut self,
|
|
||||||
operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation<Rectangle>),
|
|
||||||
) {
|
|
||||||
operate(self);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
|
|
||||||
if self.found.is_none() && id == Some(&self.target) {
|
|
||||||
self.found = Some(bounds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish(&self) -> advanced_widget::operation::Outcome<Rectangle> {
|
|
||||||
match self.found {
|
|
||||||
Some(bounds) => advanced_widget::operation::Outcome::Some(bounds),
|
|
||||||
None => advanced_widget::operation::Outcome::None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
advanced_widget::operate(FindBounds {
|
|
||||||
target,
|
|
||||||
found: None,
|
|
||||||
})
|
|
||||||
.map(move |bounds| crate::Message::BoundsFound(label.clone(), bounds))
|
|
||||||
}
|
|
||||||
-225
@@ -1,225 +0,0 @@
|
|||||||
//! Wayland layer-shell status bar built on `iced` + `iced_layershell`.
|
|
||||||
|
|
||||||
mod bounds;
|
|
||||||
mod module;
|
|
||||||
mod modules;
|
|
||||||
|
|
||||||
use iced::widget::{container, text, Row};
|
|
||||||
use iced::{window, Alignment, Element, Length, Rectangle, Task, Theme};
|
|
||||||
use iced_layershell::daemon;
|
|
||||||
use iced_layershell::reexport::{Anchor, PopupAnchor, PopupGravity};
|
|
||||||
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode};
|
|
||||||
use iced_layershell::to_layer_message;
|
|
||||||
|
|
||||||
use crate::module::{ModuleMsg, ModuleState, MODULES};
|
|
||||||
|
|
||||||
/// Height of the bar in logical pixels.
|
|
||||||
const BAR_HEIGHT: u32 = 36;
|
|
||||||
|
|
||||||
const POPUP_W: u32 = 150;
|
|
||||||
const POPUP_H: u32 = 100;
|
|
||||||
|
|
||||||
/// Vertical gap (logical px) between the bar's bottom edge and the popup.
|
|
||||||
const POPUP_GAP: i32 = 32;
|
|
||||||
|
|
||||||
struct Bar {
|
|
||||||
/// Which button triggered the open popup? None = closed. Keyed by module label.
|
|
||||||
active_popup: Option<String>,
|
|
||||||
/// Surface ID of the currently open popup (for removal).
|
|
||||||
popup_id: Option<window::Id>,
|
|
||||||
/// Live module states, in registry order.
|
|
||||||
modules: Vec<Box<dyn ModuleState>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Bar {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
active_popup: None,
|
|
||||||
popup_id: None,
|
|
||||||
modules: MODULES.iter().map(|m| m.new_state()).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[to_layer_message(multi)]
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
enum Message {
|
|
||||||
Noop,
|
|
||||||
ModuleClicked(String),
|
|
||||||
BoundsFound(String, Rectangle),
|
|
||||||
ClosePopup(window::Id),
|
|
||||||
Module(String, Box<dyn ModuleMsg>),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn namespace() -> String {
|
|
||||||
String::from("barbar")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<(), iced_layershell::Error> {
|
|
||||||
let start_mode = match std::env::args().nth(1) {
|
|
||||||
Some(output) => StartMode::TargetScreen(output),
|
|
||||||
None => StartMode::Active,
|
|
||||||
};
|
|
||||||
|
|
||||||
daemon(Bar::default, namespace, update, view)
|
|
||||||
.style(style)
|
|
||||||
.subscription(subscription)
|
|
||||||
.settings(Settings {
|
|
||||||
layer_settings: LayerShellSettings {
|
|
||||||
size: Some((0, BAR_HEIGHT)),
|
|
||||||
exclusive_zone: BAR_HEIGHT as i32,
|
|
||||||
anchor: Anchor::Top | Anchor::Left | Anchor::Right,
|
|
||||||
start_mode,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Folds module subscriptions into one daemon subscription.
|
|
||||||
fn subscription(bar: &Bar) -> iced::Subscription<Message> {
|
|
||||||
iced::Subscription::batch(bar.modules.iter().flat_map(|m| m.subscriptions()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
|
|
||||||
match msg {
|
|
||||||
Message::ModuleClicked(label) => {
|
|
||||||
// Toggle: close if already open, else open.
|
|
||||||
if bar.active_popup.as_deref() == Some(label.as_str()) {
|
|
||||||
return maybe_close_popup(bar);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query the widget tree for the module's actual laid-out bounds.
|
|
||||||
// The resulting `BoundsFound` message opens the popup there.
|
|
||||||
bounds::capture_bounds(label)
|
|
||||||
}
|
|
||||||
|
|
||||||
Message::BoundsFound(label, bounds) => {
|
|
||||||
bar.active_popup = Some(label.clone());
|
|
||||||
|
|
||||||
// Anchor rect = the button's bounds, extended below the bar so the
|
|
||||||
// `Bottom` anchor point (bottom-center) sits POPUP_GAP below the
|
|
||||||
// button's bottom edge. With `Bottom` gravity the popup grows
|
|
||||||
// downward from there, leaving a gap under the bar.
|
|
||||||
let (x, y, w, h) = (
|
|
||||||
bounds.x.round() as i32,
|
|
||||||
bounds.y.round() as i32,
|
|
||||||
bounds.width.round() as i32,
|
|
||||||
bounds.height.round() as i32,
|
|
||||||
);
|
|
||||||
let anchor_rect = (x, y, w, h + POPUP_GAP);
|
|
||||||
let settings = iced_layershell::actions::IcedNewPopupSettings::on_current_surface(
|
|
||||||
(POPUP_W, POPUP_H),
|
|
||||||
anchor_rect,
|
|
||||||
)
|
|
||||||
.anchor(PopupAnchor::Bottom)
|
|
||||||
.gravity(PopupGravity::Bottom);
|
|
||||||
|
|
||||||
let (id, task) = Message::popup_open(settings);
|
|
||||||
bar.popup_id = Some(id);
|
|
||||||
|
|
||||||
task
|
|
||||||
}
|
|
||||||
|
|
||||||
Message::Module(label, msg) => {
|
|
||||||
let state = bar
|
|
||||||
.modules
|
|
||||||
.iter_mut()
|
|
||||||
.find(|m| m.label() == label)
|
|
||||||
.expect("unknown module label");
|
|
||||||
state.handle(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
Message::ClosePopup(id) => {
|
|
||||||
if bar.popup_id == Some(id) {
|
|
||||||
bar.popup_id = None;
|
|
||||||
bar.active_popup = None;
|
|
||||||
}
|
|
||||||
Task::done(Message::RemoveWindow(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
Message::Noop => Task::none(),
|
|
||||||
|
|
||||||
// Forward multi-window mutations to their internal handlers.
|
|
||||||
Message::AnchorChange { .. }
|
|
||||||
| Message::SetInputRegion { .. }
|
|
||||||
| Message::AnchorSizeChange { .. }
|
|
||||||
| Message::LayerChange { .. }
|
|
||||||
| Message::MarginChange { .. }
|
|
||||||
| Message::SizeChange { .. }
|
|
||||||
| Message::ExclusiveZoneChange { .. }
|
|
||||||
| Message::KeyboardInteractivityChange { .. }
|
|
||||||
| Message::NewBaseWindow { .. }
|
|
||||||
| Message::NewInputPanel { .. }
|
|
||||||
| Message::NewLayerShell { .. }
|
|
||||||
| Message::NewMenu { .. }
|
|
||||||
| Message::NewPopUp { .. }
|
|
||||||
| Message::RemoveWindow(_)
|
|
||||||
| Message::ForgetLastOutput
|
|
||||||
| Message::VirtualKeyboardPressed { .. } => Task::none(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_close_popup(bar: &mut Bar) -> Task<Message> {
|
|
||||||
if let Some(id) = bar.popup_id.take() {
|
|
||||||
bar.active_popup = None;
|
|
||||||
return Task::done(Message::RemoveWindow(id));
|
|
||||||
}
|
|
||||||
Task::none()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch view by which surface is being rendered.
|
|
||||||
fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> {
|
|
||||||
if bar.popup_id == Some(id) {
|
|
||||||
popup_view(bar)
|
|
||||||
} else {
|
|
||||||
bar_view(bar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Main bar widget tree.
|
|
||||||
fn bar_view(bar: &Bar) -> Element<'static, Message> {
|
|
||||||
let mut children: Vec<Element<Message>> = Vec::new();
|
|
||||||
|
|
||||||
for (i, module) in bar.modules.iter().enumerate() {
|
|
||||||
children.push(module.button());
|
|
||||||
if i < bar.modules.len() - 1 {
|
|
||||||
children.push(text("|").size(14).into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
container(
|
|
||||||
Row::from_vec(children)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.align_y(Alignment::Center),
|
|
||||||
)
|
|
||||||
.padding(8)
|
|
||||||
.align_x(Alignment::Center)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Popup widget tree. Rendered on its own floating LayerShell surface.
|
|
||||||
fn popup_view(bar: &Bar) -> Element<'static, Message> {
|
|
||||||
let label = bar
|
|
||||||
.active_popup
|
|
||||||
.as_ref()
|
|
||||||
.expect("popup open without active module");
|
|
||||||
let popup_id = bar.popup_id.expect("popup open without surface id");
|
|
||||||
|
|
||||||
let module = bar
|
|
||||||
.modules
|
|
||||||
.iter()
|
|
||||||
.find(|m| m.label() == label)
|
|
||||||
.expect("unknown module label");
|
|
||||||
|
|
||||||
module.popup(popup_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style {
|
|
||||||
use iced::theme::Style;
|
|
||||||
Style {
|
|
||||||
background_color: theme.palette().background,
|
|
||||||
text_color: theme.palette().text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
use std::any::Any;
|
|
||||||
use std::fmt::Debug;
|
|
||||||
|
|
||||||
use iced::widget::{container, mouse_area, text, Id};
|
|
||||||
use iced::{window, Alignment, Element, Length, Subscription, Task};
|
|
||||||
|
|
||||||
use crate::Message;
|
|
||||||
|
|
||||||
/// A module's own message type, boxed for transport through [`Message::Module`].
|
|
||||||
pub trait ModuleMsg: Any + Debug + Send + 'static {
|
|
||||||
fn clone_box(&self) -> Box<dyn ModuleMsg>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Clone for Box<dyn ModuleMsg> {
|
|
||||||
fn clone(&self) -> Self {
|
|
||||||
self.clone_box()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Any + Debug + Clone + Send + 'static> ModuleMsg for T {
|
|
||||||
fn clone_box(&self) -> Box<dyn ModuleMsg> {
|
|
||||||
Box::new((*self).clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Downcast a boxed module message to a concrete type.
|
|
||||||
pub fn downcast<T: Any>(boxed: Box<dyn ModuleMsg>) -> Result<Box<T>, ()> {
|
|
||||||
let erased: Box<dyn Any> = boxed;
|
|
||||||
match erased.downcast::<T>() {
|
|
||||||
Ok(t) => Ok(t),
|
|
||||||
Err(_) => Err(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Static module definition: how to construct the live state.
|
|
||||||
pub trait ModuleDef: Send + Sync {
|
|
||||||
fn new_state(&self) -> Box<dyn ModuleState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Live, object-safe module state held by the bar.
|
|
||||||
pub trait ModuleState: Send + 'static {
|
|
||||||
/// Stable label used as widget-tree id and message routing key.
|
|
||||||
fn label(&self) -> &'static str;
|
|
||||||
|
|
||||||
/// Stable widget-tree id used for popup bounds capture.
|
|
||||||
fn id(&self) -> Id {
|
|
||||||
Id::from(self.label().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the module's clickable bar segment.
|
|
||||||
fn button(&self) -> Element<'static, Message> {
|
|
||||||
container(
|
|
||||||
mouse_area(text(self.label()).size(14))
|
|
||||||
.on_press(Message::ModuleClicked(self.label().to_string())),
|
|
||||||
)
|
|
||||||
.id(self.id())
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a message routed to this module.
|
|
||||||
fn handle(&mut self, msg: Box<dyn ModuleMsg>) -> Task<Message>;
|
|
||||||
|
|
||||||
/// Render the popup body when this module's popup is open.
|
|
||||||
fn popup(&self, popup_id: window::Id) -> Element<'static, Message> {
|
|
||||||
let _ = popup_id;
|
|
||||||
text(format!("{} menu", self.label()))
|
|
||||||
.size(14)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.align_x(Alignment::Center)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subscriptions this module wants to run.
|
|
||||||
fn subscriptions(&self) -> Vec<Subscription<Message>> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registry of all bar modules, in display order.
|
|
||||||
pub static MODULES: &[&'static dyn ModuleDef] = &[
|
|
||||||
&crate::modules::workspaces::Workspaces,
|
|
||||||
&crate::modules::clock::Clock,
|
|
||||||
&crate::modules::system::System,
|
|
||||||
];
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
use iced::widget::{column, mouse_area, text};
|
|
||||||
use iced::{window, Alignment, Element, Length};
|
|
||||||
|
|
||||||
use crate::module::{ModuleDef, ModuleMsg, ModuleState};
|
|
||||||
use crate::Message;
|
|
||||||
|
|
||||||
pub struct Clock;
|
|
||||||
|
|
||||||
pub struct State;
|
|
||||||
|
|
||||||
impl ModuleDef for Clock {
|
|
||||||
fn new_state(&self) -> Box<dyn ModuleState> {
|
|
||||||
Box::new(State)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModuleState for State {
|
|
||||||
fn label(&self) -> &'static str {
|
|
||||||
"clock"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ponytail: clock has no internal messages yet; the tick subscription
|
|
||||||
// that gives it live time is the upgrade path (add `Msg` + `update`
|
|
||||||
// here when needed).
|
|
||||||
fn handle(&mut self, _msg: Box<dyn ModuleMsg>) -> iced::Task<Message> {
|
|
||||||
iced::Task::none()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn popup(&self, popup_id: window::Id) -> Element<'static, Message> {
|
|
||||||
let items: Vec<Element<'static, Message>> = ["Time format", "Date display"]
|
|
||||||
.into_iter()
|
|
||||||
.map(|label| {
|
|
||||||
mouse_area(text(label).size(13))
|
|
||||||
.on_press(Message::ClosePopup(popup_id))
|
|
||||||
.into()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
column![
|
|
||||||
text("clock menu")
|
|
||||||
.size(14)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.align_x(Alignment::Center),
|
|
||||||
column(items).spacing(1),
|
|
||||||
mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)),
|
|
||||||
]
|
|
||||||
.spacing(1)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod clock;
|
|
||||||
pub mod system;
|
|
||||||
pub mod workspaces;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
use iced::widget::{column, mouse_area, text};
|
|
||||||
use iced::{window, Alignment, Element, Length};
|
|
||||||
|
|
||||||
use crate::module::{ModuleDef, ModuleMsg, ModuleState};
|
|
||||||
use crate::Message;
|
|
||||||
|
|
||||||
pub struct System;
|
|
||||||
|
|
||||||
pub struct State;
|
|
||||||
|
|
||||||
impl ModuleDef for System {
|
|
||||||
fn new_state(&self) -> Box<dyn ModuleState> {
|
|
||||||
Box::new(State)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModuleState for State {
|
|
||||||
fn label(&self) -> &'static str {
|
|
||||||
"system"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ponytail: no internal messages yet; real brightness/volume/power
|
|
||||||
// actions (each with its own `Msg` variant) are the upgrade path.
|
|
||||||
fn handle(&mut self, _msg: Box<dyn ModuleMsg>) -> iced::Task<Message> {
|
|
||||||
iced::Task::none()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn popup(&self, popup_id: window::Id) -> Element<'static, Message> {
|
|
||||||
let items: Vec<Element<'static, Message>> = ["Brightness", "Volume", "Power"]
|
|
||||||
.into_iter()
|
|
||||||
.map(|label| {
|
|
||||||
mouse_area(text(label).size(13))
|
|
||||||
.on_press(Message::ClosePopup(popup_id))
|
|
||||||
.into()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
column![
|
|
||||||
text("system menu")
|
|
||||||
.size(14)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.align_x(Alignment::Center),
|
|
||||||
column(items).spacing(1),
|
|
||||||
mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)),
|
|
||||||
]
|
|
||||||
.spacing(1)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use iced::widget::{column, mouse_area, text, Row};
|
|
||||||
use iced::{window, Alignment, Element, Length, Task};
|
|
||||||
|
|
||||||
use crate::module::{downcast, ModuleDef, ModuleMsg, ModuleState};
|
|
||||||
use crate::Message;
|
|
||||||
|
|
||||||
pub struct Workspaces;
|
|
||||||
|
|
||||||
pub struct State {
|
|
||||||
workspaces: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum Msg {
|
|
||||||
Select(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModuleDef for Workspaces {
|
|
||||||
fn new_state(&self) -> Box<dyn ModuleState> {
|
|
||||||
Box::new(State {
|
|
||||||
workspaces: vec!["1".into(), "2".into(), "3".into(), "4".into()],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModuleState for State {
|
|
||||||
fn label(&self) -> &'static str {
|
|
||||||
"workspaces"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle(&mut self, msg: Box<dyn ModuleMsg>) -> Task<Message> {
|
|
||||||
let msg = *downcast::<Msg>(msg).expect("workspaces module received foreign message");
|
|
||||||
match msg {
|
|
||||||
Msg::Select(ws) => {
|
|
||||||
self.workspaces.sort_by_key(|w| w != &ws);
|
|
||||||
Task::none()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn popup(&self, popup_id: window::Id) -> Element<'static, Message> {
|
|
||||||
let items: Vec<Element<'static, Message>> = self
|
|
||||||
.workspaces
|
|
||||||
.iter()
|
|
||||||
.map(|ws| {
|
|
||||||
mouse_area(text(ws.clone()).size(13))
|
|
||||||
.on_press(Message::Module(
|
|
||||||
"workspaces".into(),
|
|
||||||
Box::new(Msg::Select(ws.clone())),
|
|
||||||
))
|
|
||||||
.into()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
column![
|
|
||||||
text("workspaces menu")
|
|
||||||
.size(14)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.align_x(Alignment::Center),
|
|
||||||
Row::from_vec(items).spacing(1),
|
|
||||||
mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)),
|
|
||||||
]
|
|
||||||
.spacing(1)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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