diff --git a/Cargo.lock b/Cargo.lock index 7701d39..1b4c869 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 3c5ff4d..ea18fba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/common/src/display.rs b/crates/common/src/display.rs index d79d2ce..879e5fe 100644 --- a/crates/common/src/display.rs +++ b/crates/common/src/display.rs @@ -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, } 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, } } } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index b371d9d..2dd1276 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -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}; diff --git a/crates/common/src/messages/services/datetime.rs b/crates/common/src/messages/services/datetime.rs index 04ddd11..89b1040 100644 --- a/crates/common/src/messages/services/datetime.rs +++ b/crates/common/src/messages/services/datetime.rs @@ -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), } diff --git a/crates/common/src/reusable_elements.rs b/crates/common/src/reusable_elements.rs new file mode 100644 index 0000000..9b957ee --- /dev/null +++ b/crates/common/src/reusable_elements.rs @@ -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() + } +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 7bd7796..3fc765c 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -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" } diff --git a/crates/core/src/main.rs b/crates/core/src/main.rs index 4c4cb6e..63f9f02 100644 --- a/crates/core/src/main.rs +++ b/crates/core/src/main.rs @@ -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() diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs new file mode 100644 index 0000000..17a48b6 --- /dev/null +++ b/crates/core/src/theme.rs @@ -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, + 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() +} diff --git a/crates/core/src/view.rs b/crates/core/src/view.rs index b418a9c..0ca9af9 100644 --- a/crates/core/src/view.rs +++ b/crates/core/src/view.rs @@ -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, } } diff --git a/crates/modules/src/audio.rs b/crates/modules/src/audio.rs index 2aaa3f8..84227f6 100644 --- a/crates/modules/src/audio.rs +++ b/crates/modules/src/audio.rs @@ -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() - } -} diff --git a/crates/modules/src/clock.rs b/crates/modules/src/clock.rs index bd7d329..d3e1bf1 100644 --- a/crates/modules/src/clock.rs +++ b/crates/modules/src/clock.rs @@ -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 { // The service publishes a raw `ClockPayload`; UI sends `ClockMsg`. if let Some(payload) = msg.downcast::() { - self.value = payload.value.clone(); + self.value = match payload { + ClockPayload::Mins(v) | ClockPayload::Seconds(v) => v.clone(), + }; return Task::none(); } match msg.downcast::() { diff --git a/crates/modules/src/weather.rs b/crates/modules/src/weather.rs index b88c289..c6864d1 100644 --- a/crates/modules/src/weather.rs +++ b/crates/modules/src/weather.rs @@ -76,7 +76,10 @@ impl BarModule for WeatherModule { } // Clock tick from the datetime service: track the current hour. if let Some(payload) = msg.downcast::() { - 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::() { diff --git a/crates/modules/src/workspaces.rs b/crates/modules/src/workspaces.rs index f27e02c..e3e37f7 100644 --- a/crates/modules/src/workspaces.rs +++ b/crates/modules/src/workspaces.rs @@ -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)) diff --git a/crates/services/src/datetime.rs b/crates/services/src/datetime.rs index b9ce727..dbf2d53 100644 --- a/crates/services/src/datetime.rs +++ b/crates/services/src/datetime.rs @@ -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."`. 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 } diff --git a/example_base16.yaml b/example_base16.yaml new file mode 100644 index 0000000..f73c33f --- /dev/null +++ b/example_base16.yaml @@ -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 diff --git a/test.toml b/test.toml index 50a2bcd..eaac046 100644 --- a/test.toml +++ b/test.toml @@ -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"]