From bdec970b6278856ffe6a0ac46aa8941c1ce3d62c Mon Sep 17 00:00:00 2001 From: Doloro1978 Date: Thu, 10 Sep 2026 02:08:45 +0100 Subject: [PATCH] reorginise --- Cargo.lock | 1 + Cargo.toml | 1 + crates/common/src/effect.rs | 15 ++ crates/common/src/lib.rs | 138 +------------- crates/common/src/module.rs | 69 +++++++ crates/common/src/service.rs | 16 ++ crates/core/src/app.rs | 35 ++++ crates/core/src/main.rs | 254 +++---------------------- crates/core/src/msg.rs | 37 ++++ crates/core/src/popup.rs | 3 +- crates/core/src/subscription.rs | 29 +++ crates/core/src/update.rs | 82 ++++++++ crates/core/src/view.rs | 43 +++++ crates/modules/clock/Cargo.toml | 1 + crates/modules/clock/src/lib.rs | 116 +---------- crates/modules/clock/src/module.rs | 116 +++++++++++ crates/services/datetime/src/kind.rs | 28 +++ crates/services/datetime/src/lib.rs | 74 +------ crates/services/datetime/src/ticker.rs | 70 +++++++ crates/services/src/lib.rs | 13 +- 20 files changed, 593 insertions(+), 548 deletions(-) create mode 100644 crates/common/src/effect.rs create mode 100644 crates/common/src/module.rs create mode 100644 crates/common/src/service.rs create mode 100644 crates/core/src/app.rs create mode 100644 crates/core/src/msg.rs create mode 100644 crates/core/src/subscription.rs create mode 100644 crates/core/src/update.rs create mode 100644 crates/core/src/view.rs create mode 100644 crates/modules/clock/src/module.rs create mode 100644 crates/services/datetime/src/kind.rs create mode 100644 crates/services/datetime/src/ticker.rs diff --git a/Cargo.lock b/Cargo.lock index 51ba97a..58286d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1719,6 +1719,7 @@ version = "0.1.0" dependencies = [ "common", "iced", + "service-datetime", "thiserror 2.0.20", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index fcd7f52..8ffcda8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ chrono = "0.4" toml = "1.1" serde = { version = "1", features = ["derive"] } thiserror = "2" +clap = { version = "4.5", features = ["derive"] } # Optimize all dependencies even in dev builds; workspace members keep dev # defaults (opt-level 0) and release settings under --release. diff --git a/crates/common/src/effect.rs b/crates/common/src/effect.rs new file mode 100644 index 0000000..4f39b95 --- /dev/null +++ b/crates/common/src/effect.rs @@ -0,0 +1,15 @@ +use iced::{window, Rectangle}; + +/// 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. +#[derive(Debug, Clone)] +pub enum ModuleEffect { + /// Toggle a popup for the given module id (element id anchors it). + RequestPopup(String, String), + /// A widget-tree pass reported a module's laid-out bounds; anchor the + /// popup there. App-internal, but one effect type keeps routing trivial. + BoundsFound(String, Rectangle), + /// Request removal of the popup surface. + ClosePopup(window::Id), +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index d3d1db9..a82781f 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -4,136 +4,10 @@ //! layer-shell effects). `common` is the hub every other crate depends on, //! so it must stay acyclic and pure. -use std::any::Any; -use std::error::Error; -use std::fmt; -use std::sync::Arc; +mod effect; +mod module; +mod service; -use iced::window; -use iced::{Element, Rectangle, Task}; - -// --------------------------------------------------------------------------- -// Service model -// --------------------------------------------------------------------------- - -/// What a clock subscription wants: the tick interval and payload selector. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] -pub enum ClockKind { - Mins, - 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, -} - -/// Route key a module subscribes with; `Clock(kind)` selects the ticker. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Service { - Clock(ClockKind), -} - -/// A service event with a typed payload. The app only reads -/// `key()` for fan-out; the consuming module downcasts the payload. -#[derive(Clone, Debug)] -pub enum ServicePayloadKind { - Clock(ClockPayload), -} - -impl ServicePayloadKind { - /// Route key for this event. One arm per kind — adding a service - /// touches only this match, never the routing code. - pub fn key(&self) -> Service { - match self { - Self::Clock(payload) => Service::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), - } - } -} - -// --------------------------------------------------------------------------- -// Module protocol -// --------------------------------------------------------------------------- - -/// 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() - } -} - -/// 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. -#[derive(Debug, Clone)] -pub enum ModuleEffect { - /// Toggle a popup for the given module id (element id anchors it). - RequestPopup(String, String), - /// A widget-tree pass reported a module's laid-out bounds; anchor the - /// popup there. App-internal, but one effect type keeps routing trivial. - BoundsFound(String, Rectangle), - /// Request removal of the popup surface. - ClosePopup(window::Id), -} - -/// 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; - - /// 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; may return app-level tasks (e.g. popups). - fn update(&mut self, msg: ModuleMsg) -> Task; - - /// What services this module wants. - fn services(&self) -> Vec; - - /// Requested popup size when it's not the generic small menu. - fn popup_size(&self) -> Option<(u32, u32)> { - None - } - - /// Digest config from `module.{module-id}`. Error is boxed because this - /// runs through `dyn BarModule`; each impl keeps its own concrete error - /// internally and may surface it pre-box via its constructor instead. - fn config(&mut self, _config: toml::Table) -> Result<(), Box> { - Ok(()) - } -} +pub use effect::ModuleEffect; +pub use module::{BarModule, ModuleMsg}; +pub use service::{Service, ServiceEvent}; diff --git a/crates/common/src/module.rs b/crates/common/src/module.rs new file mode 100644 index 0000000..fae9212 --- /dev/null +++ b/crates/common/src/module.rs @@ -0,0 +1,69 @@ +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() + } +} + +/// 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; + + /// 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; may return app-level tasks (e.g. popups). + fn update(&mut self, msg: ModuleMsg) -> Task; + + /// What services this module wants. + fn services(&self) -> Vec; + + /// Requested popup size when it's not the generic small menu. + fn popup_size(&self) -> Option<(u32, u32)> { + None + } + + /// Digest config from `module.{module-id}`. Error is boxed because this + /// runs through `dyn BarModule`; each impl keeps its own concrete error + /// internally and may surface it pre-box via its constructor instead. + fn config(&mut self, _config: toml::Table) -> Result<(), Box> { + Ok(()) + } +} diff --git a/crates/common/src/service.rs b/crates/common/src/service.rs new file mode 100644 index 0000000..51a8edf --- /dev/null +++ b/crates/common/src/service.rs @@ -0,0 +1,16 @@ +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/core/src/app.rs b/crates/core/src/app.rs new file mode 100644 index 0000000..880786e --- /dev/null +++ b/crates/core/src/app.rs @@ -0,0 +1,35 @@ +use std::collections::BTreeMap; + +use iced::window; + +use common::{BarModule, Service}; + +/// Application state: module registry + popup bookkeeping. +pub(crate) struct Bar { + /// Module whose popup is open; None = closed. + pub(crate) active_popup: Option, + /// Module registry keyed by stable module id. + pub(crate) modules: BTreeMap<&'static str, Box>, + /// Surface id of the open popup. + pub(crate) popup_id: Option, + /// Fan-out routing table: module id -> services it wants. + pub(crate) routes: BTreeMap<&'static str, Vec>, +} + +impl Bar { + pub(crate) fn new() -> 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 + .iter() + .map(|(id, module)| (*id, module.services())) + .collect(); + Self { + active_popup: None, + modules, + popup_id: None, + routes, + } + } +} diff --git a/crates/core/src/main.rs b/crates/core/src/main.rs index 12670f7..f322206 100644 --- a/crates/core/src/main.rs +++ b/crates/core/src/main.rs @@ -4,248 +4,46 @@ //! `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; - -use iced::widget::{column, container, text}; -use iced::{window, Alignment, Element, Length, Subscription, Task, Theme}; use iced_layershell::daemon; use iced_layershell::reexport::Anchor; use iced_layershell::settings::{LayerShellSettings, Settings, StartMode}; -use iced_layershell::to_layer_message; - -use common::{BarModule, ModuleEffect, ModuleMsg, Service as ModuleService, ServicePayloadKind}; -use services::IntoSubscription; +mod app; +mod msg; mod popup; +mod subscription; +mod update; +mod view; + +pub(crate) use app::Bar; +pub(crate) use msg::{Message, Msg}; /// Height of the bar in logical pixels. const BAR_HEIGHT: u32 = 36; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/// Application state: module registry + popup bookkeeping. -struct Bar { - /// 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 open popup. - popup_id: Option, - /// Fan-out routing table: module id -> services it wants. - routes: BTreeMap<&'static str, Vec>, -} - -impl Bar { - fn new() -> 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 - .iter() - .map(|(id, module)| (*id, module.services())) - .collect(); - Self { - active_popup: None, - modules, - popup_id: None, - routes, - } - } -} - -// --------------------------------------------------------------------------- -// 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), - /// Service event; fan out by route key. - Subscription(ServicePayloadKind), - /// The compositor confirmed a window closed. The popup is really gone. - WindowClosed(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 { - Event(Msg), - /// Effects. `common::ModuleEffect` is the one effect vocabulary, so - /// mapping module output is a plain `.map(Message::Effect)`. - Effect(ModuleEffect), -} - -// --------------------------------------------------------------------------- -// Application wiring -// --------------------------------------------------------------------------- - -fn namespace() -> String { - String::from("barbar") -} - -/// 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. -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))) - }) - .collect::>(), - ); - let close_events = window::close_events().map(|id| Message::Event(Msg::WindowClosed(id))); - Subscription::batch(vec![route_sub, close_events]) -} - fn main() -> Result<(), iced_layershell::Error> { let start_mode = match std::env::args().nth(1) { Some(output) => StartMode::TargetScreen(output), None => StartMode::Active, }; - daemon(Bar::new, namespace, update, view) - .style(style) - .subscription(gather_subscriptions) - .settings(Settings { - layer_settings: LayerShellSettings { - size: Some((0, BAR_HEIGHT)), - exclusive_zone: BAR_HEIGHT as i32, - anchor: Anchor::Top | Anchor::Left | Anchor::Right, - start_mode, - ..Default::default() - }, - ..Default::default() - }) - .run() -} - -// --------------------------------------------------------------------------- -// Update -// --------------------------------------------------------------------------- - -fn update(bar: &mut Bar, msg: Message) -> Task { - match msg { - Message::Event(event) => handle_event(bar, event), - Message::Effect(effect) => handle_effect(bar, effect), - // 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 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 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(&key)) - }) - .map(|(_, module)| { - module - .update(ModuleMsg { - id: module.id(), - payload: payload.clone(), - }) - .map(Message::Effect) - }) - .fold(Task::none(), |acc, t| acc.chain(t)) - } - - 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::none() - } - } -} - -/// 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 { - ModuleEffect::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) - } - } - - ModuleEffect::BoundsFound(module_id, bounds) => popup::open_popup(bar, module_id, bounds), - - ModuleEffect::ClosePopup(id) => { - // Request removal only; keep popup state so the popup content - // renders until `WindowClosed` confirms it's gone. - Task::done(Message::RemoveWindow(id)) - } - } -} - -fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> { - if bar.popup_id == Some(id) { - popup::view(bar) // popup surface - } else { - bar_view(bar) // main bar surface - } -} - -/// 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(|m| Message::Event(Msg::Module(m)))); - if i < bar.modules.len() - 1 { - children.push(text("|").size(14).into()); - } - } - - container( - column(children) - .width(Length::Fill) - .align_x(Alignment::Center), + daemon( + app::Bar::new, + subscription::namespace, + update::update, + view::view, ) - .padding(8) - .align_x(Alignment::Center) - .into() -} - -// --------------------------------------------------------------------------- -// Style -// --------------------------------------------------------------------------- - -fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style { - use iced::theme::Style; - Style { - background_color: theme.palette().background, - text_color: theme.palette().text, - } + .style(view::style) + .subscription(subscription::gather_subscriptions) + .settings(Settings { + layer_settings: LayerShellSettings { + size: Some((0, BAR_HEIGHT)), + exclusive_zone: BAR_HEIGHT as i32, + anchor: Anchor::Top | Anchor::Left | Anchor::Right, + start_mode, + ..Default::default() + }, + ..Default::default() + }) + .run() } diff --git a/crates/core/src/msg.rs b/crates/core/src/msg.rs new file mode 100644 index 0000000..81436b7 --- /dev/null +++ b/crates/core/src/msg.rs @@ -0,0 +1,37 @@ +use iced::window; +use iced_layershell::to_layer_message; + +use common::{ModuleEffect, ModuleMsg, ServiceEvent}; + +/// 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), + /// Service event; fan out by route key. + Subscription(ServiceEvent), + /// The compositor confirmed a window closed. The popup is really gone. + WindowClosed(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 { + Event(Msg), + /// Effects. `common::ModuleEffect` is the one effect vocabulary, so + /// mapping module output is a plain `.map(Message::Effect)`. + Effect(ModuleEffect), +} + +/// `to_layer_message` generates `popup_open` as a fn private to this module; +/// re-expose it for the popup surface code, which lives elsewhere. +pub(crate) fn popup_open( + settings: iced_layershell::actions::IcedNewPopupSettings, +) -> ( + iced_layershell::reexport::IcedId, + iced_layershell::reexport::Task, +) { + Message::popup_open(settings) +} diff --git a/crates/core/src/popup.rs b/crates/core/src/popup.rs index d192d0b..f7772f9 100644 --- a/crates/core/src/popup.rs +++ b/crates/core/src/popup.rs @@ -12,6 +12,7 @@ use iced_layershell::reexport::{PopupAnchor, PopupGravity}; use common::ModuleEffect; +use crate::msg::popup_open; use crate::{Bar, Message, Msg}; /// Default (small menu) popup size. @@ -45,7 +46,7 @@ pub fn open_popup(bar: &mut Bar, module_id: String, bounds: Rectangle) -> Task String { + String::from("barbar") +} + +/// 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. +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))) + }) + .collect::>(), + ); + let close_events = window::close_events().map(|id| Message::Event(Msg::WindowClosed(id))); + Subscription::batch(vec![route_sub, close_events]) +} diff --git a/crates/core/src/update.rs b/crates/core/src/update.rs new file mode 100644 index 0000000..1a55e74 --- /dev/null +++ b/crates/core/src/update.rs @@ -0,0 +1,82 @@ +use iced::Task; + +use common::{ModuleEffect, ModuleMsg}; + +use crate::app::Bar; +use crate::msg::{Message, Msg}; +use crate::popup; + +pub(crate) fn update(bar: &mut Bar, msg: Message) -> Task { + match msg { + Message::Event(event) => handle_event(bar, event), + Message::Effect(effect) => handle_effect(bar, effect), + // 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 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::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::none() + } + } +} + +/// 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 { + ModuleEffect::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) + } + } + + ModuleEffect::BoundsFound(module_id, bounds) => popup::open_popup(bar, module_id, bounds), + + ModuleEffect::ClosePopup(id) => { + // Request removal only; keep popup state so the popup content + // renders until `WindowClosed` confirms it's gone. + Task::done(Message::RemoveWindow(id)) + } + } +} diff --git a/crates/core/src/view.rs b/crates/core/src/view.rs new file mode 100644 index 0000000..e88c8da --- /dev/null +++ b/crates/core/src/view.rs @@ -0,0 +1,43 @@ +use iced::widget::{column, container, text}; +use iced::{window, Alignment, Element, Length, Theme}; + +use crate::app::Bar; +use crate::msg::{Message, Msg}; +use crate::popup; + +pub(crate) fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> { + if bar.popup_id == Some(id) { + popup::view(bar) // popup surface + } else { + bar_view(bar) // main bar surface + } +} + +/// 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(|m| Message::Event(Msg::Module(m)))); + if i < bar.modules.len() - 1 { + children.push(text("|").size(14).into()); + } + } + + container( + column(children) + .width(Length::Fill) + .align_x(Alignment::Center), + ) + .padding(8) + .align_x(Alignment::Center) + .into() +} + +pub(crate) fn style(_bar: &Bar, theme: &Theme) -> iced::theme::Style { + use iced::theme::Style; + Style { + background_color: theme.palette().background, + text_color: theme.palette().text, + } +} diff --git a/crates/modules/clock/Cargo.toml b/crates/modules/clock/Cargo.toml index 3afb47e..4c960af 100644 --- a/crates/modules/clock/Cargo.toml +++ b/crates/modules/clock/Cargo.toml @@ -9,6 +9,7 @@ path = "src/lib.rs" [dependencies] common = { path = "../../common" } +service_datetime = { package = "service-datetime", path = "../../services/datetime" } iced = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } diff --git a/crates/modules/clock/src/lib.rs b/crates/modules/clock/src/lib.rs index f783933..a14201f 100644 --- a/crates/modules/clock/src/lib.rs +++ b/crates/modules/clock/src/lib.rs @@ -2,118 +2,6 @@ //! Left-click toggles the seconds suffix; right-click opens a popup with //! the current time in large text. -use iced::widget::{container, mouse_area, text}; -use iced::{Element, Task}; +mod module; -use common::{BarModule, ClockKind, ClockPayload, ModuleEffect, ModuleMsg, Service}; - -/// Big-text popup size (logical px). -const POPUP_W: u32 = 360; -const POPUP_H: u32 = 160; - -/// Errors digesting the `module.clock` config table. -#[derive(Debug, thiserror::Error)] -pub enum ClockError { - /// Config value was present but not a bool. - #[error("module.clock.{0} must be a bool")] - 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, -} - -impl Clock { - pub fn new() -> Self { - Self { - value: "--:--:--".to_string(), - 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 Default for Clock { - fn default() -> Self { - Self::new() - } -} - -impl BarModule for Clock { - fn id(&self) -> &'static str { - "clock" - } - - fn view(&self, window_id: Option) -> Element<'_, ModuleMsg> { - 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 { - // Subscription fan-out delivers a raw `ClockPayload`, not `ClockMsg`. - if let Some(payload) = msg.downcast::() { - self.value = payload.value.clone(); - return Task::none(); - } - match msg.downcast::() { - Some(ClockMsg::ToggleSeconds) => { - self.show_seconds = !self.show_seconds; - Task::none() - } - Some(ClockMsg::OpenPopup) => Task::done(ModuleEffect::RequestPopup( - self.id().to_string(), - self.id().to_string(), - )), - None => Task::none(), - } - } - - fn services(&self) -> Vec { - vec![Service::Clock(ClockKind::Seconds)] - } - - fn popup_size(&self) -> Option<(u32, u32)> { - Some((POPUP_W, POPUP_H)) - } - fn config(&mut self, config: toml::Table) -> Result<(), Box> { - if let toml::Value::Boolean(e) = config["format"] { - if e { - self.show_seconds = true; - }; - } - Ok(()) - } -} +pub use module::{Clock, ClockError, ClockMsg}; diff --git a/crates/modules/clock/src/module.rs b/crates/modules/clock/src/module.rs new file mode 100644 index 0000000..74ef411 --- /dev/null +++ b/crates/modules/clock/src/module.rs @@ -0,0 +1,116 @@ +use iced::widget::{container, mouse_area, text}; +use iced::{Element, Task}; + +use common::{BarModule, ModuleEffect, ModuleMsg, Service}; +use service_datetime::{ClockKind, ClockPayload}; + +/// Big-text popup size (logical px). +const POPUP_W: u32 = 360; +const POPUP_H: u32 = 160; + +/// Errors digesting the `module.clock` config table. +#[derive(Debug, thiserror::Error)] +pub enum ClockError { + /// Config value was present but not a bool. + #[error("module.clock.{0} must be a bool")] + 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, +} + +impl Clock { + pub fn new() -> Self { + Self { + value: "--:--:--".to_string(), + 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 Default for Clock { + fn default() -> Self { + Self::new() + } +} + +impl BarModule for Clock { + fn id(&self) -> &'static str { + "clock" + } + + fn view(&self, window_id: Option) -> Element<'_, ModuleMsg> { + 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 { + // Subscription fan-out delivers a raw `ClockPayload`, not `ClockMsg`. + if let Some(payload) = msg.downcast::() { + self.value = payload.value.clone(); + return Task::none(); + } + match msg.downcast::() { + Some(ClockMsg::ToggleSeconds) => { + self.show_seconds = !self.show_seconds; + Task::none() + } + Some(ClockMsg::OpenPopup) => Task::done(ModuleEffect::RequestPopup( + self.id().to_string(), + self.id().to_string(), + )), + None => Task::none(), + } + } + + fn services(&self) -> Vec { + vec![ClockKind::Seconds.key()] + } + + fn popup_size(&self) -> Option<(u32, u32)> { + Some((POPUP_W, POPUP_H)) + } + fn config(&mut self, config: toml::Table) -> Result<(), Box> { + if let toml::Value::Boolean(e) = config["format"] { + if e { + self.show_seconds = true; + }; + } + Ok(()) + } +} diff --git a/crates/services/datetime/src/kind.rs b/crates/services/datetime/src/kind.rs new file mode 100644 index 0000000..06f3359 --- /dev/null +++ b/crates/services/datetime/src/kind.rs @@ -0,0 +1,28 @@ +use common::Service; + +/// 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, +} diff --git a/crates/services/datetime/src/lib.rs b/crates/services/datetime/src/lib.rs index bef8f08..d9a0f2f 100644 --- a/crates/services/datetime/src/lib.rs +++ b/crates/services/datetime/src/lib.rs @@ -1,73 +1,11 @@ -//! Clock service: emits a `ServicePayloadKind` event whenever a clock kind's -//! displayed value changes (minute or second rollover). +//! Clock service: emits a `ServiceEvent` whenever a clock kind's displayed +//! value changes (minute or second rollover). //! //! The ticker knows nothing about the app's `Message` type; it produces //! protocol events from `common` that core routes to subscribed modules. -use std::collections::HashMap; +mod kind; +mod ticker; -use iced::{futures::SinkExt, Subscription}; - -use common::{ClockKind, ClockPayload, ServicePayloadKind}; - -/// 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, -} - -impl ClockTicker { - pub fn new() -> Self { - Self::default() - } - - /// Subscription that emits `ServicePayloadKind::Clock` on every change. - 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(ServicePayloadKind::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] - .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 }) - } - } - }) - .collect() - } -} +pub use kind::{ClockKind, ClockPayload, NAMESPACE}; +pub use ticker::ClockTicker; diff --git a/crates/services/datetime/src/ticker.rs b/crates/services/datetime/src/ticker.rs new file mode 100644 index 0000000..eb24845 --- /dev/null +++ b/crates/services/datetime/src/ticker.rs @@ -0,0 +1,70 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use iced::{futures::SinkExt, Subscription}; + +use common::ServiceEvent; + +use crate::kind::{ClockKind, ClockPayload}; + +/// 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, +} + +impl ClockTicker { + pub fn new() -> Self { + Self::default() + } + + /// Subscription that emits a `ServiceEvent` on every change. + 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() { + let event = ServiceEvent { + key: payload.kind.key(), + payload: Arc::new(payload), + }; + if sender.send(event).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] + .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 }) + } + } + }) + .collect() + } +} diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 8131417..d304f90 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -4,7 +4,7 @@ use iced::Subscription; -use common::{Service, ServicePayloadKind}; +use common::{Service, ServiceEvent}; /// Conversion from a route key to the subscription backing it. /// @@ -12,13 +12,16 @@ use common::{Service, ServicePayloadKind}; /// 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) -> Subscription; } impl IntoSubscription for Service { - fn into_subscription(self) -> Subscription { - match self { - Service::Clock(_) => service_datetime::ClockTicker::run(), + fn into_subscription(self) -> Subscription { + match self.0 { + key if key.starts_with(service_datetime::NAMESPACE) => { + service_datetime::ClockTicker::run() + } + _ => Subscription::none(), } } }