From 175bdd46d5368734b84bca93bf21a08cd29b52c1 Mon Sep 17 00:00:00 2001 From: kit-foxboy Date: Sat, 20 Dec 2025 14:26:46 -0700 Subject: [PATCH 1/6] Fixed panel/flatpak launch issue --- flatpak/io.vulpapps.Chronomancer.json | 61 ++++++++++++++++++++++ justfile | 9 ++++ resources/io.vulpapps.Chronomancer.desktop | 4 +- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 flatpak/io.vulpapps.Chronomancer.json diff --git a/flatpak/io.vulpapps.Chronomancer.json b/flatpak/io.vulpapps.Chronomancer.json new file mode 100644 index 0000000..ec068ae --- /dev/null +++ b/flatpak/io.vulpapps.Chronomancer.json @@ -0,0 +1,61 @@ +{ + "id": "io.vulpapps.Chronomancer", + "runtime": "org.freedesktop.Platform", + "runtime-version": "24.08", + "sdk": "org.freedesktop.Sdk", + "sdk-extensions": [ + "org.freedesktop.Sdk.Extension.rust-stable" + ], + "command": "chronomancer", + "finish-args": [ + "--socket=wayland", + "--socket=fallback-x11", + "--device=dri", + "--share=ipc", + "--talk-name=org.freedesktop.Notifications", + "--system-talk-name=org.freedesktop.login1", + "--filesystem=xdg-config/cosmic:rw", + "--talk-name=com.system76.CosmicSettingsDaemon", + "--persist=.local/share/io.vulpapps.Chronomancer", + "--persist=.config/io.vulpapps.Chronomancer" + ], + "cleanup": [ + "/include", + "/lib/pkgconfig", + "/share/man", + "/share/doc", + "*.a", + "*.la" + ], + "modules": [ + { + "name": "chronomancer", + "buildsystem": "simple", + "build-options": { + "append-path": "/usr/lib/sdk/rust-stable/bin", + "env": { + "CARGO_HOME": "/run/build/chronomancer/cargo", + "CARGO_NET_OFFLINE": "true" + } + }, + "build-commands": [ + "cargo --offline fetch --manifest-path Cargo.toml --verbose", + "cargo --offline build --release --verbose", + "install -Dm0755 ./target/release/chronomancer /app/bin/chronomancer", + "install -Dm0644 ./resources/io.vulpapps.Chronomancer.desktop /app/share/applications/io.vulpapps.Chronomancer.desktop", + "install -Dm0644 ./resources/io.vulpapps.Chronomancer.metainfo.xml /app/share/metainfo/io.vulpapps.Chronomancer.metainfo.xml", + "install -Dm0644 ./resources/icons/hicolor/scalable/apps/hourglass.svg /app/share/icons/hicolor/scalable/apps/io.vulpapps.Chronomancer.svg", + "for icon in ./resources/icons/hicolor/scalable/apps/*.svg; do name=\"$(basename \"$icon\")\"; if [ \"$name\" != \"hourglass.svg\" ]; then install -Dm0644 \"$icon\" \"/app/share/icons/hicolor/scalable/apps/$name\"; fi; done", + "install -Dm0644 LICENSE /app/share/licenses/io.vulpapps.Chronomancer/LICENSE" + ], + "sources": [ + { + "type": "git", + "url": "https://github.com/kit-foxboy/chronomancer.git", + "commit": "9b92cc8c0115d66033c2baf2eec48d08a0f350e5" + }, + "cargo-sources.json" + ] + } + ] +} diff --git a/justfile b/justfile index 504cabc..4b4d684 100644 --- a/justfile +++ b/justfile @@ -31,6 +31,15 @@ install: install -Dm644 resources/icons/hicolor/scalable/apps/{{ appid }}-eye.svg /usr/share/icons/hicolor/scalable/apps/{{ appid }}-eye.svg install -Dm644 resources/icons/hicolor/scalable/apps/{{ appid }}-stay-awake.svg /usr/share/icons/hicolor/scalable/apps/{{ appid }}-stay-awake.svg +# Uninstall from system (requires root) +uninstall: + rm -f /usr/bin/chronomancer + rm -f /usr/share/applications/{{ appid }}.desktop + rm -f /usr/share/metainfo/{{ appid }}.metainfo.xml + rm -f /usr/share/icons/hicolor/scalable/apps/{{ appid }}.svg + rm -f /usr/share/icons/hicolor/scalable/apps/{{ appid }}-eye.svg + rm -f /usr/share/icons/hicolor/scalable/apps/{{ appid }}-stay-awake.svg + # Generate cargo-sources.json for Flatpak flatpak-sources: flatpak-cargo-generator Cargo.lock -o flatpak/cargo-sources.json diff --git a/resources/io.vulpapps.Chronomancer.desktop b/resources/io.vulpapps.Chronomancer.desktop index 8fa19cf..3510b15 100644 --- a/resources/io.vulpapps.Chronomancer.desktop +++ b/resources/io.vulpapps.Chronomancer.desktop @@ -3,12 +3,12 @@ Name=Chronomancer Comment=An applet for creating and managing system timers Type=Application Icon=io.vulpapps.Chronomancer -Exec=chronomancer %F +Exec=chronomancer Terminal=false StartupNotify=true Categories=Utility; Keywords=Timer;Reminder;Countdown;Systemd;Sleep;COSMIC; MimeType= -NoDisplay=false +NoDisplay=true X-CosmicApplet=true X-CosmicHoverPopup=Auto From 31f934475d37cda356cf29964d5514aa59b3878c Mon Sep 17 00:00:00 2001 From: kit-foxboy Date: Sun, 21 Dec 2025 13:45:06 -0700 Subject: [PATCH 2/6] reorganized messages and made view functions message agnostic --- .github/architectural-idioms.md | 308 ++++++++++++++++----- src/app.rs | 66 +++-- src/{utils/messages.rs => app_messages.rs} | 39 +-- src/components/icon_button.rs | 31 ++- src/components/mod.rs | 11 - src/components/power_form.rs | 163 +++++------ src/components/radio_components.rs | 115 +++++--- src/main.rs | 1 + src/pages/mod.rs | 9 +- src/pages/power_controls.rs | 171 +++++++----- src/utils/mod.rs | 1 - 11 files changed, 574 insertions(+), 341 deletions(-) rename src/{utils/messages.rs => app_messages.rs} (54%) diff --git a/.github/architectural-idioms.md b/.github/architectural-idioms.md index 9d7e656..1cc3c63 100644 --- a/.github/architectural-idioms.md +++ b/.github/architectural-idioms.md @@ -1,123 +1,285 @@ # Architectural Idioms for Chronomancer -This document outlines key architectural patterns and idioms used throughout the Chronomancer codebase. These patterns maintain consistency, reduce boilerplate, and make the message flow predictable. This is made up on the fly as we identify recurring patterns. Cosmic doesn't have strong opinions on architecture, so we define our own idioms here. Some of these patterns are well defined in web frameworks (my background), and others are more specific to Rust and Cosmic's MVU needs. I disliked how global application messages were handled in previous projects as a large app could have dozens of messages, so I devised a cleaner pattern here... in theory... +This document outlines key architectural patterns and idioms used throughout the Chronomancer codebase. These patterns maintain consistency, reduce boilerplate, and make the message flow predictable. This is made up on the fly as we identify recurring patterns. Cosmic doesn't have strong opinions on architecture, so we define our own idioms here. Some of these patterns are well defined in web frameworks (my background), and others are more specific to Rust and Cosmic's MVU needs. --- -## Component-to-Page Message Flow +## Module-Based Message Pattern (libcosmic Style) ### The Problem -In a layered MVU (Model-View-Update) architecture with Components → Pages → App, we need a clean way for components to emit page-level messages without tightly coupling them to the app's message types or dealing with complex type conversions. +In a layered MVU (Model-View-Update) architecture with Components → Pages → App, we need a clean way to organize messages and handle message flow without creating a single massive message enum or tightly coupling modules. -### The Solution: Optional Return with Recursive Update +### The Solution: Self-Contained Module Messages -Components return `Option` instead of `Task>`. Pages check the return value and recursively call their own `update` method if a message was emitted. +Following the libcosmic pattern (see [libcosmic book - Modules](https://pop-os.github.io/libcosmic-book/modules.html)), each page and component module defines its own `Message` enum. The app-level message enum uses enum composition to wrap page-specific messages, and `From` implementations handle conversions. ### Pattern Structure -#### Component Trait +#### Page Module Structure + +Each page module is self-contained with its own types: + ```rust -pub trait Component { - fn view(&self) -> Element<'_, ComponentMessage>; - fn update(&mut self, message: ComponentMessage) -> Option; +// src/pages/power_controls.rs +pub mod power_controls { + #[derive(Debug, Clone)] + pub enum Message { + // Component interactions + RadioOptionSelected(usize), + FormTextChanged(String), + FormTimeUnitChanged(TimeUnit), + FormSubmitPressed, + + // Actions to bubble up to app + ToggleStayAwake, + SetSuspendTime(i32), + SetShutdownTime(i32), + SetLogoutTime(i32), + } + + pub struct Page { + power_buttons: RadioComponents, + power_form: PowerForm, + } + + impl Page { + pub fn view(&self) -> Element<'_, Message> { + // Components accept message constructors + self.power_form.view( + Message::FormTextChanged, + Message::FormTimeUnitChanged, + Message::FormSubmitPressed, + ) + } + + pub fn update(&mut self, message: Message) -> Task> { + match message { + Message::FormTextChanged(text) => { + self.power_form.handle_text_input(text); + Task::none() + } + Message::ToggleStayAwake => { + // These bubble up to app level, so just return none + // App will intercept and handle them + Task::none() + } + // ... handle other messages + } + } + } } ``` -#### Component Implementation +#### Message-Agnostic Components + +Components don't define their own message types. Instead, they accept message constructors as function parameters. This is the best improvement for reusability and I finally get the point and utility of the where clause pattern. It's generics with extra type safety because of lifetimes: + ```rust -impl Component for PowerForm { - fn update(&mut self, message: ComponentMessage) -> Option { - match message { - ComponentMessage::TextChanged(new_text) => { - self.input_value = new_text; - None // Local state change only - } - ComponentMessage::SubmitPressed => { - if self.validate() { - Some(PageMessage::PowerFormSubmitted(self.get_value())) - } else { - None - } - } - _ => None, +pub struct PowerForm { + pub input_value: String, + pub time_unit: TimeUnit, + // ... other fields +} + +impl PowerForm { + /// View method accepts message constructors + pub fn view( + &self, + on_text_input: impl Fn(String) -> Message + 'static, + on_time_unit: impl Fn(TimeUnit) -> Message + 'static, + on_submit: Message, + ) -> Element<'_, Message> + where + Message: Clone + 'static, + { + column![ + TextInput::new(&self.placeholder_text, &self.input_value) + .on_input(on_text_input) + .on_submit(move |_| on_submit.clone()), + ComboBox::new( + &self.time_unit_options, + &fl!("unit-label"), + Some(&self.time_unit), + on_time_unit, + ), + button::text(fl!("set-button-label")) + .on_press(on_submit) + ] + .into() + } + + /// Public methods for state manipulation (no messages) + pub fn handle_text_input(&mut self, new_text: String) { + if let Ok(value) = new_text.parse::() { + self.input_value = value.to_string(); } } } ``` -#### Page Update Handler +#### App-Level Message Composition + +The app uses enum composition to wrap page messages: + ```rust -impl Page for PowerControls { - fn update(&mut self, message: PageMessage) -> Task> { +// src/app_messages.rs +#[derive(Debug, Clone)] +pub enum AppMessage { + TogglePopup, + UpdateConfig(Config), + Tick, + + // Page messages wrapped + PowerControlsMessage(power_controls::Message), + + // Service-level messages + DatabaseMessage(DatabaseMessage), + TimerMessage(TimerMessage), + PowerMessage(PowerMessage), +} + +// Conversion for convenient .map() usage +impl From for AppMessage { + fn from(msg: power_controls::Message) -> Self { + AppMessage::PowerControlsMessage(msg) + } +} +``` + +#### App-Level Message Handling + +The app intercepts page messages that need app-level handling: + +```rust +// src/app.rs +impl Application for AppModel { + fn update(&mut self, message: AppMessage) -> Task> { match message { - PageMessage::ComponentMessage(msg) => { - // Check if component emits a page message - let page_message = self.power_form.update(msg); - if let Some(page_msg) = page_message { - // Recursively handle the page message - self.update(page_msg) - } else { - Task::done(Action::None) - } + AppMessage::PowerControlsMessage(msg) => { + self.handle_power_controls_message(msg) } - PageMessage::PowerFormSubmitted(value) => { - // Convert to AppMessage - Task::done(Action::App(AppMessage::PowerMessage( - PowerMessage::SetValue(value) - ))) + // ... other messages + } + } +} + +impl AppModel { + fn handle_power_controls_message( + &mut self, + msg: power_controls::Message, + ) -> Task> { + // Intercept messages that need app-level handling + match msg { + power_controls::Message::ToggleStayAwake => { + self.handle_power_message(PowerMessage::ToggleStayAwake) } + power_controls::Message::SetSuspendTime(time) => { + self.handle_power_message(PowerMessage::SetSuspendTime(time)) + } + // ... other intercepted messages + + // Pass through to page for local state updates + _ => self.power_controls.update(msg).map(|action| match action { + Action::App(page_msg) => Action::App(AppMessage::PowerControlsMessage(page_msg)), + Action::None => Action::None, + Action::Cosmic(cosmic_action) => Action::Cosmic(cosmic_action), + Action::DbusActivation(dbus_action) => Action::DbusActivation(dbus_action), + }), } } } ``` +#### View Mapping + +Pages map their view to app-level messages: + +```rust +impl Application for AppModel { + fn view(&self) -> Element { + self.power_controls + .view() + .map(AppMessage::PowerControlsMessage) + } +} +``` + ### Benefits -1. **No Type Conversion Gymnastics**: No need for helper functions to convert `Action` to `Action` -2. **Components Stay Simple**: Components don't need to know about `Task` or `Action` types -3. **Clear Message Flow**: Easy to trace: Component → (optional) PageMessage → Page → AppMessage -4. **Self-Documenting**: The `Option` return type makes it obvious that a component *might* emit a page message -5. **Natural Recursion**: Pages naturally handle their own messages through the recursive `self.update()` call +1. **Module Encapsulation**: Each page/component is self-contained with its own message types +2. **No Global Message Bloat**: App-level messages only contain top-level routing, not every possible UI interaction. This was becoming an obvious problem even in a tiny app like this +3. **Clear Ownership**: Easy to see which module handles which messages +4. **Reusable Components**: Components are message-agnostic and work with any message type +5. **Type Safety**: Compiler ensures message types match at boundaries and lifetimes are respected +6. **Follows libcosmic Conventions**: Better aligns with recommended patterns from the framework + +### Component Design Guidelines + +**Message-Agnostic Components** (preferred for reusability): +- Accept message constructors as `view()` parameters +- Use trait bounds: `where Message: Clone + 'static` +- Provide public methods for state manipulation (e.g., `handle_text_input()`) +- Don't define their own message types + +**Example**: +```rust +pub fn view( + &self, + on_select: impl Fn(usize) -> Message + 'static, +) -> Element<'_, Message> +where + Message: Clone + 'static, +{ + button::text("Click me").on_press(on_select(self.index)) +} +``` ### When to Use This Pattern -- ✅ Component needs to signal the page that something significant happened (form submitted, selection changed, etc.) -- ✅ The component's action requires page-level context (e.g., which radio button is selected) -- ✅ The component should remain reusable across different pages +- ✅ Building pages with multiple components and interactions +- ✅ Need clear separation between page-level and app-level concerns +- ✅ Want reusable components across different pages/apps +- ✅ Following libcosmic's recommended architecture ### When NOT to Use This Pattern -- ❌ Component only changes its own internal state → return `None` -- ❌ Component needs to trigger async operations → consider a different architecture (or emit a PageMessage that the page converts to an async task) -- ❌ Message needs to go directly to the app without page involvement → this pattern adds an unnecessary layer - -### Alternative Patterns Considered +- ❌ Simple single-page app with minimal state → flat message structure is fine +- ❌ Component is page-specific and will never be reused → can use page messages directly +- ❌ Prototyping/experimenting → use whatever is fastest to write -1. **Components return `Task>`** - - ❌ Requires pages to map actions: `task.map(|a| a.map(|page_msg| AppMessage::from(page_msg)))` - - ❌ Components need to know about `Task` and `Action` types - - ❌ More verbose and harder to read +### Key Differences from Previous Pattern -2. **Components return `Task>`** - - ❌ Tightly couples components to app-level message types - - ❌ Makes components non-reusable across different apps or pages - - ❌ Breaks separation of concerns +**Old Pattern** (Component trait with `Option`): +- Components implemented a `Component` trait +- Components returned `Option` +- Pages handled component messages through recursive `update()` calls +- Required a global `ComponentMessage` and `PageMessage` enum -3. **Callback closures passed to components** - - ❌ Not idiomatic in cosmic/iced MVU architecture - - ❌ Harder to debug message flow - - ❌ Requires careful lifetime management +**New Pattern** (libcosmic module style): +- No `Component` trait needed +- Components are message-agnostic (accept message constructors) +- Each page has its own `Message` enum +- App uses enum composition to wrap page messages +- More aligned with libcosmic conventions -### Real-World Example +### Real-World Examples -See the `PowerForm` component and `PowerControls` page interaction: -- `src/components/power_form.rs` - Component implementation -- `src/pages/power_controls.rs` - Page handling with recursive update -- `src/components/mod.rs` - `Component` trait definition +See the following files for reference: +- `src/pages/power_controls.rs` - Page with self-contained `Message` enum +- `src/components/power_form.rs` - Message-agnostic component accepting message constructors +- `src/components/radio_components.rs` - Generic component with message constructor parameters +- `src/app_messages.rs` - App-level message composition +- `src/app.rs` - Message interception and routing --- ## Future Idioms -As the project grows, document new architectural patterns here. I'm sure a lot will change as I write more applications and get feedback from contributors. \ No newline at end of file +As the project grows, document new architectural patterns here. I'm sure a lot will change as I write more applications and get feedback from contributors. + +**Potential areas to document**: +- Async task patterns (database queries, systemd calls, etc.) +- Service layer patterns (systemd integration, D-Bus communication) +- State management strategies (when to use app state vs page state) +- Testing strategies for MVU architecture diff --git a/src/app.rs b/src/app.rs index 60e416f..df96bab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,14 +17,12 @@ use notify_rust::{Hint, Notification}; use std::{fs::File, str::FromStr, sync::Arc}; use crate::{ + app_messages::{AppMessage as Message, DatabaseMessage, PowerMessage, TimerMessage}, config::Config, models::{Timer, timer::TimerType}, - pages::{Page, PowerControls}, + pages::{PowerControls, power_controls}, utils::{ database::{Repository, SQLiteDatabase}, - messages::{ - AppMessage as Message, DatabaseMessage, PageMessage, PowerMessage, TimerMessage, - }, resources, }, }; @@ -122,7 +120,10 @@ impl Application for AppModel { if matches!(self.popup, Some(p) if p == id) { let Spacing { space_m, .. } = theme::active().cosmic().spacing; - let power = self.power_controls.view().map(Message::PageMessage); + let power = self + .power_controls + .view() + .map(Message::PowerControlsMessage); let content = column![power].spacing(space_m); self.core @@ -165,7 +166,7 @@ impl Application for AppModel { t.map(|_| Action::::None) } - Message::PageMessage(msg) => self.handle_page_message(msg), + Message::PowerControlsMessage(msg) => self.handle_power_controls_message(msg), Message::DatabaseMessage(msg) => self.handle_database_message(msg), @@ -316,10 +317,33 @@ impl AppModel { /// Handle messages from pages // Todo: If necessary, expand to route to multiple pages - // applets work a little differently than full apps with multiple pages so unsure if this is problem - // attempting to define opinionated architecture around pages/components even in applets though - fn handle_page_message(&mut self, msg: PageMessage) -> Task> { - self.power_controls.update(msg) + // Handle messages from the power controls page + fn handle_power_controls_message( + &mut self, + msg: power_controls::Message, + ) -> Task> { + // Check if this is a message that needs to be handled at the app level + match msg { + power_controls::Message::ToggleStayAwake => { + self.handle_power_message(PowerMessage::ToggleStayAwake) + } + power_controls::Message::SetSuspendTime(time) => { + self.handle_power_message(PowerMessage::SetSuspendTime(time)) + } + power_controls::Message::SetShutdownTime(time) => { + self.handle_power_message(PowerMessage::SetShutdownTime(time)) + } + power_controls::Message::SetLogoutTime(time) => { + self.handle_power_message(PowerMessage::SetLogoutTime(time)) + } + // Let the page handle its own state updates + _ => self.power_controls.update(msg).map(|action| match action { + Action::App(page_msg) => Action::App(Message::PowerControlsMessage(page_msg)), + Action::None => Action::None, + Action::Cosmic(cosmic_action) => Action::Cosmic(cosmic_action), + Action::DbusActivation(dbus_action) => Action::DbusActivation(dbus_action), + }), + } } fn handle_database_message(&mut self, msg: DatabaseMessage) -> Task> { @@ -534,8 +558,6 @@ impl AppModel { #[cfg(test)] mod tests { - use crate::utils::messages::ComponentMessage; - use super::*; fn get_test_app() -> AppModel { @@ -545,7 +567,7 @@ mod tests { #[test] fn test_app_initialization() { let app = get_test_app(); - assert_eq!(app.icon_name, "chronomancer-hourglass"); + assert_eq!(app.icon_name, "io.vulpapps.Chronomancer"); assert!( app.popup.is_none(), "Popup should be None on initialization" @@ -632,18 +654,18 @@ mod tests { } #[test] - fn test_handle_page_message_power_controls() { + fn test_handle_power_controls_message() { let mut app = get_test_app(); - // Create a sample PageMessage for PowerControls - let msg = PageMessage::ComponentMessage(ComponentMessage::SubmitPressed); + // Create a sample message for PowerControls form text change + let msg = power_controls::Message::FormTextChanged("15".to_string()); // Handle the message - let task = app.handle_page_message(msg); + let task = app.handle_power_controls_message(msg); - // Since PowerControls does not handle SubmitPressed, no task should be scheduled. + // The message is forwarded to power_controls.update() let _ = task.map(|_action| { - unreachable!("Uh-oh, spaghettios, SubmitPressed should not produce any action here!"); + // All good just wanted to test message handling doesn't panic }); } @@ -908,11 +930,11 @@ mod tests { } #[test] - fn test_update_page_message() { + fn test_update_power_controls_message() { let mut app = get_test_app(); - let msg = PageMessage::ComponentMessage(ComponentMessage::SubmitPressed); - let _task = app.update(Message::PageMessage(msg)); + let msg = power_controls::Message::FormTextChanged("20".to_string()); + let _task = app.update(Message::PowerControlsMessage(msg)); // The message is forwarded to power_controls.update() // We can't easily verify internal state without exposing it, diff --git a/src/utils/messages.rs b/src/app_messages.rs similarity index 54% rename from src/utils/messages.rs rename to src/app_messages.rs index 9536003..0f772cc 100644 --- a/src/utils/messages.rs +++ b/src/app_messages.rs @@ -1,31 +1,19 @@ +// SPDX-License-Identifier: MIT + use std::{fs::File, sync::Arc}; use crate::{ - config::Config, - models::Timer, - utils::{TimeUnit, database::SQLiteDatabase}, + config::Config, models::Timer, pages::power_controls, utils::database::SQLiteDatabase, }; -#[derive(Debug, Clone)] -pub enum ComponentMessage { - TextChanged(String), - TimeUnitChanged(TimeUnit), - SubmitPressed, - RadioOptionSelected(usize), -} - -#[derive(Debug, Clone)] -pub enum PageMessage { - PowerFormSubmitted(i32), - ComponentMessage(ComponentMessage), -} - +/// Database-related messages #[derive(Debug, Clone)] pub enum DatabaseMessage { Initialized(Result), FailedToInitialize(String), } +/// Power management-related messages #[derive(Debug, Clone)] pub enum PowerMessage { ToggleStayAwake, @@ -38,31 +26,28 @@ pub enum PowerMessage { ExecuteShutdown, } +/// Timer-related messages #[derive(Debug, Clone)] pub enum TimerMessage { Created(Result), ActiveFetched(Result, String>), } +/// Application-level messages #[derive(Debug, Clone)] pub enum AppMessage { TogglePopup, UpdateConfig(Config), Tick, - PageMessage(PageMessage), + PowerControlsMessage(power_controls::Message), DatabaseMessage(DatabaseMessage), TimerMessage(TimerMessage), PowerMessage(PowerMessage), } -impl From for AppMessage { - fn from(msg: PageMessage) -> Self { - AppMessage::PageMessage(msg) - } -} - -impl From for PageMessage { - fn from(msg: ComponentMessage) -> Self { - PageMessage::ComponentMessage(msg) +/// Conversion implementations for page-level message routing +impl From for AppMessage { + fn from(msg: power_controls::Message) -> Self { + AppMessage::PowerControlsMessage(msg) } } diff --git a/src/components/icon_button.rs b/src/components/icon_button.rs index e858742..874ded0 100644 --- a/src/components/icon_button.rs +++ b/src/components/icon_button.rs @@ -1,5 +1,5 @@ use super::radio_components::RadioComponent; -use crate::utils::{messages::ComponentMessage, resources, ui::ComponentSize}; +use crate::utils::{resources, ui::ComponentSize}; use cosmic::{ Element, iced::Length, @@ -10,6 +10,7 @@ use cosmic::{ /// Struct representing a toggle-able `ToggleIconRadio` button #[derive(Debug, Clone)] pub struct ToggleIconRadio { + #[allow(dead_code)] pub index: usize, pub name: &'static str, } @@ -35,13 +36,16 @@ impl ToggleIconRadio { impl RadioComponent for ToggleIconRadio { /// Render the toggle icon radio button. - fn view(&self, is_active: bool) -> Element<'_, ComponentMessage> { + fn view(&self, is_active: bool, on_select: Message) -> Element<'_, Message> + where + Message: Clone + 'static, + { button::custom( container(resources::system_icon(self.name, ComponentSize::ICON_SIZE)) .width(Length::Fill) .center(Length::Fill), ) - .on_press(ComponentMessage::RadioOptionSelected(self.index)) + .on_press(on_select) .width(Length::Fill) .height(ComponentSize::ICON_BUTTON_HEIGHT) .class(self.button_style(is_active)) @@ -53,6 +57,13 @@ impl RadioComponent for ToggleIconRadio { mod tests { use super::*; + #[test] + fn test_toggle_icon_radio_creation() { + let radio = ToggleIconRadio::new(0, "test-icon"); + assert_eq!(radio.index, 0); + assert_eq!(radio.name, "test-icon"); + } + #[test] fn test_toggle_icon_radio_style_active() { let radio = ToggleIconRadio::new(0, "test-icon"); @@ -66,4 +77,18 @@ mod tests { let style = radio.button_style(false); assert!(matches!(style, theme::Button::Text)); } + + #[test] + fn test_view_compiles() { + #[derive(Debug, Clone)] + enum TestMessage { + Selected, + } + + let radio = ToggleIconRadio::new(0, "system-suspend-symbolic"); + + // Just verify that the view method compiles and returns an Element + let _element = radio.view(true, TestMessage::Selected); + let _element = radio.view(false, TestMessage::Selected); + } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 95e0087..b3516a0 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,17 +1,6 @@ -use cosmic::Element; - -use crate::utils::messages::{ComponentMessage, PageMessage}; - pub mod icon_button; pub mod power_form; - pub mod radio_components; pub use icon_button::ToggleIconRadio; pub use power_form::PowerForm; - -/// Trait for UI components -pub trait Component { - fn view(&self) -> Element<'_, ComponentMessage>; - fn update(&mut self, message: ComponentMessage) -> Option; -} diff --git a/src/components/power_form.rs b/src/components/power_form.rs index 89524e1..32e0b1b 100644 --- a/src/components/power_form.rs +++ b/src/components/power_form.rs @@ -6,13 +6,8 @@ use cosmic::{ }; use crate::{ - components::Component, fl, - utils::{ - Padding, TimeUnit, - messages::{ComponentMessage, PageMessage}, - ui::Gaps, - }, + utils::{Padding, TimeUnit, ui::Gaps}, }; /// Struct representing a power form component @@ -24,22 +19,46 @@ pub struct PowerForm { pub placeholder_text: String, } -impl Component for PowerForm { - fn view(&self) -> Element<'_, ComponentMessage> { +impl PowerForm { + pub fn new(placeholder_text: impl Into) -> Self { + Self { + input_value: String::new(), + time_unit: TimeUnit::Seconds, // Default to seconds + time_unit_options: combo_box::State::new(vec![ + TimeUnit::Seconds, + TimeUnit::Minutes, + TimeUnit::Hours, + TimeUnit::Days, + ]), + placeholder_text: placeholder_text.into(), + } + } + + /// Render the power form with message constructors + pub fn view( + &self, + on_text_input: impl Fn(String) -> Message + 'static, + on_time_unit: impl Fn(TimeUnit) -> Message + 'static, + on_submit: Message, + ) -> Element<'_, Message> + where + Message: Clone + 'static, + { + let on_submit_clone = on_submit.clone(); column![ TextInput::new(&self.placeholder_text, &self.input_value) - .on_input(ComponentMessage::TextChanged) - .on_submit(|_| { ComponentMessage::SubmitPressed }) + .on_input(on_text_input) + .on_submit(move |_| on_submit_clone.clone()) .width(Fill), ComboBox::new( &self.time_unit_options, &fl!("unit-label"), Some(&self.time_unit), - ComponentMessage::TimeUnitChanged, + on_time_unit, ) .width(Fill), button::text(fl!("set-button-label")) - .on_press(ComponentMessage::SubmitPressed) + .on_press(on_submit) .class(Button::Suggested) ] .align_x(Alignment::Center) @@ -48,56 +67,21 @@ impl Component for PowerForm { .into() } - /// Update the power form state based on messages - fn update(&mut self, message: ComponentMessage) -> Option { - match message { - ComponentMessage::TextChanged(new_text) => { - if let Ok(value) = new_text.parse::() { - self.input_value = value.to_string(); - } - None - } - ComponentMessage::TimeUnitChanged(unit) => { - self.time_unit = unit; - None - } - ComponentMessage::SubmitPressed => { - if self.validate_input() { - println!("valid input"); - let value = self.input_value.parse::().unwrap() - * self.time_unit.to_seconds_multiplier(); - Some(PageMessage::PowerFormSubmitted(value)) - } else { - self.clear(); - None - } - } - ComponentMessage::RadioOptionSelected(_) => None, - } - } -} - -impl PowerForm { - pub fn new(placeholder_text: impl Into) -> Self { - Self { - input_value: String::new(), - time_unit: TimeUnit::Seconds, // Default to seconds - time_unit_options: combo_box::State::new(vec![ - TimeUnit::Seconds, - TimeUnit::Minutes, - TimeUnit::Hours, - TimeUnit::Days, - ]), - placeholder_text: placeholder_text.into(), + /// Handle text input, validating numeric values + pub fn handle_text_input(&mut self, new_text: &str) { + if let Ok(value) = new_text.parse::() { + self.input_value = value.to_string(); } } - fn validate_input(&self) -> bool { + /// Validate that input is a positive integer + pub fn validate_input(&self) -> bool { let value = self.input_value.parse::(); value.is_ok() && value.unwrap_or_default() > 0 } - fn clear(&mut self) { + /// Clear the form inputs + pub fn clear(&mut self) { self.input_value.clear(); self.time_unit = TimeUnit::Seconds; } @@ -107,6 +91,14 @@ impl PowerForm { mod tests { use super::*; + #[derive(Debug, Clone)] + #[allow(dead_code)] + enum TestMessage { + TextChanged(String), + TimeUnitChanged(TimeUnit), + Submit, + } + #[test] fn test_power_form_creation() { let form = PowerForm::new("Enter time"); @@ -116,47 +108,60 @@ mod tests { } #[test] - fn test_power_form_update_text() { + fn test_handle_text_input_valid() { let mut form = PowerForm::new("Enter time"); - form.update(ComponentMessage::TextChanged("15".to_string())); + form.handle_text_input("15"); assert_eq!(form.input_value, "15"); } #[test] - fn test_power_form_update_time_unit() { + fn test_handle_text_input_invalid() { let mut form = PowerForm::new("Enter time"); - form.update(ComponentMessage::TimeUnitChanged(TimeUnit::Minutes)); - assert_eq!(form.time_unit, TimeUnit::Minutes); + form.handle_text_input("potato"); + assert_eq!(form.input_value, ""); // Should remain empty } #[test] - fn test_power_form_submit_valid() { + fn test_validation_valid_input() { let mut form = PowerForm::new("Enter time"); - form.input_value = "5".to_string(); - form.time_unit = TimeUnit::Minutes; - let result = form.update(ComponentMessage::SubmitPressed); - - assert!(result.is_some()); - if let PageMessage::PowerFormSubmitted(time) = result.unwrap() { - assert_eq!(time, 300); // 5 minutes in seconds XwX - } else { - panic!("Expected PowerFormSubmitted message"); - } + form.input_value = "10".to_string(); + assert!(form.validate_input()); } #[test] - fn test_power_form_submit_invalid() { + fn test_validation_invalid_input() { let mut form = PowerForm::new("Enter time"); - form.input_value = "potato".to_string(); - let result = form.update(ComponentMessage::SubmitPressed); - assert!(result.is_none()); - assert_eq!(form.input_value, ""); + form.input_value = "0".to_string(); + assert!(!form.validate_input()); + + form.input_value = "-5".to_string(); + assert!(!form.validate_input()); + + form.input_value = "".to_string(); + assert!(!form.validate_input()); } #[test] - fn test_power_form_validation() { + fn test_clear() { let mut form = PowerForm::new("Enter time"); - form.input_value = "10".to_string(); - assert!(form.validate_input()); + form.input_value = "123".to_string(); + form.time_unit = TimeUnit::Hours; + + form.clear(); + + assert_eq!(form.input_value, ""); + assert_eq!(form.time_unit, TimeUnit::Seconds); + } + + #[test] + fn test_view_compiles() { + let form = PowerForm::new("Enter time"); + + // Just verify that the view method compiles and returns an Element + let _element = form.view( + TestMessage::TextChanged, + TestMessage::TimeUnitChanged, + TestMessage::Submit, + ); } } diff --git a/src/components/radio_components.rs b/src/components/radio_components.rs index 7c51482..341dff1 100644 --- a/src/components/radio_components.rs +++ b/src/components/radio_components.rs @@ -1,7 +1,5 @@ use cosmic::{Element, iced_widget::row}; -use crate::utils::messages::ComponentMessage; - // Note: This is a simplified radio button component system. If mixed type radio components ends up being desireable, enums with a trait // object approach can be implemented instead. Rust enums are hot shit! Example: // pub enum RadioOption { @@ -10,17 +8,19 @@ use crate::utils::messages::ComponentMessage; // } // // impl RadioComponent for RadioOption { -// fn view(&self, is_active: bool) -> Element<'_, ComponentMessage> { +// fn view(&self, is_active: bool, on_select: Message) -> Element<'_, Message> { // match self { -// RadioOption::ToggleIconRadio(option) => option.view(is_active), -// RadioOption::AnotherRadioType(option) => option.view(is_active), +// RadioOption::ToggleIconRadio(option) => option.view(is_active, on_select), +// RadioOption::AnotherRadioType(option) => option.view(is_active, on_select), // } // } // } /// Trait for radio button components pub trait RadioComponent: Clone + std::fmt::Debug { - fn view(&self, is_active: bool) -> Element<'_, ComponentMessage>; + fn view(&self, is_active: bool, on_select: Message) -> Element<'_, Message> + where + Message: Clone + 'static; } /// Struct to manage a group of radio button components @@ -31,15 +31,6 @@ pub struct RadioComponents { } impl RadioComponents { - /// set the active option by index - fn set_active(&mut self, index: usize) { - self.selected = Some(index); - } - - fn deactivate(&mut self) { - self.selected = None; - } - /// create a new `RadioComponents` instance #[must_use] pub fn new(options: Vec) -> Self { @@ -49,33 +40,37 @@ impl RadioComponents { } } - /// display options in a row layout + /// display options in a row layout with message constructor + #[must_use] + pub fn view( + &self, + on_select: impl Fn(usize) -> Message + 'static, + ) -> Element<'_, Message> + where + Message: Clone + 'static, + { + self.row_with_spacing(16, on_select) + } + + /// display options in a row layout with custom spacing #[must_use] - pub fn row(&self, spacing: u16) -> Element<'_, ComponentMessage> { + pub fn row_with_spacing( + &self, + spacing: u16, + on_select: impl Fn(usize) -> Message + 'static, + ) -> Element<'_, Message> + where + Message: Clone + 'static, + { let mut row_elements = vec![]; for (index, option) in self.options.iter().enumerate() { - let is_active = match self.selected { - Some(selected_index) => selected_index == index, - None => false, - }; - row_elements.push(option.view(is_active)); + let is_active = self.selected == Some(index); + row_elements.push(option.view(is_active, on_select(index))); } row(row_elements).spacing(spacing).into() } - - /// TODO: add a more flexible view method that embeds the options in a custom layout/element or something if needed - /// handle messages to update selected option - pub fn update(&mut self, message: &ComponentMessage) { - if let ComponentMessage::RadioOptionSelected(index) = *message { - if self.selected == Some(index) { - self.deactivate(); - } else { - self.set_active(index); - } - } - } } #[cfg(test)] @@ -85,14 +80,24 @@ mod tests { use super::*; #[derive(Debug, Clone)] + #[allow(dead_code)] + enum TestMessage { + RadioSelected(usize), + } + + #[derive(Debug, Clone)] + #[allow(dead_code)] struct MockRadioOption { - #[allow(dead_code)] pub index: usize, } impl RadioComponent for MockRadioOption { - fn view(&self, _is_active: bool) -> Element<'_, ComponentMessage> { - // Simplified view for testing + fn view(&self, _is_active: bool, on_select: Message) -> Element<'_, Message> + where + Message: Clone + 'static, + { + // Simplified view for testing - would normally be a button with on_press(on_select) + let _ = on_select; // Acknowledge we received it Space::new(10, 10).into() } } @@ -104,27 +109,47 @@ mod tests { } #[test] - fn test_radio_components_selection() { + fn test_radio_components_creation() { let options = vec![ MockRadioOption::new(0), MockRadioOption::new(1), MockRadioOption::new(2), ]; - let mut radio_components = RadioComponents::new(options); + let radio_components = RadioComponents::new(options); // Initially, no option should be selected assert_eq!(radio_components.selected, None); + assert_eq!(radio_components.options.len(), 3); + } + + #[test] + fn test_radio_components_manual_selection() { + let options = vec![ + MockRadioOption::new(0), + MockRadioOption::new(1), + MockRadioOption::new(2), + ]; + let mut radio_components = RadioComponents::new(options); - // Select the first option - radio_components.update(&ComponentMessage::RadioOptionSelected(0)); + // Manually set selection + radio_components.selected = Some(0); assert_eq!(radio_components.selected, Some(0)); - // Select the second option - radio_components.update(&ComponentMessage::RadioOptionSelected(1)); + // Change selection + radio_components.selected = Some(1); assert_eq!(radio_components.selected, Some(1)); - // Deselect the second option - radio_components.update(&ComponentMessage::RadioOptionSelected(1)); + // Deselect + radio_components.selected = None; assert_eq!(radio_components.selected, None); } + + #[test] + fn test_view_compiles() { + let options = vec![MockRadioOption::new(0), MockRadioOption::new(1)]; + let radio_components = RadioComponents::new(options); + + // Just verify that the view method compiles and returns an Element + let _element = radio_components.view(TestMessage::RadioSelected); + } } diff --git a/src/main.rs b/src/main.rs index 0a52dd8..12e9837 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use i18n::init; use i18n_embed::DesktopLanguageRequester; mod app; +mod app_messages; mod components; mod config; mod i18n; diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 29a5229..156ed73 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,10 +1,3 @@ -use crate::utils::messages::{AppMessage, PageMessage}; -use cosmic::{Action, Element, Task}; - pub mod power_controls; -pub use power_controls::PowerControls; -pub trait Page { - fn view(&self) -> Element<'_, PageMessage>; - fn update(&mut self, message: PageMessage) -> Task>; -} +pub use power_controls::Page as PowerControls; diff --git a/src/pages/power_controls.rs b/src/pages/power_controls.rs index 6490334..3ecd2e7 100644 --- a/src/pages/power_controls.rs +++ b/src/pages/power_controls.rs @@ -1,23 +1,43 @@ use crate::{ - components::{Component, PowerForm, ToggleIconRadio, radio_components::RadioComponents}, + components::{PowerForm, ToggleIconRadio, radio_components::RadioComponents}, fl, - pages::Page, utils::{ - messages::{AppMessage, ComponentMessage, PageMessage, PowerMessage}, + TimeUnit, ui::{Gaps, Padding}, }, }; use cosmic::{Action, Element, Task, iced::Alignment, iced_widget::column, widget::Space}; +/// Messages for the power controls page +#[derive(Debug, Clone)] +pub enum Message { + /// Radio button was selected + RadioOptionSelected(usize), + /// Text input changed in the power form + FormTextChanged(String), + /// Time unit changed in the power form + FormTimeUnitChanged(TimeUnit), + /// Form submit button pressed + FormSubmitPressed, + /// Request to toggle stay awake mode + ToggleStayAwake, + /// Request to set suspend timer + SetSuspendTime(i32), + /// Request to set shutdown timer + SetShutdownTime(i32), + /// Request to set logout timer + SetLogoutTime(i32), +} + /// Struct representing the power controls page #[derive(Debug, Clone)] -pub struct PowerControls { +pub struct Page { pub power_buttons: RadioComponents, pub power_form: PowerForm, } -impl Default for PowerControls { - /// Create a default instance of `PowerControls` +impl Default for Page { + /// Create a default instance of `Page` fn default() -> Self { Self { power_buttons: RadioComponents::new(vec![ @@ -31,19 +51,20 @@ impl Default for PowerControls { } } -impl Page for PowerControls { +impl Page { /// Render the power controls page - fn view(&self) -> Element<'_, PageMessage> { - let power_buttons = self - .power_buttons - .row(Gaps::s()) - .map(PageMessage::ComponentMessage); + pub fn view(&self) -> Element<'_, Message> { + let power_buttons = self.power_buttons.view(Message::RadioOptionSelected); // Show power form only if one of the radio buttons is active (not stay-awake) let form = if let Some(index) = self.power_buttons.selected && index > 0 { - self.power_form.view().map(PageMessage::ComponentMessage) + self.power_form.view( + Message::FormTextChanged, + Message::FormTimeUnitChanged, + Message::FormSubmitPressed, + ) } else { Space::new(0, 0).into() }; @@ -56,77 +77,75 @@ impl Page for PowerControls { } /// Update the power controls page state based on messages - fn update(&mut self, message: PageMessage) -> Task> { + pub fn update(&mut self, message: Message) -> Task> { match message { - PageMessage::ComponentMessage(msg) => { - if let ComponentMessage::RadioOptionSelected(new_index) = msg.clone() { - self.handle_radio_selection(new_index, &msg) - } else { - let page_message = self.power_form.update(msg); - if let Some(page_msg) = page_message { - self.update(page_msg) - } else { - Task::done(Action::None) - } - } + Message::RadioOptionSelected(new_index) => self.handle_radio_selection(new_index), + Message::FormTextChanged(new_text) => { + self.power_form.handle_text_input(&new_text); + Task::none() } - PageMessage::PowerFormSubmitted(time) => { - if let Some(index) = self.power_buttons.selected { - match index { - 1 => Task::done(Action::App(AppMessage::PowerMessage( - PowerMessage::SetSuspendTime(time), - ))), - 2 => Task::done(Action::App(AppMessage::PowerMessage( - PowerMessage::SetShutdownTime(time), - ))), - 3 => Task::done(Action::App(AppMessage::PowerMessage( - PowerMessage::SetLogoutTime(time), - ))), - _ => Task::done(Action::None), - } - } else { - Task::done(Action::None) - } + Message::FormTimeUnitChanged(unit) => { + self.power_form.time_unit = unit; + Task::none() } + Message::FormSubmitPressed => self.handle_form_submit(), + // These messages bubble up to the app level, so we just pass them through + Message::ToggleStayAwake + | Message::SetSuspendTime(_) + | Message::SetShutdownTime(_) + | Message::SetLogoutTime(_) => Task::none(), } } -} -impl PowerControls { - /// handle radio button selection - fn handle_radio_selection( - &mut self, - new_index: usize, - msg: &ComponentMessage, - ) -> Task> { + /// Handle radio button selection + fn handle_radio_selection(&mut self, new_index: usize) -> Task> { let previous = self.power_buttons.selected; - self.power_buttons.update(msg); + self.power_buttons.selected = Some(new_index); + + // Update form placeholder based on selected action self.power_form.placeholder_text = match new_index { 2 => fl!("set-time-label", operation = fl!("operation-shutdown")), 3 => fl!("set-time-label", operation = fl!("operation-logout")), _ => fl!("set-time-label", operation = fl!("operation-suspend")), }; + + // Toggle stay awake if switching to/from stay awake button if new_index == 0 || previous == Some(0) { - Task::done(Action::App(AppMessage::PowerMessage( - PowerMessage::ToggleStayAwake, - ))) + Task::done(Action::App(Message::ToggleStayAwake)) + } else { + Task::none() + } + } + + /// Handle form submission + fn handle_form_submit(&mut self) -> Task> { + if !self.power_form.validate_input() { + self.power_form.clear(); + return Task::none(); + } + + let value = self.power_form.input_value.parse::().unwrap() + * self.power_form.time_unit.to_seconds_multiplier(); + + if let Some(index) = self.power_buttons.selected { + match index { + 1 => Task::done(Action::App(Message::SetSuspendTime(value))), + 2 => Task::done(Action::App(Message::SetShutdownTime(value))), + 3 => Task::done(Action::App(Message::SetLogoutTime(value))), + _ => Task::none(), + } } else { - Task::done(Action::None) + Task::none() } } } #[cfg(test)] mod tests { + use super::*; - use crate::{ - fl, - pages::{Page, PowerControls}, - utils::messages::{ComponentMessage, PageMessage}, - }; - - fn get_test_page() -> PowerControls { - PowerControls::default() + fn get_test_page() -> Page { + Page::default() } #[test] @@ -144,30 +163,38 @@ mod tests { let mut page = get_test_page(); // Select shutdown option - let msg = ComponentMessage::RadioOptionSelected(2); - let _ = page.update(PageMessage::ComponentMessage(msg)); - + let _ = page.update(Message::RadioOptionSelected(2)); assert_eq!( page.power_form.placeholder_text, fl!("set-time-label", operation = fl!("operation-shutdown")) ); // Select logout option - let msg = ComponentMessage::RadioOptionSelected(3); - let _ = page.update(PageMessage::ComponentMessage(msg)); - + let _ = page.update(Message::RadioOptionSelected(3)); assert_eq!( page.power_form.placeholder_text, fl!("set-time-label", operation = fl!("operation-logout")) ); // Select suspend option - let msg = ComponentMessage::RadioOptionSelected(1); - let _ = page.update(PageMessage::ComponentMessage(msg)); - + let _ = page.update(Message::RadioOptionSelected(1)); assert_eq!( page.power_form.placeholder_text, fl!("set-time-label", operation = fl!("operation-suspend")) ); } + + #[test] + fn test_form_text_input() { + let mut page = get_test_page(); + let _ = page.update(Message::FormTextChanged("15".to_string())); + assert_eq!(page.power_form.input_value, "15"); + } + + #[test] + fn test_form_time_unit_change() { + let mut page = get_test_page(); + let _ = page.update(Message::FormTimeUnitChanged(TimeUnit::Minutes)); + assert_eq!(page.power_form.time_unit, TimeUnit::Minutes); + } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 5584b41..7ed4c7d 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,4 @@ pub mod database; -pub mod messages; pub mod resources; pub mod time; pub mod ui; From a17f6c586fe0ccd51fa1707da30e76d47dad0d1f Mon Sep 17 00:00:00 2001 From: kit-foxboy Date: Sun, 21 Dec 2025 15:10:48 -0700 Subject: [PATCH 3/6] Fixed misspelling my own company name XwX --- resources/io.vulpapps.Chronomancer.metainfo.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/io.vulpapps.Chronomancer.metainfo.xml b/resources/io.vulpapps.Chronomancer.metainfo.xml index 0230cd2..00bdda9 100644 --- a/resources/io.vulpapps.Chronomancer.metainfo.xml +++ b/resources/io.vulpapps.Chronomancer.metainfo.xml @@ -6,7 +6,7 @@ Chronomancer Applet for managing timers, reminders, and sleep overrides - Vulpine Apps + Vulpine Interactive

