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
This commit is contained in:
1 parent
53ba599918
commit
2c9bb85f2c
18 files changed
+1209
-16
No files matched your search
@@ -1,5 +1,7 @@
|
|||||||
//! Session broker: verified process identities, one-shot policy deadlines and
|
//! Session broker: verified process identities, one-shot policy deadlines and
|
||||||
//! bounded long-poll subscribers. Process inspection runs on IPC workers.
|
//! bounded long-poll subscribers. Process inspection runs on IPC workers.
|
||||||
|
pub(crate) mod deadline;
|
||||||
|
mod pomodoro;
|
||||||
mod publication;
|
mod publication;
|
||||||
use ferese_activities::{Data, Id, Owner, Source, State, Store, Template, Value};
|
use ferese_activities::{Data, Id, Owner, Source, State, Store, Template, Value};
|
||||||
use ferese_ipc::{
|
use ferese_ipc::{
|
||||||
@@ -10,7 +12,7 @@ use serde_json::json;
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::mpsc::SyncSender;
|
use std::sync::mpsc::SyncSender;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct Rules {
|
pub(crate) struct Rules {
|
||||||
@@ -21,12 +23,15 @@ pub(crate) type SharedRules = Arc<RwLock<Arc<Rules>>>;
|
|||||||
|
|
||||||
pub(crate) struct Engine {
|
pub(crate) struct Engine {
|
||||||
pub store: Store,
|
pub store: Store,
|
||||||
|
pomodoro: Option<pomodoro::Timer>,
|
||||||
|
pomodoro_settings: ferese_activities::pomodoro::Settings,
|
||||||
|
alerts: Option<pomodoro::Alerts>,
|
||||||
pub rules: SharedRules,
|
pub rules: SharedRules,
|
||||||
pub monitor: Option<crate::ipc::activity_owner::Monitor>,
|
pub monitor: Option<crate::ipc::activity_owner::Monitor>,
|
||||||
process_enabled: bool,
|
process_enabled: bool,
|
||||||
process_sender: Option<smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>>,
|
process_sender: Option<smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>>,
|
||||||
process_watcher: Option<crate::activities_process::Watcher>,
|
process_watcher: Option<crate::activities_process::Watcher>,
|
||||||
pub timer: Option<crate::ipc::DeadlineTimer<crate::Ferese>>,
|
pub timer: Option<deadline::Deadline>,
|
||||||
revision: u64,
|
revision: u64,
|
||||||
cached_snapshot: Option<Arc<Snapshot>>,
|
cached_snapshot: Option<Arc<Snapshot>>,
|
||||||
publisher: Option<publication::Publisher>,
|
publisher: Option<publication::Publisher>,
|
||||||
@@ -40,6 +45,9 @@ impl Default for Engine {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let settings = ferese_config::activities::Settings::default();
|
let settings = ferese_config::activities::Settings::default();
|
||||||
Self {
|
Self {
|
||||||
|
pomodoro: None,
|
||||||
|
pomodoro_settings: Default::default(),
|
||||||
|
alerts: None,
|
||||||
store: Store::new(ferese_ipc::activities::now_ms() ^ u64::from(std::process::id()) << 32),
|
store: Store::new(ferese_ipc::activities::now_ms() ^ u64::from(std::process::id()) << 32),
|
||||||
rules: Arc::new(RwLock::new(Arc::new(Rules {
|
rules: Arc::new(RwLock::new(Arc::new(Rules {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -109,6 +117,7 @@ impl Engine {
|
|||||||
matcher: ferese_config::activities::Matcher,
|
matcher: ferese_config::activities::Matcher,
|
||||||
) {
|
) {
|
||||||
self.store.limits = settings.limits();
|
self.store.limits = settings.limits();
|
||||||
|
self.pomodoro_settings = settings.pomodoro.clone();
|
||||||
self.process_enabled = settings.sources.process_watch;
|
self.process_enabled = settings.sources.process_watch;
|
||||||
*self.rules.write().expect("activity rules") = Arc::new(Rules {
|
*self.rules.write().expect("activity rules") = Arc::new(Rules {
|
||||||
enabled: settings.sources.shell_hook,
|
enabled: settings.sources.shell_hook,
|
||||||
@@ -145,7 +154,16 @@ impl Engine {
|
|||||||
revision: self.revision,
|
revision: self.revision,
|
||||||
now_ms: ferese_ipc::activities::now_ms(),
|
now_ms: ferese_ipc::activities::now_ms(),
|
||||||
limits: self.store.limits,
|
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: self
|
||||||
.windows
|
.windows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -218,7 +236,9 @@ impl Engine {
|
|||||||
self.schedule();
|
self.schedule();
|
||||||
}
|
}
|
||||||
pub fn tick(&mut self) {
|
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();
|
self.changed();
|
||||||
}
|
}
|
||||||
pub fn schedule(&self) {
|
pub fn schedule(&self) {
|
||||||
@@ -234,11 +254,15 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
if let Some(timer) = &self.timer {
|
if let Some(timer) = &self.timer {
|
||||||
let now = ferese_ipc::activities::now_ms();
|
let now = ferese_ipc::activities::now_ms();
|
||||||
timer.update(
|
if let Err(error) = timer.update(
|
||||||
self.store
|
self.store
|
||||||
.next_deadline(now)
|
.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(
|
pub fn publish_verified(
|
||||||
|
|||||||
@@ -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<Option<u64>>,
|
||||||
|
handle: LoopHandle<'static, crate::Ferese>,
|
||||||
|
token: RegistrationToken,
|
||||||
|
}
|
||||||
|
impl Deadline {
|
||||||
|
pub fn new(handle: LoopHandle<'static, crate::Ferese>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
// 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<u64>) -> 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::<crate::Ferese>::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::<Vec<_>>() == ["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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
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<String>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
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::<String>::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<serde_json::Value, String> {
|
||||||
|
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<String>,
|
||||||
|
hints: std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
|
||||||
|
_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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-1
@@ -232,6 +232,33 @@ pub(crate) fn init(
|
|||||||
Err(error) => Response::error(call.request.id, "activity_rejected", error),
|
Err(error) => Response::error(call.request.id, "activity_rejected", error),
|
||||||
};
|
};
|
||||||
let _ = call.response.try_send(response);
|
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" {
|
} else if call.request.command == "activity-get" {
|
||||||
if state.session_lock.active() {
|
if state.session_lock.active() {
|
||||||
let _ = call.response.try_send(Response::error(
|
let _ = call.response.try_send(Response::error(
|
||||||
@@ -415,7 +442,7 @@ pub(crate) fn init(
|
|||||||
})?;
|
})?;
|
||||||
let worker = Worker::spawn(encoded);
|
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 (rules_send, rules_receive) = sync_channel(1);
|
||||||
let monitor = activity_owner::Monitor::new(sender.clone());
|
let monitor = activity_owner::Monitor::new(sender.clone());
|
||||||
let state_monitor = monitor.clone();
|
let state_monitor = monitor.clone();
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ impl Ferese {
|
|||||||
.find(|candidate| *candidate != output)
|
.find(|candidate| *candidate != output)
|
||||||
.map(|output| output.name());
|
.map(|output| output.name());
|
||||||
self.activities.store.remove_output(&output.name(), fallback.as_deref());
|
self.activities.store.remove_output(&output.name(), fallback.as_deref());
|
||||||
|
self.activities
|
||||||
|
.pomodoro_remove_output(&output.name(), fallback.as_deref());
|
||||||
self.activities.changed();
|
self.activities.changed();
|
||||||
self.display_presentation.remove_output(output);
|
self.display_presentation.remove_output(output);
|
||||||
self.output_redraw_pending.retain(|pending| pending != output);
|
self.output_redraw_pending.retain(|pending| pending != output);
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ impl Ferese {
|
|||||||
self.desktop_transition.as_mut().unwrap().publishing = true;
|
self.desktop_transition.as_mut().unwrap().publishing = true;
|
||||||
let activity_output = self.space.outputs().next().map(|output| output.name());
|
let activity_output = self.space.outputs().next().map(|output| output.name());
|
||||||
if let Some(output) = activity_output
|
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();
|
self.activities.changed();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Typed activity data and deterministic policy. The host supplies verified owners
|
//! Typed activity data and deterministic policy. The host supplies verified owners
|
||||||
//! and suspend-aware milliseconds; this crate performs no I/O or rendering.
|
//! and suspend-aware milliseconds; this crate performs no I/O or rendering.
|
||||||
|
pub mod pomodoro;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -32,6 +34,7 @@ pub enum Value {
|
|||||||
Progress(f32),
|
Progress(f32),
|
||||||
Text(String),
|
Text(String),
|
||||||
Countdown { remaining_ms: u64, sampled_at: Time },
|
Countdown { remaining_ms: u64, sampled_at: Time },
|
||||||
|
PausedCountdown { remaining_ms: u64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Value {
|
impl Value {
|
||||||
@@ -41,6 +44,7 @@ impl Value {
|
|||||||
remaining_ms,
|
remaining_ms,
|
||||||
sampled_at,
|
sampled_at,
|
||||||
} => Some(remaining_ms.saturating_sub(now.saturating_sub(*sampled_at))),
|
} => Some(remaining_ms.saturating_sub(now.saturating_sub(*sampled_at))),
|
||||||
|
Self::PausedCountdown { remaining_ms } => Some(*remaining_ms),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +113,9 @@ impl Data {
|
|||||||
return Err("progress must be finite and between zero and one");
|
return Err("progress must be finite and between zero and one");
|
||||||
}
|
}
|
||||||
Value::Text(value) => text(value)?,
|
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 {
|
Value::Countdown {
|
||||||
remaining_ms,
|
remaining_ms,
|
||||||
sampled_at,
|
sampled_at,
|
||||||
@@ -121,10 +128,10 @@ impl Data {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
match &self.template {
|
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");
|
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");
|
return Err("countdown requires timer template");
|
||||||
}
|
}
|
||||||
Template::Media { artist, .. } => {
|
Template::Media { artist, .. } => {
|
||||||
@@ -242,6 +249,10 @@ impl Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn session(&self) -> u64 {
|
||||||
|
self.session
|
||||||
|
}
|
||||||
|
|
||||||
pub fn entries(&self) -> impl Iterator<Item = &Activity> {
|
pub fn entries(&self) -> impl Iterator<Item = &Activity> {
|
||||||
self.entries.values()
|
self.entries.values()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Time>,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Completion {
|
||||||
|
pub phase: Phase,
|
||||||
|
pub next: Option<Phase>,
|
||||||
|
}
|
||||||
|
impl Session {
|
||||||
|
pub fn start(settings: Settings, now: Time) -> Result<Self, &'static str> {
|
||||||
|
settings.validate()?;
|
||||||
|
let remaining_ms = settings.duration(Phase::Work);
|
||||||
|
Ok(Self {
|
||||||
|
settings,
|
||||||
|
phase: Phase::Work,
|
||||||
|
status: Status::Running,
|
||||||
|
completed_work: 0,
|
||||||
|
remaining_ms,
|
||||||
|
deadline: Some(now.saturating_add(remaining_ms)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn remaining(&self, now: Time) -> u64 {
|
||||||
|
self.deadline
|
||||||
|
.map_or(self.remaining_ms, |deadline| deadline.saturating_sub(now))
|
||||||
|
}
|
||||||
|
fn next_phase(&self) -> Option<Phase> {
|
||||||
|
match self.phase {
|
||||||
|
Phase::Work if self.completed_work >= self.settings.intervals => Some(Phase::LongBreak),
|
||||||
|
Phase::Work => Some(Phase::ShortBreak),
|
||||||
|
Phase::ShortBreak => Some(Phase::Work),
|
||||||
|
Phase::LongBreak => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Completes at most the interval that was actually running. After suspend,
|
||||||
|
/// automatic advancement starts the next interval at this observation, rather
|
||||||
|
/// than counting unattended work intervals during sleep.
|
||||||
|
pub fn advance(&mut self, now: Time) -> Option<Completion> {
|
||||||
|
if self.deadline.is_none_or(|deadline| now < deadline) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.phase == Phase::Work {
|
||||||
|
self.completed_work += 1;
|
||||||
|
}
|
||||||
|
let completion = Completion {
|
||||||
|
phase: self.phase,
|
||||||
|
next: self.next_phase(),
|
||||||
|
};
|
||||||
|
self.select(completion.next, now, self.settings.auto_advance);
|
||||||
|
Some(completion)
|
||||||
|
}
|
||||||
|
fn select(&mut self, phase: Option<Phase>, now: Time, running: bool) {
|
||||||
|
self.deadline = None;
|
||||||
|
if let Some(phase) = phase {
|
||||||
|
self.phase = phase;
|
||||||
|
self.remaining_ms = self.settings.duration(phase);
|
||||||
|
self.status = if running { Status::Running } else { Status::Ready };
|
||||||
|
if running {
|
||||||
|
self.deadline = Some(now.saturating_add(self.remaining_ms));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.remaining_ms = 0;
|
||||||
|
self.status = Status::Finished;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn pause(&mut self, now: Time) -> Result<(), &'static str> {
|
||||||
|
match self.status {
|
||||||
|
Status::Paused => Ok(()),
|
||||||
|
Status::Running => {
|
||||||
|
self.remaining_ms = self.remaining(now);
|
||||||
|
self.deadline = None;
|
||||||
|
self.status = Status::Paused;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err("No running Pomodoro interval"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn resume(&mut self, now: Time) -> Result<(), &'static str> {
|
||||||
|
match self.status {
|
||||||
|
Status::Running => Ok(()),
|
||||||
|
Status::Paused | Status::Ready => {
|
||||||
|
self.deadline = Some(now.saturating_add(self.remaining_ms));
|
||||||
|
self.status = Status::Running;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Status::Finished => Err("Start a new Pomodoro session"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Skipping never completes work or emits a completion. A Ready interval is
|
||||||
|
/// already the next phase, so Next starts it rather than skipping twice.
|
||||||
|
pub fn next(&mut self, now: Time) -> Result<(), &'static str> {
|
||||||
|
match self.status {
|
||||||
|
Status::Finished => Err("Start a new Pomodoro session"),
|
||||||
|
Status::Ready => self.resume(now),
|
||||||
|
_ => {
|
||||||
|
self.select(self.next_phase(), now, true);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn pauses_preserve_milliseconds_and_do_not_count_sleep() {
|
||||||
|
let mut session = Session::start(Settings::default(), 100).unwrap();
|
||||||
|
session.pause(1_337).unwrap();
|
||||||
|
assert_eq!(session.remaining(100_000_000), 1_498_763);
|
||||||
|
assert!(session.advance(100_000_000).is_none());
|
||||||
|
session.resume(100_000_000).unwrap();
|
||||||
|
assert_eq!(session.deadline, Some(101_498_763));
|
||||||
|
session.pause(100_000_001).unwrap();
|
||||||
|
assert_eq!(session.remaining_ms, 1_498_762);
|
||||||
|
assert_eq!(session.completed_work, 0);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn boundaries_and_long_break_are_exactly_once() {
|
||||||
|
let mut session = Session::start(
|
||||||
|
Settings {
|
||||||
|
intervals: 2,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for (finished, next, count) in [
|
||||||
|
(Phase::Work, Some(Phase::ShortBreak), 1),
|
||||||
|
(Phase::ShortBreak, Some(Phase::Work), 1),
|
||||||
|
(Phase::Work, Some(Phase::LongBreak), 2),
|
||||||
|
(Phase::LongBreak, None, 2),
|
||||||
|
] {
|
||||||
|
let deadline = session.deadline.unwrap();
|
||||||
|
assert!(session.advance(deadline - 1).is_none());
|
||||||
|
assert_eq!(session.advance(deadline), Some(Completion { phase: finished, next }));
|
||||||
|
assert_eq!(session.completed_work, count);
|
||||||
|
assert!(session.advance(deadline + 99_000_000).is_none());
|
||||||
|
if next.is_some() {
|
||||||
|
assert_eq!(session.status, Status::Ready);
|
||||||
|
session.next(deadline).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(session.status, Status::Finished);
|
||||||
|
assert!(session.resume(0).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn skipping_and_cancelling_never_complete_work() {
|
||||||
|
let mut session = Session::start(Settings::default(), 0).unwrap();
|
||||||
|
session.next(50).unwrap();
|
||||||
|
assert_eq!(session.phase, Phase::ShortBreak);
|
||||||
|
session.next(60).unwrap();
|
||||||
|
assert_eq!(session.phase, Phase::Work);
|
||||||
|
assert_eq!(session.completed_work, 0);
|
||||||
|
session.pause(70).unwrap();
|
||||||
|
session.next(80).unwrap();
|
||||||
|
assert_eq!(session.completed_work, 0);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn suspend_finishes_only_the_running_interval() {
|
||||||
|
let mut session = Session::start(
|
||||||
|
Settings {
|
||||||
|
auto_advance: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(session.advance(100_000_000).unwrap().phase, Phase::Work);
|
||||||
|
assert_eq!(session.completed_work, 1);
|
||||||
|
assert_eq!(session.deadline, Some(100_300_000));
|
||||||
|
assert!(session.advance(100_000_000).is_none());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn invalid_settings_are_rejected() {
|
||||||
|
for settings in [
|
||||||
|
Settings {
|
||||||
|
work_minutes: 0,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Settings {
|
||||||
|
short_break_minutes: 61,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Settings {
|
||||||
|
long_break_minutes: 121,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Settings {
|
||||||
|
intervals: 13,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(Session::start(settings, 0).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ pub struct Settings {
|
|||||||
pub start_delay_ms: u64,
|
pub start_delay_ms: u64,
|
||||||
pub result_flash_ms: u64,
|
pub result_flash_ms: u64,
|
||||||
pub sources: Sources,
|
pub sources: Sources,
|
||||||
|
pub pomodoro: ferese_activities::pomodoro::Settings,
|
||||||
pub rules: Vec<Rule>,
|
pub rules: Vec<Rule>,
|
||||||
}
|
}
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
@@ -49,6 +50,7 @@ impl Default for Settings {
|
|||||||
start_delay_ms: 300,
|
start_delay_ms: 300,
|
||||||
result_flash_ms: 1_500,
|
result_flash_ms: 1_500,
|
||||||
sources: Sources::default(),
|
sources: Sources::default(),
|
||||||
|
pomodoro: Default::default(),
|
||||||
rules: builtins(),
|
rules: builtins(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +183,7 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
self.pomodoro.validate().map_err(str::to_owned)?;
|
||||||
if !(1..=2).contains(&self.max_visible)
|
if !(1..=2).contains(&self.max_visible)
|
||||||
|| !(1..=128).contains(&self.max_total)
|
|| !(1..=128).contains(&self.max_total)
|
||||||
|| self.start_delay_ms > 60_000
|
|| self.start_delay_ms > 60_000
|
||||||
@@ -282,6 +285,7 @@ pub(crate) fn parse(node: &KdlNode) -> Result<Value, Error> {
|
|||||||
| "start-delay-ms"
|
| "start-delay-ms"
|
||||||
| "result-flash-ms"
|
| "result-flash-ms"
|
||||||
| "sources"
|
| "sources"
|
||||||
|
| "pomodoro"
|
||||||
) {
|
) {
|
||||||
return Err(diagnostic(child, "unknown activity node"));
|
return Err(diagnostic(child, "unknown activity node"));
|
||||||
}
|
}
|
||||||
@@ -352,6 +356,27 @@ fn builtins() -> Vec<Rule> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
|
fn pomodoro_settings_are_strict_and_validate_duration_ranges() {
|
||||||
|
let doc=crate::Document::parse("activities { pomodoro { work-minutes 40; short-break-minutes 7; long-break-minutes 20; intervals 3; auto-advance #true; notifications #false; }; }").unwrap();
|
||||||
|
let settings: Settings = serde_json::from_value(doc.value()["activities"].clone()).unwrap();
|
||||||
|
assert_eq!(settings.pomodoro.work_minutes, 40);
|
||||||
|
assert_eq!(settings.pomodoro.intervals, 3);
|
||||||
|
assert!(settings.pomodoro.auto_advance);
|
||||||
|
assert!(!settings.pomodoro.notifications);
|
||||||
|
for invalid in [
|
||||||
|
"work-minutes 0",
|
||||||
|
"short-break-minutes 61",
|
||||||
|
"long-break-minutes 121",
|
||||||
|
"intervals 13",
|
||||||
|
"work-minuts 20",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
crate::Document::parse(&format!("activities {{ pomodoro {{ {invalid}; }}; }}")).is_err(),
|
||||||
|
"{invalid}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn settings_are_opt_in_and_matching_discards_nonmatches() {
|
fn settings_are_opt_in_and_matching_discards_nonmatches() {
|
||||||
let settings = Settings::default();
|
let settings = Settings::default();
|
||||||
assert!(!settings.sources.shell_hook);
|
assert!(!settings.sources.shell_hook);
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ pub struct Snapshot {
|
|||||||
pub activities: Vec<Activity>,
|
pub activities: Vec<Activity>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub windows: Vec<ActivityWindow>,
|
pub windows: Vec<ActivityWindow>,
|
||||||
|
pub pomodoro: Option<Pomodoro>,
|
||||||
}
|
}
|
||||||
impl Default for Snapshot {
|
impl Default for Snapshot {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -97,6 +98,34 @@ impl Default for Snapshot {
|
|||||||
limits: Limits::default(),
|
limits: Limits::default(),
|
||||||
activities: vec![],
|
activities: vec![],
|
||||||
windows: vec![],
|
windows: vec![],
|
||||||
|
pomodoro: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum PomodoroAction {
|
||||||
|
Start,
|
||||||
|
Pause,
|
||||||
|
Resume,
|
||||||
|
Next,
|
||||||
|
Stop,
|
||||||
|
Reset,
|
||||||
|
Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PomodoroRequest {
|
||||||
|
pub version: u32,
|
||||||
|
pub action: PomodoroAction,
|
||||||
|
/// Popup controls carry the displayed generation, so a delayed click cannot
|
||||||
|
/// affect a replacement session. CLI commands may address the current one.
|
||||||
|
pub session: Option<Id>,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Pomodoro {
|
||||||
|
pub id: Id,
|
||||||
|
pub timer: ferese_activities::pomodoro::Session,
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ pub enum Command {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
action: LauncherAction,
|
action: LauncherAction,
|
||||||
},
|
},
|
||||||
|
/// Control the session’s work and break timer.
|
||||||
|
Pomodoro {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: crate::pomodoro::Action,
|
||||||
|
},
|
||||||
/// Publish bounded activity data owned by the invoking shell.
|
/// Publish bounded activity data owned by the invoking shell.
|
||||||
Activity {
|
Activity {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -248,7 +253,7 @@ pub enum LauncherAction {
|
|||||||
impl Command {
|
impl Command {
|
||||||
pub fn ipc(&self) -> Option<(&'static str, Value)> {
|
pub fn ipc(&self) -> Option<(&'static str, Value)> {
|
||||||
let (command, args) = match self {
|
let (command, args) = match self {
|
||||||
Self::Activity { .. } | Self::Run { .. } => return None,
|
Self::Pomodoro { .. } | Self::Activity { .. } | Self::Run { .. } => return None,
|
||||||
Self::Launcher {
|
Self::Launcher {
|
||||||
action: LauncherAction::Toggle,
|
action: LauncherAction::Toggle,
|
||||||
} => ("toggle-launcher", json!({})),
|
} => ("toggle-launcher", json!({})),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod activities;
|
|||||||
mod autostart;
|
mod autostart;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod output;
|
mod output;
|
||||||
|
mod pomodoro;
|
||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
@@ -55,6 +56,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
match &cli.command {
|
match &cli.command {
|
||||||
Command::Theme { action } => theme_command(action, &socket, cli.json),
|
Command::Theme { action } => theme_command(action, &socket, cli.json),
|
||||||
Command::Activity { action } => activities::command(action, &socket, cli.json),
|
Command::Activity { action } => activities::command(action, &socket, cli.json),
|
||||||
|
Command::Pomodoro { action } => pomodoro::command(action, &socket, cli.json),
|
||||||
Command::EventStream => event_stream(&socket),
|
Command::EventStream => event_stream(&socket),
|
||||||
command => {
|
command => {
|
||||||
let (command, args) = command.ipc().expect("IPC command");
|
let (command, args) = command.ipc().expect("IPC command");
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
use clap::Subcommand;
|
||||||
|
use ferese_ipc::activities::{PomodoroAction, PomodoroRequest, VERSION};
|
||||||
|
use std::{error::Error, os::unix::net::UnixStream, path::Path, time::Duration};
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Start a session using activities.pomodoro settings.
|
||||||
|
Start,
|
||||||
|
Pause,
|
||||||
|
Resume,
|
||||||
|
/// Skip the current interval, or start a phase that is ready.
|
||||||
|
Next,
|
||||||
|
/// Cancel and remove the session without a completion alert.
|
||||||
|
Stop,
|
||||||
|
/// Cancel and start a new session with current settings.
|
||||||
|
Reset,
|
||||||
|
Status,
|
||||||
|
}
|
||||||
|
pub fn command(action: &Action, socket: &Path, json: bool) -> Result<(), Box<dyn Error>> {
|
||||||
|
let action = match action {
|
||||||
|
Action::Start => PomodoroAction::Start,
|
||||||
|
Action::Pause => PomodoroAction::Pause,
|
||||||
|
Action::Resume => PomodoroAction::Resume,
|
||||||
|
Action::Next => PomodoroAction::Next,
|
||||||
|
Action::Stop => PomodoroAction::Stop,
|
||||||
|
Action::Reset => PomodoroAction::Reset,
|
||||||
|
Action::Status => PomodoroAction::Status,
|
||||||
|
};
|
||||||
|
let mut stream = UnixStream::connect(socket)?;
|
||||||
|
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||||
|
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
|
||||||
|
let result = super::request(
|
||||||
|
&mut stream,
|
||||||
|
"pomodoro",
|
||||||
|
serde_json::to_value(PomodoroRequest {
|
||||||
|
version: VERSION,
|
||||||
|
action,
|
||||||
|
session: None,
|
||||||
|
})?,
|
||||||
|
)?;
|
||||||
|
super::output::print(&result, json)
|
||||||
|
}
|
||||||
@@ -485,3 +485,26 @@ fn launcher_toggle_uses_the_shell_command() {
|
|||||||
);
|
);
|
||||||
server.join().unwrap();
|
server.join().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pomodoro_commands_use_the_versioned_session_api() {
|
||||||
|
for action in ["start", "pause", "resume", "next", "stop", "reset", "status"] {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let socket = directory.path().join("control.sock");
|
||||||
|
let listener = UnixListener::bind(&socket).unwrap();
|
||||||
|
let worker = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let request: Request = read_frame(&mut stream).unwrap();
|
||||||
|
assert_eq!(request.command, "pomodoro");
|
||||||
|
assert_eq!(request.args, json!({"version":1,"action":action,"session":null}));
|
||||||
|
write_frame(&mut stream, &Response::success(request.id, json!({"phase":"work"}))).unwrap();
|
||||||
|
});
|
||||||
|
success(
|
||||||
|
ctl(&["--socket", socket.to_str().unwrap(), "pomodoro", action])
|
||||||
|
.output()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
worker.join().unwrap();
|
||||||
|
}
|
||||||
|
success(ctl(&["pomodoro", "--help"]).output().unwrap());
|
||||||
|
}
|
||||||
@@ -107,6 +107,35 @@ class Activities(unittest.TestCase):
|
|||||||
shutil.copyfile(cls.root / "native.log", Path(destination) / "activities-native.log")
|
shutil.copyfile(cls.root / "native.log", Path(destination) / "activities-native.log")
|
||||||
cls.temporary.cleanup()
|
cls.temporary.cleanup()
|
||||||
|
|
||||||
|
def test_pomodoro_survives_cli_disconnect_and_controls_preserve_time(self):
|
||||||
|
self.call("pomodoro", "stop")
|
||||||
|
try:
|
||||||
|
first = self.query("pomodoro", "start")
|
||||||
|
self.assertIsNotNone(first)
|
||||||
|
self.assertNotEqual(first["id"]["owner"]["pid"], os.getpid())
|
||||||
|
self.assertEqual(first["timer"]["phase"], "work")
|
||||||
|
self.assertNotEqual(self.call("pomodoro", "start", check=False).returncode, 0)
|
||||||
|
paused = self.query("pomodoro", "pause")
|
||||||
|
remaining = paused["timer"]["remaining_ms"]
|
||||||
|
time.sleep(.15)
|
||||||
|
self.assertEqual(self.query("pomodoro", "status")["timer"]["remaining_ms"], remaining)
|
||||||
|
entries = self.query("activity", "list")["activities"]
|
||||||
|
timer = next(entry for entry in entries if entry["id"] == first["id"])
|
||||||
|
self.assertEqual(timer["data"]["value"]["kind"], "paused-countdown")
|
||||||
|
self.assertIn("Paused", timer["data"]["title"])
|
||||||
|
resumed = self.query("pomodoro", "resume")
|
||||||
|
self.assertEqual(resumed["id"], first["id"])
|
||||||
|
self.assertEqual(resumed["timer"]["remaining_ms"], remaining)
|
||||||
|
skipped = self.query("pomodoro", "next")
|
||||||
|
self.assertEqual(skipped["timer"]["phase"], "short-break")
|
||||||
|
self.assertEqual(skipped["timer"]["completed_work"], 0)
|
||||||
|
reset = self.query("pomodoro", "reset")
|
||||||
|
self.assertNotEqual(reset["id"], first["id"])
|
||||||
|
self.assertTrue(all(child.poll() is None for child in self.children))
|
||||||
|
finally:
|
||||||
|
self.call("pomodoro", "stop")
|
||||||
|
self.assertIsNone(self.query("pomodoro", "status"))
|
||||||
|
|
||||||
def test_live_watch_and_native_bar_render_progress_without_resizing(self):
|
def test_live_watch_and_native_bar_render_progress_without_resizing(self):
|
||||||
def bar():
|
def bar():
|
||||||
trace = (self.root / "native.log").read_text()
|
trace = (self.root / "native.log").read_text()
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ fn label(activity: &Activity) -> String {
|
|||||||
match &activity.data.value {
|
match &activity.data.value {
|
||||||
Value::Text(text) => text.clone(),
|
Value::Text(text) => text.clone(),
|
||||||
Value::Progress(progress) => format!("{}%", (progress * 100.).round() as u32),
|
Value::Progress(progress) => format!("{}%", (progress * 100.).round() as u32),
|
||||||
Value::Countdown { .. } => {
|
Value::Countdown { .. } | Value::PausedCountdown { .. } => {
|
||||||
let seconds = activity.data.value.remaining_ms(now_ms()).unwrap_or(0).div_ceil(1_000);
|
let seconds = activity.data.value.remaining_ms(now_ms()).unwrap_or(0).div_ceil(1_000);
|
||||||
format!("{}:{:02}", seconds / 60, seconds % 60)
|
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||||
}
|
}
|
||||||
@@ -449,7 +449,9 @@ impl FereseShell {
|
|||||||
};
|
};
|
||||||
let ring = ring::view(
|
let ring = ring::view(
|
||||||
progress,
|
progress,
|
||||||
animate && activity.state == State::Running,
|
animate
|
||||||
|
&& activity.state == State::Running
|
||||||
|
&& !matches!(activity.data.value, Value::PausedCountdown { .. }),
|
||||||
reduced,
|
reduced,
|
||||||
ring_color,
|
ring_color,
|
||||||
);
|
);
|
||||||
@@ -947,6 +949,60 @@ impl FereseShell {
|
|||||||
.width(if intrinsic { Length::Fixed(budget) } else { Length::Fill })
|
.width(if intrinsic { Length::Fixed(budget) } else { Length::Fill })
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
fn pomodoro_controls(&self, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
|
||||||
|
use ferese_activities::pomodoro::Status;
|
||||||
|
use ferese_ipc::activities::PomodoroAction as Action;
|
||||||
|
let current = self.activities.snapshot.pomodoro.as_ref();
|
||||||
|
let session = current.map(|current| current.id.clone());
|
||||||
|
let control = |label: &'static str, action| {
|
||||||
|
button::text(label).on_press(cosmic::Action::App(Message::Pomodoro(action, session.clone())))
|
||||||
|
};
|
||||||
|
let mut controls = row::with_capacity(4).spacing(6).align_y(Alignment::Center);
|
||||||
|
let description = if let Some(current) = current {
|
||||||
|
let timer = ¤t.timer;
|
||||||
|
if timer.status == Status::Finished {
|
||||||
|
controls = controls
|
||||||
|
.push(control("Start again", Action::Start))
|
||||||
|
.push(control("Dismiss", Action::Stop));
|
||||||
|
format!("Pomodoro finished · {} work intervals", timer.completed_work)
|
||||||
|
} else {
|
||||||
|
controls = controls.push(match timer.status {
|
||||||
|
Status::Running => control("Pause", Action::Pause),
|
||||||
|
Status::Paused => control("Resume", Action::Resume),
|
||||||
|
_ => control("Start phase", Action::Resume),
|
||||||
|
});
|
||||||
|
if timer.status != Status::Ready {
|
||||||
|
controls = controls.push(control("Skip", Action::Next));
|
||||||
|
}
|
||||||
|
controls = controls
|
||||||
|
.push(control("Reset", Action::Reset))
|
||||||
|
.push(control("Stop", Action::Stop));
|
||||||
|
format!(
|
||||||
|
"Pomodoro · {}{} · {}/{}",
|
||||||
|
timer.phase.label(),
|
||||||
|
match timer.status {
|
||||||
|
Status::Paused => " · Paused",
|
||||||
|
Status::Ready => " · Ready",
|
||||||
|
_ => "",
|
||||||
|
},
|
||||||
|
timer.completed_work,
|
||||||
|
timer.settings.intervals
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
controls = controls.push(control("Start Pomodoro", Action::Start));
|
||||||
|
"Pomodoro".into()
|
||||||
|
};
|
||||||
|
column![
|
||||||
|
self.render.text(description).size(12).class(theme::Text::Color(
|
||||||
|
self.config.theme.palette().text.scale_alpha(opacity)
|
||||||
|
)),
|
||||||
|
controls
|
||||||
|
]
|
||||||
|
.spacing(6)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
pub fn activity_list(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
|
pub fn activity_list(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
|
||||||
let palette = self.config.theme.palette();
|
let palette = self.config.theme.palette();
|
||||||
let name = self.output_for_bar(id).map(|output| output.name.as_str()).unwrap_or("");
|
let name = self.output_for_bar(id).map(|output| output.name.as_str()).unwrap_or("");
|
||||||
@@ -965,7 +1021,7 @@ impl FereseShell {
|
|||||||
});
|
});
|
||||||
let mut rows = Vec::with_capacity(entries.len());
|
let mut rows = Vec::with_capacity(entries.len());
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
return self.render.text("No active tasks").size(13).into();
|
rows.push(self.render.text("No active tasks").size(13).into());
|
||||||
}
|
}
|
||||||
for activity in entries {
|
for activity in entries {
|
||||||
let mut text = column![
|
let mut text = column![
|
||||||
@@ -1073,6 +1129,12 @@ impl FereseShell {
|
|||||||
// Keep unnamed row roots: the toolkit's column does not move directly
|
// Keep unnamed row roots: the toolkit's column does not move directly
|
||||||
// named child trees when their order changes. Inner button IDs restore
|
// named child trees when their order changes. Inner button IDs restore
|
||||||
// focus through the normal named-state reconciliation on each rebuild.
|
// focus through the normal named-state reconciliation on each rebuild.
|
||||||
column::with_children(rows).spacing(4).width(Length::Fill).into()
|
column![
|
||||||
|
self.pomodoro_controls(opacity),
|
||||||
|
column::with_children(rows).spacing(4).width(Length::Fill)
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,6 +233,8 @@ enum Message {
|
|||||||
MediaTick,
|
MediaTick,
|
||||||
ActivitiesUpdated(std::sync::Arc<ferese_ipc::activities::Snapshot>),
|
ActivitiesUpdated(std::sync::Arc<ferese_ipc::activities::Snapshot>),
|
||||||
ActivityTick,
|
ActivityTick,
|
||||||
|
Pomodoro(ferese_ipc::activities::PomodoroAction, Option<ferese_activities::Id>),
|
||||||
|
PomodoroResult(Result<(), String>),
|
||||||
ActivityHover(window::Id, bool),
|
ActivityHover(window::Id, bool),
|
||||||
ActivityActivate(window::Id, ferese_activities::Id),
|
ActivityActivate(window::Id, ferese_activities::Id),
|
||||||
ActivityNavigation(
|
ActivityNavigation(
|
||||||
@@ -494,6 +496,27 @@ impl cosmic::Application for FereseShell {
|
|||||||
self.sync_hub_activity()
|
self.sync_hub_activity()
|
||||||
}
|
}
|
||||||
Message::ActivityTick => self.sync_hub_activity(),
|
Message::ActivityTick => self.sync_hub_activity(),
|
||||||
|
Message::Pomodoro(action, session) => cosmic::task::future(async move {
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
let args = serde_json::to_value(ferese_ipc::activities::PomodoroRequest {
|
||||||
|
version: ferese_ipc::activities::VERSION,
|
||||||
|
action,
|
||||||
|
session,
|
||||||
|
})
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
crate::compositor_ipc::request("pomodoro", args).map(|_| ())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|error| Err(error.to_string()));
|
||||||
|
cosmic::Action::App(Message::PomodoroResult(result))
|
||||||
|
}),
|
||||||
|
Message::PomodoroResult(result) => {
|
||||||
|
if let Err(error) = result {
|
||||||
|
self.notifications
|
||||||
|
.service_error("Pomodoro could not be changed", &error);
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
Message::ActivityHover(id, entered) => {
|
Message::ActivityHover(id, entered) => {
|
||||||
self.activity_hover(id, entered);
|
self.activity_hover(id, entered);
|
||||||
self.sync_hub_activity()
|
self.sync_hub_activity()
|
||||||
|
|||||||
@@ -1496,6 +1496,126 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn pomodoro_controls_fit_and_tab_enter_targets_the_current_session() {
|
||||||
|
use cosmic::iced::advanced::{renderer::Headless, widget::operation};
|
||||||
|
use cosmic::iced::keyboard::{Event as KeyEvent, Key, Location, Modifiers, key};
|
||||||
|
use ferese_activities::pomodoro::{Session, Status};
|
||||||
|
use ferese_ipc::activities::PomodoroAction;
|
||||||
|
#[derive(Default)]
|
||||||
|
struct TextBounds(Vec<(String, Rectangle)>);
|
||||||
|
impl widget::Operation for TextBounds {
|
||||||
|
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||||
|
visit(self);
|
||||||
|
}
|
||||||
|
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
|
||||||
|
self.0.push((text.into(), bounds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
|
||||||
|
cosmic::iced::Font::default(),
|
||||||
|
cosmic::iced::Pixels(14.),
|
||||||
|
Some("tiny-skia"),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
for status in [
|
||||||
|
None,
|
||||||
|
Some(Status::Running),
|
||||||
|
Some(Status::Paused),
|
||||||
|
Some(Status::Ready),
|
||||||
|
Some(Status::Finished),
|
||||||
|
] {
|
||||||
|
let mut app = shell();
|
||||||
|
let bar = app.outputs[0].bar;
|
||||||
|
seed_activities(&mut app, 1);
|
||||||
|
let id = app.activities.snapshot.activities[0].id.clone();
|
||||||
|
if let Some(status) = status {
|
||||||
|
let mut timer = Session::start(Default::default(), ferese_ipc::activities::now_ms()).unwrap();
|
||||||
|
timer.status = status;
|
||||||
|
if status != Status::Running {
|
||||||
|
timer.deadline = None;
|
||||||
|
}
|
||||||
|
std::sync::Arc::make_mut(&mut app.activities.snapshot).pomodoro =
|
||||||
|
Some(ferese_ipc::activities::Pomodoro { id: id.clone(), timer });
|
||||||
|
}
|
||||||
|
for width in [260., 312.] {
|
||||||
|
let mut view = app.activity_list(bar, 1.);
|
||||||
|
let mut tree = widget::Tree::new(&view);
|
||||||
|
let node = view.as_widget_mut().layout(
|
||||||
|
&mut tree,
|
||||||
|
&renderer,
|
||||||
|
&layout::Limits::new(Size::ZERO, Size::new(width, 600.)),
|
||||||
|
);
|
||||||
|
let mut labels = TextBounds::default();
|
||||||
|
view.as_widget_mut()
|
||||||
|
.operate(&mut tree, Layout::new(&node), &renderer, &mut labels);
|
||||||
|
for (text, bounds) in &labels.0 {
|
||||||
|
assert!(
|
||||||
|
bounds.x >= 0. && bounds.x + bounds.width <= width + 0.5,
|
||||||
|
"{status:?} {width} {text}: {bounds:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut tab: Box<dyn widget::Operation> = Box::new(operation::focusable::focus_next());
|
||||||
|
loop {
|
||||||
|
view.as_widget_mut()
|
||||||
|
.operate(&mut tree, Layout::new(&node), &renderer, &mut tab);
|
||||||
|
match tab.finish() {
|
||||||
|
operation::Outcome::Chain(next) => tab = next,
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let event = Event::Keyboard(KeyEvent::KeyPressed {
|
||||||
|
key: Key::Named(key::Named::Enter),
|
||||||
|
modified_key: Key::Named(key::Named::Enter),
|
||||||
|
physical_key: key::Physical::Code(key::Code::Enter),
|
||||||
|
location: Location::Standard,
|
||||||
|
modifiers: Modifiers::empty(),
|
||||||
|
text: None,
|
||||||
|
repeat: false,
|
||||||
|
});
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
view.as_widget_mut().update(
|
||||||
|
&mut tree,
|
||||||
|
&event,
|
||||||
|
Layout::new(&node),
|
||||||
|
mouse::Cursor::Unavailable,
|
||||||
|
&renderer,
|
||||||
|
&mut clipboard::Null,
|
||||||
|
&mut Shell::new(&mut messages),
|
||||||
|
&Rectangle::with_size(Size::new(width, 600.)),
|
||||||
|
);
|
||||||
|
let expected = match status {
|
||||||
|
Some(Status::Running) => PomodoroAction::Pause,
|
||||||
|
Some(Status::Paused | Status::Ready) => PomodoroAction::Resume,
|
||||||
|
_ => PomodoroAction::Start,
|
||||||
|
};
|
||||||
|
assert!(messages.iter().any(|message|matches!(message,cosmic::Action::App(Message::Pomodoro(action,session)) if *action==expected && *session==status.map(|_|id.clone()))),"{status:?}: {messages:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paused_pomodoro_never_requests_countdown_frames() {
|
||||||
|
let mut app = shell();
|
||||||
|
let bar = app.outputs[0].bar;
|
||||||
|
seed_activities(&mut app, 1);
|
||||||
|
let now = ferese_ipc::activities::now_ms();
|
||||||
|
let entry = &mut std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0];
|
||||||
|
entry.data.template = ferese_activities::Template::Timer;
|
||||||
|
entry.data.value = ferese_activities::Value::Countdown {
|
||||||
|
remaining_ms: 50_000,
|
||||||
|
sampled_at: now,
|
||||||
|
};
|
||||||
|
drop(app.sync_hub_activity());
|
||||||
|
assert!(app.activity_tick_subscription().units() > 0);
|
||||||
|
std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0]
|
||||||
|
.data
|
||||||
|
.value = ferese_activities::Value::PausedCountdown { remaining_ms: 50_000 };
|
||||||
|
drop(app.sync_hub_activity());
|
||||||
|
assert_eq!(app.activity_tick_subscription().units(), 0);
|
||||||
|
assert!(app.activity_available(bar));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activity_target_must_still_exist_and_activities_has_no_duplicate_shortcut() {
|
fn activity_target_must_still_exist_and_activities_has_no_duplicate_shortcut() {
|
||||||
let mut app = shell();
|
let mut app = shell();
|
||||||
|
|||||||
Reference in new issue
Block a user