hyprland workspace, service

This commit is contained in:
2026-09-13 18:15:22 +01:00
parent 57201bc0ff
commit a14b6f4967
12 changed files with 551 additions and 7 deletions
+2
View File
@@ -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",
}
}
}
@@ -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 },
}
@@ -1,7 +1,9 @@
//! Messages owned by services.
mod datetime;
mod hyprland;
mod weather;
pub use datetime::*;
pub use hyprland::*;
pub use weather::*;
+9 -4
View File
@@ -34,7 +34,8 @@ fn bar_view(bar: &Bar) -> Element<'_, Message> {
.map(|w| Message::Event(Msg::Wire(w)))
})
.collect::<Vec<Element<Message>>>())
.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::<Vec<Element<Message>>>())
.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::<Vec<Element<Message>>>())
.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()
}
+3 -1
View File
@@ -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<Box<dyn BarModule>> {
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))),
]
}
+140
View File
@@ -0,0 +1,140 @@
//! Workspaces module: renders workspaces 110 as cubes, coloured and scaled by
//! state — focused largest, existing mid, non-existent smallest. State arrives
//! as `HyprlandEvent`s from the hyprland service.
//!
//! Ported from the Quickshell `Workspaces.qml`: constant 10×15 slot per
//! workspace, corner radius 2.5, and the inactive cubes scaled about their
//! centre (0.85 existing / 0.75 absent) so the shrink never shifts the row.
use std::collections::BTreeSet;
use common::{BarModule, HyprlandEvent, HyprlandMsg, ModuleEffect, Service, Wire};
use iced::border;
use iced::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<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 (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<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()]
}
}
+2 -1
View File
@@ -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"] }
+205
View File
@@ -0,0 +1,205 @@
//! Hyprland compositor service: listens on the compositor's event socket and
//! republishes focus changes (`HyprlandEvent`) to modules subscribed to
//! `hyprland.events`.
//!
//! Seeds the workspace list and current focus on startup so subscribers have
//! state before the first event — the event socket only reports changes.
//!
//! The startup snapshot is queried over Hyprland's command socket directly:
//! the `hyprland` crate's `data` types cannot deserialize 0.56 responses
//! (`Workspace.id` was dropped), though its event stream still works.
use std::path::PathBuf;
use common::{Endpoint, HyprlandEvent, Inbox, Wire};
use hyprland::event_listener::{Event, EventStream};
use iced::futures::{channel::mpsc, SinkExt, StreamExt};
use iced::Subscription;
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
/// Route-key namespace owned by this service; the key is `"hyprland.events"`.
pub const NAMESPACE: &str = "hyprland.";
/// A workspace as far as the bar cares — only the name, which for numbered
/// workspaces is also the id.
#[derive(Deserialize)]
struct WorkspaceState {
name: String,
}
#[derive(Deserialize)]
struct ClientState {
class: String,
title: String,
}
#[derive(Deserialize)]
struct MonitorState {
name: String,
focused: bool,
}
pub struct HyprlandService;
impl HyprlandService {
pub fn run(inbox: Inbox) -> Subscription<Wire> {
Subscription::run_with(inbox, |inbox| {
let key = inbox.key();
let mut _rx = inbox.take().expect("inbox receiver is taken once");
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
tracing::info!(key, "hyprland service started");
if !seed(&mut sender, key).await {
return;
}
let mut stream = EventStream::new();
while let Some(event) = stream.next().await {
let event = match event {
Ok(event) => event,
Err(e) => {
tracing::warn!(error = %e, "hyprland event stream error");
continue;
}
};
tracing::debug!(?event, "hyprland raw event");
for event in translate(event) {
if !publish(&mut sender, key, event).await {
return;
}
}
}
tracing::warn!(key, "hyprland event stream ended");
})
})
}
}
/// Publishes the current compositor state before live events start; `false`
/// if the subscriber channel is gone.
async fn seed(sender: &mut mpsc::Sender<Wire>, key: &'static str) -> bool {
if let Some(list) = query::<Vec<WorkspaceState>>("workspaces").await {
for ws in list {
let event = HyprlandEvent::WorkspaceAdded {
id: workspace_id(&ws.name, -1),
name: ws.name,
};
if !publish(sender, key, event).await {
return false;
}
}
}
if let Some(ws) = query::<WorkspaceState>("activeworkspace").await {
let event = HyprlandEvent::WorkspaceChanged {
id: workspace_id(&ws.name, -1),
name: ws.name,
};
if !publish(sender, key, event).await {
return false;
}
}
if let Some(m) = query::<Vec<MonitorState>>("monitors")
.await
.and_then(|list| list.into_iter().find(|m| m.focused))
{
if !publish(sender, key, HyprlandEvent::Monitor { name: m.name }).await {
return false;
}
}
if let Some(c) = query::<ClientState>("activewindow").await {
let event = HyprlandEvent::ActiveWindow {
class: c.class,
title: c.title,
};
if !publish(sender, key, event).await {
return false;
}
}
true
}
/// Runs `j/<cmd>` against Hyprland's command socket and parses the reply.
/// `None` if Hyprland isn't running, the query fails, or the shape is unknown.
async fn query<T: for<'de> Deserialize<'de>>(cmd: &str) -> Option<T> {
let runtime = std::env::var_os("XDG_RUNTIME_DIR")?;
let signature = std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE")?;
let path = PathBuf::from(runtime)
.join("hypr")
.join(signature)
.join(".socket.sock");
let mut stream = UnixStream::connect(path).await.ok()?;
stream.write_all(format!("j/{cmd}").as_bytes()).await.ok()?;
let mut reply = String::new();
stream.read_to_string(&mut reply).await.ok()?;
serde_json::from_str(&reply).ok()
}
/// Workspace id for the bar: the numeric name when it is one (numbered
/// workspaces), else `fallback` (the event's own id, `-1` when unknown).
fn workspace_id(name: &str, fallback: i32) -> i32 {
name.parse().unwrap_or(fallback)
}
/// Publishes one event to subscribers; `false` if the channel is gone.
async fn publish(sender: &mut mpsc::Sender<Wire>, key: &'static str, event: HyprlandEvent) -> bool {
tracing::debug!(?event, "hyprland event");
let wire = Wire::topic(Endpoint::service(key), key, event);
if sender.send(wire).await.is_err() {
tracing::warn!(key, "hyprland send failed; stopping service");
return false;
}
true
}
/// Maps one crate event onto the protocol payload(s); empty for events the
/// bar does not publish. `focusedmon` yields two: the monitor change *and* the
/// workspace that monitor just focused.
fn translate(event: Event) -> Vec<HyprlandEvent> {
match event {
Event::WorkspaceChanged(w) => {
let name = w.name.to_string();
vec![HyprlandEvent::WorkspaceChanged {
id: workspace_id(&name, w.id),
name,
}]
}
Event::WorkspaceAdded(w) => {
let name = w.name.to_string();
vec![HyprlandEvent::WorkspaceAdded {
id: workspace_id(&name, w.id),
name,
}]
}
Event::WorkspaceDeleted(w) => {
let name = w.name.to_string();
vec![HyprlandEvent::WorkspaceRemoved {
id: workspace_id(&name, w.id),
name,
}]
}
Event::ActiveWindowChanged(w) => vec![HyprlandEvent::ActiveWindow {
class: w.as_ref().map(|w| w.class.clone()).unwrap_or_default(),
title: w.map(|w| w.title).unwrap_or_default(),
}],
Event::ActiveMonitorChanged(m) => {
let mut events = vec![HyprlandEvent::Monitor {
name: m.monitor_name,
}];
// Focusing a monitor also focuses its workspace; switching to a
// workspace on another monitor fires only this event, so without
// it the bar's active workspace never updates.
if let Some(ws) = m.workspace_name {
let name = ws.to_string();
events.push(HyprlandEvent::WorkspaceChanged {
id: workspace_id(&name, -1),
name,
});
}
events
}
_ => Vec::new(),
}
}
+1
View File
@@ -0,0 +1 @@
pub mod hyprland;
+4
View File
@@ -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");