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
@@ -145,7 +145,7 @@ fn label(activity: &Activity) -> String {
|
||||
match &activity.data.value {
|
||||
Value::Text(text) => text.clone(),
|
||||
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);
|
||||
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||
}
|
||||
@@ -449,7 +449,9 @@ impl FereseShell {
|
||||
};
|
||||
let ring = ring::view(
|
||||
progress,
|
||||
animate && activity.state == State::Running,
|
||||
animate
|
||||
&& activity.state == State::Running
|
||||
&& !matches!(activity.data.value, Value::PausedCountdown { .. }),
|
||||
reduced,
|
||||
ring_color,
|
||||
);
|
||||
@@ -947,6 +949,60 @@ impl FereseShell {
|
||||
.width(if intrinsic { Length::Fixed(budget) } else { Length::Fill })
|
||||
.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>> {
|
||||
let palette = self.config.theme.palette();
|
||||
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());
|
||||
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 {
|
||||
let mut text = column![
|
||||
@@ -1073,6 +1129,12 @@ impl FereseShell {
|
||||
// Keep unnamed row roots: the toolkit's column does not move directly
|
||||
// named child trees when their order changes. Inner button IDs restore
|
||||
// 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,
|
||||
ActivitiesUpdated(std::sync::Arc<ferese_ipc::activities::Snapshot>),
|
||||
ActivityTick,
|
||||
Pomodoro(ferese_ipc::activities::PomodoroAction, Option<ferese_activities::Id>),
|
||||
PomodoroResult(Result<(), String>),
|
||||
ActivityHover(window::Id, bool),
|
||||
ActivityActivate(window::Id, ferese_activities::Id),
|
||||
ActivityNavigation(
|
||||
@@ -494,6 +496,27 @@ impl cosmic::Application for FereseShell {
|
||||
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) => {
|
||||
self.activity_hover(id, entered);
|
||||
self.sync_hub_activity()
|
||||
|
||||
@@ -1496,6 +1496,126 @@ mod tests {
|
||||
..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]
|
||||
fn activity_target_must_still_exist_and_activities_has_no_duplicate_shortcut() {
|
||||
let mut app = shell();
|
||||
|
||||
Reference in new issue
Block a user