changes
This commit is contained in:
@@ -11,3 +11,5 @@ path = "src/lib.rs"
|
||||
iced = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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()),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user