From f7d11458f922fce53af15baeda7d1dff9beda0c5 Mon Sep 17 00:00:00 2001 From: smiley Date: Fri, 10 Jul 2026 12:44:09 +0300 Subject: [PATCH] Add duration menu for caffeine sessions (15m/30m/1h/indefinite) Replaces the plain on/off toggle with a popup menu offering 15 minutes, 30 minutes, 1 hour, and indefinite options. Timed sessions auto-release the inhibit lock via a 1s subscription tick that checks against a stored deadline; picking a new duration while active resets the timer instead of re-acquiring the lock. Closes #6 --- src/window.rs | 206 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 186 insertions(+), 20 deletions(-) diff --git a/src/window.rs b/src/window.rs index a4d8afd..fff61bb 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,7 +1,11 @@ use std::os::fd::OwnedFd; +use std::time::{Duration, Instant}; use cosmic::app::Core; -use cosmic::iced::Task; +use cosmic::iced::window::Id; +use cosmic::iced::{Rectangle, Subscription, Task}; +use cosmic::surface::action::{app_popup, destroy_popup}; +use cosmic::widget::{list_column, text}; use cosmic::Element; use zbus::blocking::Connection; use zbus::zvariant::OwnedFd as ZbusFd; @@ -10,38 +14,91 @@ const ID: &str = "com.github.codevardhan.caffeine-applet"; const ON: &str = "com.github.codevardhan.caffeine-applet.On"; const OFF: &str = "com.github.codevardhan.caffeine-applet.Off"; +/// How long a caffeine session should stay active for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CaffeineDuration { + Minutes15, + Minutes30, + Hour1, + Indefinite, +} + +impl CaffeineDuration { + const ALL: [CaffeineDuration; 4] = [ + Self::Minutes15, + Self::Minutes30, + Self::Hour1, + Self::Indefinite, + ]; + + fn label(self) -> &'static str { + match self { + Self::Minutes15 => "15 minutes", + Self::Minutes30 => "30 minutes", + Self::Hour1 => "1 hour", + Self::Indefinite => "Indefinitely", + } + } + + /// `None` means the session never expires on its own. + fn duration(self) -> Option { + match self { + Self::Minutes15 => Some(Duration::from_secs(15 * 60)), + Self::Minutes30 => Some(Duration::from_secs(30 * 60)), + Self::Hour1 => Some(Duration::from_secs(60 * 60)), + Self::Indefinite => None, + } + } +} + pub struct CaffeineApplet { core: Core, + popup: Option, inhibit_fd: Option, + active_choice: Option, + deadline: Option, } impl Default for CaffeineApplet { fn default() -> Self { Self { core: Core::default(), + popup: None, inhibit_fd: None, + active_choice: None, + deadline: None, } } } #[derive(Clone, Debug)] pub enum Message { - ToggleCaffeine, + PopupClosed(Id), + Enable(CaffeineDuration), + Disable, + Tick, + Surface(cosmic::surface::Action), } /// Ask logind for an idle+sleep inhibit lock. /// Returns an OwnedFd — the inhibit stays active as long as this fd is open. fn acquire_inhibit() -> Result> { let conn = Connection::system()?; - let reply: ZbusFd = conn.call_method( - Some("org.freedesktop.login1"), - "/org/freedesktop/login1", - Some("org.freedesktop.login1.Manager"), - "Inhibit", - &("idle:sleep", "Caffeine Applet", "Caffeine session active", "block"), - )? - .body() - .deserialize()?; + let reply: ZbusFd = conn + .call_method( + Some("org.freedesktop.login1"), + "/org/freedesktop/login1", + Some("org.freedesktop.login1.Manager"), + "Inhibit", + &( + "idle:sleep", + "Caffeine Applet", + "Caffeine session active", + "block", + ), + )? + .body() + .deserialize()?; Ok(reply.into()) } @@ -66,38 +123,147 @@ impl cosmic::Application for CaffeineApplet { ) -> (Self, cosmic::Task>) { let window = CaffeineApplet { core, - inhibit_fd: None, + ..Default::default() }; (window, Task::none()) } + fn on_close_requested(&self, id: Id) -> Option { + Some(Message::PopupClosed(id)) + } + fn update(&mut self, message: Self::Message) -> cosmic::Task> { match message { - Message::ToggleCaffeine => { - if self.inhibit_fd.is_some() { - // Drop the fd → releases the inhibit lock - self.inhibit_fd = None; - } else { + Message::PopupClosed(id) => { + if self.popup == Some(id) { + self.popup = None; + } + } + Message::Enable(choice) => { + if self.inhibit_fd.is_none() { match acquire_inhibit() { Ok(fd) => self.inhibit_fd = Some(fd), - Err(err) => eprintln!("Failed to acquire inhibit lock (is logind/elogind running?): {err}"), + Err(err) => { + eprintln!( + "Failed to acquire inhibit lock (is logind/elogind running?): {err}" + ); + return Task::none(); + } + } + } + self.active_choice = Some(choice); + self.deadline = choice.duration().map(|d| Instant::now() + d); + + if let Some(id) = self.popup.take() { + return cosmic::task::message(cosmic::Action::Cosmic( + cosmic::app::Action::Surface(destroy_popup(id)), + )); + } + } + Message::Disable => { + // Drop the fd → releases the inhibit lock + self.inhibit_fd = None; + self.active_choice = None; + self.deadline = None; + + if let Some(id) = self.popup.take() { + return cosmic::task::message(cosmic::Action::Cosmic( + cosmic::app::Action::Surface(destroy_popup(id)), + )); + } + } + Message::Tick => { + if let Some(deadline) = self.deadline { + if Instant::now() >= deadline { + self.inhibit_fd = None; + self.active_choice = None; + self.deadline = None; } } } + Message::Surface(a) => { + return cosmic::task::message(cosmic::Action::Cosmic( + cosmic::app::Action::Surface(a), + )); + } } Task::none() } + fn subscription(&self) -> Subscription { + if self.deadline.is_some() { + cosmic::iced::time::every(Duration::from_secs(1)).map(|_| Message::Tick) + } else { + Subscription::none() + } + } + fn view(&self) -> Element<'_, Self::Message> { let icon = if self.inhibit_fd.is_some() { ON } else { OFF }; + let have_popup = self.popup; + self.core .applet .icon_button(icon) - .on_press_down(Message::ToggleCaffeine) + .on_press_with_rectangle(move |offset, bounds| { + if let Some(id) = have_popup { + Message::Surface(destroy_popup(id)) + } else { + Message::Surface(app_popup::( + move |state: &mut CaffeineApplet| { + let new_id = Id::unique(); + state.popup = Some(new_id); + let mut popup_settings = state.core.applet.get_popup_settings( + state.core.main_window_id().unwrap(), + new_id, + None, + None, + None, + ); + + popup_settings.positioner.anchor_rect = Rectangle { + x: (bounds.x - offset.x) as i32, + y: (bounds.y - offset.y) as i32, + width: bounds.width as i32, + height: bounds.height as i32, + }; + + popup_settings + }, + Some(Box::new(move |state: &CaffeineApplet| { + Element::from(state.core.applet.popup_container(state.menu_content())) + .map(cosmic::Action::App) + })), + )) + } + }) .into() } fn style(&self) -> Option { Some(cosmic::applet::style()) } -} \ No newline at end of file +} + +impl CaffeineApplet { + fn menu_content(&self) -> Element<'_, Message> { + let mut list = list_column().padding(5).spacing(4); + + for choice in CaffeineDuration::ALL { + let label = if self.active_choice == Some(choice) { + format!("✓ {}", choice.label()) + } else { + choice.label().to_string() + }; + list = list + .add(cosmic::applet::menu_button(text(label)).on_press(Message::Enable(choice))); + } + + if self.inhibit_fd.is_some() { + list = + list.add(cosmic::applet::menu_button(text("Turn off")).on_press(Message::Disable)); + } + + list.into() + } +}