From 2c9bb85f2c4a7980e08030eb77e24466d8d61a41 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 20 Oct 2026 15:19:47 +0100 Subject: [PATCH] Add session-owned Pomodoro cycles and controls Add configurable work and break intervals, pause/resume, CLI and activity-popup controls, and silent completion alerts. Use CLOCK_BOOTTIME deadlines so running timers include suspend without consuming paused time. Refs #31 --- compositor/src/activities.rs | 38 ++- compositor/src/activities/deadline.rs | 112 +++++++ compositor/src/activities/pomodoro.rs | 383 ++++++++++++++++++++++ compositor/src/ipc.rs | 29 +- compositor/src/state/outputs.rs | 2 + compositor/src/state/reconciliation.rs | 2 +- crates/ferese-activities/src/lib.rs | 15 +- crates/ferese-activities/src/pomodoro.rs | 274 ++++++++++++++++ crates/ferese-config/src/activities.rs | 25 ++ crates/ferese-ipc/src/activities.rs | 29 ++ feresectl/src/cli.rs | 7 +- feresectl/src/main.rs | 2 + feresectl/src/pomodoro.rs | 42 +++ feresectl/tests/cli.rs | 23 ++ scripts/tests/test_activities_isolated.py | 29 ++ shell/ferese-shell/src/activities.rs | 70 +++- shell/ferese-shell/src/main.rs | 23 ++ shell/ferese-shell/src/panel_hub.rs | 120 +++++++ 18 files changed, 1209 insertions(+), 16 deletions(-) create mode 100644 compositor/src/activities/deadline.rs create mode 100644 compositor/src/activities/pomodoro.rs create mode 100644 crates/ferese-activities/src/pomodoro.rs create mode 100644 feresectl/src/pomodoro.rs diff --git a/compositor/src/activities.rs b/compositor/src/activities.rs index 4597528..bf393fa 100644 --- a/compositor/src/activities.rs +++ b/compositor/src/activities.rs @@ -1,5 +1,7 @@ //! Session broker: verified process identities, one-shot policy deadlines and //! bounded long-poll subscribers. Process inspection runs on IPC workers. +pub(crate) mod deadline; +mod pomodoro; mod publication; use ferese_activities::{Data, Id, Owner, Source, State, Store, Template, Value}; use ferese_ipc::{ @@ -10,7 +12,7 @@ use serde_json::json; use std::collections::BTreeMap; use std::sync::mpsc::SyncSender; use std::sync::{Arc, RwLock}; -use std::time::{Duration, Instant}; +use std::time::Duration; #[derive(Clone, Debug)] pub(crate) struct Rules { @@ -21,12 +23,15 @@ pub(crate) type SharedRules = Arc>>; pub(crate) struct Engine { pub store: Store, + pomodoro: Option, + pomodoro_settings: ferese_activities::pomodoro::Settings, + alerts: Option, pub rules: SharedRules, pub monitor: Option, process_enabled: bool, process_sender: Option>, process_watcher: Option, - pub timer: Option>, + pub timer: Option, revision: u64, cached_snapshot: Option>, publisher: Option, @@ -40,6 +45,9 @@ impl Default for Engine { fn default() -> Self { let settings = ferese_config::activities::Settings::default(); Self { + pomodoro: None, + pomodoro_settings: Default::default(), + alerts: None, store: Store::new(ferese_ipc::activities::now_ms() ^ u64::from(std::process::id()) << 32), rules: Arc::new(RwLock::new(Arc::new(Rules { enabled: false, @@ -109,6 +117,7 @@ impl Engine { matcher: ferese_config::activities::Matcher, ) { self.store.limits = settings.limits(); + self.pomodoro_settings = settings.pomodoro.clone(); self.process_enabled = settings.sources.process_watch; *self.rules.write().expect("activity rules") = Arc::new(Rules { enabled: settings.sources.shell_hook, @@ -145,7 +154,16 @@ impl Engine { revision: self.revision, now_ms: ferese_ipc::activities::now_ms(), limits: self.store.limits, - activities: self.store.entries().cloned().collect(), + activities: self + .store + .entries() + .cloned() + .chain(self.pomodoro.iter().map(|timer| timer.activity())) + .collect(), + pomodoro: self.pomodoro.as_ref().map(|timer| ferese_ipc::activities::Pomodoro { + id: timer.id.clone(), + timer: timer.session.clone(), + }), windows: self .windows .iter() @@ -218,7 +236,9 @@ impl Engine { self.schedule(); } pub fn tick(&mut self) { - self.store.advance(ferese_ipc::activities::now_ms()); + let now = ferese_ipc::activities::now_ms(); + self.store.advance(now); + self.advance_pomodoro(now); self.changed(); } pub fn schedule(&self) { @@ -234,11 +254,15 @@ impl Engine { } if let Some(timer) = &self.timer { let now = ferese_ipc::activities::now_ms(); - timer.update( + if let Err(error) = timer.update( self.store .next_deadline(now) - .map(|deadline| Instant::now() + Duration::from_millis(deadline.saturating_sub(now))), - ); + .into_iter() + .chain(self.pomodoro.as_ref().and_then(|timer| timer.session.deadline)) + .min(), + ) { + tracing::error!(%error, "Activity deadline could not be armed"); + } } } pub fn publish_verified( diff --git a/compositor/src/activities/deadline.rs b/compositor/src/activities/deadline.rs new file mode 100644 index 0000000..d549a2a --- /dev/null +++ b/compositor/src/activities/deadline.rs @@ -0,0 +1,112 @@ +//! Activity deadlines share CLOCK_BOOTTIME with the published countdowns. A +//! monotonic calloop timer would otherwise keep waiting for time spent asleep. +use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction, RegistrationToken, generic::Generic}; +use std::{ + io, + os::fd::{AsRawFd, FromRawFd, OwnedFd}, +}; + +pub(crate) struct Deadline { + fd: OwnedFd, + armed: std::cell::Cell>, + handle: LoopHandle<'static, crate::Ferese>, + token: RegistrationToken, +} +impl Deadline { + pub fn new(handle: LoopHandle<'static, crate::Ferese>) -> Result> { + // SAFETY: timerfd_create returns a new owned descriptor or -1. + let fd = unsafe { libc::timerfd_create(libc::CLOCK_BOOTTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) }; + if fd < 0 { + return Err(io::Error::last_os_error().into()); + } + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + let token = handle.insert_source( + Generic::new(fd.try_clone()?, Interest::READ, Mode::Level), + |_, fd, state| { + let mut expirations = 0_u64; + // SAFETY: expirations is a writable eight-byte timerfd counter. + let result = unsafe { libc::read(fd.as_raw_fd(), (&raw mut expirations).cast(), 8) }; + if result < 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::WouldBlock { + return Err(error); + } + } else { + state.activities.tick(); + } + Ok(PostAction::Continue) + }, + )?; + Ok(Self { + fd, + armed: std::cell::Cell::new(None), + handle, + token, + }) + } + pub fn update(&self, deadline: Option) -> io::Result<()> { + if self.armed.get() == deadline { + return Ok(()); + } + let ms = deadline.unwrap_or(0); + let spec = libc::itimerspec { + it_interval: libc::timespec { tv_sec: 0, tv_nsec: 0 }, + it_value: libc::timespec { + tv_sec: (ms / 1_000) as _, + tv_nsec: ((ms % 1_000) * 1_000_000) as _, + }, + }; + // SAFETY: spec is initialized; null old_value is permitted. + if unsafe { + libc::timerfd_settime( + self.fd.as_raw_fd(), + libc::TFD_TIMER_ABSTIME, + &spec, + std::ptr::null_mut(), + ) + } < 0 + { + Err(io::Error::last_os_error()) + } else { + self.armed.set(deadline); + Ok(()) + } + } +} +impl Drop for Deadline { + fn drop(&mut self) { + self.handle.remove(self.token); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn deadline_uses_boottime_and_disarming_leaves_no_periodic_wakeups() { + let events = smithay::reexports::calloop::EventLoop::::try_new().unwrap(); + let deadline = Deadline::new(events.handle()).unwrap(); + let info = std::fs::read_to_string(format!("/proc/self/fdinfo/{}", deadline.fd.as_raw_fd())).unwrap(); + assert!( + info.lines() + .any(|line| line.split_whitespace().collect::>() == ["clockid:", "7"]), + "{info}" + ); + deadline.update(Some(ferese_ipc::activities::now_ms() + 20)).unwrap(); + let mut pollfd = libc::pollfd { + fd: deadline.fd.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: pollfd is a valid array of one descriptor. + assert_eq!(unsafe { libc::poll(&raw mut pollfd, 1, 500) }, 1); + let mut counter = 0_u64; + assert_eq!( + unsafe { libc::read(deadline.fd.as_raw_fd(), (&raw mut counter).cast(), 8) }, + 8 + ); + assert_eq!(counter, 1); + deadline.update(None).unwrap(); + assert_eq!(unsafe { libc::poll(&raw mut pollfd, 1, 0) }, 0); + } +} diff --git a/compositor/src/activities/pomodoro.rs b/compositor/src/activities/pomodoro.rs new file mode 100644 index 0000000..8868ad1 --- /dev/null +++ b/compositor/src/activities/pomodoro.rs @@ -0,0 +1,383 @@ +use super::*; +use ferese_activities::pomodoro::{Session, Status}; +use ferese_ipc::activities::{PomodoroAction, PomodoroRequest}; + +pub(super) struct Timer { + pub id: Id, + pub session: Session, + output: Option, + created_at: u64, +} +impl Timer { + pub fn activity(&self) -> ferese_activities::Activity { + let now = ferese_ipc::activities::now_ms(); + let value = if let Some(deadline) = self.session.deadline { + Value::Countdown { + remaining_ms: deadline.saturating_sub(now), + sampled_at: now, + } + } else { + Value::PausedCountdown { + remaining_ms: self.session.remaining_ms, + } + }; + ferese_activities::Activity { + id: self.id.clone(), + data: Data { + source: Source::Timer, + output: self.output.clone(), + priority: 100, + icon: "timer".into(), + title: format!( + "Pomodoro · {}{}", + self.session.phase.label(), + match self.session.status { + Status::Paused => " · Paused", + Status::Ready => " · Ready", + Status::Finished => " · Finished", + _ => "", + } + ), + value, + template: Template::Timer, + }, + state: State::Running, + created_at: self.created_at, + eligible_at: self.created_at, + refreshed_at: self.created_at, + lease_deadline: None, + result_deadline: None, + result_message: None, + flash_on_fail: false, + result_flash_ms: 0, + group: None, + } + } +} +impl Engine { + pub fn pomodoro_control( + &mut self, + request: PomodoroRequest, + output: Option, + ) -> Result { + if request.version != ferese_ipc::activities::VERSION { + return Err("Unsupported activity schema".into()); + } + if let Some(id) = &request.session + && self.pomodoro.as_ref().is_none_or(|timer| &timer.id != id) + { + return Err("Pomodoro session changed; refresh its controls".into()); + } + let now = ferese_ipc::activities::now_ms(); + if !matches!(request.action, PomodoroAction::Stop | PomodoroAction::Reset) && self.advance_pomodoro(now) { + self.changed(); + } + match request.action { + PomodoroAction::Status => {} + PomodoroAction::Start | PomodoroAction::Reset => { + if request.action == PomodoroAction::Start + && self + .pomodoro + .as_ref() + .is_some_and(|timer| timer.session.status != Status::Finished) + { + return Err("A Pomodoro session already exists; resume or reset it".into()); + } + let session = Session::start(self.pomodoro_settings.clone(), now)?; + // Verify the long-lived host, never the short-lived CLI, as owner. + let (owner, _pidfd, _) = + crate::ipc::activity_owner::inspect(std::process::id()).map_err(|error| error.to_string())?; + let id = Id { + owner, + local: "pomodoro".into(), + generation: self.revision, + session: self.store.session(), + }; + self.pomodoro = Some(Timer { + id, + session, + output, + created_at: now, + }); + } + PomodoroAction::Stop => { + self.pomodoro = None; + } + action => { + let timer = self.pomodoro.as_mut().ok_or("No Pomodoro session; start one first")?; + match action { + PomodoroAction::Pause => timer.session.pause(now)?, + PomodoroAction::Resume => timer.session.resume(now)?, + PomodoroAction::Next => timer.session.next(now)?, + _ => unreachable!(), + } + } + } + if request.action != PomodoroAction::Status { + self.changed(); + } + let snapshot = self.snapshot(); + serde_json::to_value(&snapshot.pomodoro).map_err(|error| error.to_string()) + } + pub(super) fn advance_pomodoro(&mut self, now: u64) -> bool { + let Some(timer) = &mut self.pomodoro else { + return false; + }; + let Some(completion) = timer.session.advance(now) else { + return false; + }; + if timer.session.settings.notifications { + let title = format!("Pomodoro: {} complete", completion.phase.label()); + let body = completion.next.map_or_else( + || "Your Pomodoro session is finished.".into(), + |phase| { + format!( + "{} {}.", + phase.label(), + if timer.session.status == Status::Running { + "has started" + } else { + "is ready" + } + ) + }, + ); + let alerts = self.alerts.get_or_insert_with(Alerts::new); + if alerts.sender.try_send((title, body)).is_err() { + tracing::warn!("Pomodoro alert queue unavailable"); + } + } + true + } + pub fn pomodoro_remove_output(&mut self, output: &str, fallback: Option<&str>) { + if let Some(timer) = &mut self.pomodoro + && timer.output.as_deref() == Some(output) + { + timer.output = fallback.map(str::to_owned); + } + } + pub fn pomodoro_attach_output(&mut self, output: &str) -> bool { + if let Some(timer) = &mut self.pomodoro + && timer.output.is_none() + { + timer.output = Some(output.into()); + return true; + } + false + } +} + +/// Notification delivery runs off the compositor thread and is attempted once. +/// Reconnection never replays an alert; the notification server applies DND and +/// lock-screen privacy, just as it does for other applications. +pub(super) struct Alerts { + sender: std::sync::mpsc::SyncSender<(String, String)>, +} +impl Alerts { + fn new() -> Self { + let (sender, receiver) = std::sync::mpsc::sync_channel::<(String, String)>(16); + std::thread::spawn(move || { + while let Ok((title, body)) = receiver.recv() { + let result = (|| -> zbus::Result<()> { + let connection = zbus::blocking::connection::Builder::session()? + .method_timeout(Duration::from_secs(2)) + .build()?; + let mut hints = std::collections::HashMap::<&str, zbus::zvariant::Value<'_>>::new(); + hints.insert("suppress-sound", zbus::zvariant::Value::Bool(true)); + let reply = connection.call_method( + Some("org.freedesktop.Notifications"), + "/org/freedesktop/Notifications", + Some("org.freedesktop.Notifications"), + "Notify", + &("Ferese", 0_u32, "", title, body, Vec::::new(), hints, -1_i32), + )?; + let _: u32 = reply.body().deserialize()?; + Ok(()) + })(); + if let Err(error) = result { + tracing::warn!(%error,"Pomodoro completion alert could not be delivered"); + } + } + }); + Self { sender } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn control(engine: &mut Engine, action: PomodoroAction) -> Result { + engine.pomodoro_control( + PomodoroRequest { + version: ferese_ipc::activities::VERSION, + action, + session: None, + }, + Some("test".into()), + ) + } + #[test] + fn completion_uses_the_standard_notification_service_without_sound() { + const TEST: &str = + "activities::pomodoro::tests::completion_uses_the_standard_notification_service_without_sound"; + if !crate::startup_tests::private_runtime(TEST) { + return; + } + use std::io::BufRead; + struct Bus(std::process::Child); + impl Drop for Bus { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let mut bus = Bus(std::process::Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap()); + let mut address = String::new(); + std::io::BufReader::new(bus.0.stdout.take().unwrap()) + .read_line(&mut address) + .unwrap(); + // SAFETY: private_runtime runs only this test in a separate process; + // set the bus before starting the notification service or alert worker. + unsafe { + std::env::set_var("DBUS_SESSION_BUS_ADDRESS", address.trim()); + } + struct Notifications(std::sync::mpsc::Sender<(String, String, bool)>); + #[zbus::interface(name = "org.freedesktop.Notifications")] + impl Notifications { + #[allow(clippy::too_many_arguments)] + fn notify( + &self, + _app_name: &str, + _replaces_id: u32, + _app_icon: &str, + summary: &str, + body: &str, + _actions: Vec, + hints: std::collections::HashMap, + _expire_timeout: i32, + ) -> u32 { + let silent = hints + .get("suppress-sound") + .and_then(|value| bool::try_from(value).ok()) + .unwrap_or(false); + self.0.send((summary.into(), body.into(), silent)).unwrap(); + 1 + } + } + let (sender, receiver) = std::sync::mpsc::channel(); + let _service = zbus::blocking::connection::Builder::address(address.trim()) + .unwrap() + .serve_at("/org/freedesktop/Notifications", Notifications(sender)) + .unwrap() + .name("org.freedesktop.Notifications") + .unwrap() + .build() + .unwrap(); + let mut engine = Engine::default(); + control(&mut engine, PomodoroAction::Start).unwrap(); + let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap(); + engine.advance_pomodoro(deadline); + let (title, body, silent) = receiver.recv_timeout(Duration::from_secs(3)).unwrap(); + assert_eq!(title, "Pomodoro: Work complete"); + assert_eq!(body, "Short break is ready."); + assert!(silent); + engine.advance_pomodoro(deadline + 1); + assert!(receiver.try_recv().is_err()); + } + #[test] + fn host_owns_the_single_session_and_stale_popup_controls_are_rejected() { + let mut engine = Engine::default(); + let first = control(&mut engine, PomodoroAction::Start).unwrap(); + let first: ferese_ipc::activities::Pomodoro = serde_json::from_value(first).unwrap(); + assert_eq!(first.id.owner.pid, std::process::id()); + assert!(control(&mut engine, PomodoroAction::Start).is_err()); + engine.remove_connection(10); + engine.owner_exit(Owner { pid: 999_999, birth: 0 }); + assert_eq!(engine.snapshot().pomodoro.as_ref().unwrap().id, first.id); + assert_eq!(engine.snapshot().activities.len(), 1); + control(&mut engine, PomodoroAction::Reset).unwrap(); + assert_ne!(engine.snapshot().pomodoro.as_ref().unwrap().id, first.id); + assert!( + engine + .pomodoro_control( + PomodoroRequest { + version: ferese_ipc::activities::VERSION, + action: PomodoroAction::Stop, + session: Some(first.id) + }, + None + ) + .is_err() + ); + control(&mut engine, PomodoroAction::Stop).unwrap(); + assert!(engine.snapshot().pomodoro.is_none()); + assert!(engine.snapshot().activities.is_empty()); + assert!(Engine::default().snapshot().pomodoro.is_none()); + } + #[test] + fn completions_are_delivered_once_and_cancellation_does_not_alert() { + let mut engine = Engine::default(); + let (sender, receiver) = std::sync::mpsc::sync_channel(16); + engine.alerts = Some(Alerts { sender }); + control(&mut engine, PomodoroAction::Start).unwrap(); + let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap(); + assert!(engine.advance_pomodoro(deadline)); + assert!(!engine.advance_pomodoro(deadline + 1)); + let (title, body) = receiver.try_recv().unwrap(); + assert_eq!(title, "Pomodoro: Work complete"); + assert_eq!(body, "Short break is ready."); + assert!(receiver.try_recv().is_err()); + control(&mut engine, PomodoroAction::Reset).unwrap(); + engine.pomodoro.as_mut().unwrap().session.deadline = Some(1); + control(&mut engine, PomodoroAction::Stop).unwrap(); + assert!(receiver.try_recv().is_err()); + control(&mut engine, PomodoroAction::Start).unwrap(); + engine.pomodoro.as_mut().unwrap().session.deadline = Some(1); + control(&mut engine, PomodoroAction::Reset).unwrap(); + assert!(receiver.try_recv().is_err()); + } + #[test] + fn paused_snapshot_is_constant_and_output_loss_routes_the_timer() { + let mut engine = Engine::default(); + control(&mut engine, PomodoroAction::Start).unwrap(); + control(&mut engine, PomodoroAction::Pause).unwrap(); + let snapshot = engine.snapshot(); + let activity = &snapshot.activities[0]; + assert!(activity.data.title.contains("Paused")); + assert_eq!( + activity.data.value.remaining_ms(0), + activity.data.value.remaining_ms(u64::MAX) + ); + engine.pomodoro_remove_output("test", None); + assert!(engine.pomodoro_attach_output("new")); + engine.changed(); + assert_eq!(engine.snapshot().activities[0].data.output.as_deref(), Some("new")); + } + #[test] + fn disabled_alerts_and_schema_errors_have_no_side_effects() { + let mut engine = Engine::default(); + engine.pomodoro_settings.notifications = false; + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + engine.alerts = Some(Alerts { sender }); + control(&mut engine, PomodoroAction::Start).unwrap(); + assert!( + engine + .pomodoro_control( + PomodoroRequest { + version: 99, + action: PomodoroAction::Stop, + session: None + }, + None + ) + .is_err() + ); + let deadline = engine.pomodoro.as_ref().unwrap().session.deadline.unwrap(); + engine.advance_pomodoro(deadline); + assert!(receiver.try_recv().is_err()); + } +} diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 3f6a6ec..d5c8e1e 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -232,6 +232,33 @@ pub(crate) fn init( Err(error) => Response::error(call.request.id, "activity_rejected", error), }; let _ = call.response.try_send(response); + } else if call.request.command == "pomodoro" { + let result = validate_request(&call.request) + .map_err(|error| error.message) + .and_then(|()| { + if state.session_lock.active() { + return Err("Session locked".into()); + } + let request: ferese_ipc::activities::PomodoroRequest = + serde_json::from_value(call.request.args).map_err(|error| error.to_string())?; + let output = state + .outputs_json() + .as_array() + .and_then(|outputs| { + outputs + .iter() + .find(|output| output["focused"] == true) + .or_else(|| outputs.first()) + }) + .and_then(|output| output["name"].as_str()) + .map(str::to_owned); + state.activities.pomodoro_control(request, output) + }); + let response = match result { + Ok(value) => Response::success(call.request.id, value), + Err(error) => Response::error(call.request.id, "pomodoro_rejected", error), + }; + let _ = call.response.try_send(response); } else if call.request.command == "activity-get" { if state.session_lock.active() { let _ = call.response.try_send(Response::error( @@ -415,7 +442,7 @@ pub(crate) fn init( })?; let worker = Worker::spawn(encoded); - let activity_timer = DeadlineTimer::new(event_loop.handle(), |state: &mut Ferese, _| state.activities.tick()); + let activity_timer = crate::activities::deadline::Deadline::new(event_loop.handle())?; let (rules_send, rules_receive) = sync_channel(1); let monitor = activity_owner::Monitor::new(sender.clone()); let state_monitor = monitor.clone(); diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs index 4ca95ad..f8664bc 100644 --- a/compositor/src/state/outputs.rs +++ b/compositor/src/state/outputs.rs @@ -103,6 +103,8 @@ impl Ferese { .find(|candidate| *candidate != output) .map(|output| output.name()); self.activities.store.remove_output(&output.name(), fallback.as_deref()); + self.activities + .pomodoro_remove_output(&output.name(), fallback.as_deref()); self.activities.changed(); self.display_presentation.remove_output(output); self.output_redraw_pending.retain(|pending| pending != output); diff --git a/compositor/src/state/reconciliation.rs b/compositor/src/state/reconciliation.rs index 2870b01..17d6770 100644 --- a/compositor/src/state/reconciliation.rs +++ b/compositor/src/state/reconciliation.rs @@ -168,7 +168,7 @@ impl Ferese { self.desktop_transition.as_mut().unwrap().publishing = true; let activity_output = self.space.outputs().next().map(|output| output.name()); if let Some(output) = activity_output - && self.activities.store.attach_output(&output) + && (self.activities.store.attach_output(&output) | self.activities.pomodoro_attach_output(&output)) { self.activities.changed(); } diff --git a/crates/ferese-activities/src/lib.rs b/crates/ferese-activities/src/lib.rs index eaf185e..afd87ad 100644 --- a/crates/ferese-activities/src/lib.rs +++ b/crates/ferese-activities/src/lib.rs @@ -1,5 +1,7 @@ //! Typed activity data and deterministic policy. The host supplies verified owners //! and suspend-aware milliseconds; this crate performs no I/O or rendering. +pub mod pomodoro; + use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; @@ -32,6 +34,7 @@ pub enum Value { Progress(f32), Text(String), Countdown { remaining_ms: u64, sampled_at: Time }, + PausedCountdown { remaining_ms: u64 }, } impl Value { @@ -41,6 +44,7 @@ impl Value { remaining_ms, sampled_at, } => Some(remaining_ms.saturating_sub(now.saturating_sub(*sampled_at))), + Self::PausedCountdown { remaining_ms } => Some(*remaining_ms), _ => None, } } @@ -109,6 +113,9 @@ impl Data { return Err("progress must be finite and between zero and one"); } Value::Text(value) => text(value)?, + Value::PausedCountdown { remaining_ms } if *remaining_ms > 7 * 24 * 60 * 60 * 1_000 => { + return Err("countdown exceeds seven days"); + } Value::Countdown { remaining_ms, sampled_at, @@ -121,10 +128,10 @@ impl Data { _ => {} } match &self.template { - Template::Timer if !matches!(self.value, Value::Countdown { .. }) => { + Template::Timer if !matches!(self.value, Value::Countdown { .. } | Value::PausedCountdown { .. }) => { return Err("timer requires a countdown"); } - Template::Progress if matches!(self.value, Value::Countdown { .. }) => { + Template::Progress if matches!(self.value, Value::Countdown { .. } | Value::PausedCountdown { .. }) => { return Err("countdown requires timer template"); } Template::Media { artist, .. } => { @@ -242,6 +249,10 @@ impl Store { } } + pub fn session(&self) -> u64 { + self.session + } + pub fn entries(&self) -> impl Iterator { self.entries.values() } diff --git a/crates/ferese-activities/src/pomodoro.rs b/crates/ferese-activities/src/pomodoro.rs new file mode 100644 index 0000000..10053ce --- /dev/null +++ b/crates/ferese-activities/src/pomodoro.rs @@ -0,0 +1,274 @@ +//! One session's timer. The host supplies CLOCK_BOOTTIME samples and delivers +//! returned completions; neither presentation nor client connections own time. +use crate::Time; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub work_minutes: u16, + pub short_break_minutes: u16, + pub long_break_minutes: u16, + pub intervals: u8, + pub auto_advance: bool, + pub notifications: bool, +} +impl Default for Settings { + fn default() -> Self { + Self { + work_minutes: 25, + short_break_minutes: 5, + long_break_minutes: 15, + intervals: 4, + auto_advance: false, + notifications: true, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<(), &'static str> { + if !(1..=180).contains(&self.work_minutes) + || !(1..=60).contains(&self.short_break_minutes) + || !(1..=120).contains(&self.long_break_minutes) + || !(1..=12).contains(&self.intervals) + { + return Err( + "Pomodoro requires work 1–180 min, short break 1–60 min, long break 1–120 min, and 1–12 intervals", + ); + } + Ok(()) + } + fn duration(&self, phase: Phase) -> u64 { + u64::from(match phase { + Phase::Work => self.work_minutes, + Phase::ShortBreak => self.short_break_minutes, + Phase::LongBreak => self.long_break_minutes, + }) * 60_000 + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Phase { + Work, + ShortBreak, + LongBreak, +} +impl Phase { + pub fn label(self) -> &'static str { + match self { + Self::Work => "Work", + Self::ShortBreak => "Short break", + Self::LongBreak => "Long break", + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Status { + Running, + Paused, + Ready, + Finished, +} +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct Session { + pub settings: Settings, + pub phase: Phase, + pub status: Status, + pub completed_work: u8, + pub remaining_ms: u64, + pub deadline: Option