Chronomancer is a simple and elegant applet for managing system timers. It allows users to keep their system awake or set times for suspend, shutdown, and logout.

From fe9ca487f7841bb2a2db5123f965e9eb2c292002 Mon Sep 17 00:00:00 2001 From: kit-foxboy Date: Thu, 25 Dec 2025 01:14:35 -0700 Subject: [PATCH 4/6] Refactored duplicate logic into utils and helpers --- src/app.rs | 126 +++++++++++++++++++++++++----------- src/pages/power_controls.rs | 6 ++ src/utils/mod.rs | 2 +- src/utils/time.rs | 59 ++++++++++++++--- 4 files changed, 143 insertions(+), 50 deletions(-) diff --git a/src/app.rs b/src/app.rs index df96bab..7a62902 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,7 +23,7 @@ use crate::{ pages::{PowerControls, power_controls}, utils::{ database::{Repository, SQLiteDatabase}, - resources, + format_duration, resources, }, }; @@ -219,6 +219,65 @@ impl Application for AppModel { } impl AppModel { + /// Helper function to send a notification + fn send_notification(summary: &str, body: &str, icon: &str) { + if let Err(e) = Notification::new() + .summary(summary) + .body(body) + .icon(icon) + .hint(Hint::Category("device".to_owned())) + .timeout(5000) // 5 seconds + .show() + { + eprintln!("Failed to send notification: {e}"); + } + } + + /// Helper function to create a power timer + fn create_power_timer( + &mut self, + time: i32, + timer_type: &TimerType, + notification_title: &str, + notification_body_prefix: &str, + icon: &str, + ) -> Task> { + let Some(database) = self.database.clone() else { + eprintln!("Database not yet available"); + return Task::none(); + }; + + // Send notification + let display_time = format_duration(time); + AppModel::send_notification( + notification_title, + &format!("{notification_body_prefix} {display_time}"), + icon, + ); + + // Create the timer + let timer = Timer::new(time, false, timer_type); + + // Close the popup + let close_task = self.toggle_popup(); + + // Batch all the tasks + Task::batch(vec![ + close_task.map(|_| Action::None), + Task::done(Action::App(Message::PowerControlsMessage( + power_controls::Message::ClearForm, + ))), + Task::perform( + async move { + Timer::insert(database.pool(), &timer) + .await + .map_err(|e| e.to_string()) + }, + |result| Action::App(Message::TimerMessage(TimerMessage::Created(result))), + ), + ]) + } + /// Toggles the main panel visibility. fn toggle_popup(&mut self) -> Task { if let Some(p) = self.popup.take() { @@ -277,7 +336,8 @@ impl AppModel { .icon("alarm") .hint(Hint::Category("alarm".to_owned())) .hint(Hint::Resident(true)) - .timeout(0) + // Uncomment for persistent notifications + // .timeout(0) .show() { eprintln!("Failed to send notification: {e}"); @@ -412,6 +472,10 @@ impl AppModel { } else { return AppModel::get_suspend_inhibitor(); } + + // Close the popup after toggling + let close_task = self.toggle_popup(); + return close_task.map(|_| Action::None); } PowerMessage::InhibitAcquired(result) => { match Arc::try_unwrap(result) { @@ -420,7 +484,6 @@ impl AppModel { // Double okay is a bit silly but matches the async task return type // Also makes the arc unwrap safe self.suspend_inhibitor = Some(file); - println!("Successfully acquired suspend inhibitor"); } Ok(Err(err)) => { eprintln!("Failed to acquire inhibit: {err}"); @@ -439,54 +502,39 @@ impl AppModel { // We create a suspend inhibitor when setting a suspend timer so the timer overrides system settings let _inhibitor_task = AppModel::get_suspend_inhibitor(); - let suspend_timer = Timer::new(time, false, &TimerType::Suspend); - if let Some(database) = self.database.clone() { - return Task::perform( - async move { - Timer::insert(database.pool(), &suspend_timer) - .await - .map_err(|e| e.to_string()) - }, - |result| Action::App(Message::TimerMessage(TimerMessage::Created(result))), - ); - } - eprintln!("Database not yet available"); + return self.create_power_timer( + time, + &TimerType::Suspend, + "Suspend Timer Set", + "System will suspend in", + "system-suspend-symbolic", + ); } PowerMessage::SetShutdownTime(time) => { // We create a suspend inhibitor when setting a shutdown timer so the timer overrides system settings // Otherwise the system might suspend before shutting down and never complete until it wakes up and immedately shuts down let _inhibitor_task = AppModel::get_suspend_inhibitor(); - let shutdown_timer = Timer::new(time, false, &TimerType::Shutdown); - if let Some(database) = self.database.clone() { - return Task::perform( - async move { - Timer::insert(database.pool(), &shutdown_timer) - .await - .map_err(|e| e.to_string()) - }, - |result| Action::App(Message::TimerMessage(TimerMessage::Created(result))), - ); - } - eprintln!("Database not yet available"); + return self.create_power_timer( + time, + &TimerType::Shutdown, + "Shutdown Timer Set", + "System will shutdown in", + "system-shutdown-symbolic", + ); } PowerMessage::SetLogoutTime(time) => { // We create a suspend inhibitor when setting a logout timer so the timer overrides system settings // Otherwise the system might suspend before logging out and never complete until it wakes up and immedately logs out let _inhibitor_task = AppModel::get_suspend_inhibitor(); - let logout_timer = Timer::new(time, false, &TimerType::Logout); - if let Some(database) = self.database.clone() { - return Task::perform( - async move { - Timer::insert(database.pool(), &logout_timer) - .await - .map_err(|e| e.to_string()) - }, - |result| Action::App(Message::TimerMessage(TimerMessage::Created(result))), - ); - } - eprintln!("Database not yet available"); + return self.create_power_timer( + time, + &TimerType::Logout, + "Logout Timer Set", + "System will logout in", + "system-log-out-symbolic", + ); } PowerMessage::ExecuteSuspend => { return Task::perform( diff --git a/src/pages/power_controls.rs b/src/pages/power_controls.rs index 3ecd2e7..0f52d6c 100644 --- a/src/pages/power_controls.rs +++ b/src/pages/power_controls.rs @@ -19,6 +19,8 @@ pub enum Message { FormTimeUnitChanged(TimeUnit), /// Form submit button pressed FormSubmitPressed, + /// Clear the form after successful submission + ClearForm, /// Request to toggle stay awake mode ToggleStayAwake, /// Request to set suspend timer @@ -89,6 +91,10 @@ impl Page { Task::none() } Message::FormSubmitPressed => self.handle_form_submit(), + Message::ClearForm => { + self.power_form.clear(); + Task::none() + } // These messages bubble up to the app level, so we just pass them through Message::ToggleStayAwake | Message::SetSuspendTime(_) diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 7ed4c7d..484975e 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -3,6 +3,6 @@ pub mod resources; pub mod time; pub mod ui; -pub use time::TimeUnit; +pub use time::{TimeUnit, format_duration}; #[allow(dead_code)] pub use ui::Padding; diff --git a/src/utils/time.rs b/src/utils/time.rs index 189c33e..6b2964c 100644 --- a/src/utils/time.rs +++ b/src/utils/time.rs @@ -2,14 +2,7 @@ use crate::fl; use std::fmt; /// Time units supported by Chronomancer. -/// -/// Internal arithmetic uses seconds. Use `to_seconds_multiplier()` to convert a unit into its multiplier. -/// -/// Variants: -/// - `Seconds` -/// - `Minutes` -/// - `Hours` -/// - `Days` +// Internal arithmetic uses seconds. Use `to_seconds_multiplier()` to convert a unit into its multiplier. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TimeUnit { Seconds, @@ -20,8 +13,6 @@ pub enum TimeUnit { impl TimeUnit { /// Returns the number of whole seconds represented by this time unit. - /// - /// Takes `self` by value per Clippy's convention for `to_*` methods on `Copy` types. #[must_use] pub fn to_seconds_multiplier(self) -> i32 { match self { @@ -44,3 +35,51 @@ impl fmt::Display for TimeUnit { } } } + +/// Formats a duration in seconds into a human-readable string. +#[must_use] +pub fn format_duration(seconds: i32) -> String { + let minutes = seconds / 60; + let hours = minutes / 60; + + if hours > 0 { + format!("{} hour{}", hours, if hours == 1 { "" } else { "s" }) + } else { + format!("{} minute{}", minutes, if minutes == 1 { "" } else { "s" }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_duration_hours() { + assert_eq!(format_duration(3600), "1 hour"); + assert_eq!(format_duration(7200), "2 hours"); + assert_eq!(format_duration(10800), "3 hours"); + } + + #[test] + fn test_format_duration_minutes() { + assert_eq!(format_duration(60), "1 minute"); + assert_eq!(format_duration(120), "2 minutes"); + assert_eq!(format_duration(300), "5 minutes"); + assert_eq!(format_duration(1800), "30 minutes"); + } + + #[test] + fn test_format_duration_rounds_down() { + // 90 minutes = 1 hour (rounds down) + assert_eq!(format_duration(5400), "1 hour"); + // 119 minutes = 1 hour + assert_eq!(format_duration(7140), "1 hour"); + } + + #[test] + fn test_format_duration_less_than_minute() { + // Less than a minute shows as 0 minutes + assert_eq!(format_duration(30), "0 minutes"); + assert_eq!(format_duration(59), "0 minutes"); + } +} From 6cebd377df050acbe2e70ad87587d532e94f0060 Mon Sep 17 00:00:00 2001 From: kit-foxboy Date: Thu, 25 Dec 2025 01:22:58 -0700 Subject: [PATCH 5/6] Now treating clippy warnings as errors to match CI --- justfile | 2 +- src/components/power_form.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/justfile b/justfile index 4b4d684..d627d1d 100644 --- a/justfile +++ b/justfile @@ -12,7 +12,7 @@ run *args: # Run clippy check: - cargo clippy --all-features -- -W clippy::pedantic + cargo clippy --all-targets --all-features -- -W clippy::pedantic -D warnings # Format code fmt: diff --git a/src/components/power_form.rs b/src/components/power_form.rs index 32e0b1b..10323d6 100644 --- a/src/components/power_form.rs +++ b/src/components/power_form.rs @@ -137,7 +137,7 @@ mod tests { form.input_value = "-5".to_string(); assert!(!form.validate_input()); - form.input_value = "".to_string(); + form.input_value = String::new(); assert!(!form.validate_input()); } From 29128ca5965321623116456329993f0aba35ca0c Mon Sep 17 00:00:00 2001 From: kit-foxboy Date: Thu, 25 Dec 2025 01:26:32 -0700 Subject: [PATCH 6/6] Remove commit CI to allow commits to fail clippy --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7feb2a2..5fc5b7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ -on: [push, pull_request] +on: + pull_request: jobs: lint: name: Format & Clippy (pedantic) @@ -43,4 +44,4 @@ jobs: if: always() run: | echo "Formatter and Clippy pedantic run complete." - echo "Status: ${{ job.status }}" \ No newline at end of file + echo "Status: ${{ job.status }}"