diff --git a/shell/ferese-shell/src/activities.rs b/shell/ferese-shell/src/activities.rs index 16615b6..aa6561c 100644 --- a/shell/ferese-shell/src/activities.rs +++ b/shell/ferese-shell/src/activities.rs @@ -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> { - 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> { 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() } } diff --git a/shell/ferese-shell/src/activities/pomodoro.rs b/shell/ferese-shell/src/activities/pomodoro.rs new file mode 100644 index 0000000..3ccca2c --- /dev/null +++ b/shell/ferese-shell/src/activities/pomodoro.rs @@ -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> { + 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.), + ) + } +} diff --git a/shell/ferese-shell/src/panel_hub.rs b/shell/ferese-shell/src/panel_hub.rs index e3b4659..4c0b067 100644 --- a/shell/ferese-shell/src/panel_hub.rs +++ b/shell/ferese-shell/src/panel_hub.rs @@ -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 = 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 = 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:?}"); + } } } }