Refine Pomodoro popup styling and controls
This commit is contained in:
1 parent
97bbf1b866
commit
4661f595f8
3 files changed
+307
-98
No files matched your search
@@ -1,5 +1,6 @@
|
||||
//! Shared Activities item and Morph presentation. Publishers provide data only.
|
||||
mod compact;
|
||||
mod pomodoro;
|
||||
mod ring;
|
||||
pub(crate) mod subscription;
|
||||
use crate::{FereseShell, Message, status_ui};
|
||||
@@ -949,60 +950,6 @@ 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("");
|
||||
@@ -1021,9 +968,28 @@ impl FereseShell {
|
||||
});
|
||||
let mut rows = Vec::with_capacity(entries.len());
|
||||
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 {
|
||||
// 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![
|
||||
self.render
|
||||
.text(activity.data.title.clone())
|
||||
@@ -1129,12 +1095,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![
|
||||
self.pomodoro_controls(opacity),
|
||||
column::with_children(rows).spacing(4).width(Length::Fill)
|
||||
]
|
||||
.spacing(8)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
let mut body = column![self.pomodoro_controls(id, opacity)]
|
||||
.spacing(8)
|
||||
.width(Length::Fill);
|
||||
if !rows.is_empty() {
|
||||
body = body.push(column::with_children(rows).spacing(4).width(Length::Fill));
|
||||
}
|
||||
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| ¤t.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 = ¤t.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.),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1512,12 +1512,18 @@ mod tests {
|
||||
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::Pixels(14.),
|
||||
Some("tiny-skia"),
|
||||
))
|
||||
.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 [
|
||||
None,
|
||||
Some(Status::Running),
|
||||
@@ -1525,20 +1531,23 @@ mod tests {
|
||||
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;
|
||||
}
|
||||
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 =
|
||||
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 tree = widget::Tree::new(&view);
|
||||
let node = view.as_widget_mut().layout(
|
||||
@@ -1555,41 +1564,90 @@ mod tests {
|
||||
"{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.)),
|
||||
assert!(
|
||||
!labels.0.iter().any(|(text, _)| text == "Pomodoro · Focus"),
|
||||
"the timer must not appear twice"
|
||||
);
|
||||
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::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:?}");
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user