This commit is contained in:
2026-09-14 00:55:25 +01:00
parent ec43d7b57a
commit e8c0e79bae
17 changed files with 183 additions and 46 deletions
Generated
+27
View File
@@ -563,6 +563,8 @@ dependencies = [
"iced",
"iced_layershell",
"modules",
"serde",
"serde_yaml",
"services",
"tokio",
"toml",
@@ -3352,6 +3354,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3493,6 +3501,19 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "services"
version = "0.1.0"
@@ -4225,6 +4246,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
+1
View File
@@ -16,6 +16,7 @@ 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"] }
+5
View File
@@ -7,6 +7,7 @@ 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";
@@ -37,6 +38,9 @@ pub struct Display {
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 {
@@ -48,6 +52,7 @@ impl Default for Display {
default_text_size: DEFAULT_TEXT_SIZE,
padding: DEFAULT_PADDING,
spacing: DEFAULT_SPACING,
theme: None,
}
}
}
+2
View File
@@ -9,6 +9,7 @@ mod display;
mod effect;
mod messages;
mod module;
mod reusable_elements;
mod service;
mod wire;
@@ -17,5 +18,6 @@ pub use display::Display;
pub use effect::ModuleEffect;
pub use messages::*;
pub use module::BarModule;
pub use reusable_elements::pill;
pub use service::Service;
pub use wire::{Endpoint, Inbox, Namespace, Target, Wire};
@@ -19,9 +19,9 @@ impl ClockKind {
}
}
/// Clock tick payload: the value plus the kind that produced it.
/// Clock tick: the kind is the variant, the rendered time the parameter.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ClockPayload {
pub kind: ClockKind,
pub value: String,
pub enum ClockPayload {
Mins(String),
Seconds(String),
}
+15
View File
@@ -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()
}
}
+2
View File
@@ -16,6 +16,8 @@ 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" }
+5
View File
@@ -16,6 +16,7 @@ mod args;
mod msg;
mod popup;
mod subscription;
mod theme;
mod update;
mod view;
@@ -58,6 +59,9 @@ fn main() -> Result<(), iced_layershell::Error> {
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(
@@ -76,6 +80,7 @@ fn main() -> Result<(), iced_layershell::Error> {
view::view,
)
.style(view::style)
.theme(move |_: &Bar, _: iced::window::Id| theme.clone())
.subscription(subscription::gather_subscriptions)
.settings(settings)
.run()
+60
View File
@@ -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()
}
+1 -1
View File
@@ -87,7 +87,7 @@ fn bar_view(bar: &Bar) -> Element<'_, Message> {
pub(crate) fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style {
use iced::theme::Style;
Style {
background_color: theme.palette().background,
background_color: iced::Color::TRANSPARENT,
text_color: theme.palette().text,
}
}
+2 -12
View File
@@ -2,9 +2,9 @@
//! something is muted — the signal the quickshell Audio widget gives.
use iced::widget::{container, row, space, text};
use iced::{border, Alignment, Element, Length, Task, Theme};
use iced::{Alignment, Element, Length, Task};
use common::{AudioNode, BarModule, ModuleEffect, PipewireMsg, PipewireState, Service, Wire};
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.
@@ -19,7 +19,6 @@ const MIC_MUTED: &str = "\u{f131}"; // fa-microphone_slash
/// 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;
const REF_RADIUS: f32 = 3.5;
/// 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;
@@ -95,12 +94,3 @@ fn glyph(node: AudioNode, on: &'static str, muted: &'static str) -> &'static str
on
}
}
/// The quickshell pill: rounded, a shade lighter than the bar behind it.
fn pill(theme: &Theme) -> container::Style {
container::Style {
background: Some(theme.extended_palette().background.weak.color.into()),
border: border::rounded(REF_RADIUS),
..container::Style::default()
}
}
+13 -3
View File
@@ -3,9 +3,11 @@
//! service to switch which kind it publishes; right-click opens a popup.
use iced::widget::{container, mouse_area, text};
use iced::{Element, Task};
use iced::{Element, Padding, Task};
use common::{BarModule, ClockKind, ClockMsg, ClockPayload, Endpoint, ModuleEffect, Service, Wire};
use common::{
pill, BarModule, ClockKind, ClockMsg, ClockPayload, Endpoint, ModuleEffect, Service, Wire,
};
use serde::Deserialize;
use toml::Table;
@@ -88,6 +90,12 @@ impl BarModule for Clock {
.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(),
}
@@ -96,7 +104,9 @@ impl BarModule for Clock {
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 = payload.value.clone();
self.value = match payload {
ClockPayload::Mins(v) | ClockPayload::Seconds(v) => v.clone(),
};
return Task::none();
}
match msg.downcast::<ClockMsg>() {
+4 -1
View File
@@ -76,7 +76,10 @@ impl BarModule for WeatherModule {
}
// Clock tick from the datetime service: track the current hour.
if let Some(payload) = msg.downcast::<ClockPayload>() {
self.hour = payload.value.split(':').next().and_then(|h| h.parse().ok());
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>() {
+19 -15
View File
@@ -10,17 +10,18 @@ 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, Color, Element, Length, Task};
use iced::{window, Element, Length, Task, Theme};
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);
/// 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
@@ -81,21 +82,24 @@ impl Workspaces {
}
fn cube(&self, id: i32) -> Element<'_, Wire> {
let (color, scale) = if self.active == Some(id) {
(ACTIVE, self.cfg.scale_active)
let (fade, scale) = if self.active == Some(id) {
(FADE_ACTIVE, self.cfg.scale_active)
} else if self.existing.contains(&id) {
(EXISTS, self.cfg.scale_exists)
(FADE_EXISTS, self.cfg.scale_exists)
} else {
(ABSENT, self.cfg.scale_absent)
(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 |_| container::Style {
background: Some(color.into()),
border: border::rounded(radius),
..Default::default()
.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))
+4 -10
View File
@@ -11,7 +11,7 @@ use chrono::{DateTime, Local, Timelike};
use iced::futures::{channel::mpsc, SinkExt};
use iced::Subscription;
use common::{ClockKind, ClockPayload, Endpoint, Inbox, Wire};
use common::{ClockPayload, Endpoint, Inbox, Wire};
/// Route-key namespace owned by this service; keys are `"clock.<kind>"`.
pub const NAMESPACE: &str = "clock.";
@@ -44,7 +44,7 @@ impl ClockTicker {
tokio::select! {
_ = interval.tick() => {
if let Some(payload) = clock.tick() {
tracing::debug!(key, kind = ?payload.kind, "publishing clock");
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");
@@ -63,16 +63,10 @@ impl ClockTicker {
let value = chrono::Local::now();
let meow = self.last;
if meow.minute() != value.minute() {
return Some(ClockPayload {
kind: ClockKind::Mins,
value: value.format("%H:%M:%S").to_string(),
});
return Some(ClockPayload::Mins(value.format("%H:%M:%S").to_string()));
}
if meow.second() != value.second() {
return Some(ClockPayload {
kind: ClockKind::Seconds,
value: value.format("%H:%M:%S").to_string(),
});
return Some(ClockPayload::Seconds(value.format("%H:%M:%S").to_string()));
}
None
}
+18
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@ default_text_size = 14.0
padding = 4.0
# Gap between modules in the bar, logical px.
spacing = 8.0
theme = 'example_base16.yaml'
[order]
left = ["workspaces", "weather"]