From b81eb967ee5bd050167a92420f681f9a8a9f9c36 Mon Sep 17 00:00:00 2001 From: Doloro1978 Date: Sat, 12 Sep 2026 21:50:09 +0100 Subject: [PATCH] changes --- Cargo.lock | 25 ++++ Cargo.toml | 1 + crates/common/Cargo.toml | 2 + crates/common/src/lib.rs | 2 + crates/common/src/messages/modules/mod.rs | 2 + crates/common/src/messages/modules/weather.rs | 7 ++ crates/common/src/messages/services/mod.rs | 2 + .../common/src/messages/services/weather.rs | 77 ++++++++++++ crates/core/src/app.rs | 8 +- crates/core/src/view.rs | 14 +-- crates/modules/README.md | 110 +++++++++++++++++ crates/modules/src/lib.rs | 7 +- crates/modules/src/weather.rs | 53 ++++++++ crates/services/Cargo.toml | 4 +- crates/services/README.md | 113 ++++++++++++++++++ crates/services/src/lib.rs | 2 +- crates/services/src/weather.rs | 53 +++++++- 17 files changed, 468 insertions(+), 14 deletions(-) create mode 100644 crates/common/src/messages/modules/weather.rs create mode 100644 crates/common/src/messages/services/weather.rs create mode 100644 crates/modules/README.md create mode 100644 crates/modules/src/weather.rs create mode 100644 crates/services/README.md diff --git a/Cargo.lock b/Cargo.lock index f8a7366..fe03afa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 25f99d1..e02b0ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 874ad22..1bdb6b7 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -11,3 +11,5 @@ path = "src/lib.rs" iced = { workspace = true } toml = { workspace = true } serde = { workspace = true } +chrono = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 71e33a3..9d44b63 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -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}; diff --git a/crates/common/src/messages/modules/mod.rs b/crates/common/src/messages/modules/mod.rs index 0de0b5a..8e4e50c 100644 --- a/crates/common/src/messages/modules/mod.rs +++ b/crates/common/src/messages/modules/mod.rs @@ -1,5 +1,7 @@ //! Messages owned by bar modules. mod clock; +mod weather; pub use clock::ClockMsg; +pub use weather::WeatherKind; diff --git a/crates/common/src/messages/modules/weather.rs b/crates/common/src/messages/modules/weather.rs new file mode 100644 index 0000000..05c1dbd --- /dev/null +++ b/crates/common/src/messages/modules/weather.rs @@ -0,0 +1,7 @@ +//! Weather module messages. + +/// A module-local message for the weather tile (bar/popup interactions). +#[derive(Clone)] +pub enum WeatherKind { + Poke, +} diff --git a/crates/common/src/messages/services/mod.rs b/crates/common/src/messages/services/mod.rs index 6dfae68..a644389 100644 --- a/crates/common/src/messages/services/mod.rs +++ b/crates/common/src/messages/services/mod.rs @@ -1,5 +1,7 @@ //! Messages owned by services. mod datetime; +mod weather; pub use datetime::{ClockKind, ClockPayload}; +pub use weather::{WeatherKind, WeatherPayload, WeatherResponse}; diff --git a/crates/common/src/messages/services/weather.rs b/crates/common/src/messages/services/weather.rs new file mode 100644 index 0000000..643ba34 --- /dev/null +++ b/crates/common/src/messages/services/weather.rs @@ -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, +} + +/// 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, + pub temperature_2m: Vec, + pub rain: Vec, + pub showers: Vec, + pub precipitation: Vec, + pub relative_humidity_2m: Vec, +} + +/// 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, D::Error> + where + D: Deserializer<'de>, + { + Vec::::deserialize(d)? + .iter() + .map(|s| NaiveDateTime::parse_from_str(s, FMT).map_err(D::Error::custom)) + .collect() + } +} diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index 60de098..85ad36e 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -11,6 +11,8 @@ pub(crate) struct Bar { pub(crate) active_popup: Option, /// Module registry keyed by stable module id. pub(crate) modules: BTreeMap<&'static str, Box>, + /// Left, Middle, Right ; Module Order + pub(crate) order: (Vec, Vec, Vec), /// Surface id of the open popup. pub(crate) popup_id: Option, /// 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, Vec, Vec), + ) -> Self { // Registry owns construction: adding a module never edits this file. let modules: BTreeMap<&'static str, Box> = 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, diff --git a/crates/core/src/view.rs b/crates/core/src/view.rs index 46ad9c9..51fb30f 100644 --- a/crates/core/src/view.rs +++ b/crates/core/src/view.rs @@ -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,14 +24,10 @@ fn bar_view(bar: &Bar) -> Element<'_, Message> { } } - container( - column(children) - .width(Length::Fill) - .align_x(Alignment::Center), - ) - .padding(8) - .align_x(Alignment::Center) - .into() + container(row(children).width(Length::Fill).align_y(Alignment::Center)) + .padding(8) + .align_x(Alignment::Center) + .into() } pub(crate) fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style { diff --git a/crates/modules/README.md b/crates/modules/README.md new file mode 100644 index 0000000..e57155f --- /dev/null +++ b/crates/modules/README.md @@ -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` 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) -> Element<'_, Wire> { + text(&self.text).into() + } + + fn update(&mut self, msg: Wire) -> Task { + match msg.downcast::() { + Some(GreeterMsg::Poke) => { + self.text = "poked".into(); + Task::none() + } + None => Task::none(), + } + } + + /// Route keys this module subscribes to. + fn services(&self) -> Vec { + Vec::new( + } + + /// Optional: read `module.greeter` from the config table. + fn config(&mut self, _config: toml::Table) -> Result<(), Box> { + 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> { + 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. diff --git a/crates/modules/src/lib.rs b/crates/modules/src/lib.rs index cbbddeb..00112c1 100644 --- a/crates/modules/src/lib.rs +++ b/crates/modules/src/lib.rs @@ -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> { - vec![Box::new(clock::Clock::new(Some(module_config)))] + vec![ + Box::new(clock::Clock::new(Some(module_config))), + Box::new(weather::WeatherModule::default()), + ] } diff --git a/crates/modules/src/weather.rs b/crates/modules/src/weather.rs new file mode 100644 index 0000000..411e53a --- /dev/null +++ b/crates/modules/src/weather.rs @@ -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) -> 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 { + match msg.downcast::() { + Some(WeatherKind::Poke) => { + self.text = "poked".into(); + Task::none() + } + None => Task::none(), + } + } + + /// Route keys this module subscribes to. + fn services(&self) -> Vec { + vec![] + } + + /// Optional: read `module.weather` from the config table. + fn config(&mut self, _config: toml::Table) -> Result<(), Box> { + Ok(()) + } +} diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index 88645bb..9f96271 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -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"] } diff --git a/crates/services/README.md b/crates/services/README.md new file mode 100644 index 0000000..63ff487 --- /dev/null +++ b/crates/services/README.md @@ -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 { + 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| 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 { + 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. diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index c1ec3be..aac0e36 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -22,7 +22,7 @@ impl IntoSubscription for Service { fn into_subscription(self, inbox: Inbox) -> Subscription { 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(), } } diff --git a/crates/services/src/weather.rs b/crates/services/src/weather.rs index 7b0b45b..2deadaf 100644 --- a/crates/services/src/weather.rs +++ b/crates/services/src/weather.rs @@ -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, +} + +impl WeatherService { + fn new() -> Self { + Self { + client: reqwest::Client::default(), + latest: None, + } + } + pub fn run(inbox: Inbox) -> Subscription { + 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| 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 { + self.client + .get(FORECAST_URL) + .send() + .await + .ok()? + .json::() + .await + .ok() + } +}