From a14b6f4967ee471d6f9522515ec6ecffb707aaf1 Mon Sep 17 00:00:00 2001 From: Doloro1978 Date: Sun, 13 Sep 2026 18:15:22 +0100 Subject: [PATCH] hyprland workspace, service --- Cargo.lock | 134 ++++++++++++ crates/common/src/config.rs | 2 + .../common/src/messages/services/hyprland.rs | 33 +++ crates/common/src/messages/services/mod.rs | 2 + crates/core/src/view.rs | 13 +- crates/modules/src/lib.rs | 4 +- crates/modules/src/workspaces.rs | 140 ++++++++++++ crates/services/Cargo.toml | 3 +- crates/services/src/compositors/hyprland.rs | 205 ++++++++++++++++++ crates/services/src/compositors/mod.rs | 1 + crates/services/src/lib.rs | 4 + test.toml | 17 +- 12 files changed, 551 insertions(+), 7 deletions(-) create mode 100644 crates/common/src/messages/services/hyprland.rs create mode 100644 crates/modules/src/workspaces.rs create mode 100644 crates/services/src/compositors/hyprland.rs create mode 100644 crates/services/src/compositors/mod.rs diff --git a/Cargo.lock b/Cargo.lock index d0eb799..810b51f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,28 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -362,6 +384,17 @@ dependencies = [ "rand_core", ] +[[package]] +name = "chardetng" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + [[package]] name = "chrono" version = "0.4.45" @@ -510,6 +543,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core" version = "0.1.0" @@ -702,6 +744,29 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -765,6 +830,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "encoding_rs" version = "0.8.41" @@ -1002,6 +1073,16 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1376,6 +1457,35 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "hyprland" +version = "0.4.0-beta.3" +source = "git+https://github.com/hyprland-community/hyprland-rs?branch=master#c7037d4ce5c64e7f376a58802b806471792b0993" +dependencies = [ + "async-stream", + "chardetng", + "derive_more", + "either", + "encoding_rs", + "futures-lite", + "hyprland-macros", + "pastey", + "serde", + "serde_json", + "serde_repr", + "tokio", +] + +[[package]] +name = "hyprland-macros" +version = "0.4.0-beta.3" +source = "git+https://github.com/hyprland-community/hyprland-rs?branch=master#c7037d4ce5c64e7f376a58802b806471792b0993" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2610,6 +2720,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3251,6 +3367,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -3266,6 +3393,7 @@ version = "0.1.0" dependencies = [ "chrono", "common", + "hyprland", "iced", "reqwest", "serde", @@ -3977,6 +4105,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index a711a8c..e3627b8 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -15,6 +15,7 @@ pub struct BarbarConfig { pub enum Modules { Clock, Weather, + Workspaces, } impl Modules { @@ -22,6 +23,7 @@ impl Modules { match self { Modules::Clock => "clock", Modules::Weather => "weather", + Modules::Workspaces => "workspaces", } } } diff --git a/crates/common/src/messages/services/hyprland.rs b/crates/common/src/messages/services/hyprland.rs new file mode 100644 index 0000000..36f6df2 --- /dev/null +++ b/crates/common/src/messages/services/hyprland.rs @@ -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 }, +} diff --git a/crates/common/src/messages/services/mod.rs b/crates/common/src/messages/services/mod.rs index c374dd8..8690b19 100644 --- a/crates/common/src/messages/services/mod.rs +++ b/crates/common/src/messages/services/mod.rs @@ -1,7 +1,9 @@ //! Messages owned by services. mod datetime; +mod hyprland; mod weather; pub use datetime::*; +pub use hyprland::*; pub use weather::*; diff --git a/crates/core/src/view.rs b/crates/core/src/view.rs index 9aa1ce7..60f17a0 100644 --- a/crates/core/src/view.rs +++ b/crates/core/src/view.rs @@ -34,7 +34,8 @@ fn bar_view(bar: &Bar) -> Element<'_, Message> { .map(|w| Message::Event(Msg::Wire(w))) }) .collect::>>()) - .spacing(8); + .spacing(8) + .align_y(Alignment::Center); let middle = row(bar .order @@ -48,7 +49,8 @@ fn bar_view(bar: &Bar) -> Element<'_, Message> { .map(|w| Message::Event(Msg::Wire(w))) }) .collect::>>()) - .spacing(8); + .spacing(8) + .align_y(Alignment::Center); let right = row(bar .order .2 @@ -61,7 +63,8 @@ fn bar_view(bar: &Bar) -> Element<'_, Message> { .map(|w| Message::Event(Msg::Wire(w))) }) .collect::>>()) - .spacing(8); + .spacing(8) + .align_y(Alignment::Center); let row = row![ left, @@ -70,11 +73,13 @@ fn bar_view(bar: &Bar) -> Element<'_, Message> { space::horizontal(), right, ] - .width(Length::Fill); + .width(Length::Fill) + .align_y(Alignment::Center); container(row) .padding(8) .width(Length::Fill) + .height(Length::Fill) .align_y(Alignment::Center) .into() } diff --git a/crates/modules/src/lib.rs b/crates/modules/src/lib.rs index 00112c1..de5cc9c 100644 --- a/crates/modules/src/lib.rs +++ b/crates/modules/src/lib.rs @@ -3,6 +3,7 @@ mod clock; mod weather; +mod workspaces; pub use clock::{Clock, ClockError}; use common::BarModule; @@ -12,7 +13,8 @@ pub use weather::*; /// One instance of every enabled module, ready to insert by `id()`. pub fn all(module_config: Table) -> Vec> { vec![ - Box::new(clock::Clock::new(Some(module_config))), + Box::new(clock::Clock::new(Some(module_config.clone()))), Box::new(weather::WeatherModule::default()), + Box::new(workspaces::Workspaces::new(Some(module_config))), ] } diff --git a/crates/modules/src/workspaces.rs b/crates/modules/src/workspaces.rs new file mode 100644 index 0000000..f27e02c --- /dev/null +++ b/crates/modules/src/workspaces.rs @@ -0,0 +1,140 @@ +//! 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::widget::{container, row, space}; +use iced::{window, Color, Element, Length, Task}; +use serde::Deserialize; +use toml::Table; + +/// Focused workspace (gruvbox base10 / `Colors.primaryText`). +const ACTIVE: Color = Color::from_rgb(0.9216, 0.8588, 0.6980); +/// Workspace exists but is not focused (base06 / `Colors.textSecondary`). +const EXISTS: Color = Color::from_rgb(0.4863, 0.4353, 0.3922); +/// Workspace does not exist (base04 / `Colors.textDisabled`). +const ABSENT: Color = Color::from_rgb(0.3137, 0.2863, 0.2706); + +/// 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) -> Option { + let ws = table?.get("workspaces")?.clone(); + ws.try_into().ok() +} + +pub struct Workspaces { + cfg: WorkspacesConfig, + active: Option, + existing: BTreeSet, +} + +impl Workspaces { + pub fn new(config: Option
) -> Self { + Self { + cfg: config_workspaces(config).unwrap_or_default(), + active: None, + existing: BTreeSet::new(), + } + } + + fn cube(&self, id: i32) -> Element<'_, Wire> { + let (color, scale) = if self.active == Some(id) { + (ACTIVE, self.cfg.scale_active) + } else if self.existing.contains(&id) { + (EXISTS, self.cfg.scale_exists) + } else { + (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 |_| container::Style { + background: Some(color.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) -> Element<'_, Wire> { + row((1..=self.cfg.count).map(|id| self.cube(id))) + .spacing(self.cfg.gap) + .into() + } + + fn update(&mut self, msg: Wire) -> Task { + if let Some(event) = msg.downcast::() { + 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 { + vec![HyprlandMsg::Events.key()] + } +} diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index a20f044..b9817e4 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -10,9 +10,10 @@ path = "src/lib.rs" [dependencies] common = { path = "../common" } iced = { workspace = true } -tokio = { 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"] } +hyprland = { git = "https://github.com/hyprland-community/hyprland-rs", branch = "master", default-features = false, features = ["listener", "tokio"] } diff --git a/crates/services/src/compositors/hyprland.rs b/crates/services/src/compositors/hyprland.rs new file mode 100644 index 0000000..e2b604f --- /dev/null +++ b/crates/services/src/compositors/hyprland.rs @@ -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 { + 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| 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, key: &'static str) -> bool { + if let Some(list) = query::>("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::("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::>("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::("activewindow").await { + let event = HyprlandEvent::ActiveWindow { + class: c.class, + title: c.title, + }; + if !publish(sender, key, event).await { + return false; + } + } + true +} + +/// Runs `j/` 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 Deserialize<'de>>(cmd: &str) -> Option { + 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, 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 { + 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(), + } +} diff --git a/crates/services/src/compositors/mod.rs b/crates/services/src/compositors/mod.rs new file mode 100644 index 0000000..5b745cb --- /dev/null +++ b/crates/services/src/compositors/mod.rs @@ -0,0 +1 @@ +pub mod hyprland; diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 1682e8b..265da28 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -6,6 +6,7 @@ use iced::Subscription; use common::{Inbox, Service, Wire}; +pub mod compositors; pub mod datetime; pub mod weather; @@ -23,6 +24,9 @@ impl IntoSubscription for Service { let key = self.0; match key { 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), _ => { tracing::warn!(service = key, "no service impl for route key"); diff --git a/test.toml b/test.toml index 40eb8fb..2590424 100644 --- a/test.toml +++ b/test.toml @@ -1,9 +1,24 @@ monitor = "DP-2" [order] -left = ["weather"] +left = ["workspaces", "weather"] middle = [] right = ["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