refac: workspace
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "barbar-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "barbar-core"
|
||||
|
||||
[dependencies]
|
||||
iced = { workspace = true }
|
||||
iced_layershell = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
@@ -0,0 +1,253 @@
|
||||
//! barbar — a Wayland layer-shell status bar (iced + iced_layershell).
|
||||
//!
|
||||
//! Renders a full-width bar pinned to the top of the screen via
|
||||
//! `wlr-layer-shell`. Optional first CLI arg = target output name.
|
||||
//! Clicking a module's button spawns a LayerShell popup anchored to it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
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;
|
||||
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode};
|
||||
use iced_layershell::to_layer_message;
|
||||
|
||||
use crate::module::{BarModule, ModuleMsg};
|
||||
use crate::services::Service as ModuleService;
|
||||
use crate::services::ServicePayloadKind;
|
||||
|
||||
mod module;
|
||||
mod popup;
|
||||
mod services;
|
||||
|
||||
/// 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<String>,
|
||||
/// Module registry keyed by stable module id.
|
||||
modules: BTreeMap<&'static str, Box<dyn BarModule>>,
|
||||
/// Surface id of the open popup.
|
||||
popup_id: Option<window::Id>,
|
||||
/// Fan-out routing table: module id -> services it wants.
|
||||
routes: BTreeMap<&'static str, Vec<ModuleService>>,
|
||||
}
|
||||
|
||||
impl Bar {
|
||||
fn new() -> Self {
|
||||
let mut modules: BTreeMap<&'static str, Box<dyn BarModule>> = BTreeMap::new();
|
||||
modules.insert("clock", Box::new(crate::module::clock::Clock::new()));
|
||||
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),
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Message {
|
||||
Event(Msg),
|
||||
Effect(Cmd),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Application wiring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn namespace() -> String {
|
||||
String::from("barbar")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Route subscriptions + window-close events (popup really destroyed).
|
||||
fn gather_subscriptions(bar: &Bar) -> Subscription<Message> {
|
||||
let route_sub = Subscription::batch(
|
||||
bar.routes
|
||||
.values()
|
||||
.flat_map(|x| x.iter().map(ModuleService::subscription))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let close_events = window::close_events().map(|id| Message::Event(Msg::WindowClosed(id)));
|
||||
Subscription::batch(vec![route_sub, close_events])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn update(bar: &mut Bar, msg: Message) -> Task<Message> {
|
||||
match msg {
|
||||
Message::Event(event) => handle_event(bar, event),
|
||||
Message::Effect(cmd) => handle_effect(bar, cmd),
|
||||
// Layer-shell variants injected by `to_layer_message`.
|
||||
_ => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an input event to state; effects come back as `Task`s.
|
||||
fn handle_event(bar: &mut Bar, event: Msg) -> Task<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),
|
||||
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(),
|
||||
})
|
||||
})
|
||||
.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, 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)
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::BoundsFound(module_id, bounds) => popup::open_popup(bar, module_id, bounds),
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<Element<Message>> = 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()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use iced::widget::{container, mouse_area, text};
|
||||
use iced::{Element, Task};
|
||||
|
||||
use crate::module::{BarModule, ModuleMsg};
|
||||
use crate::services::clock::{ClockKind, ClockPayload};
|
||||
use crate::services::Service;
|
||||
use crate::{Cmd, Message};
|
||||
|
||||
/// Clock module: subscribes to second ticks, renders the time in the bar.
|
||||
/// Left-click toggles the seconds suffix; right-click opens a popup with
|
||||
/// the current time in large text.
|
||||
#[derive(Clone)]
|
||||
pub enum ClockMsg {
|
||||
/// Toggle the seconds suffix.
|
||||
ToggleSeconds,
|
||||
/// Open the big-time popup.
|
||||
OpenPopup,
|
||||
}
|
||||
|
||||
/// Big-text popup size (logical px).
|
||||
const POPUP_W: u32 = 360;
|
||||
const POPUP_H: u32 = 160;
|
||||
|
||||
pub struct Clock {
|
||||
value: String,
|
||||
show_seconds: bool,
|
||||
}
|
||||
|
||||
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::<Vec<_>>()
|
||||
.join(":")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BarModule for Clock {
|
||||
fn id(&self) -> &'static str {
|
||||
"clock"
|
||||
}
|
||||
|
||||
fn view(&self, window_id: Option<iced::window::Id>) -> 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<Message> {
|
||||
// Subscription fan-out delivers a raw `ClockPayload`, not `ClockMsg`.
|
||||
if let Some(payload) = msg.downcast::<ClockPayload>() {
|
||||
self.value = payload.value.clone();
|
||||
return Task::none();
|
||||
}
|
||||
match msg.downcast::<ClockMsg>() {
|
||||
Some(ClockMsg::ToggleSeconds) => {
|
||||
self.show_seconds = !self.show_seconds;
|
||||
Task::none()
|
||||
}
|
||||
Some(ClockMsg::OpenPopup) => Task::done(Message::Effect(Cmd::RequestPopup(
|
||||
self.id().to_string(),
|
||||
self.id().to_string(),
|
||||
))),
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
fn services(&self) -> Vec<Service> {
|
||||
vec![Service::Clock(ClockKind::Seconds)]
|
||||
}
|
||||
|
||||
fn popup_size(&self) -> Option<(u32, u32)> {
|
||||
Some((POPUP_W, POPUP_H))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::any::Any;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use iced::{Element, Task};
|
||||
|
||||
use crate::services::Service;
|
||||
use crate::Message;
|
||||
|
||||
pub mod clock;
|
||||
|
||||
/// 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<dyn Any + Send + Sync>,
|
||||
}
|
||||
|
||||
impl ModuleMsg {
|
||||
/// 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 {
|
||||
Self {
|
||||
id,
|
||||
payload: Arc::new(msg),
|
||||
}
|
||||
}
|
||||
|
||||
/// Downcasts to the module's own message type.
|
||||
pub fn downcast<T: Any + Send + Sync>(&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<iced::window::Id>) -> Element<'_, ModuleMsg>;
|
||||
|
||||
/// Handles a routed message; may return app-level tasks (e.g. popups).
|
||||
fn update(&mut self, msg: ModuleMsg) -> Task<Message>;
|
||||
|
||||
/// What services this module wants.
|
||||
fn services(&self) -> Vec<Service>;
|
||||
|
||||
/// Requested popup size when it's not the generic small menu.
|
||||
fn popup_size(&self) -> Option<(u32, u32)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Popup surfaces: bounds capture, open/close, and rendering.
|
||||
//!
|
||||
//! A popup is a separate LayerShell surface anchored to its module's button.
|
||||
//! The app captures the button's laid-out bounds via a widget-tree operation,
|
||||
//! then opens the popup there; the active module renders its content.
|
||||
|
||||
use iced::advanced::widget as advanced_widget;
|
||||
use iced::widget::{column, mouse_area, text};
|
||||
use iced::{Alignment, Element, Length, Rectangle, Task};
|
||||
use iced_layershell::actions::IcedNewPopupSettings;
|
||||
use iced_layershell::reexport::{PopupAnchor, PopupGravity};
|
||||
|
||||
use crate::{Bar, Cmd, Message, Msg};
|
||||
|
||||
/// Default (small menu) popup size.
|
||||
const POPUP_W: u32 = 150;
|
||||
const POPUP_H: u32 = 100;
|
||||
|
||||
/// Gap (logical px) between the bar's bottom edge and the popup.
|
||||
const POPUP_GAP: i32 = 32;
|
||||
|
||||
/// 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> {
|
||||
bar.active_popup = Some(module_id.clone());
|
||||
|
||||
let (w, h) = bar
|
||||
.modules
|
||||
.get(module_id.as_str())
|
||||
.and_then(|m| m.popup_size())
|
||||
.unwrap_or((POPUP_W, POPUP_H));
|
||||
|
||||
// Anchor at the button's bottom edge + POPUP_GAP so the popup grows
|
||||
// downward from just below the bar.
|
||||
let (bx, by, bw, bh) = (
|
||||
bounds.x.round() as i32,
|
||||
bounds.y.round() as i32,
|
||||
bounds.width.round() as i32,
|
||||
bounds.height.round() as i32,
|
||||
);
|
||||
let anchor_rect = (bx, by, bw, bh + POPUP_GAP);
|
||||
let settings = IcedNewPopupSettings::on_current_surface((w, h), anchor_rect)
|
||||
.anchor(PopupAnchor::Bottom)
|
||||
.gravity(PopupGravity::Bottom);
|
||||
|
||||
let (id, task) = Message::popup_open(settings);
|
||||
bar.popup_id = Some(id);
|
||||
task
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if let Some(id) = bar.popup_id {
|
||||
return Task::done(Message::Effect(Cmd::ClosePopup(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<Message> {
|
||||
let target = iced::widget::Id::from(element_id);
|
||||
|
||||
struct FindBounds {
|
||||
target: iced::widget::Id,
|
||||
found: Option<Rectangle>,
|
||||
}
|
||||
|
||||
impl advanced_widget::Operation<Rectangle> for FindBounds {
|
||||
fn traverse(
|
||||
&mut self,
|
||||
operate: &mut dyn FnMut(&mut dyn advanced_widget::Operation<Rectangle>),
|
||||
) {
|
||||
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<Rectangle> {
|
||||
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::Effect(Cmd::BoundsFound(module_id.clone(), bounds)))
|
||||
}
|
||||
|
||||
/// Popup widget tree on its own LayerShell surface; the active module
|
||||
/// renders its content via `view(Some(id))`.
|
||||
pub fn view(bar: &Bar) -> Element<'_, Message> {
|
||||
let module_id = bar.active_popup.as_deref().unwrap();
|
||||
let popup_id = bar.popup_id.unwrap();
|
||||
let content = bar
|
||||
.modules
|
||||
.get(module_id)
|
||||
.map(|module| {
|
||||
module
|
||||
.view(Some(popup_id))
|
||||
.map(|m| Message::Event(Msg::Module(m)))
|
||||
})
|
||||
.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::Effect(Cmd::ClosePopup(popup_id))),
|
||||
]
|
||||
.spacing(1)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use iced::{futures::SinkExt, Subscription};
|
||||
|
||||
use crate::{services::ServicePayloadKind, Message, Msg};
|
||||
|
||||
/// What a clock subscription wants: the tick interval and payload selector.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum ClockKind {
|
||||
Mins,
|
||||
Seconds,
|
||||
}
|
||||
|
||||
/// Clock tick payload: the value plus the kind that produced it.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ClockPayload {
|
||||
pub kind: ClockKind,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Emits a payload only when a kind's displayed value changes (minute or
|
||||
/// second rollover). Drive `tick` once per second.
|
||||
#[derive(Default)]
|
||||
pub struct ClockTicker {
|
||||
last: HashMap<ClockKind, String>,
|
||||
}
|
||||
|
||||
impl ClockTicker {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn run() -> Subscription<Message> {
|
||||
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::Event(Msg::Subscription(
|
||||
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<ClockPayload> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use std::any::Any;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
services::clock::{ClockKind, ClockPayload, ClockTicker},
|
||||
Message,
|
||||
};
|
||||
|
||||
pub mod clock;
|
||||
|
||||
/// Route key a module subscribes with; `Clock(kind)` selects the ticker.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Service {
|
||||
Clock(ClockKind),
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub fn subscription(&self) -> iced::Subscription<Message> {
|
||||
match self {
|
||||
Self::Clock(_) => ClockTicker::run(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<dyn Any + Send + Sync> {
|
||||
match self {
|
||||
Self::Clock(payload) => Arc::new(payload),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user