more framework perfecting

This commit is contained in:
2026-09-05 14:04:11 +01:00
parent 0ee71a0618
commit 4db39f718a
6 changed files with 277 additions and 191 deletions
+104 -103
View File
@@ -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 //! Renders a full-width bar pinned to the top of the screen via
//! `wlr-layer-shell` protocol. Optionally target a specific output by //! `wlr-layer-shell`. Optional first CLI arg = target output name.
//! passing its name as the first CLI argument. //! Clicking a module's button spawns a LayerShell popup anchored to it.
//!
//! Clicking any button spawns a separate LayerShell popup surface anchored
//! to that button's position on the bar.
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -16,10 +13,7 @@ use iced_layershell::reexport::Anchor;
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode}; use iced_layershell::settings::{LayerShellSettings, Settings, StartMode};
use iced_layershell::to_layer_message; use iced_layershell::to_layer_message;
use iced::futures::sink::SinkExt;
use crate::module::{BarModule, ModuleMsg}; use crate::module::{BarModule, ModuleMsg};
use crate::subscriptions::clock::ClockTicker;
use crate::subscriptions::Subscription as ModuleSub; use crate::subscriptions::Subscription as ModuleSub;
use crate::subscriptions::SubscriptionPayloadKind; use crate::subscriptions::SubscriptionPayloadKind;
@@ -34,21 +28,19 @@ const BAR_HEIGHT: u32 = 36;
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Application state. Tracks which popup is open and owns the module registry. /// Application state: module registry + popup bookkeeping.
struct Bar { struct Bar {
/// Which module's button triggered the open popup? None = closed. /// Module whose popup is open; None = closed.
active_popup: Option<String>, active_popup: Option<String>,
/// Module registry keyed by stable module id. /// Module registry keyed by stable module id.
modules: BTreeMap<&'static str, Box<dyn BarModule>>, modules: BTreeMap<&'static str, Box<dyn BarModule>>,
/// Surface ID of the currently open popup (for removal). /// Surface id of the open popup.
popup_id: Option<window::Id>, popup_id: Option<window::Id>,
/// Fan-out routing table: module id -> the subscription kinds it wants. /// Fan-out routing table: module id -> subscription kinds it wants.
/// Built once at boot from `BarModule::subscriptions()`.
routes: BTreeMap<&'static str, Vec<ModuleSub>>, routes: BTreeMap<&'static str, Vec<ModuleSub>>,
} }
impl Bar { impl Bar {
/// Builds the module registry. The daemon calls this once at boot.
fn new() -> Self { fn new() -> Self {
let mut modules: BTreeMap<&'static str, Box<dyn BarModule>> = BTreeMap::new(); let mut modules: BTreeMap<&'static str, Box<dyn BarModule>> = BTreeMap::new();
modules.insert("clock", Box::new(crate::module::clock::Clock::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<Message>`.
#[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)] #[to_layer_message(multi)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) enum Message { pub(crate) enum Message {
Noop, Event(Msg),
/// A routed module message; dispatch by `id` without naming its type. Effect(Cmd),
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),
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -114,40 +122,16 @@ fn main() -> Result<(), iced_layershell::Error> {
.run() .run()
} }
/// Route subscriptions + window-close events (popup really destroyed).
fn gather_subscriptions(bar: &Bar) -> Subscription<Message> { fn gather_subscriptions(bar: &Bar) -> Subscription<Message> {
//TODO: Get a vec of subscription enum 'bar_wants' and spawn a batch depending on what the let route_sub = Subscription::batch(
//modules want. bar.routes
let wants_clock = bar
.routes
.values() .values()
.any(|kinds| kinds.iter().any(|k| matches!(k, ModuleSub::Clock(_)))); .flat_map(|x| x.iter().map(ModuleSub::subscription))
if !wants_clock { .collect::<Vec<_>>(),
return Subscription::none(); );
} let close_events = window::close_events().map(|id| Message::Event(Msg::WindowClosed(id)));
Subscription::batch(vec![route_sub, close_events])
Subscription::run_with((), |_| {
iced::stream::channel(
0,
|mut sender: iced::futures::channel::mpsc::Sender<Message>| 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;
}
}
}
},
)
})
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -156,53 +140,74 @@ fn gather_subscriptions(bar: &Bar) -> Subscription<Message> {
fn update(bar: &mut Bar, msg: Message) -> Task<Message> { fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
match msg { match msg {
Message::Module(m) => match bar.modules.get_mut(m.id) { Message::Event(event) => handle_event(bar, event),
// The module produces its own (app-level) tasks, e.g. opening a Message::Effect(cmd) => handle_effect(bar, cmd),
// popup for itself — nothing to map here. // 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<Message> {
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), Some(module) => module.update(m),
None => Task::none(), None => Task::none(),
}, },
Message::Subscription(kind) => match kind { Msg::Subscription(event) => {
// Fan out by kind to every module whose route requests it. The // Fan out by route key; the payload is opaque here, the module
// payload kind is typed, so no downcast needed. // downcasts it. New kinds touch only `SubscriptionPayloadKind`.
SubscriptionPayloadKind::Clock(payload) => bar let key = event.key();
.modules let payload = event.into_payload();
bar.modules
.iter_mut() .iter_mut()
.filter(|(id, _)| { .filter(|(id, _)| {
bar.routes bar.routes
.get(*id) .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()))) .map(|(_, module)| {
.fold(Task::none(), |acc, t| acc.chain(t)), module.update(ModuleMsg {
}, id: module.id(),
payload: payload.clone(),
Message::RequestPopup(module_id, element_id) => { })
// Toggle: close if already open, else open. })
if bar.active_popup.as_deref() == Some(module_id.as_str()) { .fold(Task::none(), |acc, t| acc.chain(t))
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)
}
} }
Message::BoundsFound(module_id, bounds) => popup::open_popup(bar, module_id, bounds), Msg::WindowClosed(id) => {
// Popup gone; forget it. `popup_id` stays set until this event
Message::ClosePopup(id) => { // so the popup content (not the bar) renders during teardown.
if bar.popup_id == Some(id) { if bar.popup_id == Some(id) {
bar.popup_id = None; bar.popup_id = None;
bar.active_popup = None; bar.active_popup = None;
} }
Task::done(Message::RemoveWindow(id)) Task::none()
}
}
} }
Message::Noop => 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<Message> {
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)
}
}
// Forward multi-window mutations to their internal handlers. Cmd::BoundsFound(module_id, bounds) => popup::open_popup(bar, module_id, bounds),
_ => 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<Message> {
// View — routed per-surface by window ID // View — routed per-surface by window ID
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Dispatch view by which surface is being rendered.
fn view(bar: &Bar, id: window::Id) -> Element<'_, Message> { 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) { if bar.popup_id == Some(id) {
// This is the popup surface. popup::view(bar) // popup surface
popup::view(bar)
} else { } else {
// This is the main bar surface. bar_view(bar) // main bar surface
bar_view(bar)
} }
} }
/// Main bar widget tree. Each module renders itself; the app only lays the /// Each module renders itself; the bar lays them out in a row.
/// 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> { fn bar_view(bar: &Bar) -> Element<'_, Message> {
let mut children: Vec<Element<Message>> = Vec::new(); let mut children: Vec<Element<Message>> = Vec::new();
for (i, (_, module)) in bar.modules.iter().enumerate() { 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 { if i < bar.modules.len() - 1 {
children.push(text("|").size(14).into()); children.push(text("|").size(14).into());
} }
+51 -27
View File
@@ -1,21 +1,26 @@
use iced::widget::{button, text}; use iced::widget::{container, mouse_area, text};
use iced::{Element, Task}; use iced::{Element, Task};
use crate::module::{BarModule, ModuleMsg}; use crate::module::{BarModule, ModuleMsg};
use crate::subscriptions::clock::{ClockKind, ClockPayload}; use crate::subscriptions::clock::{ClockKind, ClockPayload};
use crate::subscriptions::Subscription; use crate::subscriptions::Subscription;
use crate::Message; use crate::{Cmd, Message};
/// A dummy clock module: subscribes to second ticks, stores the latest /// Clock module: subscribes to second ticks, renders the time in the bar.
/// value, renders it as text in the bar. Clicking toggles between showing /// Left-click toggles the seconds suffix; right-click opens a popup with
/// seconds (HH:MM:SS) and hiding them (HH:MM) — purely a display choice, /// the current time in large text.
/// the subscription always ticks seconds.
#[derive(Clone)] #[derive(Clone)]
pub enum ClockMsg { pub enum ClockMsg {
/// The bar was clicked; toggle the seconds suffix. /// Toggle the seconds suffix.
ToggleSeconds, 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 { pub struct Clock {
value: String, value: String,
show_seconds: bool, show_seconds: bool,
@@ -28,6 +33,19 @@ impl Clock {
show_seconds: true, 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::<Vec<_>>()
.join(":")
}
}
} }
impl BarModule for Clock { impl BarModule for Clock {
@@ -36,39 +54,45 @@ impl BarModule for Clock {
} }
fn view(&self, window_id: Option<iced::window::Id>) -> Element<'_, ModuleMsg> { fn view(&self, window_id: Option<iced::window::Id>) -> Element<'_, ModuleMsg> {
let _ = window_id; match window_id {
// Derive the display string from the stored value each render, so a // Popup surface: current time in large text.
// click just flips `show_seconds` and the text updates. Some(_) => container(text(&self.value).size(56)).padding(20).into(),
let shown = if self.show_seconds { // Bar surface: clickable time. Container carries the module id
self.value.clone() // so the popup can anchor to these bounds.
} else { None => container(
self.value mouse_area(text(self.shown()).size(16))
.split(':') // [hh, mm, ss]
.take(2)
.collect::<Vec<_>>()
.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)) .on_press(ModuleMsg::new(self.id(), ClockMsg::ToggleSeconds))
.into() .on_right_press(ModuleMsg::new(self.id(), ClockMsg::OpenPopup)),
)
.id(iced::widget::Id::from(self.id()))
.into(),
}
} }
fn update(&mut self, msg: ModuleMsg) -> Task<Message> { fn update(&mut self, msg: ModuleMsg) -> Task<Message> {
// The subscription fan-out delivers a raw `ClockPayload` (never // Subscription fan-out delivers a raw `ClockPayload`, not `ClockMsg`.
// wrapped in `ClockMsg`) — handle it first.
if let Some(payload) = msg.downcast::<ClockPayload>() { if let Some(payload) = msg.downcast::<ClockPayload>() {
self.value = payload.value.clone(); self.value = payload.value.clone();
return Task::none(); return Task::none();
} }
// Module-local messages (button clicks) come as `ClockMsg`. match msg.downcast::<ClockMsg>() {
if let Some(ClockMsg::ToggleSeconds) = msg.downcast::<ClockMsg>() { Some(ClockMsg::ToggleSeconds) => {
self.show_seconds = !self.show_seconds; self.show_seconds = !self.show_seconds;
}
Task::none() Task::none()
} }
Some(ClockMsg::OpenPopup) => Task::done(Message::Effect(Cmd::RequestPopup(
self.id().to_string(),
self.id().to_string(),
))),
None => Task::none(),
}
}
fn subscriptions(&self) -> Vec<Subscription> { fn subscriptions(&self) -> Vec<Subscription> {
vec![Subscription::Clock(ClockKind::Seconds)] vec![Subscription::Clock(ClockKind::Seconds)]
} }
fn popup_size(&self) -> Option<(u32, u32)> {
Some((POPUP_W, POPUP_H))
}
} }
+15 -17
View File
@@ -9,10 +9,8 @@ use crate::Message;
pub mod clock; pub mod clock;
/// A module-local message, boxed with its owner's `id`. /// 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.
/// 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)] #[derive(Clone)]
pub struct ModuleMsg { pub struct ModuleMsg {
pub id: &'static str, pub id: &'static str,
@@ -20,7 +18,7 @@ pub struct ModuleMsg {
} }
impl 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<T: Any + Send + Sync>(id: &'static str, msg: T) -> Self { pub fn new<T: Any + Send + Sync>(id: &'static str, msg: T) -> Self {
Self { Self {
id, id,
@@ -28,8 +26,7 @@ impl ModuleMsg {
} }
} }
/// Downcasts to the module's own message type. Returns `None` if the /// Downcasts to the module's own message type.
/// payload belongs to another module.
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&T> { pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&T> {
self.payload.downcast_ref() self.payload.downcast_ref()
} }
@@ -44,22 +41,23 @@ impl fmt::Debug for ModuleMsg {
} }
} }
/// A bar module. Object-safe so modules live in /// A bar module. Object-safe; modules live in a `BTreeMap` keyed by id. The
/// `BTreeMap<String, Box<dyn BarModule>>`. The `ModuleMsg` boundary keeps the /// `ModuleMsg` boundary keeps the app ignorant of each module's message enum.
/// app ignorant of each module's message enum.
pub trait BarModule: Send { pub trait BarModule: Send {
fn id(&self) -> &'static str; fn id(&self) -> &'static str;
/// Returns this module's bar contents. When `window_id` is `Some`, the /// Bar contents. When `window_id` is `Some`, this is the module's popup
/// popup surface for this module is being rendered and the module should /// surface and the module returns its popup contents instead.
/// return its popup contents instead.
fn view(&self, window_id: Option<iced::window::Id>) -> Element<'_, ModuleMsg>; fn view(&self, window_id: Option<iced::window::Id>) -> Element<'_, ModuleMsg>;
/// Handles a routed message. Implementations downcast the payload to /// Handles a routed message; may return app-level tasks (e.g. popups).
/// 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<Message>; fn update(&mut self, msg: ModuleMsg) -> Task<Message>;
/// Declarative: what subscription kinds this module wants. /// What subscription kinds this module wants.
fn subscriptions(&self) -> Vec<Subscription>; fn subscriptions(&self) -> Vec<Subscription>;
/// Requested popup size when it's not the generic small menu.
fn popup_size(&self) -> Option<(u32, u32)> {
None
}
} }
+33 -26
View File
@@ -1,9 +1,8 @@
//! Popup surfaces: bounds capture, open/close, and rendering. //! Popup surfaces: bounds capture, open/close, and rendering.
//! //!
//! Popups are separate LayerShell surfaces anchored to the triggering module's //! A popup is a separate LayerShell surface anchored to its module's button.
//! button. The bar captures the button's laid-out bounds via a widget-tree //! The app captures the button's laid-out bounds via a widget-tree operation,
//! operation, then opens a popup anchored there; the active module renders the //! then opens the popup there; the active module renders its content.
//! popup's contents through its own `view(Some(id))`.
use iced::advanced::widget as advanced_widget; use iced::advanced::widget as advanced_widget;
use iced::widget::{column, mouse_area, text}; 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::actions::IcedNewPopupSettings;
use iced_layershell::reexport::{PopupAnchor, PopupGravity}; 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_W: u32 = 150;
const POPUP_H: u32 = 100; 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; 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<Message> { pub fn open_popup(bar: &mut Bar, module_id: String, bounds: Rectangle) -> Task<Message> {
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 let (w, h) = bar
// `Bottom` anchor point (bottom-center) sits POPUP_GAP below the button's .modules
// bottom edge. With `Bottom` gravity the popup grows downward from there. .get(module_id.as_str())
let (x, y, w, h) = ( .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.x.round() as i32,
bounds.y.round() as i32, bounds.y.round() as i32,
bounds.width.round() as i32, bounds.width.round() as i32,
bounds.height.round() as i32, bounds.height.round() as i32,
); );
let anchor_rect = (x, y, w, h + POPUP_GAP); let anchor_rect = (bx, by, bw, bh + POPUP_GAP);
let settings = IcedNewPopupSettings::on_current_surface((POPUP_W, POPUP_H), anchor_rect) let settings = IcedNewPopupSettings::on_current_surface((w, h), anchor_rect)
.anchor(PopupAnchor::Bottom) .anchor(PopupAnchor::Bottom)
.gravity(PopupGravity::Bottom); .gravity(PopupGravity::Bottom);
@@ -43,11 +48,11 @@ pub fn open_popup(bar: &mut Bar, module_id: String, bounds: Rectangle) -> Task<M
task task
} }
/// Closes the popup if one is open. /// Requests closing the popup. Does not clear popup state — that happens on
/// `Msg::WindowClosed`, so popup content keeps rendering until then.
pub fn close_popup(bar: &mut Bar) -> Task<Message> { pub fn close_popup(bar: &mut Bar) -> Task<Message> {
if let Some(id) = bar.popup_id.take() { if let Some(id) = bar.popup_id {
bar.active_popup = None; return Task::done(Message::Effect(Cmd::ClosePopup(id)));
return Task::done(Message::RemoveWindow(id));
} }
Task::none() Task::none()
} }
@@ -63,8 +68,6 @@ pub fn capture_bounds(module_id: String, element_id: String) -> Task<Message> {
} }
impl advanced_widget::Operation<Rectangle> for FindBounds { impl advanced_widget::Operation<Rectangle> for FindBounds {
/// Delegate traversal so the `container` hook below sees every
/// container in the tree, matching the target id.
fn traverse( fn traverse(
&mut self, &mut self,
operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation<Rectangle>), operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation<Rectangle>),
@@ -90,18 +93,22 @@ pub fn capture_bounds(module_id: String, element_id: String) -> Task<Message> {
target, target,
found: None, 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 /// Popup widget tree on its own LayerShell surface; the active module
/// active module renders its own popup contents via `view(Some(id))`. /// renders its content via `view(Some(id))`.
pub fn view(bar: &Bar) -> Element<'_, Message> { pub fn view(bar: &Bar) -> Element<'_, Message> {
let module_id = bar.active_popup.as_deref().unwrap(); let module_id = bar.active_popup.as_deref().unwrap();
let popup_id = bar.popup_id.unwrap(); let popup_id = bar.popup_id.unwrap();
let content = bar let content = bar
.modules .modules
.get(module_id) .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()); .unwrap_or_else(|| text("unknown module").into());
column![ column![
@@ -110,7 +117,7 @@ pub fn view(bar: &Bar) -> Element<'_, Message> {
.width(Length::Fill) .width(Length::Fill)
.align_x(Alignment::Center), .align_x(Alignment::Center),
content, 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) .spacing(1)
.into() .into()
+36 -10
View File
@@ -1,24 +1,25 @@
use std::collections::HashMap; use std::collections::HashMap;
/// What a clock subscription wants. Carried in `Subscription::Clock(kind)` use iced::{futures::SinkExt, Subscription};
/// so the runner knows the tick interval and the module knows the payload.
use crate::{subscriptions::SubscriptionPayloadKind, Message, Msg};
/// What a clock subscription wants: the tick interval and payload selector.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum ClockKind { pub enum ClockKind {
Mins, Mins,
Seconds, Seconds,
} }
/// The event payload for a clock subscription: the dynamic value plus the /// Clock tick payload: the value plus the kind that produced it.
/// kind that produced it, so a module can re-render without extra state.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct ClockPayload { pub struct ClockPayload {
pub kind: ClockKind, pub kind: ClockKind,
pub value: String, pub value: String,
} }
/// Tracks the last displayed value per kind and only emits a payload when /// Emits a payload only when a kind's displayed value changes (minute or
/// the value changes — minutes when the minute rolls over, seconds when the /// second rollover). Drive `tick` once per second.
/// second rolls over. Drive `tick` once per second.
#[derive(Default)] #[derive(Default)]
pub struct ClockTicker { pub struct ClockTicker {
last: HashMap<ClockKind, String>, last: HashMap<ClockKind, String>,
@@ -29,9 +30,34 @@ impl ClockTicker {
Self::default() Self::default()
} }
/// Checks all kinds in one per-second call and returns a payload for pub fn run() -> Subscription<Message> {
/// every kind whose displayed value changed since the last tick. Batch: Subscription::run_with((), |_| {
/// the second that rolls into a new minute yields `[Seconds, Mins]`. iced::stream::channel(
0,
|mut sender: iced::futures::channel::mpsc::Sender<Message>| 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<ClockPayload> { pub fn tick(&mut self) -> Vec<ClockPayload> {
let now = chrono::Local::now(); let now = chrono::Local::now();
[ClockKind::Seconds, ClockKind::Mins] [ClockKind::Seconds, ClockKind::Mins]
+35 -5
View File
@@ -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; pub mod clock;
/// What a module wants from a runner. `Clock(kind)` is both the route key /// Route key a module subscribes with; `Clock(kind)` selects the ticker.
/// (which ticker to spawn) and the typed event selector.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum Subscription { pub enum Subscription {
Clock(ClockKind), Clock(ClockKind),
} }
/// A subscription event carrying a typed payload; `update` matches on this impl Subscription {
/// to fan out to every module whose route requests the kind. pub fn subscription(&self) -> iced::Subscription<Message> {
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)] #[derive(Clone, Debug)]
pub enum SubscriptionPayloadKind { pub enum SubscriptionPayloadKind {
Clock(ClockPayload), 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<dyn Any + Send + Sync> {
match self {
Self::Clock(payload) => Arc::new(payload),
}
}
}