//! 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) -> 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 (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) -> 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()] } }