This commit is contained in:
2026-09-12 21:50:09 +01:00
parent b20e03def7
commit b81eb967ee
17 changed files with 468 additions and 14 deletions
Generated
+25
View File
@@ -485,8 +485,10 @@ dependencies = [
name = "common"
version = "0.1.0"
dependencies = [
"chrono",
"iced",
"serde",
"serde_json",
"toml",
]
@@ -2912,6 +2914,8 @@ dependencies = [
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
@@ -3180,6 +3184,19 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
@@ -3197,6 +3214,8 @@ dependencies = [
"common",
"iced",
"reqwest",
"serde",
"serde_json",
"tokio",
]
@@ -4847,3 +4866,9 @@ dependencies = [
"quote",
"syn 3.0.5",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+1
View File
@@ -13,6 +13,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"
chrono = "0.4"
toml = "1.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
clap = { version = "4.6.6", features = ["derive", "help"] }
+2
View File
@@ -11,3 +11,5 @@ path = "src/lib.rs"
iced = { workspace = true }
toml = { workspace = true }
serde = { workspace = true }
chrono = { workspace = true }
serde_json = { workspace = true }
+2
View File
@@ -14,6 +14,8 @@ mod wire;
pub use config::BarbarConfig;
pub use effect::ModuleEffect;
pub use messages::{ClockKind, ClockMsg, ClockPayload};
pub use messages::modules::WeatherKind;
pub use messages::services::WeatherResponse;
pub use module::BarModule;
pub use service::Service;
pub use wire::{Endpoint, Inbox, Namespace, Target, Wire};
@@ -1,5 +1,7 @@
//! Messages owned by bar modules.
mod clock;
mod weather;
pub use clock::ClockMsg;
pub use weather::WeatherKind;
@@ -0,0 +1,7 @@
//! Weather module messages.
/// A module-local message for the weather tile (bar/popup interactions).
#[derive(Clone)]
pub enum WeatherKind {
Poke,
}
@@ -1,5 +1,7 @@
//! Messages owned by services.
mod datetime;
mod weather;
pub use datetime::{ClockKind, ClockPayload};
pub use weather::{WeatherKind, WeatherPayload, WeatherResponse};
@@ -0,0 +1,77 @@
use std::{any::Any, sync::Arc};
use serde::Deserialize;
use crate::Service;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum WeatherKind {
State,
}
impl WeatherKind {
pub fn key(self) -> Service {
Service(match self {
Self::State => "weather.state",
})
}
}
#[derive(Clone, Debug)]
pub struct WeatherPayload {
pub kind: WeatherKind,
pub payload: Arc<dyn Any + Send + Sync>,
}
/// Open-Meteo forecast response.
#[derive(Debug, Deserialize)]
pub struct WeatherResponse {
pub latitude: f64,
pub longitude: f64,
pub generationtime_ms: f64,
pub utc_offset_seconds: i64,
pub timezone: String,
pub timezone_abbreviation: String,
pub elevation: f64,
pub hourly_units: HourlyUnits,
pub hourly: Hourly,
}
#[derive(Debug, Deserialize)]
pub struct HourlyUnits {
pub time: String,
pub temperature_2m: String,
pub rain: String,
pub showers: String,
pub precipitation: String,
pub relative_humidity_2m: String,
}
#[derive(Debug, Deserialize)]
pub struct Hourly {
#[serde(deserialize_with = "naive_dt::deserialize")]
pub time: Vec<chrono::NaiveDateTime>,
pub temperature_2m: Vec<f64>,
pub rain: Vec<f64>,
pub showers: Vec<f64>,
pub precipitation: Vec<f64>,
pub relative_humidity_2m: Vec<f64>,
}
/// Open-Meteo emits local timestamps as `"%Y-%m-%dT%H:%M"` (no seconds).
mod naive_dt {
use chrono::NaiveDateTime;
use serde::{de::Error, Deserialize, Deserializer};
const FMT: &str = "%Y-%m-%dT%H:%M";
pub fn deserialize<'de, D>(d: D) -> Result<Vec<NaiveDateTime>, D::Error>
where
D: Deserializer<'de>,
{
Vec::<String>::deserialize(d)?
.iter()
.map(|s| NaiveDateTime::parse_from_str(s, FMT).map_err(D::Error::custom))
.collect()
}
}
+7 -1
View File
@@ -11,6 +11,8 @@ pub(crate) struct Bar {
pub(crate) active_popup: Option<String>,
/// Module registry keyed by stable module id.
pub(crate) modules: BTreeMap<&'static str, Box<dyn BarModule>>,
/// Left, Middle, Right ; Module Order
pub(crate) order: (Vec<String>, Vec<String>, Vec<String>),
/// Surface id of the open popup.
pub(crate) popup_id: Option<window::Id>,
/// Fan-out routing table: module id -> services it wants.
@@ -21,7 +23,10 @@ pub(crate) struct Bar {
}
impl Bar {
pub(crate) fn new(modules_config_table: &Table) -> Self {
pub(crate) fn new(
modules_config_table: &Table,
modules_order: (Vec<String>, Vec<String>, Vec<String>),
) -> Self {
// Registry owns construction: adding a module never edits this file.
let modules: BTreeMap<&'static str, Box<dyn BarModule>> =
modules::all(modules_config_table.clone())
@@ -41,6 +46,7 @@ impl Bar {
Self {
active_popup: None,
modules,
order: modules_order,
popup_id: None,
routes,
inputs,
+2 -6
View File
@@ -1,4 +1,4 @@
use iced::widget::{column, container, text};
use iced::widget::{container, row, text};
use iced::{window, Alignment, Element, Length, Theme};
use crate::app::Bar;
@@ -24,11 +24,7 @@ fn bar_view(bar: &Bar) -> Element<'_, Message> {
}
}
container(
column(children)
.width(Length::Fill)
.align_x(Alignment::Center),
)
container(row(children).width(Length::Fill).align_y(Alignment::Center))
.padding(8)
.align_x(Alignment::Center)
.into()
+110
View File
@@ -0,0 +1,110 @@
# modules
Bar modules. Each module renders a piece of the bar (or its popup) and is
object-safe behind `common::BarModule`, so core holds them as
`Box<dyn BarModule>` and never knows their message types — everything crosses
the `common::Wire` boundary.
Message types are shared, so they live in the `common` crate, under
`common/src/messages/modules/`. That is what lets a module and core name the
same payload without either depending on the other.
## Getting started: add a module
1. Declare the module's messages in `crates/common/src/messages/modules/greeter.rs`:
```rust
//! Greeter module messages.
#[derive(Clone)]
pub enum GreeterMsg {
Poke,
}
```
2. Re-export them from `crates/common/src/messages/modules/mod.rs`:
```rust
mod clock;
mod greeter;
pub use clock::ClockMsg;
pub use greeter::GreeterMsg;
```
3. Create `crates/modules/src/greeter.rs`:
```rust
use std::error::Error;
use iced::widget::text;
use iced::{Element, Task};
use common::{BarModule, Endpoint, GreeterMsg, ModuleEffect, Service, Wire};
pub struct Greeter {
text: String,
}
impl Default for Greeter {
fn default() -> Self {
Self { text: "hi".into() }
}
}
impl BarModule for Greeter {
fn id(&self) -> &'static str {
"greeter"
}
fn view(&self, _window_id: Option<iced::window::Id>) -> Element<'_, Wire> {
text(&self.text).into()
}
fn update(&mut self, msg: Wire) -> Task<ModuleEffect> {
match msg.downcast::<GreeterMsg>() {
Some(GreeterMsg::Poke) => {
self.text = "poked".into();
Task::none()
}
None => Task::none(),
}
}
/// Route keys this module subscribes to.
fn services(&self) -> Vec<Service> {
Vec::new(
}
/// Optional: read `module.greeter` from the config table.
fn config(&mut self, _config: toml::Table) -> Result<(), Box<dyn Error>> {
Ok(())
}
}
```
4. Register it in `crates/modules/src/lib.rs`'s `all()` and re-export it:
```rust
mod greeter;
pub use greeter::Greeter;
pub fn all(module_config: toml::Table) -> Vec<Box<dyn BarModule>> {
vec![
Box::new(clock::Clock::new(Some(module_config.clone()))),
Box::new(greeter::Greeter::default()),
]
}
```
That's it — core picks it up by `id()`. Use `wire` targeting:
```rust
// Module-local message back to yourself:
Wire::module(Endpoint::module(self.id()), self.id(), GreeterMsg::Poke)
```
To talk to a service, add it to `services()` and send via
`Wire::service(...)`; service ticks arrive in `update()` and you `downcast`
to the payload type (e.g. `common::ClockPayload`). See `src/clock.rs` for a
full example.
+6 -1
View File
@@ -2,12 +2,17 @@
//! a new file under `src/` plus one entry in `all()` — core never changes.
mod clock;
mod weather;
pub use clock::{Clock, ClockError};
use common::BarModule;
use toml::Table;
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)))]
vec![
Box::new(clock::Clock::new(Some(module_config))),
Box::new(weather::WeatherModule::default()),
]
}
+53
View File
@@ -0,0 +1,53 @@
use std::error::Error;
use common::{BarModule, Endpoint, ModuleEffect, Service, WeatherKind, Wire};
use iced::{
widget::{container, mouse_area, text},
Element, Task,
};
pub struct WeatherModule {
text: String,
}
impl Default for WeatherModule {
fn default() -> Self {
Self { text: "hi".into() }
}
}
impl BarModule for WeatherModule {
fn id(&self) -> &'static str {
"weather"
}
fn view(&self, _window_id: Option<iced::window::Id>) -> Element<'_, Wire> {
let me = Endpoint::module(self.id());
container(mouse_area(text(&self.text).size(16)).on_press(Wire::module(
me,
self.id(),
WeatherKind::Poke,
)))
.into()
}
fn update(&mut self, msg: Wire) -> Task<ModuleEffect> {
match msg.downcast::<WeatherKind>() {
Some(WeatherKind::Poke) => {
self.text = "poked".into();
Task::none()
}
None => Task::none(),
}
}
/// Route keys this module subscribes to.
fn services(&self) -> Vec<Service> {
vec![]
}
/// Optional: read `module.weather` from the config table.
fn config(&mut self, _config: toml::Table) -> Result<(), Box<dyn Error>> {
Ok(())
}
}
+3 -1
View File
@@ -12,4 +12,6 @@ common = { path = "../common" }
iced = { workspace = true }
tokio = { workspace = true }
chrono = { workspace = true }
reqwest = "0.13.5"
serde = { workspace = true }
serde_json = { workspace = true }
reqwest = { version = "0.13.5", features = ["json"] }
+113
View File
@@ -0,0 +1,113 @@
# services
Background services. A service owns a route key, runs as an iced
`Subscription`, receives `common::Wire`s on its `Inbox`, and publishes
payloads back to every module subscribed to its key. Core maps a `Service`
route key to a subscription through `IntoSubscription`.
Message types are shared, so they live in the `common` crate, under
`common/src/messages/services/`. That is what lets a service and the modules
that consume it name the same payload without either depending on the other.
## Getting started: add a service
1. Declare the service's route key and payload in
`crates/common/src/messages/services/greeter.rs`:
```rust
//! Greeter service messages.
use crate::Service;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum GreeterKind {
Hello,
}
impl GreeterKind {
/// Route key a module subscribes under to receive this kind's ticks.
pub fn key(self) -> Service {
Service(match self {
Self::Hello => "greeter.hello",
})
}
}
#[derive(Clone)]
pub struct GreeterPayload {
pub value: String,
}
```
2. Re-export them from `crates/common/src/messages/services/mod.rs`:
```rust
mod datetime;
mod greeter;
mod weather;
pub use datetime::{ClockKind, ClockPayload};
pub use greeter::{GreeterKind, GreeterPayload};
pub use weather::*;
```
3. Create `crates/services/src/greeter.rs`:
```rust
use std::time::Duration;
use common::{Endpoint, Inbox, Wire, GreeterPayload};
use iced::{futures::channel::mpsc, Subscription};
/// Route-key namespace owned by this service; keys look like `"greeter.hello"`.
pub const NAMESPACE: &str = "greeter.";
pub struct GreeterService;
impl GreeterService {
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 {
let mut interval = tokio::time::interval(Duration::from_secs(5));
loop {
interval.tick().await;
// Fan-out: publish to every module subscribed to `key`.
let wire = Wire::topic(Endpoint::service(key), key, GreeterPayload {
value: "hello".into(),
});
if sender.send(wire).await.is_err() {
return;
}
}
})
})
}
}
```
4. Add one arm to the `IntoSubscription` match in `crates/services/src/lib.rs`:
```rust
pub mod greeter;
impl IntoSubscription for Service {
fn into_subscription(self, inbox: Inbox) -> Subscription<Wire> {
match self.0 {
key if key.starts_with(datetime::NAMESPACE) => datetime::ClockTicker::run(inbox),
key if key.starts_with(weather::NAMESPACE) => weather::WeatherService::run(inbox),
key if key.starts_with(greeter::NAMESPACE) => greeter::GreeterService::run(inbox),
_ => Subscription::none(),
}
}
}
```
Then a module subscribes by returning `GreeterKind::Hello.key()` from its
`services()` and downcasts `GreeterPayload` in `update()`.
`run_with(inbox, ..)` keys the subscription by the inbox route key, so the
same subscription returned on every rebuild is not restarted. The receiver is
taken once inside the closure — don't call `take()` anywhere else.
+1 -1
View File
@@ -22,7 +22,7 @@ impl IntoSubscription for Service {
fn into_subscription(self, inbox: Inbox) -> Subscription<Wire> {
match self.0 {
key if key.starts_with(datetime::NAMESPACE) => datetime::ClockTicker::run(inbox),
key if key.starts_with(weather::NAMESPACE) => datetime::ClockTicker::run(inbox),
key if key.starts_with(weather::NAMESPACE) => weather::WeatherService::run(inbox),
_ => Subscription::none(),
}
}
+52 -1
View File
@@ -1,2 +1,53 @@
pub struct WeatherService {}
use std::time::Duration;
use common::{Inbox, WeatherResponse, Wire};
use iced::{futures::channel::mpsc, Subscription};
use tokio::time::interval;
pub const NAMESPACE: &str = "weather.";
const FORECAST_URL: &str = "https://api.open-meteo.com/v1/forecast\
?latitude=51.3714&longitude=-0.2302\
&hourly=temperature_2m,rain,showers,precipitation,relative_humidity_2m";
pub struct WeatherService {
client: reqwest::Client,
latest: Option<WeatherResponse>,
}
impl WeatherService {
fn new() -> Self {
Self {
client: reqwest::Client::default(),
latest: None,
}
}
pub fn run(inbox: Inbox) -> Subscription<Wire> {
Subscription::run_with(inbox, |inbox| {
let _key = inbox.key();
let mut _rx = inbox.take().expect("cant take");
iced::stream::channel(0, move |mut _sender: mpsc::Sender<Wire>| async move {
let mut weather = WeatherService::new();
let mut interval = interval(Duration::from_mins(30));
loop {
tokio::select! {
_ = interval.tick() => {
weather.latest = weather.get_weather().await;
}
}
}
})
})
}
async fn get_weather(&self) -> Option<WeatherResponse> {
self.client
.get(FORECAST_URL)
.send()
.await
.ok()?
.json::<WeatherResponse>()
.await
.ok()
}
}