From 7aa1550f4181009b9baf47464a9b1d5cfdd3223b Mon Sep 17 00:00:00 2001 From: Doloro1978 Date: Tue, 1 Sep 2026 16:34:58 +0100 Subject: [PATCH] bullshit --- Cargo.lock | 38 +++++ Cargo.toml | 3 +- src/main.rs | 342 ++++++++++++++----------------------- src/module/clock.rs | 74 ++++++++ src/module/mod.rs | 65 +++++++ src/popup.rs | 117 +++++++++++++ src/subscriptions/clock.rs | 56 ++++++ src/subscriptions/mod.rs | 17 ++ 8 files changed, 500 insertions(+), 212 deletions(-) create mode 100644 src/module/clock.rs create mode 100644 src/module/mod.rs create mode 100644 src/popup.rs create mode 100644 src/subscriptions/clock.rs create mode 100644 src/subscriptions/mod.rs diff --git a/Cargo.lock b/Cargo.lock index dced3ac..bb140ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,6 +102,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" name = "barbar" version = "0.1.0" dependencies = [ + "chrono", "iced", "iced_layershell", "tokio", @@ -266,6 +267,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -981,6 +995,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "iced" version = "0.14.0" diff --git a/Cargo.toml b/Cargo.toml index d81ab14..4503e83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,4 +6,5 @@ edition = "2021" [dependencies] iced = { version = "0.14", default-features = false, features = ["wgpu", "wayland", "crisp", "web-colors", "thread-pool", "advanced", "tokio"] } iced_layershell = { version = "0.19", default-features = false } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } \ No newline at end of file +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +chrono = "0.4" \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 2575209..76a90b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,75 +7,79 @@ //! Clicking any button spawns a separate LayerShell popup surface anchored //! to that button's position on the bar. -use iced::advanced::widget as advanced_widget; -use iced::widget::{column, container, mouse_area, text}; -use iced::{window, Alignment, Element, Length, Rectangle, Task, Theme}; +use std::collections::BTreeMap; + +use iced::widget::{column, container, text}; +use iced::{window, Alignment, Element, Length, Rectangle, Subscription, Task, Theme}; use iced_layershell::daemon; -use iced_layershell::reexport::{Anchor, PopupAnchor, PopupGravity}; -use iced_layershell::settings::{LayerShellSettings, StartMode, Settings}; +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; + +mod module; +mod popup; +mod subscriptions; + /// Height of the bar in logical pixels. const BAR_HEIGHT: u32 = 36; -/// Popup dimensions. -const POPUP_W: u32 = 150; -const POPUP_H: u32 = 100; - -/// Vertical gap (logical px) between the bar's bottom edge and the popup. -const POPUP_GAP: i32 = 32; - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -/// Identifies which bar module triggered an action. -#[derive(Debug, Clone, Copy, PartialEq)] -enum Module { - Workspace, - Clock, - System, -} - -impl Module { - const ALL: &'static [Self] = &[Self::Workspace, Self::Clock, Self::System]; - - fn label(&self) -> &'static str { - match self { - Self::Workspace => "workspaces", - Self::Clock => "clock", - Self::System => "system", - } - } - - /// Stable widget-tree id used to look up the laid-out bounds of the - /// module's clickable area. - fn id(&self) -> iced::widget::Id { - iced::widget::Id::new(self.label()) - } -} - -/// Application state. Tracks which popup is open and shared module data. -#[derive(Debug, Default)] +/// Application state. Tracks which popup is open and owns the module registry. struct Bar { - /// Which button triggered the open popup? None = closed. - active_popup: Option, + /// Which module's button triggered the open popup? 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). popup_id: Option, - /// Example workspace list populated when workspace menu opens. - workspaces: Vec, + /// Fan-out routing table: module id -> the subscription kinds it wants. + /// Built once at boot from `BarModule::subscriptions()`. + 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())); + let routes = modules + .iter() + .map(|(id, module)| (*id, module.subscriptions())) + .collect(); + Self { + active_popup: None, + modules, + popup_id: None, + routes, + } + } } /// Messages produced by user input, events and subscriptions. #[to_layer_message(multi)] #[derive(Debug, Clone)] -enum Message { +pub(crate) enum Message { Noop, - /// A bar module was clicked; opens or toggles a popup. - ModuleClicked(Module), + /// 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(Module, Rectangle), + BoundsFound(String, Rectangle), /// Closes the currently open popup. ClosePopup(window::Id), } @@ -94,8 +98,9 @@ fn main() -> Result<(), iced_layershell::Error> { None => StartMode::Active, }; - daemon(Bar::default, namespace, update, view) + daemon(Bar::new, namespace, update, view) .style(style) + .subscription(gather_subscriptions) .settings(Settings { layer_settings: LayerShellSettings { size: Some((0, BAR_HEIGHT)), @@ -109,54 +114,84 @@ fn main() -> Result<(), iced_layershell::Error> { .run() } +fn gather_subscriptions(bar: &Bar) -> Subscription { + // Only spawn a runner if some module actually wants it. `bar` is passed + // precisely so we can check routes and avoid idle tickers. + let wants_clock = bar + .routes + .values() + .any(|kinds| kinds.iter().any(|k| matches!(k, ModuleSub::Clock(_)))); + if !wants_clock { + return Subscription::none(); + } + + // A single 1s clock stream drives ALL modules that want a clock kind. + // The stream emits a `SubscriptionMsg` with kind+payload; `update` fans + // out to every module in `routes` that wants that kind. + // The builder must be non-capturing (fn pointer), so the Clock state + // lives inside the stream body; identity `()` keeps it alive forever. + 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; + } + } + } + }) + }) +} + // --------------------------------------------------------------------------- // Update // --------------------------------------------------------------------------- fn update(bar: &mut Bar, msg: Message) -> Task { match msg { - Message::ModuleClicked(module) => { + 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. + 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 + .iter_mut() + .filter(|(id, _)| { + bar.routes + .get(*id) + .is_some_and(|kinds| kinds.contains(&ModuleSub::Clock(payload.kind))) + }) + .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 == Some(module) { - return maybe_close_popup(bar); + 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) } - - // Query the widget tree for the module's actual laid-out bounds. - // The resulting `BoundsFound` message opens the popup there. - capture_bounds(module) } - Message::BoundsFound(module, bounds) => { - bar.active_popup = Some(module); - - // 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, leaving a gap under the bar. - let (x, y, w, h) = ( - 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 = iced_layershell::actions::IcedNewPopupSettings::on_current_surface( - (POPUP_W, POPUP_H), - anchor_rect, - ) - .anchor(PopupAnchor::Bottom) - .gravity(PopupGravity::Bottom); - - let (id, task) = Message::popup_open(settings); - bar.popup_id = Some(id); - - // Seed example data for the workspace menu. - if module == Module::Workspace { - bar.workspaces = vec!["1".into(), "2".into(), "3".into(), "4".into()]; - } - - task - } + Message::BoundsFound(module_id, bounds) => popup::open_popup(bar, module_id, bounds), Message::ClosePopup(id) => { if bar.popup_id == Some(id) { @@ -169,75 +204,10 @@ fn update(bar: &mut Bar, msg: Message) -> Task { Message::Noop => Task::none(), // Forward multi-window mutations to their internal handlers. - Message::AnchorChange { .. } - | Message::SetInputRegion { .. } - | Message::AnchorSizeChange { .. } - | Message::LayerChange { .. } - | Message::MarginChange { .. } - | Message::SizeChange { .. } - | Message::ExclusiveZoneChange { .. } - | Message::KeyboardInteractivityChange { .. } - | Message::NewBaseWindow { .. } - | Message::NewInputPanel { .. } - | Message::NewLayerShell { .. } - | Message::NewMenu { .. } - | Message::NewPopUp { .. } - | Message::RemoveWindow(_) - | Message::ForgetLastOutput - | Message::VirtualKeyboardPressed { .. } => Task::none(), + _ => Task::none(), } } -/// Helper: closes the popup if one is open. -fn maybe_close_popup(bar: &mut Bar) -> Task { - if let Some(id) = bar.popup_id.take() { - bar.active_popup = None; - return Task::done(Message::RemoveWindow(id)); - } - Task::none() -} - -/// Runs a widget-tree [`Operation`] that captures the laid-out bounds of the -/// given module's clickable area, then reports them via `BoundsFound`. -fn capture_bounds(module: Module) -> Task { - let target = module.id(); - - struct FindBounds { - target: iced::widget::Id, - found: Option, - } - - impl advanced_widget::Operation for FindBounds { - fn traverse( - &mut self, - operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation), - ) { - // Delegate traversal so widgets can visit their children; the - // container id is matched in `container` above. - operate(self); - } - - fn container(&mut self, id: Option<&iced::widget::Id>, bounds: Rectangle) { - if self.found.is_none() && id == Some(&self.target) { - self.found = Some(bounds); - } - } - - fn finish(&self) -> advanced_widget::operation::Outcome { - match self.found { - Some(bounds) => advanced_widget::operation::Outcome::Some(bounds), - None => advanced_widget::operation::Outcome::None, - } - } - } - - advanced_widget::operate(FindBounds { - target, - found: None, - }) - .map(move |bounds| Message::BoundsFound(module, bounds)) -} - // --------------------------------------------------------------------------- // View — routed per-surface by window ID // --------------------------------------------------------------------------- @@ -249,20 +219,22 @@ fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> { // known ID (stored implicitly as "first window"). if bar.popup_id == Some(id) { // This is the popup surface. - popup_view(bar) + popup::view(bar) } else { // This is the main bar surface. bar_view(bar) } } -/// Main bar widget tree. -fn bar_view(_bar: &Bar) -> Element<'static, Message> { +/// 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. +fn bar_view(bar: &Bar) -> Element<'_, Message> { let mut children: Vec> = Vec::new(); - for (i, module) in Module::ALL.iter().enumerate() { - children.push(button(*module)); - if i < Module::ALL.len() - 1 { + for (i, (_, module)) in bar.modules.iter().enumerate() { + children.push(module.view(None).map(Message::Module)); + if i < bar.modules.len() - 1 { children.push(text("|").size(14).into()); } } @@ -277,58 +249,6 @@ fn bar_view(_bar: &Bar) -> Element<'static, Message> { .into() } -/// Standalone button wrapped in MouseArea for click capture. The container -/// carries the module's id so a widget-tree [`Operation`] can find its -/// laid-out bounds for popup anchoring. -fn button(module: Module) -> Element<'static, Message> { - container(mouse_area(text(module.label()).size(14)).on_press(Message::ModuleClicked(module))) - .id(module.id()) - .into() -} - -/// Popup widget tree. Rendered on its own floating LayerShell surface. -fn popup_view<'a>(bar: &'a Bar) -> Element<'a, Message> { - let module = bar.active_popup.unwrap(); - let popup_id = bar.popup_id.unwrap(); - let label = module.label(); - - // Build list items based on which module was clicked. - let items: Vec> = match module { - Module::Workspace => bar - .workspaces - .iter() - .map(|ws| popup_item(ws.clone(), popup_id)) - .collect(), - Module::Clock => vec![ - popup_item("Time format", popup_id), - popup_item("Date display", popup_id), - ], - Module::System => vec![ - popup_item("Brightness", popup_id), - popup_item("Volume", popup_id), - popup_item("Power", popup_id), - ], - }; - - column![ - text(format!("{} menu", label)) - .size(14) - .width(Length::Fill) - .align_x(Alignment::Center), - column(items).spacing(1), - mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)), - ] - .spacing(1) - .into() -} - -/// Generic clickable item inside a popup. -fn popup_item(label: impl Into, popup_id: window::Id) -> Element<'static, Message> { - mouse_area(text(label.into()).size(13)) - .on_press(Message::ClosePopup(popup_id)) - .into() -} - // --------------------------------------------------------------------------- // Style // --------------------------------------------------------------------------- diff --git a/src/module/clock.rs b/src/module/clock.rs new file mode 100644 index 0000000..2dc140e --- /dev/null +++ b/src/module/clock.rs @@ -0,0 +1,74 @@ +use iced::widget::{button, text}; +use iced::{Element, Task}; + +use crate::module::{BarModule, ModuleMsg}; +use crate::subscriptions::clock::{ClockKind, ClockPayload}; +use crate::subscriptions::Subscription; +use crate::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. +#[derive(Clone)] +pub enum ClockMsg { + /// The bar was clicked; toggle the seconds suffix. + ToggleSeconds, +} + +pub struct Clock { + value: String, + show_seconds: bool, +} + +impl Clock { + pub fn new() -> Self { + Self { + value: "--:--:--".to_string(), + show_seconds: true, + } + } +} + +impl BarModule for Clock { + fn id(&self) -> &'static str { + "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() + } + + fn update(&mut self, msg: ModuleMsg) -> Task { + // The subscription fan-out delivers a raw `ClockPayload` (never + // wrapped in `ClockMsg`) — handle it first. + 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; + } + Task::none() + } + + fn subscriptions(&self) -> Vec { + vec![Subscription::Clock(ClockKind::Seconds)] + } +} diff --git a/src/module/mod.rs b/src/module/mod.rs new file mode 100644 index 0000000..59fd27c --- /dev/null +++ b/src/module/mod.rs @@ -0,0 +1,65 @@ +use std::any::Any; +use std::fmt; +use std::sync::Arc; + +use iced::{Element, Task}; + +use crate::subscriptions::Subscription; +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. +#[derive(Clone)] +pub struct ModuleMsg { + pub id: &'static str, + pub payload: Arc, +} + +impl ModuleMsg { + /// Wraps a module's own message. `id` must match the module's `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. Returns `None` if the + /// payload belongs to another module. + 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 so modules live in +/// `BTreeMap>`. 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. + 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). + fn update(&mut self, msg: ModuleMsg) -> Task; + + /// Declarative: what subscription kinds this module wants. + fn subscriptions(&self) -> Vec; +} diff --git a/src/popup.rs b/src/popup.rs new file mode 100644 index 0000000..0ff0749 --- /dev/null +++ b/src/popup.rs @@ -0,0 +1,117 @@ +//! 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))`. + +use iced::advanced::widget as advanced_widget; +use iced::widget::{column, mouse_area, text}; +use iced::{Alignment, Element, Length, Rectangle, Task}; +use iced_layershell::actions::IcedNewPopupSettings; +use iced_layershell::reexport::{PopupAnchor, PopupGravity}; + +use crate::{Bar, Message}; + +/// Popup dimensions. +const POPUP_W: u32 = 150; +const POPUP_H: u32 = 100; + +/// Vertical 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`. +pub fn open_popup(bar: &mut Bar, module_id: String, bounds: Rectangle) -> Task { + bar.active_popup = Some(module_id); + + // 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) = ( + 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) + .anchor(PopupAnchor::Bottom) + .gravity(PopupGravity::Bottom); + + let (id, task) = Message::popup_open(settings); + bar.popup_id = Some(id); + task +} + +/// Closes the popup if one is open. +pub fn close_popup(bar: &mut Bar) -> Task { + if let Some(id) = bar.popup_id.take() { + bar.active_popup = None; + return Task::done(Message::RemoveWindow(id)); + } + Task::none() +} + +/// Runs a widget-tree [`Operation`] that captures the laid-out bounds of the +/// given element, then reports them via `BoundsFound` for the module. +pub fn capture_bounds(module_id: String, element_id: String) -> Task { + let target = iced::widget::Id::from(element_id); + + struct FindBounds { + target: iced::widget::Id, + found: Option, + } + + 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), + ) { + operate(self); + } + + fn container(&mut self, id: Option<&iced::widget::Id>, bounds: Rectangle) { + if self.found.is_none() && id == Some(&self.target) { + self.found = Some(bounds); + } + } + + fn finish(&self) -> advanced_widget::operation::Outcome { + match self.found { + Some(bounds) => advanced_widget::operation::Outcome::Some(bounds), + None => advanced_widget::operation::Outcome::None, + } + } + } + + advanced_widget::operate(FindBounds { + target, + found: None, + }) + .map(move |bounds| Message::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))`. +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)) + .unwrap_or_else(|| text("unknown module").into()); + + column![ + text(format!("{} menu", module_id)) + .size(14) + .width(Length::Fill) + .align_x(Alignment::Center), + content, + mouse_area(text("close").size(12)).on_press(Message::ClosePopup(popup_id)), + ] + .spacing(1) + .into() +} diff --git a/src/subscriptions/clock.rs b/src/subscriptions/clock.rs new file mode 100644 index 0000000..d94a42e --- /dev/null +++ b/src/subscriptions/clock.rs @@ -0,0 +1,56 @@ +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. +#[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. +#[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. +#[derive(Default)] +pub struct ClockTicker { + last: HashMap, +} + +impl ClockTicker { + pub fn new() -> Self { + 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 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/src/subscriptions/mod.rs b/src/subscriptions/mod.rs new file mode 100644 index 0000000..1a56dfb --- /dev/null +++ b/src/subscriptions/mod.rs @@ -0,0 +1,17 @@ +use crate::subscriptions::clock::{ClockKind, ClockPayload}; + +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. +#[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. +#[derive(Clone, Debug)] +pub enum SubscriptionPayloadKind { + Clock(ClockPayload), +}