diff --git a/Cargo.lock b/Cargo.lock index 88c72d6..c3e412c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1831,7 +1831,6 @@ dependencies = [ "common", "iced", "serde", - "services", "thiserror 2.0.20", "toml", ] diff --git a/crates/common/src/effect.rs b/crates/common/src/effect.rs index 4f39b95..4447e28 100644 --- a/crates/common/src/effect.rs +++ b/crates/common/src/effect.rs @@ -1,5 +1,7 @@ use iced::{window, Rectangle}; +use crate::Wire; + /// What a module asks the app to do — the single effect vocabulary shared by /// modules and app, so mapping module output into a `Message` is a plain /// `.map(Message::Effect)` with no mirror enum. @@ -12,4 +14,6 @@ pub enum ModuleEffect { BoundsFound(String, Rectangle), /// Request removal of the popup surface. ClosePopup(window::Id), + /// Route a wire to its target (a module, a service inbox, or a topic). + Send(Wire), } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index ff8dcbb..71e33a3 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -6,10 +6,14 @@ mod config; mod effect; +mod messages; mod module; mod service; +mod wire; pub use config::BarbarConfig; pub use effect::ModuleEffect; -pub use module::{BarModule, ModuleMsg}; -pub use service::{Service, ServiceEvent}; +pub use messages::{ClockKind, ClockMsg, ClockPayload}; +pub use module::BarModule; +pub use service::Service; +pub use wire::{Endpoint, Inbox, Namespace, Target, Wire}; diff --git a/crates/common/src/messages/mod.rs b/crates/common/src/messages/mod.rs new file mode 100644 index 0000000..28e8f85 --- /dev/null +++ b/crates/common/src/messages/mod.rs @@ -0,0 +1,13 @@ +//! Wire vocabulary: every message type that crosses a crate boundary. +//! +//! Laid out to mirror the crate that owns the behavior: `modules/*` are +//! messages a bar module sends or receives, `services/*` are messages a +//! service produces or accepts. Types live here so either side can name a +//! payload without depending on the other's crate; only the types are +//! shared, the behavior stays in the owning crate. + +pub mod modules; +pub mod services; + +pub use modules::*; +pub use services::*; diff --git a/crates/common/src/messages/modules/clock.rs b/crates/common/src/messages/modules/clock.rs new file mode 100644 index 0000000..5bc1817 --- /dev/null +++ b/crates/common/src/messages/modules/clock.rs @@ -0,0 +1,10 @@ +//! Clock module messages. + +/// A module-local message for the clock (bar/popup interactions). +#[derive(Clone)] +pub enum ClockMsg { + /// Toggle the seconds suffix. + ToggleSeconds, + /// Open the big-time popup. + OpenPopup, +} diff --git a/crates/common/src/messages/modules/mod.rs b/crates/common/src/messages/modules/mod.rs new file mode 100644 index 0000000..0de0b5a --- /dev/null +++ b/crates/common/src/messages/modules/mod.rs @@ -0,0 +1,5 @@ +//! Messages owned by bar modules. + +mod clock; + +pub use clock::ClockMsg; diff --git a/crates/common/src/messages/services/datetime.rs b/crates/common/src/messages/services/datetime.rs new file mode 100644 index 0000000..04ddd11 --- /dev/null +++ b/crates/common/src/messages/services/datetime.rs @@ -0,0 +1,27 @@ +//! Clock service messages. + +use crate::Service; + +/// What a clock subscription wants: the tick interval and payload selector. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum ClockKind { + Mins, + Seconds, +} + +impl ClockKind { + /// Route key a module subscribes under to receive this kind's ticks. + pub fn key(self) -> Service { + Service(match self { + Self::Mins => "clock.mins", + Self::Seconds => "clock.seconds", + }) + } +} + +/// Clock tick payload: the value plus the kind that produced it. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ClockPayload { + pub kind: ClockKind, + pub value: String, +} diff --git a/crates/common/src/messages/services/mod.rs b/crates/common/src/messages/services/mod.rs new file mode 100644 index 0000000..6dfae68 --- /dev/null +++ b/crates/common/src/messages/services/mod.rs @@ -0,0 +1,5 @@ +//! Messages owned by services. + +mod datetime; + +pub use datetime::{ClockKind, ClockPayload}; diff --git a/crates/common/src/module.rs b/crates/common/src/module.rs index fae9212..9e87720 100644 --- a/crates/common/src/module.rs +++ b/crates/common/src/module.rs @@ -1,56 +1,22 @@ -use std::any::Any; use std::error::Error; -use std::fmt; -use std::sync::Arc; use iced::window; use iced::{Element, Task}; -use crate::ModuleEffect; - -/// A module-local message, boxed with its owner's `id`. The app routes by -/// `id` and never names a module's message type; the module downcasts. -#[derive(Clone)] -pub struct ModuleMsg { - pub id: &'static str, - pub payload: Arc, -} - -impl ModuleMsg { - /// Wraps a module's own message; `id` must match its `BarModule::id`. - pub fn new(id: &'static str, msg: T) -> Self { - Self { - id, - payload: Arc::new(msg), - } - } - - /// Downcasts to the module's own message type. - pub fn downcast(&self) -> Option<&T> { - self.payload.downcast_ref() - } -} - -impl fmt::Debug for ModuleMsg { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ModuleMsg") - .field("id", &self.id) - .field("payload", &self.payload.type_id()) - .finish() - } -} +use crate::{ModuleEffect, Wire}; /// A bar module. Object-safe; modules live in a `BTreeMap` keyed by id. The -/// `ModuleMsg` boundary keeps the app ignorant of each module's message enum. +/// `Wire` boundary keeps the app ignorant of each module's message enum — +/// the module downcasts whatever arrives. pub trait BarModule: Send { fn id(&self) -> &'static str; /// Bar contents. When `window_id` is `Some`, this is the module's popup /// surface and the module returns its popup contents instead. - fn view(&self, window_id: Option) -> Element<'_, ModuleMsg>; + fn view(&self, window_id: Option) -> Element<'_, Wire>; /// Handles a routed message; may return app-level tasks (e.g. popups). - fn update(&mut self, msg: ModuleMsg) -> Task; + fn update(&mut self, msg: Wire) -> Task; /// What services this module wants. fn services(&self) -> Vec; diff --git a/crates/common/src/service.rs b/crates/common/src/service.rs index 51a8edf..b795134 100644 --- a/crates/common/src/service.rs +++ b/crates/common/src/service.rs @@ -1,16 +1,5 @@ -use std::any::Any; -use std::sync::Arc; - /// Opaque route key identifying a service subscription. Services name their /// own keys, so `common` never enumerates them and adding a service never /// edits this crate. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub struct Service(pub &'static str); - -/// A service event: the route key plus an erased typed payload. The app reads -/// `key` for fan-out; the consuming module downcasts `payload`. -#[derive(Clone, Debug)] -pub struct ServiceEvent { - pub key: Service, - pub payload: Arc, -} diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs new file mode 100644 index 0000000..fde5003 --- /dev/null +++ b/crates/common/src/wire.rs @@ -0,0 +1,151 @@ +use std::any::Any; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; + +use iced::futures::channel::mpsc; + +/// Which namespace an endpoint lives in. Module ids and service route keys +/// are separate namespaces, so a module and a service may share a name. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Namespace { + Module, + Service, +} + +/// A wire's sender: a module id or a service route key. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct Endpoint { + pub ns: Namespace, + pub name: &'static str, +} + +impl Endpoint { + pub const fn module(name: &'static str) -> Self { + Self { + ns: Namespace::Module, + name, + } + } + + pub const fn service(name: &'static str) -> Self { + Self { + ns: Namespace::Service, + name, + } + } +} + +/// Where a wire goes. Core owns the routing rule for each variant. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Target { + /// One module, by id. + Module(&'static str), + /// One running service's inbox, by route key. + Service(&'static str), + /// Every module subscribed to a route key (a service's fan-out). + Topic(&'static str), +} + +/// One typed message on the wire. Core routes by `to`; the receiver +/// downcasts `payload` to one of *its own* types. Sender and receiver keep +/// their own concrete types — nothing here enumerates them. +#[derive(Clone)] +pub struct Wire { + pub from: Endpoint, + pub to: Target, + pub payload: Arc, +} + +impl Wire { + /// A module-local message: addressed straight back to one module. + pub fn module(from: Endpoint, id: &'static str, msg: T) -> Self { + Self { + from, + to: Target::Module(id), + payload: Arc::new(msg), + } + } + + /// Sent into a service's inbox. + pub fn service(from: Endpoint, key: &'static str, msg: T) -> Self { + Self { + from, + to: Target::Service(key), + payload: Arc::new(msg), + } + } + + /// Published by a service to every module subscribed to `key`. + pub fn topic(from: Endpoint, key: &'static str, msg: T) -> Self { + Self { + from, + to: Target::Topic(key), + payload: Arc::new(msg), + } + } + + /// Downcasts to the receiver's own message type. + pub fn downcast(&self) -> Option<&T> { + self.payload.downcast_ref() + } +} + +impl fmt::Debug for Wire { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Wire") + .field("from", &self.from) + .field("to", &self.to) + .field("payload", &self.payload.type_id()) + .finish() + } +} + +/// A service's inbound mailbox: modules send `Wire`s here, the service's +/// subscription task reads them. Cloneable; the receiver is taken once. +/// +/// The `Arc>>` lets `Bar` keep a clone and hand the same +/// value to `Subscription::run_with` on every rebuild without moving the +/// single-consumer receiver out of its reach. +#[derive(Clone)] +pub struct Inbox { + key: &'static str, + tx: mpsc::UnboundedSender, + rx: Arc>>>, +} + +impl Inbox { + /// Creates the channel backing a service's inbox. + pub fn new(key: &'static str) -> Self { + let (tx, rx) = mpsc::unbounded(); + Self { + key, + tx, + rx: Arc::new(Mutex::new(Some(rx))), + } + } + + /// The service's route key, used as the `Topic` it publishes under. + pub fn key(&self) -> &'static str { + self.key + } + + /// Sends a wire into the service; `false` if the service is gone. + pub fn send(&self, wire: Wire) -> bool { + self.tx.unbounded_send(wire).is_ok() + } + + /// Takes the receiver; the service stream calls this once, when iced + /// first starts it. + pub fn take(&self) -> Option> { + self.rx.lock().unwrap().take() + } +} + +impl Hash for Inbox { + /// Identity for `Subscription::run_with`: the route key, so returning + /// the same service subscription each pass does not restart its stream. + fn hash(&self, state: &mut H) { + self.key.hash(state); + } +} diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index 880786e..60de098 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -1,10 +1,11 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use iced::window; -use common::{BarModule, Service}; +use common::{BarModule, Inbox, Service}; +use toml::Table; -/// Application state: module registry + popup bookkeeping. +/// Application state: module registry, routing, popup bookkeeping. pub(crate) struct Bar { /// Module whose popup is open; None = closed. pub(crate) active_popup: Option, @@ -14,22 +15,35 @@ pub(crate) struct Bar { pub(crate) popup_id: Option, /// Fan-out routing table: module id -> services it wants. pub(crate) routes: BTreeMap<&'static str, Vec>, + /// One inbound mailbox per running service, keyed by route key. Modules + /// send here (`Target::Service`) to talk to a running service. + pub(crate) inputs: BTreeMap<&'static str, Inbox>, } impl Bar { - pub(crate) fn new() -> Self { + pub(crate) fn new(modules_config_table: &Table) -> Self { // Registry owns construction: adding a module never edits this file. let modules: BTreeMap<&'static str, Box> = - modules::all().into_iter().map(|m| (m.id(), m)).collect(); - let routes = modules + modules::all(modules_config_table.clone()) + .into_iter() + .map(|m| (m.id(), m)) + .collect(); + let routes: BTreeMap<&'static str, Vec> = modules .iter() .map(|(id, module)| (*id, module.services())) .collect(); + // One inbox per distinct service any module wants. + let wanted: BTreeSet<&'static str> = routes.values().flatten().map(|s| s.0).collect(); + let inputs = wanted + .into_iter() + .map(|key| (key, Inbox::new(key))) + .collect(); Self { active_popup: None, modules, popup_id: None, routes, + inputs, } } } diff --git a/crates/core/src/main.rs b/crates/core/src/main.rs index c9e5fd2..d9ac02d 100644 --- a/crates/core/src/main.rs +++ b/crates/core/src/main.rs @@ -46,8 +46,10 @@ fn main() -> Result<(), iced_layershell::Error> { None => StartMode::Active, }; + let modules = config.modules.clone().unwrap_or_default(); + daemon( - app::Bar::new, + move || Bar::new(&modules), subscription::namespace, update::update, view::view, diff --git a/crates/core/src/msg.rs b/crates/core/src/msg.rs index 81436b7..f6d6044 100644 --- a/crates/core/src/msg.rs +++ b/crates/core/src/msg.rs @@ -1,15 +1,13 @@ use iced::window; use iced_layershell::to_layer_message; -use common::{ModuleEffect, ModuleMsg, ServiceEvent}; +use common::{ModuleEffect, Wire}; -/// Synchronous input: module traffic, subscription payloads, window events. +/// Synchronous input: wires plus window events. #[derive(Debug, Clone)] pub(crate) enum Msg { - /// Routed module message; dispatch by `id`, module downcasts. - Module(ModuleMsg), - /// Service event; fan out by route key. - Subscription(ServiceEvent), + /// One wire; core dispatches by its `to` target. + Wire(Wire), /// The compositor confirmed a window closed. The popup is really gone. WindowClosed(window::Id), } diff --git a/crates/core/src/popup.rs b/crates/core/src/popup.rs index f7772f9..ba7c9ad 100644 --- a/crates/core/src/popup.rs +++ b/crates/core/src/popup.rs @@ -110,7 +110,7 @@ pub fn view(bar: &Bar) -> Element<'_, Message> { .map(|module| { module .view(Some(popup_id)) - .map(|m| Message::Event(Msg::Module(m))) + .map(|w| Message::Event(Msg::Wire(w))) }) .unwrap_or_else(|| text("unknown module").into()); diff --git a/crates/core/src/subscription.rs b/crates/core/src/subscription.rs index c8ebdb3..484a390 100644 --- a/crates/core/src/subscription.rs +++ b/crates/core/src/subscription.rs @@ -1,5 +1,6 @@ use iced::{window, Subscription}; +use common::Service; use services::IntoSubscription; use crate::app::Bar; @@ -10,17 +11,15 @@ pub(crate) fn namespace() -> String { } /// Route subscriptions + window-close events (popup really destroyed). -/// Each module's wants go through the services registry, so adding a -/// service never edits this file. +/// One subscription per service inbox, so adding a service never edits this. pub(crate) fn gather_subscriptions(bar: &Bar) -> Subscription { let route_sub = Subscription::batch( - bar.routes - .values() - .flat_map(|wants| wants.iter()) - .map(|&service| { - service - .into_subscription() - .map(|event| Message::Event(Msg::Subscription(event))) + bar.inputs + .iter() + .map(|(&key, inbox)| { + Service(key) + .into_subscription(inbox.clone()) + .map(|wire| Message::Event(Msg::Wire(wire))) }) .collect::>(), ); diff --git a/crates/core/src/update.rs b/crates/core/src/update.rs index 1a55e74..2ee9c62 100644 --- a/crates/core/src/update.rs +++ b/crates/core/src/update.rs @@ -1,6 +1,6 @@ use iced::Task; -use common::{ModuleEffect, ModuleMsg}; +use common::{ModuleEffect, Service, Target, Wire}; use crate::app::Bar; use crate::msg::{Message, Msg}; @@ -18,34 +18,7 @@ pub(crate) fn update(bar: &mut Bar, msg: Message) -> Task { /// Applies an input event to state; effects come back as `Task`s. fn handle_event(bar: &mut Bar, event: Msg) -> Task { match event { - Msg::Module(m) => match bar.modules.get_mut(m.id) { - // Modules return protocol tasks; wrap effects into messages. - Some(module) => module.update(m).map(Message::Effect), - None => Task::none(), - }, - - Msg::Subscription(event) => { - // Fan out by route key; the payload is opaque here, the module - // downcasts it. New services never touch this file. - let key = event.key; - let payload = event.payload; - bar.modules - .iter_mut() - .filter(|(id, _)| { - bar.routes - .get(*id) - .is_some_and(|kinds| kinds.contains(&key)) - }) - .map(|(_, module)| { - module - .update(ModuleMsg { - id: module.id(), - payload: payload.clone(), - }) - .map(Message::Effect) - }) - .fold(Task::none(), |acc, t| acc.chain(t)) - } + Msg::Wire(wire) => route(bar, wire), Msg::WindowClosed(id) => { // Popup gone; forget it. `popup_id` stays set until this event @@ -59,6 +32,38 @@ fn handle_event(bar: &mut Bar, event: Msg) -> Task { } } +/// Delivers a wire by its target — the only place routing lives. +fn route(bar: &mut Bar, wire: Wire) -> Task { + match wire.to { + Target::Module(id) => match bar.modules.get_mut(id) { + Some(module) => module.update(wire).map(Message::Effect), + None => Task::none(), + }, + + Target::Service(key) => { + if let Some(inbox) = bar.inputs.get(key) { + inbox.send(wire); + } + Task::none() + } + + Target::Topic(key) => { + // Fan out by route key; the payload is opaque here, each module + // downcasts it. New services never touch this file. + let topic = Service(key); + bar.modules + .iter_mut() + .filter(|(id, _)| { + bar.routes + .get(*id) + .is_some_and(|keys| keys.contains(&topic)) + }) + .map(|(_, module)| module.update(wire.clone()).map(Message::Effect)) + .fold(Task::none(), |acc, t| acc.chain(t)) + } + } +} + /// Runs an effect; may set up bar state for it (e.g. which popup is open). fn handle_effect(bar: &mut Bar, effect: ModuleEffect) -> Task { match effect { @@ -78,5 +83,7 @@ fn handle_effect(bar: &mut Bar, effect: ModuleEffect) -> Task { // renders until `WindowClosed` confirms it's gone. Task::done(Message::RemoveWindow(id)) } + + ModuleEffect::Send(wire) => route(bar, wire), } } diff --git a/crates/core/src/view.rs b/crates/core/src/view.rs index e88c8da..46ad9c9 100644 --- a/crates/core/src/view.rs +++ b/crates/core/src/view.rs @@ -18,7 +18,7 @@ fn bar_view(bar: &Bar) -> Element<'_, Message> { let mut children: Vec> = Vec::new(); for (i, (_, module)) in bar.modules.iter().enumerate() { - children.push(module.view(None).map(|m| Message::Event(Msg::Module(m)))); + children.push(module.view(None).map(|w| Message::Event(Msg::Wire(w)))); if i < bar.modules.len() - 1 { children.push(text("|").size(14).into()); } diff --git a/crates/modules/Cargo.toml b/crates/modules/Cargo.toml index 7f0cfd2..b4021d7 100644 --- a/crates/modules/Cargo.toml +++ b/crates/modules/Cargo.toml @@ -9,7 +9,6 @@ path = "src/lib.rs" [dependencies] common = { path = "../common" } -services = { path = "../services" } iced = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } diff --git a/crates/modules/src/clock.rs b/crates/modules/src/clock.rs index 8227d4b..eed5bce 100644 --- a/crates/modules/src/clock.rs +++ b/crates/modules/src/clock.rs @@ -1,13 +1,14 @@ -//! Clock module: subscribes to second ticks, renders the time in the bar. -//! Left-click toggles the seconds suffix; right-click opens a popup with -//! the current time in large text. +//! Clock module: subscribes to the clock service, renders the time in the +//! bar. Left-click toggles the seconds suffix — and pokes the running clock +//! service to switch which kind it publishes; right-click opens a popup. use iced::widget::{container, mouse_area, text}; use iced::{Element, Task}; -use common::{BarModule, ModuleEffect, ModuleMsg, Service}; +use common::{ + BarModule, ClockKind, ClockMsg, ClockPayload, Endpoint, ModuleEffect, Service, Wire, +}; use serde::Deserialize; -use services::datetime::{ClockKind, ClockPayload}; use toml::Table; /// Big-text popup size (logical px). @@ -22,15 +23,6 @@ pub enum ClockError { NotBool(&'static str), } -/// A module-local message for the clock. -#[derive(Clone)] -pub enum ClockMsg { - /// Toggle the seconds suffix. - ToggleSeconds, - /// Open the big-time popup. - OpenPopup, -} - pub struct Clock { value: String, show_seconds: bool, @@ -44,11 +36,10 @@ struct ClockConfig { impl Clock { pub fn new(config: Option) -> Self { - let module_config = config_clock(config); - match module_config { + match config_clock(config) { Some(x) => Self { value: "--:--:--".to_string(), - show_seconds: { x.format }, + show_seconds: x.format, }, None => Self::default(), } @@ -70,7 +61,10 @@ impl Clock { impl Default for Clock { fn default() -> Self { - Self::new(None) + Self { + value: "--:--:--".to_string(), + show_seconds: false, + } } } @@ -84,7 +78,8 @@ impl BarModule for Clock { "clock" } - fn view(&self, window_id: Option) -> Element<'_, ModuleMsg> { + fn view(&self, window_id: Option) -> Element<'_, Wire> { + let me = Endpoint::module(self.id()); match window_id { // Popup surface: current time in large text. Some(_) => container(text(&self.value).size(56)).padding(20).into(), @@ -92,16 +87,16 @@ impl BarModule for Clock { // so the popup can anchor to these bounds. None => container( mouse_area(text(self.shown()).size(16)) - .on_press(ModuleMsg::new(self.id(), ClockMsg::ToggleSeconds)) - .on_right_press(ModuleMsg::new(self.id(), ClockMsg::OpenPopup)), + .on_press(Wire::module(me, self.id(), ClockMsg::ToggleSeconds)) + .on_right_press(Wire::module(me, self.id(), ClockMsg::OpenPopup)), ) .id(iced::widget::Id::from(self.id())) .into(), } } - fn update(&mut self, msg: ModuleMsg) -> Task { - // Subscription fan-out delivers a raw `ClockPayload`, not `ClockMsg`. + 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(); return Task::none(); @@ -109,7 +104,17 @@ impl BarModule for Clock { match msg.downcast::() { Some(ClockMsg::ToggleSeconds) => { self.show_seconds = !self.show_seconds; - Task::none() + let want = if self.show_seconds { + ClockKind::Seconds + } else { + ClockKind::Mins + }; + // Poke the running clock service: tell it which kind to publish. + Task::done(ModuleEffect::Send(Wire::service( + Endpoint::module(self.id()), + ClockKind::Seconds.key().0, + want, + ))) } Some(ClockMsg::OpenPopup) => Task::done(ModuleEffect::RequestPopup( self.id().to_string(), @@ -127,3 +132,17 @@ impl BarModule for Clock { Some((POPUP_W, POPUP_H)) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression: `new(None)` used to call `default()`, which called + /// `new(None)` — infinite recursion (hard stack overflow) whenever the + /// clock config was absent or failed to parse. + #[test] + fn no_config_terminates() { + assert!(!Clock::new(None).show_seconds); + assert!(!Clock::default().show_seconds); + } +} diff --git a/crates/modules/src/lib.rs b/crates/modules/src/lib.rs index 8ee5a01..cbbddeb 100644 --- a/crates/modules/src/lib.rs +++ b/crates/modules/src/lib.rs @@ -3,7 +3,7 @@ mod clock; -pub use clock::{Clock, ClockError, ClockMsg}; +pub use clock::{Clock, ClockError}; use common::BarModule; use toml::Table; diff --git a/crates/services/src/datetime.rs b/crates/services/src/datetime.rs index ecc17d3..dff048c 100644 --- a/crates/services/src/datetime.rs +++ b/crates/services/src/datetime.rs @@ -1,48 +1,33 @@ -//! Clock service: emits a `ServiceEvent` whenever a clock kind's displayed -//! value changes (minute or second rollover). +//! Clock service: publishes a `Wire` on every change of the displayed +//! value, and accepts `ClockKind` wires from modules that want it to switch +//! which kind it publishes. //! //! The ticker knows nothing about the app's `Message` type; it produces -//! protocol events from `common` that core routes to subscribed modules. +//! protocol wires from `common` that core routes to subscribers. use std::collections::HashMap; -use std::sync::Arc; -use iced::{futures::SinkExt, Subscription}; +use iced::futures::{channel::mpsc, SinkExt, StreamExt}; +use iced::Subscription; -use common::{Service, ServiceEvent}; +use common::{ClockKind, ClockPayload, Endpoint, Inbox, Wire}; /// Route-key namespace owned by this service; keys are `"clock."`. pub const NAMESPACE: &str = "clock."; -/// What a clock subscription wants: the tick interval and payload selector. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] -pub enum ClockKind { - Mins, - Seconds, -} - -impl ClockKind { - /// Route key a module subscribes under to receive this kind's ticks. - pub fn key(self) -> Service { - Service(match self { - Self::Mins => "clock.mins", - Self::Seconds => "clock.seconds", - }) - } -} - -/// Clock tick payload: the value plus the kind that produced it. -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct ClockPayload { - pub kind: ClockKind, - pub value: String, -} - -/// Emits a payload only when a kind's displayed value changes (minute or -/// second rollover). Drive `tick` once per second. -#[derive(Default)] +/// Drives the clock, publishing only the kind modules last asked for. pub struct ClockTicker { last: HashMap, + want: ClockKind, +} + +impl Default for ClockTicker { + fn default() -> Self { + Self { + last: HashMap::new(), + want: ClockKind::Seconds, + } + } } impl ClockTicker { @@ -50,24 +35,41 @@ impl ClockTicker { Self::default() } - /// Subscription that emits a `ServiceEvent` on every change. - pub fn run() -> Subscription { - Subscription::run_with((), |_| { + /// Subscription that reads inbound wires from `inbox` and publishes + /// changes to every module subscribed to this service's route key. + pub fn run(inbox: Inbox) -> Subscription { + Subscription::run_with(inbox, |inbox| { + let key = inbox.key(); + // ponytail: the builder runs once (identity is stable, see `Inbox` + // hashing). If a module ever makes `services()` dynamic and a key + // is dropped then recreated, `take()` returns None and this panics + // — make `Bar` rebuild the inbox per start instead. + let mut rx = inbox.take().expect("inbox receiver is taken once"); iced::stream::channel( 0, - |mut sender: iced::futures::channel::mpsc::Sender| async move { + move |mut sender: mpsc::Sender| async move { let mut clock = ClockTicker::new(); let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); loop { - interval.tick().await; - for payload in clock.tick() { - let event = ServiceEvent { - key: payload.kind.key(), - payload: Arc::new(payload), - }; - if sender.send(event).await.is_err() { - return; + tokio::select! { + _ = interval.tick() => { + if let Some(payload) = clock.tick() { + let wire = Wire::topic(Endpoint::service(key), key, payload); + if sender.send(wire).await.is_err() { + return; + } + } } + msg = rx.next() => match msg { + Some(wire) => { + // A module poking us: switch published kind. + if let Some(kind) = wire.downcast::() { + clock.want = *kind; + clock.last.clear(); + } + } + None => return, + }, } } }, @@ -75,27 +77,21 @@ impl ClockTicker { }) } - /// Returns a payload per kind whose value changed since the last tick. - /// The second that rolls into a new minute yields `[Seconds, Mins]`. - pub fn tick(&mut self) -> Vec { - let now = chrono::Local::now(); - [ClockKind::Seconds, ClockKind::Mins] - .into_iter() - .filter_map(|kind| { - let value = now - .format(match kind { - ClockKind::Mins => "%H:%M", - ClockKind::Seconds => "%H:%M:%S", - }) - .to_string(); - match self.last.get(&kind) { - Some(prev) if *prev == value => None, - _ => { - self.last.insert(kind, value.clone()); - Some(ClockPayload { kind, value }) - } - } + /// Latest value for the wanted kind, or `None` if unchanged. + pub fn tick(&mut self) -> Option { + let value = chrono::Local::now() + .format(match self.want { + ClockKind::Mins => "%H:%M", + ClockKind::Seconds => "%H:%M:%S", }) - .collect() + .to_string(); + if self.last.get(&self.want) == Some(&value) { + return None; + } + self.last.insert(self.want, value.clone()); + Some(ClockPayload { + kind: self.want, + value, + }) } } diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 5ea76a1..187b17d 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -4,23 +4,23 @@ use iced::Subscription; -use common::{Service, ServiceEvent}; +use common::{Inbox, Service, Wire}; pub mod datetime; -/// Conversion from a route key to the subscription backing it. +/// Conversion from a route key + its inbox to the subscription backing it. /// /// An extension trait (not `impl From for Subscription<...>`) because /// both `Service` and `Subscription` are foreign to this crate — the orphan /// rule blocks a foreign trait (`From`) on a foreign type (`Subscription`). pub trait IntoSubscription { - fn into_subscription(self) -> Subscription; + fn into_subscription(self, inbox: Inbox) -> Subscription; } impl IntoSubscription for Service { - fn into_subscription(self) -> Subscription { + fn into_subscription(self, inbox: Inbox) -> Subscription { match self.0 { - key if key.starts_with(datetime::NAMESPACE) => datetime::ClockTicker::run(), + key if key.starts_with(datetime::NAMESPACE) => datetime::ClockTicker::run(inbox), _ => Subscription::none(), } } diff --git a/test.toml b/test.toml index cf0c621..8b6b57c 100644 --- a/test.toml +++ b/test.toml @@ -1,4 +1,4 @@ monitor = "DP-2" [modules.clock] -format = "meow" +format = true