Refine Pomodoro popup styling and controls

This commit is contained in:
Samuel Onoja committed 2026-10-20 17:57:07 +01:00
1 parent 97bbf1b866
commit 4661f595f8
3 files changed
+307 -98

No files matched your search

+28 -62
View File
@@ -1,5 +1,6 @@
//! Shared Activities item and Morph presentation. Publishers provide data only. //! Shared Activities item and Morph presentation. Publishers provide data only.
mod compact; mod compact;
mod pomodoro;
mod ring; mod ring;
pub(crate) mod subscription; pub(crate) mod subscription;
use crate::{FereseShell, Message, status_ui}; use crate::{FereseShell, Message, status_ui};
@@ -949,60 +950,6 @@ 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 = &current.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("");
@@ -1021,9 +968,28 @@ 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() {
rows.push(self.render.text("No active tasks").size(13).into()); rows.push(
container(
self.render
.text("No active tasks")
.size(12)
.class(theme::Text::Color(palette.muted.scale_alpha(opacity))),
)
.padding([8, 10])
.into(),
);
} }
for activity in entries { for activity in entries {
// The timer card already represents this activity and owns its focus ID.
if self
.activities
.snapshot
.pomodoro
.as_ref()
.is_some_and(|timer| timer.id == activity.id)
{
continue;
}
let mut text = column![ let mut text = column![
self.render self.render
.text(activity.data.title.clone()) .text(activity.data.title.clone())
@@ -1129,12 +1095,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![ let mut body = column![self.pomodoro_controls(id, opacity)]
self.pomodoro_controls(opacity), .spacing(8)
column::with_children(rows).spacing(4).width(Length::Fill) .width(Length::Fill);
] if !rows.is_empty() {
.spacing(8) body = body.push(column::with_children(rows).spacing(4).width(Length::Fill));
.width(Length::Fill) }
.into() body.into()
} }
} }
@@ -0,0 +1,185 @@
//! Pomodoro uses the same type, material, and animated controls as status popups.
use crate::{FereseShell, Message, color_with_opacity, motion};
use cosmic::iced::{Alignment, Length, window};
use cosmic::widget::{button, column, row, tooltip};
use cosmic::{Element, theme};
use ferese_activities::pomodoro::{Phase, Status};
use ferese_ipc::activities::{PomodoroAction as Action, now_ms};
use ferese_theme::{icons, menus};
impl FereseShell {
pub(super) fn pomodoro_controls(&self, bar: window::Id, opacity: f32) -> Element<'_, cosmic::Action<Message>> {
let palette = self.config.theme;
let primary = color_with_opacity(palette.text_primary, opacity);
let muted = color_with_opacity(palette.text_muted, opacity);
let accent = color_with_opacity(palette.accent, opacity);
let current = self.activities.snapshot.pomodoro.as_ref();
let session = current.map(|current| current.id.clone());
let settings = current.map_or(&self.config.activities.pomodoro, |current| &current.timer.settings);
let (phase, seconds, description, label, action, symbol) = match current {
None => (
"Focus timer",
u64::from(settings.work_minutes) * 60,
format!(
"{} interval{} · {} min breaks",
settings.intervals,
if settings.intervals == 1 { "" } else { "s" },
settings.short_break_minutes
),
"Start Pomodoro",
Action::Start,
icons::MEDIA_PLAY,
),
Some(current) => {
let timer = &current.timer;
let phase = match timer.phase {
Phase::Work => "Focus",
Phase::ShortBreak => "Short break",
Phase::LongBreak => "Long break",
};
let description = if timer.status == Status::Finished {
format!(
"{} focus interval{} complete",
timer.completed_work,
if timer.completed_work == 1 { "" } else { "s" }
)
} else {
let interval = if timer.phase == Phase::Work {
format!(
"Interval {} of {}",
timer.completed_work.saturating_add(1).min(settings.intervals),
settings.intervals
)
} else {
format!("{} of {} complete", timer.completed_work, settings.intervals)
};
format!(
"{}{interval}",
match timer.status {
Status::Paused => "Paused · ",
Status::Ready => "Ready · ",
_ => "",
},
)
};
let (label, action, symbol) = match timer.status {
Status::Running => ("Pause", Action::Pause, icons::MEDIA_PAUSE),
Status::Paused => ("Resume", Action::Resume, icons::MEDIA_PLAY),
Status::Ready => (
if timer.phase == Phase::Work {
"Start focus"
} else {
"Start break"
},
Action::Resume,
icons::MEDIA_PLAY,
),
Status::Finished => ("Start again", Action::Start, icons::MEDIA_PLAY),
};
(
if timer.status == Status::Finished {
"Complete"
} else {
phase
},
timer.remaining(now_ms()).div_ceil(1_000),
description,
label,
action,
symbol,
)
}
};
let message = |action| cosmic::Action::App(Message::Pomodoro(action, session.clone()));
let badge = menus::badge(
icons::tinted(
if current.is_some_and(|current| current.timer.status == Status::Finished) {
icons::CHECK
} else {
icons::ACTIVITY_TIMER
},
20,
accent,
)
.into(),
crate::color(palette.accent),
opacity,
self.render.radius(18.),
);
let heading = row![
badge,
column![
self.render.text("Pomodoro").size(13).class(theme::Text::Color(primary)),
self.render.text(phase).size(11).class(theme::Text::Color(muted)),
]
.spacing(3)
.width(Length::Fill),
self.render
.text(format!("{:02}:{:02}", seconds / 60, seconds % 60))
.size(26)
.width(96)
.align_x(cosmic::iced::alignment::Horizontal::Right)
.wrapping(cosmic::iced::widget::text::Wrapping::None)
.class(theme::Text::Color(primary)),
]
.spacing(8)
.align_y(Alignment::Center);
let primary_button = button::custom(
row![icons::tinted(symbol, 16, accent), self.render.text(label).size(13)]
.spacing(6)
.align_y(Alignment::Center),
)
.id(session.as_ref().map_or_else(
|| cosmic::widget::Id::new(format!("pomodoro-start-{bar:?}")),
|id| super::activity_widget_id(bar, id),
))
.name(label)
.padding([7, 10])
.width(Length::Fill)
.on_press(message(action));
let mut controls = row![motion::button(self.render, primary_button, accent, true, opacity)]
.spacing(4)
.align_y(Alignment::Center);
let secondary = |label: &'static str, symbol, action| {
let control = button::custom(icons::tinted(symbol, 16, primary))
.name(label)
.padding(8)
.on_press(message(action));
tooltip(
motion::button(self.render, control, primary, false, opacity),
self.render.text(label).size(12).class(theme::Text::Color(primary)),
cosmic::widget::tooltip::Position::Top,
)
};
if let Some(current) = current {
if matches!(current.timer.status, Status::Running | Status::Paused) {
controls = controls.push(secondary("Skip", icons::MEDIA_NEXT, Action::Next));
}
if current.timer.status != Status::Finished {
controls = controls.push(secondary("Reset", icons::RESTART, Action::Reset));
}
controls = controls.push(secondary(
if current.timer.status == Status::Finished {
"Dismiss"
} else {
"Stop"
},
icons::RECORD_STOP,
Action::Stop,
));
}
menus::section(
column![
heading,
self.render.text(description).size(11).class(theme::Text::Color(muted)),
controls,
]
.spacing(10)
.width(Length::Fill)
.into(),
crate::color(palette.text_primary),
opacity,
self.render.radius(22.),
)
}
}
+94 -36
View File
@@ -1512,12 +1512,18 @@ mod tests {
self.0.push((text.into(), bounds)); self.0.push((text.into(), bounds));
} }
} }
let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new(
cosmic::iced::Font::default(), cosmic::iced::Font::default(),
cosmic::iced::Pixels(14.), cosmic::iced::Pixels(14.),
Some("tiny-skia"), Some("tiny-skia"),
)) ))
.unwrap(); .unwrap();
let mut app = shell();
app.config.font_family = Some("JetBrains Mono".into());
app.render = crate::rendering::Rendering::new(&app.config);
let bar = app.outputs[0].bar;
seed_activities(&mut app, 1);
let id = app.activities.snapshot.activities[0].id.clone();
for status in [ for status in [
None, None,
Some(Status::Running), Some(Status::Running),
@@ -1525,20 +1531,23 @@ mod tests {
Some(Status::Ready), Some(Status::Ready),
Some(Status::Finished), 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 { if let Some(status) = status {
let mut timer = Session::start(Default::default(), ferese_ipc::activities::now_ms()).unwrap(); let mut timer = Session::start(Default::default(), ferese_ipc::activities::now_ms()).unwrap();
timer.status = status; timer.status = status;
if status != Status::Running { if status != Status::Running {
timer.deadline = None; timer.deadline = None;
} }
if status == Status::Finished {
timer.remaining_ms = 0;
timer.completed_work = timer.settings.intervals;
}
std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0]
.data
.title = "Pomodoro · Focus".into();
std::sync::Arc::make_mut(&mut app.activities.snapshot).pomodoro = std::sync::Arc::make_mut(&mut app.activities.snapshot).pomodoro =
Some(ferese_ipc::activities::Pomodoro { id: id.clone(), timer }); Some(ferese_ipc::activities::Pomodoro { id: id.clone(), timer });
} }
for width in [260., 312.] { for width in [240., 260., 312.] {
let mut view = app.activity_list(bar, 1.); let mut view = app.activity_list(bar, 1.);
let mut tree = widget::Tree::new(&view); let mut tree = widget::Tree::new(&view);
let node = view.as_widget_mut().layout( let node = view.as_widget_mut().layout(
@@ -1555,41 +1564,90 @@ mod tests {
"{status:?} {width} {text}: {bounds:?}" "{status:?} {width} {text}: {bounds:?}"
); );
} }
let mut tab: Box<dyn widget::Operation> = Box::new(operation::focusable::focus_next()); assert!(
loop { !labels.0.iter().any(|(text, _)| text == "Pomodoro · Focus"),
view.as_widget_mut() "the timer must not appear twice"
.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 { if let Some(directory) = std::env::var_os("FERESE_POMODORO_CAPTURE_DIR") {
use cosmic::iced::advanced::renderer::Renderer as _;
let directory = std::path::PathBuf::from(directory);
std::fs::create_dir_all(&directory).unwrap();
let viewport = Rectangle::with_size(node.size());
renderer.reset(viewport);
view.as_widget().draw(
&tree,
&mut renderer,
&app.config.theme.palette().native_theme(),
&cosmic::iced::advanced::renderer::Style {
text_color: crate::color(app.config.theme.text_primary),
icon_color: crate::color(app.config.theme.text_primary),
scale_factor: 1.5,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&viewport,
);
let physical = Size::new(
(node.size().width * 1.5).ceil() as u32,
(node.size().height * 1.5).ceil() as u32,
);
let pixels = renderer.screenshot(physical, 1.5, crate::color(app.config.theme.surface_popover));
image_codec::save_buffer(
directory.join(format!("{status:?}-{width}.png")),
&pixels,
physical.width,
physical.height,
image_codec::ColorType::Rgba8,
)
.unwrap();
}
let primary = match status {
Some(Status::Running) => PomodoroAction::Pause, Some(Status::Running) => PomodoroAction::Pause,
Some(Status::Paused | Status::Ready) => PomodoroAction::Resume, Some(Status::Paused | Status::Ready) => PomodoroAction::Resume,
_ => PomodoroAction::Start, _ => 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:?}"); let mut expected = vec![primary];
if matches!(status, Some(Status::Running | Status::Paused)) {
expected.push(PomodoroAction::Next);
}
if status.is_some_and(|status| status != Status::Finished) {
expected.push(PomodoroAction::Reset);
}
if status.is_some() {
expected.push(PomodoroAction::Stop);
}
for expected in expected {
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.)),
);
assert!(messages.iter().any(|message|matches!(message,cosmic::Action::App(Message::Pomodoro(action,session)) if *action==expected && *session==status.map(|_|id.clone()))),"{status:?} {expected:?}: {messages:?}");
}
} }
} }
} }