diff --git a/src/main.rs b/src/main.rs index b2304d3..9d531a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,8 @@ -//! barbar — a Wayland layer-shell status bar built on `iced` + `iced_layershell`. +//! barbar — a Wayland layer-shell status bar (iced + iced_layershell). //! -//! Renders a full-width bar pinned to the top of the screen via the -//! `wlr-layer-shell` protocol. Optionally target a specific output by -//! passing its name as the first CLI argument. -//! -//! Clicking any button spawns a separate LayerShell popup surface anchored -//! to that button's position on the bar. +//! Renders a full-width bar pinned to the top of the screen via +//! `wlr-layer-shell`. Optional first CLI arg = target output name. +//! Clicking a module's button spawns a LayerShell popup anchored to it. use std::collections::BTreeMap; @@ -16,10 +13,7 @@ use iced_layershell::reexport::Anchor; use iced_layershell::settings::{LayerShellSettings, Settings, StartMode}; use iced_layershell::to_layer_message; -use iced::futures::sink::SinkExt; - use crate::module::{BarModule, ModuleMsg}; -use crate::subscriptions::clock::ClockTicker; use crate::subscriptions::Subscription as ModuleSub; use crate::subscriptions::SubscriptionPayloadKind; @@ -34,21 +28,19 @@ const BAR_HEIGHT: u32 = 36; // Types // --------------------------------------------------------------------------- -/// Application state. Tracks which popup is open and owns the module registry. +/// Application state: module registry + popup bookkeeping. struct Bar { - /// Which module's button triggered the open popup? None = closed. + /// Module whose popup is open; None = closed. active_popup: Option, /// Module registry keyed by stable module id. modules: BTreeMap<&'static str, Box>, - /// Surface ID of the currently open popup (for removal). + /// Surface id of the open popup. popup_id: Option, - /// Fan-out routing table: module id -> the subscription kinds it wants. - /// Built once at boot from `BarModule::subscriptions()`. + /// Fan-out routing table: module id -> subscription kinds it wants. routes: BTreeMap<&'static str, Vec>, } impl Bar { - /// Builds the module registry. The daemon calls this once at boot. fn new() -> Self { let mut modules: BTreeMap<&'static str, Box> = BTreeMap::new(); modules.insert("clock", Box::new(crate::module::clock::Clock::new())); @@ -65,23 +57,39 @@ impl Bar { } } -/// Messages produced by user input, events and subscriptions. +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +/// Synchronous input: module traffic, subscription payloads, window events. +#[derive(Debug, Clone)] +pub(crate) enum Msg { + /// Routed module message; dispatch by `id`, module downcasts. + Module(ModuleMsg), + /// Subscription event; fan out by route key. + Subscription(SubscriptionPayloadKind), + /// The compositor confirmed a window closed. The popup is really gone. + WindowClosed(window::Id), +} + +/// App-level effects (popups, window ops), emitted via `Task`. +#[derive(Debug, Clone)] +pub(crate) enum Cmd { + /// Toggle a module's popup: close if open for it, else open it. + RequestPopup(String, String), + /// Widget-tree pass reported a module's laid-out bounds; anchor popup. + BoundsFound(String, Rectangle), + /// Request removal of the popup surface. + ClosePopup(window::Id), +} + +/// iced needs one `Message` type. `to_layer_message` must sit here: it +/// injects the layer-shell effect variants + their `TryInto` impl. #[to_layer_message(multi)] #[derive(Debug, Clone)] pub(crate) enum Message { - Noop, - /// A routed module message; dispatch by `id` without naming its type. - Module(ModuleMsg), - /// A subscription event, pre-fanned by kind; `update` routes it. - Subscription(SubscriptionPayloadKind), - /// A bar module was clicked; requests a popup for that module. - /// Carries the module to run `view` from, and the element to stick to. - RequestPopup(String, String), - /// Internal: the widget-tree layout pass reported the laid-out bounds of - /// a module's clickable area; the popup is anchored there. - BoundsFound(String, Rectangle), - /// Closes the currently open popup. - ClosePopup(window::Id), + Event(Msg), + Effect(Cmd), } // --------------------------------------------------------------------------- @@ -114,40 +122,16 @@ fn main() -> Result<(), iced_layershell::Error> { .run() } +/// Route subscriptions + window-close events (popup really destroyed). fn gather_subscriptions(bar: &Bar) -> Subscription { - //TODO: Get a vec of subscription enum 'bar_wants' and spawn a batch depending on what the - //modules want. - let wants_clock = bar - .routes - .values() - .any(|kinds| kinds.iter().any(|k| matches!(k, ModuleSub::Clock(_)))); - if !wants_clock { - return Subscription::none(); - } - - Subscription::run_with((), |_| { - iced::stream::channel( - 0, - |mut sender: iced::futures::channel::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() { - if sender - .send(Message::Subscription(SubscriptionPayloadKind::Clock( - payload, - ))) - .await - .is_err() - { - return; - } - } - } - }, - ) - }) + let route_sub = Subscription::batch( + bar.routes + .values() + .flat_map(|x| x.iter().map(ModuleSub::subscription)) + .collect::>(), + ); + let close_events = window::close_events().map(|id| Message::Event(Msg::WindowClosed(id))); + Subscription::batch(vec![route_sub, close_events]) } // --------------------------------------------------------------------------- @@ -156,53 +140,74 @@ fn gather_subscriptions(bar: &Bar) -> Subscription { fn update(bar: &mut Bar, msg: Message) -> Task { match msg { - Message::Module(m) => match bar.modules.get_mut(m.id) { - // The module produces its own (app-level) tasks, e.g. opening a - // popup for itself — nothing to map here. + Message::Event(event) => handle_event(bar, event), + Message::Effect(cmd) => handle_effect(bar, cmd), + // Layer-shell variants injected by `to_layer_message`. + _ => Task::none(), + } +} + +/// 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 their own app-level tasks (e.g. popups). Some(module) => module.update(m), None => Task::none(), }, - Message::Subscription(kind) => match kind { - // Fan out by kind to every module whose route requests it. The - // payload kind is typed, so no downcast needed. - SubscriptionPayloadKind::Clock(payload) => bar - .modules + Msg::Subscription(event) => { + // Fan out by route key; the payload is opaque here, the module + // downcasts it. New kinds touch only `SubscriptionPayloadKind`. + let key = event.key(); + let payload = event.into_payload(); + bar.modules .iter_mut() .filter(|(id, _)| { bar.routes .get(*id) - .is_some_and(|kinds| kinds.contains(&ModuleSub::Clock(payload.kind))) + .is_some_and(|kinds| kinds.contains(&key)) }) - .map(|(_, module)| module.update(ModuleMsg::new(module.id(), payload.clone()))) - .fold(Task::none(), |acc, t| acc.chain(t)), - }, - - Message::RequestPopup(module_id, element_id) => { - // Toggle: close if already open, else open. - if bar.active_popup.as_deref() == Some(module_id.as_str()) { - popup::close_popup(bar) - } else { - // Query the widget tree for the element's laid-out bounds; - // the resulting `BoundsFound` opens the popup there. - popup::capture_bounds(module_id, element_id) - } + .map(|(_, module)| { + module.update(ModuleMsg { + id: module.id(), + payload: payload.clone(), + }) + }) + .fold(Task::none(), |acc, t| acc.chain(t)) } - Message::BoundsFound(module_id, bounds) => popup::open_popup(bar, module_id, bounds), - - Message::ClosePopup(id) => { + Msg::WindowClosed(id) => { + // Popup gone; forget it. `popup_id` stays set until this event + // so the popup content (not the bar) renders during teardown. if bar.popup_id == Some(id) { bar.popup_id = None; bar.active_popup = None; } - Task::done(Message::RemoveWindow(id)) + Task::none() + } + } +} + +/// Runs an effect; may set up bar state for it (e.g. which popup is open). +fn handle_effect(bar: &mut Bar, cmd: Cmd) -> Task { + match cmd { + Cmd::RequestPopup(module_id, element_id) => { + // Toggle: close if already open for this module, else open. + if bar.active_popup.as_deref() == Some(module_id.as_str()) { + popup::close_popup(bar) + } else { + popup::capture_bounds(module_id, element_id) + } } - Message::Noop => Task::none(), + Cmd::BoundsFound(module_id, bounds) => popup::open_popup(bar, module_id, bounds), - // Forward multi-window mutations to their internal handlers. - _ => Task::none(), + Cmd::ClosePopup(id) => { + // Request removal only; keep popup state so the popup content + // renders until `WindowClosed` confirms it's gone. + Task::done(Message::RemoveWindow(id)) + } } } @@ -210,28 +215,24 @@ fn update(bar: &mut Bar, msg: Message) -> Task { // View — routed per-surface by window ID // --------------------------------------------------------------------------- -/// Dispatch view by which surface is being rendered. fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> { - // The bar is always created first. Any subsequently opened popup has a - // different IcedId we can distinguish. We compare against the bar's - // known ID (stored implicitly as "first window"). if bar.popup_id == Some(id) { - // This is the popup surface. - popup::view(bar) + popup::view(bar) // popup surface } else { - // This is the main bar surface. - bar_view(bar) + bar_view(bar) // main bar surface } } -/// Main bar widget tree. Each module renders itself; the app only lays the -/// modules out in a row. Popup requests are the module's own business: its -/// `update` returns the task that asks the app for a popup. +/// Each module renders itself; the bar lays them out in a row. 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(Message::Module)); + children.push( + module + .view(None) + .map(|m| Message::Event(Msg::Module(m))), + ); if i < bar.modules.len() - 1 { children.push(text("|").size(14).into()); } diff --git a/src/module/clock.rs b/src/module/clock.rs index 2dc140e..5f1cbcb 100644 --- a/src/module/clock.rs +++ b/src/module/clock.rs @@ -1,21 +1,26 @@ -use iced::widget::{button, text}; +use iced::widget::{container, mouse_area, text}; use iced::{Element, Task}; use crate::module::{BarModule, ModuleMsg}; use crate::subscriptions::clock::{ClockKind, ClockPayload}; use crate::subscriptions::Subscription; -use crate::Message; +use crate::{Cmd, Message}; -/// A dummy clock module: subscribes to second ticks, stores the latest -/// value, renders it as text in the bar. Clicking toggles between showing -/// seconds (HH:MM:SS) and hiding them (HH:MM) — purely a display choice, -/// the subscription always ticks seconds. +/// 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. #[derive(Clone)] pub enum ClockMsg { - /// The bar was clicked; toggle the seconds suffix. + /// Toggle the seconds suffix. ToggleSeconds, + /// Open the big-time popup. + OpenPopup, } +/// Big-text popup size (logical px). +const POPUP_W: u32 = 360; +const POPUP_H: u32 = 160; + pub struct Clock { value: String, show_seconds: bool, @@ -28,6 +33,19 @@ impl Clock { show_seconds: true, } } + + /// Display string: HH:MM:SS, or HH:MM when seconds are hidden. + fn shown(&self) -> String { + if self.show_seconds { + self.value.clone() + } else { + self.value + .split(':') // [hh, mm, ss] + .take(2) + .collect::>() + .join(":") + } + } } impl BarModule for Clock { @@ -36,39 +54,45 @@ impl BarModule for Clock { } fn view(&self, window_id: Option) -> Element<'_, ModuleMsg> { - let _ = window_id; - // Derive the display string from the stored value each render, so a - // click just flips `show_seconds` and the text updates. - let shown = if self.show_seconds { - self.value.clone() - } else { - self.value - .split(':') // [hh, mm, ss] - .take(2) - .collect::>() - .join(":") - }; - button(text(shown).size(16)) - .padding(0) // kill default 5/10 asymmetric padding; row centers it - .on_press(ModuleMsg::new(self.id(), ClockMsg::ToggleSeconds)) - .into() + match window_id { + // Popup surface: current time in large text. + Some(_) => container(text(&self.value).size(56)).padding(20).into(), + // Bar surface: clickable time. Container carries the module id + // 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)), + ) + .id(iced::widget::Id::from(self.id())) + .into(), + } } fn update(&mut self, msg: ModuleMsg) -> Task { - // The subscription fan-out delivers a raw `ClockPayload` (never - // wrapped in `ClockMsg`) — handle it first. + // Subscription fan-out delivers a raw `ClockPayload`, not `ClockMsg`. if let Some(payload) = msg.downcast::() { self.value = payload.value.clone(); return Task::none(); } - // Module-local messages (button clicks) come as `ClockMsg`. - if let Some(ClockMsg::ToggleSeconds) = msg.downcast::() { - self.show_seconds = !self.show_seconds; + match msg.downcast::() { + Some(ClockMsg::ToggleSeconds) => { + self.show_seconds = !self.show_seconds; + Task::none() + } + Some(ClockMsg::OpenPopup) => Task::done(Message::Effect(Cmd::RequestPopup( + self.id().to_string(), + self.id().to_string(), + ))), + None => Task::none(), } - Task::none() } fn subscriptions(&self) -> Vec { vec![Subscription::Clock(ClockKind::Seconds)] } + + fn popup_size(&self) -> Option<(u32, u32)> { + Some((POPUP_W, POPUP_H)) + } } diff --git a/src/module/mod.rs b/src/module/mod.rs index 59fd27c..6b5b252 100644 --- a/src/module/mod.rs +++ b/src/module/mod.rs @@ -9,10 +9,8 @@ use crate::Message; pub mod clock; -/// A module-local message, boxed with its owner's `id`. -/// -/// The app routes purely by `id` and never names a module's message type; -/// only the owning module downcasts the payload back to its own enum. +/// 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, @@ -20,7 +18,7 @@ pub struct ModuleMsg { } impl ModuleMsg { - /// Wraps a module's own message. `id` must match the module's `BarModule::id`. + /// Wraps a module's own message; `id` must match its `BarModule::id`. pub fn new(id: &'static str, msg: T) -> Self { Self { id, @@ -28,8 +26,7 @@ impl ModuleMsg { } } - /// Downcasts to the module's own message type. Returns `None` if the - /// payload belongs to another module. + /// Downcasts to the module's own message type. pub fn downcast(&self) -> Option<&T> { self.payload.downcast_ref() } @@ -44,22 +41,23 @@ impl fmt::Debug for ModuleMsg { } } -/// A bar module. Object-safe so modules live in -/// `BTreeMap>`. The `ModuleMsg` boundary keeps the -/// app ignorant of each module's message enum. +/// 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. pub trait BarModule: Send { fn id(&self) -> &'static str; - /// Returns this module's bar contents. When `window_id` is `Some`, the - /// popup surface for this module is being rendered and the module should - /// return its popup contents instead. + /// 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>; - /// Handles a routed message. Implementations downcast the payload to - /// their own message type and return their own app-level tasks (e.g. a - /// `RequestPopup` task to open a popup for themselves). + /// Handles a routed message; may return app-level tasks (e.g. popups). fn update(&mut self, msg: ModuleMsg) -> Task; - /// Declarative: what subscription kinds this module wants. + /// What subscription kinds this module wants. fn subscriptions(&self) -> Vec; + + /// Requested popup size when it's not the generic small menu. + fn popup_size(&self) -> Option<(u32, u32)> { + None + } } diff --git a/src/popup.rs b/src/popup.rs index 0ff0749..a1d9374 100644 --- a/src/popup.rs +++ b/src/popup.rs @@ -1,9 +1,8 @@ //! Popup surfaces: bounds capture, open/close, and rendering. //! -//! Popups are separate LayerShell surfaces anchored to the triggering module's -//! button. The bar captures the button's laid-out bounds via a widget-tree -//! operation, then opens a popup anchored there; the active module renders the -//! popup's contents through its own `view(Some(id))`. +//! A popup is a separate LayerShell surface anchored to its module's button. +//! The app captures the button's laid-out bounds via a widget-tree operation, +//! then opens the popup there; the active module renders its content. use iced::advanced::widget as advanced_widget; use iced::widget::{column, mouse_area, text}; @@ -11,30 +10,36 @@ use iced::{Alignment, Element, Length, Rectangle, Task}; use iced_layershell::actions::IcedNewPopupSettings; use iced_layershell::reexport::{PopupAnchor, PopupGravity}; -use crate::{Bar, Message}; +use crate::{Bar, Cmd, Message, Msg}; -/// Popup dimensions. +/// Default (small menu) popup size. const POPUP_W: u32 = 150; const POPUP_H: u32 = 100; -/// Vertical gap (logical px) between the bar's bottom edge and the popup. +/// Gap (logical px) between the bar's bottom edge and the popup. const POPUP_GAP: i32 = 32; -/// Opens a popup for `module_id`, anchored below its laid-out `bounds`. +/// Opens a popup for `module_id` below its laid-out `bounds`. Size comes +/// from the module's `popup_size()` (default: small menu). pub fn open_popup(bar: &mut Bar, module_id: String, bounds: Rectangle) -> Task { - bar.active_popup = Some(module_id); + bar.active_popup = Some(module_id.clone()); - // Anchor rect = the button's bounds, extended below the bar so the - // `Bottom` anchor point (bottom-center) sits POPUP_GAP below the button's - // bottom edge. With `Bottom` gravity the popup grows downward from there. - let (x, y, w, h) = ( + let (w, h) = bar + .modules + .get(module_id.as_str()) + .and_then(|m| m.popup_size()) + .unwrap_or((POPUP_W, POPUP_H)); + + // Anchor at the button's bottom edge + POPUP_GAP so the popup grows + // downward from just below the bar. + let (bx, by, bw, bh) = ( bounds.x.round() as i32, bounds.y.round() as i32, bounds.width.round() as i32, bounds.height.round() as i32, ); - let anchor_rect = (x, y, w, h + POPUP_GAP); - let settings = IcedNewPopupSettings::on_current_surface((POPUP_W, POPUP_H), anchor_rect) + let anchor_rect = (bx, by, bw, bh + POPUP_GAP); + let settings = IcedNewPopupSettings::on_current_surface((w, h), anchor_rect) .anchor(PopupAnchor::Bottom) .gravity(PopupGravity::Bottom); @@ -43,11 +48,11 @@ pub fn open_popup(bar: &mut Bar, module_id: String, bounds: Rectangle) -> Task Task { - if let Some(id) = bar.popup_id.take() { - bar.active_popup = None; - return Task::done(Message::RemoveWindow(id)); + if let Some(id) = bar.popup_id { + return Task::done(Message::Effect(Cmd::ClosePopup(id))); } Task::none() } @@ -63,8 +68,6 @@ pub fn capture_bounds(module_id: String, element_id: String) -> Task { } impl advanced_widget::Operation for FindBounds { - /// Delegate traversal so the `container` hook below sees every - /// container in the tree, matching the target id. fn traverse( &mut self, operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation), @@ -90,18 +93,22 @@ pub fn capture_bounds(module_id: String, element_id: String) -> Task { target, found: None, }) - .map(move |bounds| Message::BoundsFound(module_id.clone(), bounds)) + .map(move |bounds| Message::Effect(Cmd::BoundsFound(module_id.clone(), bounds))) } -/// Popup widget tree, rendered on its own floating LayerShell surface. The -/// active module renders its own popup contents via `view(Some(id))`. +/// Popup widget tree on its own LayerShell surface; the active module +/// renders its content via `view(Some(id))`. pub fn view(bar: &Bar) -> Element<'_, Message> { let module_id = bar.active_popup.as_deref().unwrap(); let popup_id = bar.popup_id.unwrap(); let content = bar .modules .get(module_id) - .map(|module| module.view(Some(popup_id)).map(Message::Module)) + .map(|module| { + module + .view(Some(popup_id)) + .map(|m| Message::Event(Msg::Module(m))) + }) .unwrap_or_else(|| text("unknown module").into()); column![ @@ -110,7 +117,7 @@ pub fn view(bar: &Bar) -> Element<'_, Message> { .width(Length::Fill) .align_x(Alignment::Center), content, - mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)), + mouse_area(text("close").size(12)).on_press(Message::Effect(Cmd::ClosePopup(popup_id))), ] .spacing(1) .into() diff --git a/src/subscriptions/clock.rs b/src/subscriptions/clock.rs index d94a42e..0b453fe 100644 --- a/src/subscriptions/clock.rs +++ b/src/subscriptions/clock.rs @@ -1,24 +1,25 @@ use std::collections::HashMap; -/// What a clock subscription wants. Carried in `Subscription::Clock(kind)` -/// so the runner knows the tick interval and the module knows the payload. +use iced::{futures::SinkExt, Subscription}; + +use crate::{subscriptions::SubscriptionPayloadKind, Message, Msg}; + +/// What a clock subscription wants: the tick interval and payload selector. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub enum ClockKind { Mins, Seconds, } -/// The event payload for a clock subscription: the dynamic value plus the -/// kind that produced it, so a module can re-render without extra state. +/// 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, } -/// Tracks the last displayed value per kind and only emits a payload when -/// the value changes — minutes when the minute rolls over, seconds when the -/// second rolls over. Drive `tick` once per second. +/// Emits a payload only when a kind's displayed value changes (minute or +/// second rollover). Drive `tick` once per second. #[derive(Default)] pub struct ClockTicker { last: HashMap, @@ -29,9 +30,34 @@ impl ClockTicker { Self::default() } - /// Checks all kinds in one per-second call and returns a payload for - /// every kind whose displayed value changed since the last tick. Batch: - /// the second that rolls into a new minute yields `[Seconds, Mins]`. + pub fn run() -> Subscription { + Subscription::run_with((), |_| { + iced::stream::channel( + 0, + |mut sender: iced::futures::channel::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() { + if sender + .send(Message::Event(Msg::Subscription( + SubscriptionPayloadKind::Clock(payload), + ))) + .await + .is_err() + { + return; + } + } + } + }, + ) + }) + } + + /// 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] diff --git a/src/subscriptions/mod.rs b/src/subscriptions/mod.rs index 1a56dfb..b3db263 100644 --- a/src/subscriptions/mod.rs +++ b/src/subscriptions/mod.rs @@ -1,17 +1,47 @@ -use crate::subscriptions::clock::{ClockKind, ClockPayload}; +use std::any::Any; +use std::sync::Arc; + +use crate::{ + subscriptions::clock::{ClockKind, ClockPayload, ClockTicker}, + Message, +}; pub mod clock; -/// What a module wants from a runner. `Clock(kind)` is both the route key -/// (which ticker to spawn) and the typed event selector. +/// Route key a module subscribes with; `Clock(kind)` selects the ticker. #[derive(Clone, Copy, PartialEq, Eq)] pub enum Subscription { Clock(ClockKind), } -/// A subscription event carrying a typed payload; `update` matches on this -/// to fan out to every module whose route requests the kind. +impl Subscription { + pub fn subscription(&self) -> iced::Subscription { + match self { + Self::Clock(_) => ClockTicker::run(), + } + } +} + +/// A subscription event with a typed payload. The app only reads +/// `key()` for fan-out; the consuming module downcasts the payload. #[derive(Clone, Debug)] pub enum SubscriptionPayloadKind { Clock(ClockPayload), } + +impl SubscriptionPayloadKind { + /// Route key for this event. One arm per kind — adding a subscription + /// touches only this match, never the routing code. + pub fn key(&self) -> Subscription { + match self { + Self::Clock(payload) => Subscription::Clock(payload.kind), + } + } + + /// Erases the typed payload for the module to downcast. + pub fn into_payload(self) -> Arc { + match self { + Self::Clock(payload) => Arc::new(payload), + } + } +}