diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b49448c..2d99df7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -349,12 +349,10 @@ This project is a **learning exercise**. When assisting: ## Resources ### Project Documentation -- **Architectural Idioms:** `.github/architectural-idioms.md` - Component-to-page message flow and other patterns -- **Flatpak Notes:** `.github/flatpak.md` - Future Flatpak support plans (v2) -- **UI Spacing Guide:** `.github/ui-spacing-guide.md` -- **Iterator Patterns:** `.github/iterator-patterns.md` -- **Icon Theming Notes:** `.github/icon-theming-notes.md` -- **Macro Explanations:** `.github/macro-explanations.md` +- **Architectural Idioms:** `.journal/architectural-idioms.md` - Component-to-page message flow and other patterns +- **Flatpak Notes:** `.journal/flatpak.md` - Future Flatpak support plans (v2) +- **Iterator Patterns:** `.journal/iterator-patterns.md` +- **Doctest Guide:** `.journal/doctest-guide.md` - Writing effective Rust doctests in Chronomancer ### External Documentation - **libcosmic Applets:** https://pop-os.github.io/libcosmic-book/panel-applets.html @@ -370,8 +368,8 @@ This project is a **learning exercise**. When assisting: # Build release version just -# Run the application -just run +# Run the dev application +just dev # Check for errors just check @@ -397,6 +395,52 @@ For v1, use standard installation methods above. - **Integration Tests:** Mock systemd D-Bus interface - **Manual Testing:** All four panel positions, light/dark themes, system sleep/wake cycles +## Documentation & Doctests + +### Policy: Doctests for Logic, Prose for GUI + +**✅ DO write doctests for:** +- Pure utility functions (`utils/filters.rs`, `utils/time.rs`, `utils/resources.rs`) +- Logic that can be tested with simple assertions +- Functions that return testable values + +```rust +/// ```rust +/// use chronomancer::utils::filters::filter_positive_integer; +/// assert_eq!(filter_positive_integer("42"), Some("42".to_string())); +/// assert_eq!(filter_positive_integer("0"), None); +/// ``` +``` + +**❌ DON'T write doctests for:** +- UI components (`components/**`) +- Widget layouts and cosmic integrations +- Anything requiring `Element`, `Message` enums, or widget macros + +Instead, use **prose descriptions**: +```rust +/// Icon-based radio button that changes style when active. +/// +/// Create with `new(index, icon_name)`, render with `view(is_active, message)`. +``` + +### Rationale + +- **Panel applet, not a library:** The working app IS the documentation +- **Reduce friction:** GUI boilerplate slows development without adding value +- **Focus on learning:** Time spent on examples is better spent building features +- **Test what matters:** Pure functions benefit from doctests; UI patterns don't + +### Running Tests + +```bash +cargo test # All tests (unit + integration + doctests) +cargo test --doc # Only doctests (~53 passing for utils) +cargo test --lib # Only unit tests +``` + +See `.github/doctest-guide.md` for detailed doctest usage patterns. + ## Data Storage Follow XDG Base Directory specification: diff --git a/.github/doctest-guide.md b/.github/doctest-guide.md new file mode 100644 index 0000000..cd65174 --- /dev/null +++ b/.github/doctest-guide.md @@ -0,0 +1,231 @@ +# Documentation Testing Guide + +## Project Policy: Doctests for Logic, Prose for GUI + +Chronomancer uses a **selective doctesting strategy** to balance code quality with development velocity. We write executable doctests for pure utility functions, but use prose descriptions for GUI components. + +## Current Status + +- **53 passing doctests** - Core logic in `utils/` modules +- **0 ignored doctests** - No boilerplate examples in GUI code +- **0 failing doctests** - Clean test suite + +## The Policy + +### ✅ Write Doctests For + +**Pure utility functions** in `utils/` modules: + +```rust +/// Filters input to only accept positive integers. +/// +/// Returns `Some(normalized)` for valid input, `None` otherwise. +/// +/// ```rust +/// use chronomancer::utils::filters::filter_positive_integer; +/// +/// assert_eq!(filter_positive_integer("42"), Some("42".to_string())); +/// assert_eq!(filter_positive_integer("007"), Some("7".to_string())); // Normalized +/// assert_eq!(filter_positive_integer("0"), None); +/// ``` +#[must_use] +pub fn filter_positive_integer(input: &str) -> Option { + // ... +} +``` + +**Applies to:** +- `utils/filters.rs` - Text input validation +- `utils/time.rs` - Time formatting and conversion +- `utils/resources.rs` - System integration helpers +- Any pure function that doesn't involve GUI rendering + +**Benefits:** +- Automatic regression testing +- Examples stay current with code +- Clear API contracts +- Fast to write (one-line assertions) + +### ❌ Don't Write Doctests For + +**UI components and GUI code** in `components/` and `pages/`: + +```rust +/// Icon-based radio button that changes style when active. +/// +/// Create with `ToggleIconRadio::new(index, icon_name)`, then render with +/// `.view(is_active, message)`. Use with [`RadioComponents`] for automatic +/// selection state management. +pub struct ToggleIconRadio { + // ... +} +``` + +**Applies to:** +- `components/**` - All UI components +- `pages/**` - Page implementations +- `utils/ui/**` - UI spacing and layout helpers +- Anything returning `Element` + +**Why not?** +- Requires extensive boilerplate (`Message` enums, imports, widget context) +- Users will look at real implementation code anyway +- Adds friction to development without proportional value +- Panel applet, not a library - working code IS the documentation + +## Rationale + +### This is a Panel Applet, Not a Library + +**Library crates** (like `serde`, `tokio`) need extensive API examples because: +- Users import them into other projects +- Copy-paste examples are the primary way to learn +- Public API is the product + +**Panel applets** are different: +- The working application IS the documentation +- Contributors read the actual source code +- Implementation patterns matter more than API examples + +### Development Velocity Matters + +For a learning project, we optimize for: +- **Fast iteration** - No boilerplate tax on every function +- **Focus on building** - Time writing features, not maintaining examples +- **Learning value** - Understanding code structure beats memorizing APIs + +### Test What Actually Helps + +**Doctests add value when:** +- ✅ Testing pure function behavior (filters, formatters, converters) +- ✅ Catching regressions in utility logic +- ✅ Documenting edge cases and normalization + +**Doctests add friction when:** +- ❌ Requiring `Message` enum boilerplate for every component +- ❌ Needing widget macro imports and context +- ❌ Fighting lifetime issues in examples +- ❌ Maintaining examples that drift from real usage + +## Writing Good Doctests + +### Keep It Simple + +```rust +/// ```rust +/// use chronomancer::utils::time::format_duration; +/// +/// assert_eq!(format_duration(3600), "1 hour"); +/// assert_eq!(format_duration(7200), "2 hours"); +/// ``` +``` + +### Show Edge Cases + +```rust +/// ```rust +/// use chronomancer::utils::filters::filter_positive_integer; +/// +/// // Valid positive integers +/// assert_eq!(filter_positive_integer("42"), Some("42".to_string())); +/// +/// // Empty string is allowed +/// assert_eq!(filter_positive_integer(""), Some("".to_string())); +/// +/// // Invalid inputs +/// assert_eq!(filter_positive_integer("0"), None); +/// assert_eq!(filter_positive_integer("-5"), None); +/// ``` +``` + +### Add Explanatory Comments + +```rust +/// ```rust +/// use chronomancer::utils::filters::filter_positive_integer; +/// +/// assert_eq!(filter_positive_integer("007"), Some("7".to_string())); // Normalized +/// ``` +``` + +## Writing Good Prose Documentation + +### Be Concise and Actionable + +```rust +/// Icon-based radio button that changes style when active. +/// +/// Create with `new(index, icon_name)`, render with `view(is_active, message)`. +``` + +### Reference Related Types + +```rust +/// Radio button group manager with selection state. +/// +/// Create with `RadioComponents::new(options)`, render with `.view(on_select)`. +/// Options must implement [`RadioComponent`]. +``` + +### Explain Key Concepts + +```rust +/// Padding helpers for consistent container padding. +/// +/// Provides methods for generating padding arrays in the format expected by +/// COSMIC widgets: `[top, right, bottom, left]`. +``` + +### Link to Working Examples + +```rust +/// Form component for power management operations. +/// +/// See `pages/power_controls.rs` for usage in a complete page. +``` + +## Running Tests + +```bash +# Run all tests (unit + integration + doctests) +cargo test + +# Run only doctests +cargo test --doc + +# Run doctests for specific module +cargo test --doc utils::filters + +# Run only unit tests +cargo test --lib +``` + +## Adding New Code + +### For Utility Functions + +1. Write the function +2. Add rustdoc with doctest examples +3. Run `cargo test --doc` to verify + +### For Components + +1. Write the component +2. Add concise rustdoc explaining usage +3. Reference working code if needed +4. **Don't** add doctest examples + +## Migration Notes + +This policy was adopted after initially writing comprehensive doctests for all modules. We removed ~200 lines of GUI example boilerplate while keeping 53 passing doctests for utility functions. The result: + +- **Cleaner code** - Less noise in documentation +- **Faster development** - No boilerplate tax +- **Same test coverage** - Pure logic still validated +- **Better documentation** - Prose is clearer than forced examples + +## See Also + +- [Rust Book: Documentation Tests](https://doc.rust-lang.org/book/ch14-02-publishing-to-crates-io.html#documentation-comments-as-tests) +- [rustdoc Guide](https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html) +- Project: `.github/copilot-instructions.md` - Overall testing strategy \ No newline at end of file diff --git a/.github/icon-theming-notes.md b/.github/icon-theming-notes.md deleted file mode 100644 index c08b43f..0000000 --- a/.github/icon-theming-notes.md +++ /dev/null @@ -1,137 +0,0 @@ -# Icon Theming in COSMIC/libcosmic - -## Key Finding: Custom SVG Widgets Don't Support Theme Colors - -### The Problem - -When embedding custom SVG icons using `include_bytes!()` and `svg::Handle`, the `currentColor` attribute **does not respect COSMIC's theme system**. The SVG renders as-is with literal colors. - -### Why This Happens - -1. **`icon::from_name()`** - Uses COSMIC's `Icon` widget which: - - Looks up icons through the system icon theme - - Applies theme colors (accent, text colors) automatically - - Works with symbolic icons that use `currentColor` - -2. **`svg::Handle::from_memory()`** - Uses iced's raw SVG widget which: - - Renders SVG exactly as provided - - Does NOT apply theme transformations - - Treats `currentColor` as literal black (default color) - -### The Solution - -**Use system icon names** that exist in the icon theme instead of embedding custom SVGs: - -```rust -// ✅ This works - accent color applied automatically -icon::from_name("system-shutdown-symbolic") - .size(40) - .into() - -// ❌ This doesn't theme - renders black regardless of theme -let svg_data = include_bytes!("custom-icon.svg"); -let handle = svg::Handle::from_memory(svg_data); -widget::svg(handle).width(40).height(40).into() -``` - -### Alternative: Install Custom Icons to System Theme - -If you really want custom icons with theming: - -1. **Create proper symbolic icons** (monochrome, `fill="currentColor"`) -2. **Install to icon theme directory:** - ```bash - ~/.local/share/icons/YourTheme/scalable/actions/your-icon-symbolic.svg - ``` -3. **Use with `icon::from_name()`:** - ```rust - icon::from_name("your-icon-symbolic").size(40) - ``` - -### Icon Name Conventions - -System icon names follow freedesktop.org standards: -- **Symbolic suffix:** `-symbolic` for monochrome theme-adaptive icons -- **Categories:** `system-*`, `weather-*`, `media-*`, etc. -- **Standard names:** - - `system-shutdown-symbolic` - - `system-suspend-symbolic` - - `system-log-out-symbolic` - - `system-lock-screen-symbolic` - - `weather-clear-night-symbolic` (good for "stay awake" metaphor) - -### Theme Button Classes - -When using icon buttons, apply the correct theme class: - -```rust -button::custom(icon::from_name("system-shutdown-symbolic").size(40)) - .class(theme::Button::Icon) // ✅ Applies accent color to Icon widgets - .into() -``` - -The `Button::Icon` class tells COSMIC to colorize the icon with the accent color, but this only works with `Icon` widgets, not raw `svg` widgets. - -## Verification from BeautyVulpine Theme - -The BeautyVulpine custom theme icons use **hardcoded gradients**, not `currentColor`: - -```xml - - - - -``` - -This means even theme-installed icons don't automatically adapt to the active theme unless they're designed as symbolic icons with `currentColor`. - -## Recommendation - -For the Chronomancer applet: -1. Use standard system icon names for power controls -2. Document which icons represent which actions -3. Consider contributing symbolic icon designs to COSMIC if standard names don't fit the use case - -Standard icons are: -- **Predictable** - Users recognize them across COSMIC apps -- **Theme-consistent** - Always match the active accent color -- **Maintained** - Updated by COSMIC/freedesktop.org icon theme maintainers - -## Chronomancer Solution: Install Custom Icons During Build - -We've configured the justfile to automatically install custom icons with the `chronomancer-` prefix: - -### Workflow - -1. **Create custom icons** in `resources/icons/hicolor/scalable/apps/` - - Design as symbolic icons with `fill="currentColor"` - - Name descriptively (e.g., `stay-awake.svg`) - -2. **Install icons** with `just install` - - Icons are automatically copied to system theme directory - - Prefixed with `chronomancer-` (e.g., `chronomancer-stay-awake.svg`) - -3. **Use in code** with `icon::from_name()` - ```rust - icon::from_name("chronomancer-stay-awake").size(40) - ``` - -### Justfile Configuration - -```makefile -# Install all custom icons with chronomancer- prefix -install: - @for icon in resources/icons/hicolor/scalable/apps/*.svg; do \ - basename="$$(basename "$$icon")"; \ - if [ "$$basename" != "hourglass.svg" ]; then \ - install -Dm0644 "$$icon" /usr/share/icons/hicolor/scalable/apps/chronomancer-"$$basename"; \ - fi \ - done -``` - -This approach: -- ✅ Icons get proper theme adaptation via `Icon` widget -- ✅ Namespaced with `chronomancer-` prefix (no conflicts) -- ✅ Automatically installed during `just install` -- ✅ Works with COSMIC's accent color system -- ✅ No code changes needed for new icons diff --git a/.github/macro-explanations.md b/.github/macro-explanations.md deleted file mode 100644 index d3212b9..0000000 --- a/.github/macro-explanations.md +++ /dev/null @@ -1,198 +0,0 @@ -# Macro Explanations for Chronomancer - -This document explains the custom macros used in the Chronomancer codebase. I am exploring Rust macros and documenting them here for future reference. As cosmic is still extremely new, there aren't strong opinions on best practices and patterns yet, so this can and will evolve over time. Agentic AI is being used to help generate and maintain this documentation but I'm adding my insisghts and explanations to the templates in effect making these logs more journals of my learning process than documentation or standards. - -## `component_messages!` Macro - -**Location:** `src/components/mod.rs` - -### Purpose - -Generates `From` trait implementations for converting component messages to parent messages. This reduces boilerplate when mapping component-specific messages to a parent enum, allowing you to use `.map(Message::from)` instead of `.map(Message::PowerMessage)`. - -### Syntax - -```rust -component_messages!(ParentEnum { - Variant1 => ChildType1, - Variant2 => ChildType2, - // ... more mappings -}); -``` - -### Example Usage - -```rust -#[derive(Debug, Clone)] -pub enum Message { - DatabaseMessage(DatabaseMessage), - TimerMessage(TimerMessage), - PowerMessage(PowerMessage), -} - -// Generate From implementations for all component messages -component_messages!(Message { - DatabaseMessage => DatabaseMessage, - TimerMessage => TimerMessage, - PowerMessage => PowerMessage, -}); -``` - -### What It Generates - -For each mapping, the macro generates: - -```rust -impl From for ParentMessage { - fn from(msg: ChildMessage) -> Self { - ParentMessage::Variant(msg) - } -} -``` - -So for the example above, it generates: - -```rust -impl From for Message { - fn from(msg: DatabaseMessage) -> Self { - Message::DatabaseMessage(msg) - } -} - -impl From for Message { - fn from(msg: TimerMessage) -> Self { - Message::TimerMessage(msg) - } -} - -impl From for Message { - fn from(msg: PowerMessage) -> Self { - Message::PowerMessage(msg) - } -} -``` - -### Using the Generated Code - -Once the macro has generated the `From` implementations, you can convert messages easily: - -```rust -// BEFORE: Explicit variant wrapping -let power = self.power_controls.view().map(Message::PowerMessage); - -// AFTER: Automatic conversion via From trait -let power = self.power_controls.view().map(Message::from); -``` - -Or in other contexts: - -```rust -// Direct conversion -let power_msg = PowerMessage::ToggleStayAwake; -let app_msg: Message = power_msg.into(); // Becomes Message::PowerMessage(PowerMessage::ToggleStayAwake) -``` - -### Macro Breakdown - -```rust -#[macro_export] -macro_rules! component_messages { - // Pattern match: ParentEnum { Variant => ChildType, ... } - ($parent:ident { $($variant:ident => $child:ty),* $(,)? }) => { - $( - // Generate one From impl per mapping - impl From<$child> for $parent { - fn from(msg: $child) -> Self { - $parent::$variant(msg) - } - } - )* - }; -} -``` - -**Macro Parameters Explained:** - -- `$parent:ident` - The parent enum name (e.g., `Message`, `AppMessage`) - - `:ident` means "identifier" - a name/type - -- `{ $($variant:ident => $child:ty),* $(,)? }` - List of variant mappings: - - `$(...)* ` - Repeat this pattern zero or more times - - `$variant:ident` - The parent enum variant name (e.g., `Power`, `Timer`) - - `$child:ty` - The child message type (e.g., `PowerMessage`, `TimerMessage`) - - `:ty` means "type" - - `$(,)?` - Optional trailing comma support - -**Generated Code Pattern:** - -The `$(...)* ` repetition means for each `Variant => Type` pair, generate: - -```rust -impl From for ParentEnum { - fn from(msg: Type) -> Self { - ParentEnum::Variant(msg) - } -} -``` - -### Benefits - -1. **Less Boilerplate:** No need to manually write `From` implementations -2. **Type Safety:** Compiler enforces correct types -3. **Cleaner Code:** `.map(Message::from)` is shorter and more idiomatic than `.map(Message::PowerMessage)` -4. **Easy to Maintain:** Add new component messages in one place -5. **Scalable:** Works with any number of component message types - -### Rust Macro Concepts (For Beginners) - -If you're coming from JavaScript/TypeScript/PHP, here's how to think about Rust macros: - -**Macros are NOT functions** - they run at compile time and generate code before compilation. - -Think of them like: -- **JavaScript template literals** - but for code generation -- **PHP code generators** - but built into the language -- **TypeScript type generators** - but for actual code - -**Key differences from functions:** - -```rust -// Function: Runs at runtime, takes values -fn add(a: i32, b: i32) -> i32 { a + b } - -// Macro: Runs at compile time, takes code patterns -macro_rules! repeat_twice { - ($code:expr) => { - $code; - $code; - }; -} - -// Usage: -repeat_twice!(println!("Hello")); -// Expands to: -// println!("Hello"); -// println!("Hello"); -``` - -**Macro syntax quick reference:** - -- `$name:ident` - An identifier (variable/type name) -- `$name:ty` - A type -- `$name:expr` - An expression -- `$(...)* ` - Repeat zero or more times -- `$(...)+` - Repeat one or more times -- `$(,)?` - Optional trailing comma - -**Why use macros?** - -1. **Reduce repetitive code** - Like our `component_messages!` macro -2. **Create DSLs** - Like `vec![1, 2, 3]` or `println!("Hello")` -3. **Compile-time code generation** - Zero runtime cost -4. **Type-safe metaprogramming** - Unlike string-based code generation - -### Further Reading - -- [The Rust Book - Macros](https://doc.rust-lang.org/book/ch19-06-macros.html) -- [Rust by Example - Macros](https://doc.rust-lang.org/rust-by-example/macros.html) -- [The Little Book of Rust Macros](https://veykril.github.io/tlborm/) diff --git a/.github/ui-spacing-guide.md b/.github/ui-spacing-guide.md deleted file mode 100644 index b0c9af7..0000000 --- a/.github/ui-spacing-guide.md +++ /dev/null @@ -1,199 +0,0 @@ -# UI Spacing & Sizing Guide - -This guide explains the UI spacing and sizing standards for Chronomancer, centralized in `src/utils/ui/spacing.rs`. There's very little documentation on good UI practices in cosmic but in general it follows common design principles and favors being responsive. Fixed numbers are avoided in favor of semantic constants and responsive sizing functions more often than not. I was getting annoyed with extremely verbose use statements and formatting withing components, so this guide is just some personal notes on reducing boilerplate and improving consistency. Not sure this is useful to anyone else but me, but maybe it will be! - -## Philosophy - -- **No magic numbers**: Use semantic constants instead of hardcoded values -- **Responsive by default**: Adapt to container size when possible -- **COSMIC-first**: Leverage the COSMIC theme system for consistency -- **DRY**: Define once, use everywhere. Rust is not amazing for reuse in the way an OOP language would be (might be a skill issue with a rust noob like me) but good module and function structure can still reduce a lot of duplication. - -## Quick Reference - -### Component Sizes - -```rust -use crate::utils::ui::ComponentSize; - -// Icon buttons -ComponentSize::ICON_BUTTON_HEIGHT // 48.0 -ComponentSize::ICON_BUTTON_WIDTH // 48.0 -ComponentSize::ICON_SIZE // 36 - -// Quick timer buttons -ComponentSize::QUICK_TIMER_BUTTON_HEIGHT // 40.0 - -// Text inputs -ComponentSize::INPUT_MIN_WIDTH // 60.0 - -// Typography -ComponentSize::HEADER_TEXT_SIZE // 24.0 -ComponentSize::BODY_TEXT_SIZE // 14.0 -``` - -### Spacing (Gaps) - -```rust -use crate::utils::ui::Gaps; - -Gaps::xxs() // Extra extra small - very tight spacing -Gaps::xs() // Extra small - related items within a group -Gaps::s() // Small - grouping related elements -Gaps::m() // Medium - separating distinct groups -Gaps::l() // Large - major sections -Gaps::xl() // Extra large - top-level sections -Gaps::xxl() // Extra extra large - major visual breaks -``` - -### Padding - -```rust -use crate::utils::ui::Padding; - -Padding::none() // [0, 0, 0, 0] -Padding::tight() // [xxs, xxs, xxs, xxs] -Padding::standard() // [xs, xs, xs, xs] -Padding::comfortable() // [s, s, s, s] -Padding::spacious() // [m, m, m, m] - -Padding::horizontal(Gaps::s()) // [0, s, 0, s] -Padding::vertical(Gaps::m()) // [m, 0, m, 0] -Padding::custom(1, 2, 3, 4) // [top, right, bottom, left] -``` - -### Length Helpers - -```rust -use crate::utils::ui::{fill, fixed, shrink}; - -button.width(fill()) // Length::Fill -button.height(fixed(48.0)) // Length::Fixed(48.0) -text.width(shrink()) // Length::Shrink -``` - -### Responsive Sizing - -```rust -use crate::utils::ui::ResponsiveSize; - -// Adapt icon size based on container width -let icon_size = ResponsiveSize::icon_for_width(width); -// 0-200px -> 24 -// 201-300px -> 32 -// 301-400px -> 36 -// 400+px -> 40 - -// Adapt button height based on container width -let button_height = ResponsiveSize::button_height_for_width(width); - -// Adapt spacing based on container width -let gap = ResponsiveSize::gap_for_width(width); -``` - -## Usage Examples - -### Before (with magic numbers) - -```rust -column![buttons] - .padding([8, 0]) - .spacing(12) - .into() - -button.height(Length::Fixed(48.0)) -``` - -### After (with spacing helpers) - -```rust -use crate::utils::ui::{Gaps, Padding, ComponentSize, fixed}; - -column![buttons] - .padding(Padding::vertical(Gaps::xs())) - .spacing(Gaps::s()) - .into() - -button.height(fixed(ComponentSize::ICON_BUTTON_HEIGHT)) -``` - -### Building a Responsive Component - -```rust -use crate::utils::ui::{ComponentSize, Gaps, Padding, ResponsiveSize, fill, fixed}; - -fn view(&self, container_width: f32) -> Element { - let icon_size = ResponsiveSize::icon_for_width(container_width); - let gap = ResponsiveSize::gap_for_width(container_width); - - row![ - icon_button("my-icon", icon_size), - text_input() - ] - .spacing(gap) - .padding(Padding::standard()) - .into() -} -``` - -## When to Use Each Gap Size -* I'm not a UX expert, but based on common design principles in web and mobile, here are some guidelines for when to use each gap size: -| Gap Size | Use Case | Example | -|----------|----------|---------| -| `xxs()` | Items that must be visually connected | Form label + input | -| `xs()` | Related items in a group | Buttons in a toolbar | -| `s()` | Distinct but related sections | Different control groups | -| `m()` | Major sections | Page sections | -| `l()` | Top-level layout | Header from content | -| `xl()` | Dramatic separation | Modal from background | -| `xxl()` | Rarely needed | Large empty states | - -## Component-Specific Guidelines - -### Icon Buttons -- Use `ComponentSize::ICON_BUTTON_HEIGHT` for height -- Use `ComponentSize::ICON_SIZE` for icon dimensions -- Space with `Gaps::s()` in toolbars - -### Quick Timer Buttons -- Use `ComponentSize::QUICK_TIMER_BUTTON_HEIGHT` -- Space with `Gaps::xs()` for tight groups - -### Forms -- Use `ComponentSize::INPUT_MIN_WIDTH` for inputs -- Space form fields with `Gaps::s()` -- Use `Padding::standard()` for form containers - -### Text -- Headers: `ComponentSize::HEADER_TEXT_SIZE` -- Body: `ComponentSize::BODY_TEXT_SIZE` -- Space header from content with `Gaps::s()` - -## Adding New Standards - -When you need to add a new standard: - -1. **For sizes**: Add to `ComponentSize` struct -2. **For responsive logic**: Add to `ResponsiveSize` impl -3. **For new gap semantics**: Consider if existing gaps suffice first -4. **Update this guide**: Document the new standard. I'm using AI for writing guide templates when I make a design decision to at least make some attempt at an opinionated and reusable framework for future me and others. Rather than let it write code, this just consolidates my thoughts for both LLMs and other devs. - -Example: -```rust -// In src/utils/ui/spacing.rs -impl ComponentSize { - pub const NEW_COMPONENT_HEIGHT: f32 = 64.0; -} -``` - -## Testing - -Not doing that yet but ideally we would have visual regression tests to ensure spacing consistency across UI changes. However, that type of testing is beyond my general expertise (I'm only experienced in that sort of thing regarding responsive CSS and even then, it's not my strongest area) and current project scope. If implemented, it's suggested to keep it extremely simple and broad as libcosmic is still under heavy beta development and examples of iced testing are sparse to say the least. - -## Possible Future Improvements - -- Dynamic theme switching (light/dark adjustments) -- Accessibility scaling factors -- Adding layouts that automatically apply these spacing standards (e.g., grids, fractional rows/columns, etc.) -- Better use of iced's built-in layout features (Admittedly I haven't explored iced's layout system in depth yet and am likely missing out on some useful features in favor of simplicity and just getting this functional) -- Using macros to reduce boilerplate in applying spacing/padding diff --git a/.github/architectural-idioms.md b/.journal/architectural-idioms.md similarity index 81% rename from .github/architectural-idioms.md rename to .journal/architectural-idioms.md index 1cc3c63..4295243 100644 --- a/.github/architectural-idioms.md +++ b/.journal/architectural-idioms.md @@ -274,6 +274,79 @@ See the following files for reference: --- +## Module Organization: Components vs Utils + +### The Distinction + +Chronomancer organizes code into two primary module types with clear responsibilities: + +#### Components (`src/components/`) + +**Purpose**: GUI-related functionality that participates in the MVU (Model-View-Update) cycle. + +**Characteristics**: +- Contains UI-related structs and logic +- Implements `view()` methods that return `Element` +- May implement `update()` methods or public state manipulation methods +- Accepts message constructors as parameters (message-agnostic) +- Stores UI state (form values, selections, visual state) +- Deals with user interactions and rendering + +**Examples**: +- `PowerForm` - Form component with text input and combo box +- `ToggleIconRadio` - Icon-based radio button component +- `RadioComponents` - Generic radio button group manager + +**What belongs here**: +- Reusable UI widgets +- Form components +- Custom controls +- Layout helpers that manage UI state + +**What does NOT belong here**: +- Pure functions without UI concerns +- Data transformation logic +- Validation functions that don't render UI +- File I/O, database, or network logic + +#### Utils (`src/utils/`) + +**Purpose**: Non-UI helper functions and utilities that don't participate in the message system. + +**Characteristics**: +- Pure functions or simple structs +- No `view()` or `update()` methods +- No message types or `Element` returns +- Stateless or simple state containers +- Reusable across components, pages, and services + +**Examples**: +- `filters::filter_positive_integer()` - Text input validation +- `time::format_duration()` - Duration formatting +- `resources::system_icon()` - Icon loading helper +- `database::Repository` - Database abstraction trait + +**What belongs here**: +- Text filters and validators +- Formatters and parsers +- Resource loaders +- Type definitions (enums, structs for data) +- Database interfaces +- System integration helpers (D-Bus, systemd) + +**What does NOT belong here**: +- Anything that renders UI +- Anything that handles messages +- Stateful UI components + +### References + +- Components: `src/components/` directory +- Utils: `src/utils/` directory +- Example of proper separation: `utils/filters.rs` (utilities) used by `components/power_form.rs` (UI component) + +--- + ## 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. diff --git a/.journal/doctest-guide.md b/.journal/doctest-guide.md new file mode 100644 index 0000000..053ca12 --- /dev/null +++ b/.journal/doctest-guide.md @@ -0,0 +1,206 @@ +# Documentation Testing Guide + +## Overview + +Chronomancer uses Rust's built-in **documentation testing** (doctests) to validate code examples in our API documentation. When you run `cargo test`, Rust automatically compiles and runs code blocks in `///` doc comments. +Is this immense overkill for a project this small? Almost certainly! But I saw that doctests are a thing and really wanted to check it out. And because I'm in charge, NONE SHALL STOP MY CAPRICIOUS WHIMS! >:3 + +## Current Status (as of writing) + +- **53 passing doctests** - Core logic and utilities are fully tested +- **18 ignored doctests** - UI examples marked as illustrative only +- **0 failing doctests** - All examples either pass or are explicitly ignored + +## Doctest Annotations + +Rust supports three doctest modes looks like: + +### ````rust` - Compile and Run + +Use for **pure functions** and **testable logic**: + +```rust +/// Filters input to only accept positive integers. +/// +/// # Examples +/// +/// ```rust +/// use chronomancer::utils::filters::filter_positive_integer; +/// +/// assert_eq!(filter_positive_integer("42"), Some("42".to_string())); +/// assert_eq!(filter_positive_integer("0"), None); +/// ``` +``` + +✅ **Use when:** +- Testing utility functions (filters, formatters, converters) +- Examples can be self-contained +- Logic is deterministic and doesn't require external state + +✅ **Benefits:** +- Free integration tests +- Examples stay up-to-date with code +- API contracts are validated automatically + +### ````rust,no_run` - Compile Only + +Use for **examples that compile but shouldn't execute**: + +```rust +/// Creates a new toggle radio button. +/// +/// # Examples +/// +/// ```rust,no_run +/// use chronomancer::components::icon_button::ToggleIconRadio; +/// +/// fn example() { +/// let button = ToggleIconRadio::new(0, "system-suspend-symbolic"); +/// let _element = button.view(true, Message::Selected); +/// } +/// ``` +``` + +✅ **Use when:** +- Example compiles but needs runtime context to execute +- Demonstrating API usage patterns +- Type signatures need validation + +❌ **Avoid when:** +- Example has lifetime issues or requires complex setup +- Better to use `ignore` and simplify the example + +### ````rust,ignore` - Don't Compile + +Use for **illustrative pseudocode** and **complex UI examples**: + +```rust +/// Displays radio buttons with custom spacing. +/// +/// # Examples +/// +/// ```rust,ignore +/// use chronomancer::components::radio_components::RadioComponents; +/// +/// let options = vec![ +/// ToggleIconRadio::new(0, "icon-one"), +/// ToggleIconRadio::new(1, "icon-two"), +/// ]; +/// +/// let radio_group = RadioComponents::new(options); +/// radio_group.view(|index| Message::Selected(index)) +/// ``` +``` + +✅ **Use when:** +- UI components that need full cosmic/libcosmic context +- Widget examples with macro complexity (e.g., `column!`, `row!`) +- Lifetime issues would require extensive boilerplate +- Focus is on showing usage patterns, not testing correctness + +## Categories in Chronomancer + +### Always Test (````rust`) + +These modules have **real, executable doctests** because they are nice and boring with none of that graphical interface and multithreading bullshit: + +- `utils/filters.rs` - Text input validation +- `utils/time.rs` - Time formatting and conversion +- `utils/resources.rs` - System integration helpers + +### Always Ignore (````rust,ignore`) + +These modules use **illustrative examples only** and basically exist for reading the nifty HTML that cargo generates for me in the target/doc folder: + +- `components/**` - UI components (need widget context) +- `utils/ui/spacing.rs` - Layout helpers (need widget macros) + +## Best Practices + +### ✅ DO + +1. **Write real tests for pure functions** + ```rust + /// ```rust + /// assert_eq!(my_function(5), 10); + /// ``` + ``` + +2. **Keep examples focused and minimal** + ```rust + /// ```rust,ignore + /// // Just show the key API call + /// let result = do_thing(arg); + /// ``` + ``` + +3. **Use `ignore` for complex UI code** + ```rust + /// ```rust,ignore + /// column![] + /// .spacing(Gaps::s()) + /// ``` + ``` + +4. **Add context comments when needed** + ```rust + /// ```rust + /// let filtered = filter_positive_integer("007"); + /// assert_eq!(filtered, Some("7".to_string())); // Normalized + /// ``` + ``` + +### ❌ DON'T + +1. **Don't write complex setup just to make it compile** + - If it needs more than 3 lines of boilerplate, use `ignore` + +2. **Don't ignore tests that could easily pass** + - Pure functions should always be tested + +3. **Don't return borrowed data in examples** + - Causes lifetime issues; assign to `_variable` instead + +4. **Don't use `no_run` when `ignore` is clearer** + - If it won't compile, use `ignore` not `no_run` + +## Running Doctests + +```bash +# Run all tests (including doctests) +cargo test + +# Run only doctests +cargo test --doc + +# Run doctests for specific module +cargo test --doc utils::filters + +# Show ignored doctests +cargo test --doc -- --ignored +``` + +## Maintenance + +When adding new documentation: + +1. **Start with ````rust`** for pure functions +2. **Use ````rust,ignore`** for UI/component examples +3. **Run `cargo test --doc`** to verify +4. **Fix failures by:** + - Fixing the example (if trivial) + - Adding `ignore` (if complex) + +## Philosophy + +> **Documentation examples should teach, not test UI wiring.** + +- **Teach:** Show how to call the function +- **Don't test:** Widget macro syntax and cosmic internals + +I'll be completely honest: While this is really neat because it makes your documentation both accurate and functional, holy moly is it ever a f#@*ton of overhead! I also kind of hate how it clogs files and makes it more annoying to just modify your code. I'm trying really hard to learn as much about Rust as I can, so I'm exploring all the features and working with their idioms. While I think this is important, I'm likely going to create a branch every release that has the doctests so the main codebase is cleaner. We'll see based on how annoyed I get lmao. One other thing of note: I don't let AI write code for me AND NEITHER SHOULD YOU, but I do let it help me write documentation and test. Doctests involve repetitive boilerplate that follows a strict pattern, so it's a natural fit. + +## See Also + +- [Rust Book: Documentation Tests](https://doc.rust-lang.org/book/ch14-02-publishing-to-crates-io.html#documentation-comments-as-tests) +- [rustdoc Guide: Documentation Tests](https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html) diff --git a/.github/flatpak.md b/.journal/flatpak.md similarity index 100% rename from .github/flatpak.md rename to .journal/flatpak.md diff --git a/.github/integration-testing.md b/.journal/integration-testing.md similarity index 100% rename from .github/integration-testing.md rename to .journal/integration-testing.md diff --git a/.github/iterator-patterns.md b/.journal/iterator-patterns.md similarity index 100% rename from .github/iterator-patterns.md rename to .journal/iterator-patterns.md diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..e825686 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,15 @@ +{ + "lsp": { + "rust-analyzer": { + "initialization_options": { + "diagnostics": { + "disabled": [ + "unresolved-proc-macro", + "macro-error", + "macro-backtrace", + ], + }, + }, + }, + }, +} diff --git a/Cargo.lock b/Cargo.lock index 8e98230..1581be9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -802,7 +802,7 @@ dependencies = [ [[package]] name = "chronomancer" -version = "0.1.0" +version = "0.1.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index f0d62d4..8986589 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chronomancer" -version = "0.1.2" +version = "0.1.3" edition = "2024" license = "MIT" description = "An applet fonaging system timers" diff --git a/README.md b/README.md index 39f7c5b..e5d1ae6 100644 --- a/README.md +++ b/README.md @@ -117,10 +117,12 @@ If you find this project useful and would like to support my further involvement ## Important Note / Rant -Agentic AI has been used to generate document templates and rapidly prototype design patterns in the .github folder. COSMIC is still extremely new, and there aren't strong opinions on best practices and patterns outside of MVU yet, so this can and will evolve over time. This documentation serves more as a journal of my learning process and design decisions with AI summarizing the choices made. Only rough structural code output by AI is used in production and is only meant to serve as high level examples of possible approaches. I'm against outsourcing critical thinking but I do see the value in using AI to help brainstorm and explore ideas rapidly or doing super tedious stuff like testing and automation. I find a rubber duck that talks back and writes notes and snippets of patterns I've whiteboarded to be super useful tbh. +Agentic AI has been used to generate documentation and rapidly prototype design patterns in the .github folder. COSMIC is still extremely new, and there aren't strong opinions on best practices and patterns outside of MVU yet, so this can and will evolve over time. This documentation serves more as a journal of my learning process and design decisions with AI summarizing the choices made than anything. Only rough structural code output by AI and is only meant to serve as high level examples of possible approaches. After all, it's optimal for templatized and tedious tasks that don't involve as much reasoning. These journal documents are a good example. It writes the skeleton and I yippity yap my thoughts and experiences into it. On the off chance you're a programmer reading about my dumb little project, don't be demoralized that AI is everywhere now. Remember that you're in charge and AI still makes shit up all the time. Hang in there. Just because knowing a language isn't enough to be competitive in the job market anymore doesn't mean that you don't have a role. It's up to you to actually KNOW how things work and be able to maintain them. I've always felt technology is always supposed to make life better for humans and in my own microscopic way, I want to contribute to that. Leave the tedium to skynet and don't give up on the world of computing or yourself. + "Dev who uses tools makes a good dev, tools who use devs makes the dev a tool - Codefucius (MY NEW OC, DON'T STEAL)" + ## License MIT License (see [LICENSE](./LICENSE) file) diff --git a/src/app.rs b/src/app.rs index e13e72c..10f98fa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -31,8 +31,9 @@ const APP_ID: &str = "io.vulpapps.Chronomancer"; // const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY"); // const APP_ICON: &[u8] = include_bytes!("../resources/icons/hicolor/scalable/apps/hourglass.svg"); -/// The application model stores app-specific state used to describe its interface and -/// drive its logic. +/// Application model for the Chronomancer applet. +/// +/// The application model stores app-specific state and handles messages. pub struct AppModel { /// Application state which is managed by the COSMIC runtime. core: Core, @@ -57,6 +58,10 @@ pub struct AppModel { } /// Create a COSMIC application from the app model +/// +/// The application implements the `Application` trait from COSMIC, +/// defining the app's lifecycle, view, update logic, and subscriptions. +/// This is the main entry point for the applet proper. impl Application for AppModel { type Executor = cosmic::executor::Default; type Flags = (); @@ -73,8 +78,10 @@ impl Application for AppModel { } /// Initializes the application with any given flags and startup commands. + /// + /// We initialize the app model with default state, load configuration, and start the database connection here. + /// It's also where keybinds will go if/when implemented. fn init(core: cosmic::Core, _flags: Self::Flags) -> (Self, Task>) { - // Construct the app model with the runtime's core. let app = AppModel { core, // key_binds: HashMap::new(), @@ -116,6 +123,16 @@ impl Application for AppModel { } /// Define the view window for the application. + /// + /// This method constructs the popup window content when it is open. + /// + /// # Arguments + /// + /// - `id`: The window ID to render + /// + /// # Returns + /// + /// An `Element` representing the window content. fn view_window(&self, id: window::Id) -> Element<'_, Message> { if matches!(self.popup, Some(p) if p == id) { let Spacing { space_m, .. } = theme::active().cosmic().spacing; @@ -141,6 +158,8 @@ impl Application for AppModel { } /// Describes the interface based on the current state of the application model. + /// + /// This method constructs the icon button displayed in the system tray, NOT the applet popup window. fn view(&'_ self) -> Element<'_, Message> { self.core .applet @@ -164,6 +183,13 @@ impl Application for AppModel { /// /// Tasks may be returned for asynchronous execution of code in the background /// on the application's async runtime. + /// + /// # Arguments + /// + /// - `message`: The message to handle. + /// + /// # Returns + /// The task (or batch of tasks) to be executed. fn update(&mut self, message: Self::Message) -> Task> { let task: Task> = match message { Message::TogglePopup => { @@ -223,8 +249,19 @@ impl Application for AppModel { } } +/// Helper functions for the application model. impl AppModel { - /// Helper function to send a notification + /// Sends a desktop notification with a 5-second timeout. + /// + /// Creates and displays a notification using the system notification daemon. + /// The notification is categorized as "device" and will automatically dismiss + /// after 5 seconds. Errors are logged to stderr but do not propagate. + /// + /// # Arguments + /// + /// - `summary`: Notification title + /// - `body`: Notification body text + /// - `icon`: Icon name from the freedesktop icon theme (e.g., "alarm", "battery") fn send_notification(summary: &str, body: &str, icon: &str) { if let Err(e) = Notification::new() .summary(summary) @@ -238,7 +275,25 @@ impl AppModel { } } - /// Helper function to create a power timer + /// Creates a power management timer and performs related UI/database operations. + /// + /// This is a high-level orchestration function that: + /// 1. Sends a desktop notification with the formatted timer duration + /// 2. Creates a `Timer` instance with the specified Arguments + /// 3. Closes the popup window + /// 4. Clears the power controls form + /// 5. Asynchronously inserts the timer into the database + /// + /// Returns a batched `Task` containing all these operations. If the database + /// is not yet initialized, returns `Task::none()` and logs an error. + /// + /// # Arguments + /// + /// - `time`: Duration in seconds + /// - `timer_type`: Type of power operation (Suspend, Shutdown, etc.) + /// - `notification_title`: Title for the desktop notification + /// - `notification_body_prefix`: Text prefix before the duration (e.g., "Suspending in") + /// - `icon`: Icon name for the notification fn create_power_timer( &mut self, time: i32, @@ -283,7 +338,25 @@ impl AppModel { ]) } - /// Toggles the main panel visibility. + /// Toggles the applet popup window open or closed. + /// + /// If a popup is currently open, it will be closed. If no popup exists, + /// a new one will be created with the configured size (500×500 default) + /// and minimum dimensions (300×150). + /// + /// The popup position is automatically determined by the panel location + /// (top, bottom, left, or right) via `get_popup_settings()`. + /// + /// # Test Context Behavior + /// + /// In test environments where no main window ID exists, the popup state + /// is still tracked in `self.popup`, but no actual window task is generated. + /// This allows testing popup logic without requiring a full GUI context. + /// + /// # Returns + /// + /// A `Task` that will create or destroy the popup window, or `Task::none()` + /// if running in a test context without a main window. fn toggle_popup(&mut self) -> Task { if let Some(p) = self.popup.take() { // Close the popup if it is open. @@ -311,6 +384,29 @@ impl AppModel { } } + /// Processes expired timers on each tick of the subscription interval. + /// + /// Called every second by the tick subscription to check for completed timers. + /// For each expired timer, this function: + /// 1. Determines the timer type (power operation or user-defined) + /// 2. Executes the appropriate action (system command or notification) + /// 3. Removes the timer from the active list + /// 4. Schedules database deletion + /// + /// Power operation timers trigger system actions (suspend, shutdown, logout, reboot) + /// via the power management message flow. User-defined timers show a desktop + /// notification with the timer description. + /// + /// # Implementation Note + /// + /// Database deletions are processed one per tick to avoid concurrent deletion + /// conflicts. Since ticks occur every second and sqlite database operations are fast, + /// this trade-off is prefereable to batching multiple deletions at once. + /// How often will we be processing multiple timers expiring simultaneously anyway? + /// + /// # Returns + /// + /// A batched `Task` containing all scheduled operations for this tick. fn handle_tick(&mut self) -> Task> { let mut tasks: Vec>> = vec![]; @@ -385,14 +481,27 @@ impl AppModel { Task::batch(tasks) } - /// Handle messages from pages - // Todo: If necessary, expand to route to multiple pages - // Handle messages from the power controls page + /// Routes power controls page messages to the appropriate handler. + /// + /// This function translates page-level messages from the power controls UI + /// into app-level power management messages. Most messages are forwarded + /// directly to `handle_power_message()`, while component-specific messages + /// are passed to the page's update method. + /// + /// # Current Routing + /// + /// - Timer creation messages → `handle_power_message()` + /// - Stay awake toggle → `handle_power_message()` + /// - Component messages → `power_controls.update()` + /// + /// # Future Expansion + /// + /// If additional pages are added (reminders, systemd timers), this pattern + /// can be extended with similar routing functions for each page type. 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) @@ -423,6 +532,18 @@ impl AppModel { } } + /// Handles database-related messages. + /// + /// This function processes messages related to database initialization, CRUD operations, and error handling. + /// These are app level messages exclusive to the database layer. + /// + /// # Arguments + /// + /// - `msg`: The database message to handle. + /// + /// # Returns + /// + /// A task representing the action to be performed. fn handle_database_message(&mut self, msg: DatabaseMessage) -> Task> { match msg { DatabaseMessage::Initialized(result) => { @@ -455,6 +576,19 @@ impl AppModel { Task::none() } + /// Handles timer-related messages. + /// + /// This function processes messages related to timer creation and fetching active timers. + /// This is app level messages exclusive to timer creation and management. + /// This is not where the timer ticks are handled as that is done with a subscription, not message. + /// + /// # Arguments + /// + /// - `msg`: The timer message to handle. + /// + /// # Returns + /// + /// Task representing the action to be performed. fn handle_timer_message(&mut self, msg: TimerMessage) -> Task> { match msg { TimerMessage::Created(result) => match result { @@ -478,6 +612,16 @@ impl AppModel { Task::none() } + /// Handles power management messages. + /// + /// This function processes messages related to power management actions such as toggling stay-awake mode, sleeping, and rebooting. + /// These are app level messages that trigger system power operations. + /// + /// # Arguments + /// - `msg`: The power message to handle. + /// + /// # Returns + /// Task representing the action to be performed. #[allow(clippy::too_many_lines)] fn handle_power_message(&mut self, msg: PowerMessage) -> Task> { // let _ = self.power_controls.update(&msg); @@ -485,7 +629,6 @@ impl AppModel { PowerMessage::ToggleStayAwake => { if let Some(inhibitor) = self.suspend_inhibitor.take() { resources::release_suspend_inhibit(inhibitor); - println!("Released suspend inhibitor"); } else { return AppModel::get_suspend_inhibitor(); } @@ -516,7 +659,6 @@ impl AppModel { } } PowerMessage::SetSuspendTime(time) => { - // We create a suspend inhibitor when setting a suspend timer so the timer overrides system settings let _inhibitor_task = AppModel::get_suspend_inhibitor(); return self.create_power_timer( @@ -616,6 +758,15 @@ impl AppModel { Task::none() } + /// Acquires a suspend inhibitor asynchronously. + /// + /// This prevents the system from falling asleep without overriding user settings. It uses zbus to request a suspend inhibit, relying on the logind service. + /// We use arc to wrap the result so it can be sent across thread boundaries safely and ensure there's only one active reference at a time. + /// It's a file descriptor under the hood so we need to keep it alive as long as we want to prevent sleep. + /// + /// # Returns + /// + /// A Task that resolves to an Action containing the result of the inhibitor acquisition. fn get_suspend_inhibitor() -> Task> { Task::perform( async move { diff --git a/src/app_messages.rs b/src/app_messages.rs index 403b2d5..95178bc 100644 --- a/src/app_messages.rs +++ b/src/app_messages.rs @@ -1,53 +1,129 @@ // SPDX-License-Identifier: MIT +//! Application message types for the Chronomancer panel applet. +//! +//! This module defines the message hierarchy used throughout the application. +//! We follow the Model-View-Update (MVU) architecture in libcosmic's documentation while +//! adapting it tho have a clear separation between UI pages and service layer operations. +//! Messages flow from UI interactions down to service layer operations and back +//! up as results. The main `AppMessage` enum dispatches to specialized message +//! types for logically divided subsystems (database, power management, timers, pages). +//! +//! ## Message Flow Pattern +//! +//! 1. User interacts with UI (button click, text input) +//! 2. Page generates a page-specific message (e.g., `power_controls::Message`) +//! 3. Page converts to `AppMessage` via `From` trait +//! 4. App's `update()` method routes to appropriate handler +//! 5. Handler performs async work, returns result as new message +//! 6. UI updates based on result message +//! +//! ## Examples +//! +//! Page-level messages convert automatically to app-level: +//! +//! ```rust +//! use chronomancer::{app_messages::AppMessage, pages::power_controls}; +//! +//! let page_msg = power_controls::Message::ToggleStayAwake; +//! let app_msg: AppMessage = page_msg.into(); +//! +//! // Verify the conversion preserves the message type +//! match app_msg { +//! AppMessage::PowerControlsMessage(power_controls::Message::ToggleStayAwake) => { +//! // Message was correctly wrapped +//! } +//! _ => panic!("Conversion failed"), +//! } +//! ``` + use std::{fs::File, sync::Arc}; use crate::{ config::Config, models::Timer, pages::power_controls, utils::database::SQLiteDatabase, }; -/// Database-related messages +/// Messages related to database operations. +/// +/// These messages represent the lifecycle of database initialization and results +/// from database queries. The database is initialized asynchronously on app startup. #[derive(Debug, Clone)] pub enum DatabaseMessage { + /// Database successfully initialized with the given connection Initialized(Result), + /// Database initialization failed with an error message FailedToInitialize(String), } -/// Power management-related messages +/// Messages related to power management operations. +/// +/// Handles stay-awake inhibit locks, timed power operations (suspend, logout, +/// shutdown, reboot), and immediate execution of those operations. Inhibit +/// locks prevent the system from sleeping while active without overriding user settings. #[derive(Debug, Clone)] pub enum PowerMessage { + /// Toggle the stay-awake inhibit lock on/off ToggleStayAwake, + /// Result of acquiring a systemd inhibit lock (wrapped in Arc for cheap cloning) InhibitAcquired(Arc>), + /// Schedule a suspend operation after the given number of seconds SetSuspendTime(i32), + /// Schedule a logout operation after the given number of seconds SetLogoutTime(i32), + /// Schedule a shutdown operation after the given number of seconds SetShutdownTime(i32), + /// Schedule a reboot operation after the given number of seconds SetRebootTime(i32), + /// Immediately execute a system suspend ExecuteSuspend, + /// Immediately execute a user logout ExecuteLogout, + /// Immediately execute a system shutdown ExecuteShutdown, + /// Immediately execute a system reboot ExecuteReboot, } -/// Timer-related messages +/// Messages related to timer operations. +/// +/// Represents results from timer creation and retrieval operations. Timers +/// are stored in the database and tracked for countdown display and notifications. #[derive(Debug, Clone)] pub enum TimerMessage { + /// Result of creating a new timer (contains the created Timer on success) Created(Result), + /// Result of fetching all active timers from the database ActiveFetched(Result, String>), } -/// Application-level messages +/// Top-level application messages that coordinate all subsystems. +/// +/// This is the main message type handled by the app's `update()` method. It +/// dispatches to specialized handlers based on message category. Page-specific +/// messages are automatically converted via the `From` trait implementations. #[derive(Debug, Clone)] pub enum AppMessage { + /// Toggle the applet's popup window open/closed TogglePopup, + /// Update the app configuration (triggers save to disk) UpdateConfig(Config), + /// Regular tick for timer countdown updates (fires every second) Tick, + /// Message from the power controls page (auto-converted via From trait) PowerControlsMessage(power_controls::Message), + /// Message from database operations DatabaseMessage(DatabaseMessage), + /// Message from timer operations TimerMessage(TimerMessage), + /// Message from power management operations PowerMessage(PowerMessage), } -/// Conversion implementations for page-level message routing +/// Automatic conversion from power controls page messages to app messages. +/// +/// Allows pages to return their own message types which are seamlessly converted +/// to `AppMessage` when passed up to the app's `update()` method. This keeps +/// page code decoupled from app-level concerns and lets us write messasge agnostic view and update functionsS. 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 874ded0..1b06a40 100644 --- a/src/components/icon_button.rs +++ b/src/components/icon_button.rs @@ -1,3 +1,13 @@ +//! Icon-based radio button components for toggle selection interfaces. +//! +//! This module provides [`ToggleIconRadio`], a radio button component that displays +//! an icon and changes visual style based on its active state. It implements the +//! [`RadioComponent`] trait for use in radio button groups. +//! +//! Create with `ToggleIconRadio::new(index, icon_name)`, then render with +//! `.view(is_active, message)`. Use with [`RadioComponents`](super::radio_components::RadioComponents) +//! for automatic selection state management. + use super::radio_components::RadioComponent; use crate::utils::{resources, ui::ComponentSize}; use cosmic::{ @@ -7,22 +17,56 @@ use cosmic::{ widget::{button, container}, }; -/// Struct representing a toggle-able `ToggleIconRadio` button +/// A radio button component that displays a system icon. +/// +/// `ToggleIconRadio` represents a single option in a radio button group. It displays +/// a system icon and changes its visual style (Suggested or Text theme) based on +/// whether it's the currently selected option. +/// +/// # Visual Behavior +/// +/// - **Active (selected)**: Uses `Button::Suggested` style (highlighted) +/// - **Inactive**: Uses `Button::Text` style (normal appearance) #[derive(Debug, Clone)] pub struct ToggleIconRadio { + /// The index of this option in its radio group. #[allow(dead_code)] pub index: usize, + + /// The system icon name to display (e.g., "system-suspend-symbolic"). + /// + /// This should be a valid icon name from the system icon theme or + /// a custom icon registered with the application. Use XDG icon names + /// for best compatibility. pub name: &'static str, } impl ToggleIconRadio { - /// Create a new `ToggleIconRadio` instance + /// Creates a new `ToggleIconRadio` button. + /// + /// # Arguments + /// + /// - `index` - The position of this option in its radio group + /// - `name` - The system icon name to display + /// #[must_use] pub fn new(index: usize, name: &'static str) -> Self { Self { index, name } } - /// Determine the button style based on its active state. + /// Determines the button style based on its active state. + /// + /// Returns the appropriate theme for the button: + /// - Active buttons use `Button::Suggested` (highlighted) + /// - Inactive buttons use `Button::Text` (default appearance) + /// + /// # Arguments + /// + /// - `is_active` - Whether this option is currently selected + /// + /// # Returns + /// + /// A [`theme::Button`] style to apply to the button. #[must_use] #[allow(clippy::unused_self)] pub fn button_style(&self, is_active: bool) -> theme::Button { @@ -35,7 +79,19 @@ impl ToggleIconRadio { } impl RadioComponent for ToggleIconRadio { - /// Render the toggle icon radio button. + /// Renders the toggle icon radio button as an [`Element`]. + /// + /// Creates a button containing the system icon, styled according to the + /// active state. The button fills available width and has a fixed height. + /// + /// # Arguments + /// + /// - `is_active` - Whether this option is currently selected + /// - `on_select` - Message to send when this option is clicked + /// + /// # Returns + /// + /// An [`Element`] that can be added to a view. fn view(&self, is_active: bool, on_select: Message) -> Element<'_, Message> where Message: Clone + 'static, diff --git a/src/components/mod.rs b/src/components/mod.rs index b3516a0..d0d2756 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,3 +1,36 @@ +//! Reusable UI components for the Chronomancer application. +//! +//! This module provides a collection of reusable components that can be composed +//! to build the application's user interface. Components follow libcosmic's patterns +//! and are designed to be generic, type-safe, and easy to integrate. +//! +//! # Component Categories +//! +//! ## Input Components +//! +//! - [`PowerForm`] - Time duration input form with unit selection and validation +//! +//! For text input filtering and validation helpers, see [`crate::utils::filters`]. +//! +//! ## Selection Components +//! +//! - [`RadioComponents`](radio_components::RadioComponents) - Generic radio button group manager +//! - [`ToggleIconRadio`] - Icon-based radio button option +//! - [`RadioComponent`](radio_components::RadioComponent) - Trait for implementing custom radio options +//! +//! ## Power Management +//! +//! - [`PowerOperation`](power_form::PowerOperation) - System power operation types (suspend, shutdown, etc.) +//! +//! # Design Principles +//! +//! All components in this module follow these principles: +//! +//! 1. **Generic over Message types** - Components work with any application message type +//! 2. **Composable** - Components can be nested and combined +//! 3. **Clone-able** - Components can be stored in application state +//! 4. **Documented** - Each component and method includes arguments, return values, and possible errors. Jury is still out on whether or not these will end up being doctests + pub mod icon_button; pub mod power_form; pub mod radio_components; diff --git a/src/components/power_form.rs b/src/components/power_form.rs index 261aec9..5ee09b0 100644 --- a/src/components/power_form.rs +++ b/src/components/power_form.rs @@ -1,3 +1,14 @@ +//! Power management form component with time input and operation selection. +//! +//! This module provides components for creating power management interfaces, including +//! time-based scheduling for system power operations (suspend, shutdown, reboot, etc.). +//! +//! # Components +//! +//! - [`PowerOperation`] - Enum representing different power management operations +//! - [`PowerForm`] - Form component for entering time duration and selecting time units +//! + use cosmic::{ Element, iced::{Alignment, Length::Fill, widget::column}, @@ -7,10 +18,36 @@ use cosmic::{ use crate::{ fl, - utils::{Padding, TimeUnit, ui::Gaps}, + utils::{Padding, TimeUnit, filters, ui::Gaps}, }; -/// Enum representing the different power operations +/// System power management operations. +/// +/// Represents the different power management actions that can be scheduled +/// or triggered by the application. Each operation has an associated icon, +/// index for UI selection, and localized text. +/// +/// # Variants +/// +/// - `StayAwake` - Prevent system from sleeping (keep awake mode) +/// - `Suspend` - Suspend system to RAM (sleep mode) +/// - `Shutdown` - Power off the system +/// - `Reboot` - Restart the system +/// - `Logout` - Log out current user session +/// +/// # Examples +/// +/// ```rust +/// use chronomancer::components::power_form::PowerOperation; +/// +/// // Create from radio button index +/// let operation = PowerOperation::from_index(1); +/// assert_eq!(operation, PowerOperation::Suspend); +/// +/// // Get operation properties +/// assert_eq!(operation.index(), 1); +/// assert_eq!(operation.icon_name(), "system-suspend-symbolic"); +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PowerOperation { StayAwake, @@ -21,7 +58,33 @@ pub enum PowerOperation { } impl PowerOperation { - /// Convert from radio button index to `PowerOperation` + /// Converts a radio button index to a `PowerOperation`. + /// + /// This maps UI selection indices to their corresponding power operations. + /// Invalid indices default to `Suspend`. + /// + /// # Arguments + /// + /// - `index` - Zero-based index from radio button selection + /// + /// # Returns + /// + /// The corresponding `PowerOperation`. Unknown indices return `Suspend` as fallback. + /// + /// # Examples + /// + /// ```rust + /// use chronomancer::components::power_form::PowerOperation; + /// + /// assert_eq!(PowerOperation::from_index(0), PowerOperation::StayAwake); + /// assert_eq!(PowerOperation::from_index(1), PowerOperation::Suspend); + /// assert_eq!(PowerOperation::from_index(2), PowerOperation::Logout); + /// assert_eq!(PowerOperation::from_index(3), PowerOperation::Reboot); + /// assert_eq!(PowerOperation::from_index(4), PowerOperation::Shutdown); + /// + /// // Invalid index defaults to Suspend + /// assert_eq!(PowerOperation::from_index(999), PowerOperation::Suspend); + /// ``` #[must_use] pub fn from_index(index: usize) -> Self { match index { @@ -33,7 +96,25 @@ impl PowerOperation { } } - /// Get the button index for this operation + /// Gets the radio button index for this operation. + /// + /// Returns the zero-based index used for UI selection and radio button positioning. + /// + /// # Returns + /// + /// The index corresponding to this operation (0-4). + /// + /// # Examples + /// + /// ```rust + /// use chronomancer::components::power_form::PowerOperation; + /// + /// assert_eq!(PowerOperation::StayAwake.index(), 0); + /// assert_eq!(PowerOperation::Suspend.index(), 1); + /// assert_eq!(PowerOperation::Logout.index(), 2); + /// assert_eq!(PowerOperation::Reboot.index(), 3); + /// assert_eq!(PowerOperation::Shutdown.index(), 4); + /// ``` #[must_use] pub const fn index(self) -> usize { match self { @@ -45,7 +126,37 @@ impl PowerOperation { } } - /// Get the icon name for this operation + /// Gets the system icon name for this operation. + /// + /// Returns the freedesktop.org icon name or custom application icon name + /// that should be displayed for this power operation. + /// + /// # Returns + /// + /// A static string containing the icon name. + /// + /// # Icon Names + /// + /// - `StayAwake`: `"io.vulpapps.Chronomancer-stay-awake"` (custom) + /// - Suspend: `"system-suspend-symbolic"` + /// - Logout: `"system-log-out-symbolic"` + /// - Reboot: `"system-reboot-symbolic"` + /// - Shutdown: `"system-shutdown-symbolic"` + /// + /// # Examples + /// + /// ```rust + /// use chronomancer::components::power_form::PowerOperation; + /// + /// assert_eq!( + /// PowerOperation::StayAwake.icon_name(), + /// "io.vulpapps.Chronomancer-stay-awake" + /// ); + /// assert_eq!( + /// PowerOperation::Suspend.icon_name(), + /// "system-suspend-symbolic" + /// ); + /// ``` #[must_use] pub const fn icon_name(self) -> &'static str { match self { @@ -57,7 +168,28 @@ impl PowerOperation { } } - /// Get the localized placeholder text for this operation + /// Gets the localized placeholder text for this operation. + /// + /// Returns a localized string suitable for use as placeholder text in + /// input fields. The text typically prompts the user to enter a time + /// for the operation. + /// + /// # Returns + /// + /// Localized placeholder text string. Returns empty string for `StayAwake` + /// since it doesn't require time input. + /// + /// # Examples + /// + /// ```rust,no_run + /// use chronomancer::components::power_form::PowerOperation; + /// + /// let placeholder = PowerOperation::Suspend.placeholder_text(); + /// // Returns something like "Set time to suspend" (localized) + /// + /// // StayAwake doesn't need a placeholder + /// assert_eq!(PowerOperation::StayAwake.placeholder_text(), ""); + /// ``` #[must_use] pub fn placeholder_text(self) -> String { match self { @@ -70,16 +202,87 @@ impl PowerOperation { } } -/// Struct representing a power form component +/// Form component for time duration input with unit selection. +/// +/// `PowerForm` provides a complete input interface for specifying time durations, +/// combining a numeric text input field with a combo box for selecting time units +/// (seconds, minutes, hours, days) and a submit button. +/// +/// # Fields +/// +/// - `input_value` - Current numeric value as a string +/// - `time_unit` - Selected time unit (seconds, minutes, hours, days) +/// - `time_unit_options` - Combo box state for unit selection +/// - `placeholder_text` - Placeholder text shown when input is empty +/// +/// # Validation +/// +/// The form validates that: +/// - Input is a valid positive integer (> 0) +/// - Non-numeric input is rejected +/// +/// # Examples +/// +/// ```rust,no_run +/// use chronomancer::components::power_form::PowerForm; +/// use chronomancer::utils::TimeUnit; +/// +/// let mut form = PowerForm::new("Enter duration"); +/// +/// // Initially empty +/// assert_eq!(form.input_value, ""); +/// assert_eq!(form.time_unit, TimeUnit::Seconds); +/// +/// // Validate and handle input +/// form.handle_text_input("30"); +/// assert_eq!(form.input_value, "30"); +/// assert!(form.validate_input()); +/// +/// // Clear when done +/// form.clear(); +/// assert_eq!(form.input_value, ""); +/// ``` #[derive(Debug, Clone)] pub struct PowerForm { + /// The current numeric input value as a string. pub input_value: String, + + /// The currently selected time unit. pub time_unit: TimeUnit, + + /// State for the time unit combo box. pub time_unit_options: combo_box::State, + + /// Placeholder text displayed in the input field. pub placeholder_text: String, } impl PowerForm { + /// Creates a new `PowerForm` with the given placeholder text. + /// + /// Initializes the form with: + /// - Empty input value + /// - Default time unit (Seconds) + /// - All time unit options available + /// - Custom placeholder text + /// + /// # Arguments + /// + /// - `placeholder_text` - Text to show when input is empty (accepts `String`, `&str`, etc.) + /// + /// # Returns + /// + /// A new `PowerForm` instance ready for use. + /// + /// # Examples + /// + /// ```rust + /// use chronomancer::components::power_form::PowerForm; + /// + /// let form = PowerForm::new("Enter time"); + /// assert_eq!(form.placeholder_text, "Enter time"); + /// assert_eq!(form.input_value, ""); + /// ``` pub fn new(placeholder_text: impl Into) -> Self { Self { input_value: String::new(), @@ -94,7 +297,45 @@ impl PowerForm { } } - /// Render the power form with message constructors + /// Renders the power form as an [`Element`]. + /// + /// Creates a vertical layout containing: + /// 1. Text input field for duration + /// 2. Combo box for time unit selection + /// 3. Submit button + /// + /// # Arguments + /// + /// - `on_text_input` - Handler called when text input changes + /// - `on_time_unit` - Handler called when time unit selection changes + /// - `on_submit` - Message sent when submit button is pressed or Enter is pressed + /// + /// # Returns + /// + /// An [`Element`] containing the complete form UI. + /// + /// # Examples + /// + /// ```rust,no_run + /// use chronomancer::components::power_form::PowerForm; + /// use chronomancer::utils::TimeUnit; + /// use cosmic::Element; + /// + /// #[derive(Clone, Debug)] + /// enum Message { + /// TextChanged(String), + /// UnitChanged(TimeUnit), + /// Submit, + /// } + /// + /// fn view(form: &PowerForm) -> Element<'_, Message> { + /// form.view( + /// Message::TextChanged, + /// Message::UnitChanged, + /// Message::Submit, + /// ) + /// } + /// ``` pub fn view( &self, on_text_input: impl Fn(String) -> Message + 'static, @@ -127,22 +368,114 @@ impl PowerForm { .into() } - /// Handle text input, validating numeric values + /// Handles text input changes with numeric validation. + /// + /// Uses [`filters::filter_positive_integer`] to validate input. + /// Only accepts valid positive integers. Rejects: + /// - Non-numeric characters + /// - Negative numbers + /// - Zero + /// + /// Empty input is accepted to allow clearing the field. + /// + /// # Arguments + /// + /// - `new_text` - The new text input value to validate and apply + /// + /// # Behavior + /// + /// - Valid positive integer: Updates `input_value` + /// - Empty string: Clears `input_value` + /// - Invalid input: No change to `input_value` + /// + /// # Examples + /// + /// ```rust + /// use chronomancer::components::power_form::PowerForm; + /// + /// let mut form = PowerForm::new("Enter time"); + /// + /// // Valid input + /// form.handle_text_input("15"); + /// assert_eq!(form.input_value, "15"); + /// + /// // Invalid input (no change) + /// form.handle_text_input("abc"); + /// assert_eq!(form.input_value, "15"); + /// + /// // Clear input + /// form.handle_text_input(""); + /// assert_eq!(form.input_value, ""); + /// ``` pub fn handle_text_input(&mut self, new_text: &str) { - if let Ok(value) = new_text.parse::() { - self.input_value = value.to_string(); - } else if new_text.is_empty() { - self.input_value.clear(); + if let Some(filtered) = filters::filter_positive_integer(new_text) { + self.input_value = filtered; } } - /// Validate that input is a positive integer + /// Validates that the current input is a positive integer. + /// + /// Checks whether `input_value` contains a valid positive integer (> 0). + /// Returns `false` for: + /// - Empty strings + /// - Non-numeric values + /// - Zero + /// - Negative numbers + /// + /// # Returns + /// + /// `true` if input is a positive integer, `false` otherwise. + /// + /// # Examples + /// + /// ```rust + /// use chronomancer::components::power_form::PowerForm; + /// + /// let mut form = PowerForm::new("Enter time"); + /// + /// // Valid input + /// form.input_value = "10".to_string(); + /// assert!(form.validate_input()); + /// + /// // Invalid inputs + /// form.input_value = "0".to_string(); + /// assert!(!form.validate_input()); + /// + /// form.input_value = "-5".to_string(); + /// assert!(!form.validate_input()); + /// + /// form.input_value = String::new(); + /// assert!(!form.validate_input()); + /// ``` pub fn validate_input(&self) -> bool { let value = self.input_value.parse::(); value.is_ok() && value.unwrap_or_default() > 0 } - /// Clear the form inputs + /// Clears the form and resets to default state. + /// + /// Resets: + /// - `input_value` to empty string + /// - `time_unit` to `TimeUnit::Seconds` + /// + /// The placeholder text is preserved. + /// + /// # Examples + /// + /// ```rust + /// use chronomancer::components::power_form::PowerForm; + /// use chronomancer::utils::TimeUnit; + /// + /// let mut form = PowerForm::new("Enter time"); + /// 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); + /// assert_eq!(form.placeholder_text, "Enter time"); // Preserved + /// ``` pub fn clear(&mut self) { self.input_value.clear(); self.time_unit = TimeUnit::Seconds; diff --git a/src/components/radio_components.rs b/src/components/radio_components.rs index 341dff1..8986106 100644 --- a/src/components/radio_components.rs +++ b/src/components/radio_components.rs @@ -1,3 +1,26 @@ +//! Generic radio button component system for managing groups of selectable options. +//! +//! This module provides a trait-based system for creating radio button groups with +//! any type of visual component. The [`RadioComponent`] trait defines the interface +//! for individual radio options, while [`RadioComponents`] manages the group state +//! and rendering. +//! +//! # Architecture +//! +//! - [`RadioComponent`] - Trait for individual radio button options +//! - [`RadioComponents`] - Manager for a group of radio options with selection state +//! +//! # Usage +//! +//! Create a `RadioComponents` instance with a vector of items implementing `RadioComponent` +//! (such as `ToggleIconRadio`). Set the `.selected` field to track which option is active. +//! Call `.view(on_select)` to render the group, where `on_select` is a closure mapping +//! the selected index to your message type. +//! +//! For custom radio button styles, implement the `RadioComponent` trait on your type. +//! The trait requires a `view` method that takes an `is_active` flag and selection message, +//! returning an `Element`. See `ToggleIconRadio` for a reference implementation. + use cosmic::{Element, iced_widget::row}; // Note: This is a simplified radio button component system. If mixed type radio components ends up being desireable, enums with a trait @@ -16,22 +39,161 @@ use cosmic::{Element, iced_widget::row}; // } // } -/// Trait for radio button components +/// Trait for types that can be used as radio button options. +/// +/// Implement this trait to create custom radio button components that can be +/// managed by [`RadioComponents`]. The trait requires types to be cloneable +/// and debuggable for ease of use in application state. +/// +/// # Required Methods +/// +/// - [`view`](RadioComponent::view) - Render the option with active state and selection handler +/// +/// # Type Requirements +/// +/// - Must implement [`Clone`] - Radio options are stored in collections +/// - Must implement [`Debug`] - For debugging and development +/// +/// # Examples +/// +/// ```rust,no_run +/// use chronomancer::components::radio_components::RadioComponent; +/// use cosmic::{Element, widget::{button, text}}; +/// +/// #[derive(Debug, Clone)] +/// struct SimpleRadio { +/// label: &'static str, +/// } +/// +/// impl RadioComponent for SimpleRadio { +/// fn view(&self, is_active: bool, on_select: Message) -> Element<'_, Message> +/// where +/// Message: Clone + 'static, +/// { +/// button::text(self.label) +/// .on_press(on_select) +/// .into() +/// } +/// } +/// ``` pub trait RadioComponent: Clone + std::fmt::Debug { + /// Renders the radio option as an [`Element`]. + /// + /// This method is called for each option in the radio group to generate + /// its visual representation. Implementations should handle both active + /// and inactive states appropriately. + /// + /// # Arguments + /// + /// - `is_active` - Whether this option is currently selected in the group + /// - `on_select` - Message to send when this option is clicked/activated + /// + /// # Returns + /// + /// An [`Element`] representing this radio option. + /// + /// # Examples + /// + /// ```rust,no_run + /// use chronomancer::components::radio_components::RadioComponent; + /// use cosmic::{Element, widget::button, theme::Button as ButtonTheme}; + /// + /// #[derive(Debug, Clone)] + /// struct MyRadio { + /// text: String, + /// } + /// + /// impl RadioComponent for MyRadio { + /// fn view(&self, is_active: bool, on_select: Message) -> Element<'_, Message> + /// where + /// Message: Clone + 'static, + /// { + /// let style = if is_active { + /// ButtonTheme::Suggested + /// } else { + /// ButtonTheme::Standard + /// }; + /// + /// button::text(&self.text) + /// .on_press(on_select) + /// .class(style) + /// .into() + /// } + /// } + /// ``` fn view(&self, is_active: bool, on_select: Message) -> Element<'_, Message> where Message: Clone + 'static; } -/// Struct to manage a group of radio button components +/// Manager for a group of radio button components. +/// +/// `RadioComponents` handles the state and rendering of a radio button group. +/// It stores a collection of options (any type implementing [`RadioComponent`]) +/// and tracks which option is currently selected. +/// +/// # Fields +/// +/// - `options` - Vector of radio button options +/// - `selected` - Index of the currently selected option (None if nothing selected) +/// +/// # Examples +/// +/// ```rust,no_run +/// use chronomancer::components::radio_components::RadioComponents; +/// use chronomancer::components::icon_button::ToggleIconRadio; +/// +/// let options = vec![ +/// ToggleIconRadio::new(0, "option-one"), +/// ToggleIconRadio::new(1, "option-two"), +/// ToggleIconRadio::new(2, "option-three"), +/// ]; +/// +/// let mut radio_group = RadioComponents::new(options); +/// +/// // Set initial selection +/// radio_group.selected = Some(1); +/// +/// // Check selection +/// assert_eq!(radio_group.selected, Some(1)); +/// ``` #[derive(Debug, Clone)] pub struct RadioComponents { + /// The available radio button options. pub options: Vec, + + /// The index of the currently selected option. + /// + /// `None` indicates no option is selected. pub selected: Option, } impl RadioComponents { - /// create a new `RadioComponents` instance + /// Creates a new `RadioComponents` group with no selection. + /// + /// # Arguments + /// + /// - `options` - Vector of radio button options to manage + /// + /// # Returns + /// + /// A new `RadioComponents` instance with `selected` set to `None`. + /// + /// # Examples + /// + /// ```rust,no_run + /// use chronomancer::components::radio_components::RadioComponents; + /// use chronomancer::components::icon_button::ToggleIconRadio; + /// + /// let options = vec![ + /// ToggleIconRadio::new(0, "icon-one"), + /// ToggleIconRadio::new(1, "icon-two"), + /// ]; + /// + /// let radio_group = RadioComponents::new(options); + /// assert_eq!(radio_group.selected, None); + /// assert_eq!(radio_group.options.len(), 2); + /// ``` #[must_use] pub fn new(options: Vec) -> Self { Self { @@ -40,7 +202,11 @@ impl RadioComponents { } } - /// display options in a row layout with message constructor + /// Displays options in a row layout with standard spacing (16px). + /// + /// # Arguments + /// + /// - `on_select` - Function that takes an option index and returns a message #[must_use] pub fn view( &self, @@ -52,7 +218,12 @@ impl RadioComponents { self.row_with_spacing(16, on_select) } - /// display options in a row layout with custom spacing + /// Displays options in a row layout with custom spacing. + /// + /// # Arguments + /// + /// - `spacing` - Space between options in pixels + /// - `on_select` - Function that takes an option index and returns a message #[must_use] pub fn row_with_spacing( &self, diff --git a/src/lib.rs b/src/lib.rs index 2e648f5..127cc7b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! applet runtime, while this `lib.rs` lets us test the domain & utility layers. // Core modules +pub mod app_messages; pub mod config; pub mod i18n; pub mod models; diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 156ed73..0f29b50 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,3 +1,28 @@ +//! Module for different pages in the Chronomancer application. +//! +//! This module contains the definitions and implementations of various pages (screens or screen areas in applets) +//! used in the Chronomancer application. Each page is responsible for managing +//! its own state and behavior, and they interact with the overall application state. +//! This module re-exports the pages for easier access. +//! Pages represent different screens or screen areas in the application. +//! They sit between the application state and the UI components. +//! Pages are responsible for managing the state and behavior of their respective screens. +//! +//! # Pages +//! +//! - [`PowerControls`] - Page for scheduling system power operations like shutdown and suspend. +//! +//! # Design Principles +//! +//! All pages in this module follow these principles: +//! +//! 1. **Encapsulated State** - Each page manages its own state and behavior. +//! 2. **Composable** - Pages can be composed of multiple UI components. +//! 3. **Documented** - Each page and method includes arguments, return values, and possible errors. Jury is still out on whether or not these will end up being doctests +//! 4. **Organized** - Pages are organized in a way that reflects their purpose and functionality, grouping reusable componetns together. +//! 5. **Overengineered** - ...probably... +//! + pub mod power_controls; pub use power_controls::Page as PowerControls; diff --git a/src/pages/power_controls.rs b/src/pages/power_controls.rs index d6395a0..1148fcc 100644 --- a/src/pages/power_controls.rs +++ b/src/pages/power_controls.rs @@ -38,6 +38,10 @@ pub enum Message { } /// Struct representing the power controls page +/// +/// Includes radio buttons for power operations and a form for time input +/// associated with the selected operation. +/// Shows the page view and handles updates based on messages. #[derive(Debug, Clone)] pub struct Page { pub power_buttons: RadioComponents, @@ -77,6 +81,12 @@ impl Default for Page { impl Page { /// Render the power controls page + /// + /// Displays radio buttons and conditionally shows the power form + /// based on the selected operation. + /// + /// # Returns + /// An `Element` representing the page view pub fn view(&self) -> Element<'_, Message> { let power_buttons = self.power_buttons.view(Message::RadioOptionSelected); @@ -101,6 +111,16 @@ impl Page { } /// Update the power controls page state based on messages + /// + /// Handles radio button selections, form input changes, + /// time unit changes, and form submissions. App-level messages are ignored here + /// returning `Task::none()`. + /// + /// # Arguments + /// - `message` - The message to process + /// + /// # Returns + /// A `Task` representing any actions to be taken pub fn update(&mut self, message: Message) -> Task> { match message { Message::RadioOptionSelected(new_index) => self.handle_radio_selection(new_index), @@ -117,7 +137,6 @@ impl Page { self.power_form.clear(); Task::none() } - // These messages bubble up to the app level, so we just pass them through Message::ToggleStayAwake | Message::SetSuspendTime(_) | Message::SetShutdownTime(_) @@ -128,6 +147,15 @@ impl Page { } /// Handle radio button selection + /// + /// Updates the selected operation and adjusts the power form placeholder text. + /// Special handling is included for the "stay awake" option to toggle its state. + /// + /// # Arguments + /// - `new_index` - The index of the newly selected radio button + /// + /// # Returns + /// A `Task` representing any actions to be taken fn handle_radio_selection(&mut self, new_index: usize) -> Task> { let previous = self.power_buttons.selected; let operation = PowerOperation::from_index(new_index); @@ -161,6 +189,12 @@ impl Page { } /// Handle form submission + /// + /// Validates the input and constructs the appropriate action + /// based on the selected power operation. + /// + /// # Returns + /// A `Task` representing any actions to be taken fn handle_form_submit(&mut self) -> Task> { if !self.power_form.validate_input() { self.power_form.clear(); diff --git a/src/utils/filters.rs b/src/utils/filters.rs new file mode 100644 index 0000000..e9ec0b7 --- /dev/null +++ b/src/utils/filters.rs @@ -0,0 +1,175 @@ +//! Text input filter functions for validation and formatting. +//! +//! This module provides reusable filter functions that can be used to validate +//! and format text input. These are designed to be used with libcosmic's +//! `TextInput` component or any text input handling. +//! +//! # Design Philosophy +//! +//! These are simple, pure functions that: +//! - Take an input string +//! - Return `Some(String)` if valid (possibly filtered/formatted) +//! - Return `None` if invalid +//! +//! The calling code decides what to do with invalid input (ignore it, show error, etc.). +//! +//! # Examples +//! +//! ## Basic usage in a component +//! +//! ```rust +//! use chronomancer::utils::filters; +//! +//! struct MyForm { +//! count: String, +//! } +//! +//! impl MyForm { +//! pub fn handle_input(&mut self, new_text: &str) { +//! if let Some(filtered) = filters::filter_positive_integer(new_text) { +//! self.count = filtered; +//! } +//! // If None, we just ignore the input (keep old value) +//! } +//! } +//! ``` +//! +//! ## Direct usage in message handler +//! +//! ```rust,no_run +//! use chronomancer::utils::filters; +//! +//! # struct App { value: String } +//! # enum Message { TextChanged(String) } +//! # impl App { +//! fn update(&mut self, message: Message) { +//! match message { +//! Message::TextChanged(text) => { +//! if let Some(filtered) = filters::filter_positive_integer(&text) { +//! self.value = filtered; +//! } +//! } +//! } +//! } +//! # } +//! ``` + +/// Filters input to only accept positive integers (> 0). +/// +/// This function: +/// - Accepts empty strings (returns `Some("")`) +/// - Parses input as `u32` and rejects 0 +/// - Normalizes the input by re-formatting the parsed number +/// - Rejects negative numbers, decimals, and non-numeric input +/// +/// # Arguments +/// +/// - `input` - The text to filter +/// +/// # Returns +/// +/// - `Some(String)` - Valid positive integer (normalized) or empty string +/// - `None` - Invalid input (non-numeric, zero, or negative) +/// +/// # Examples +/// +/// ```rust +/// use chronomancer::utils::filters::filter_positive_integer; +/// +/// // Valid positive integers +/// assert_eq!(filter_positive_integer("42"), Some("42".to_string())); +/// assert_eq!(filter_positive_integer("007"), Some("7".to_string())); // Normalized +/// +/// // Empty string is allowed +/// assert_eq!(filter_positive_integer(""), Some("".to_string())); +/// +/// // Invalid inputs +/// assert_eq!(filter_positive_integer("0"), None); +/// assert_eq!(filter_positive_integer("-5"), None); +/// assert_eq!(filter_positive_integer("abc"), None); +/// assert_eq!(filter_positive_integer("3.14"), None); +/// ``` +#[must_use] +pub fn filter_positive_integer(input: &str) -> Option { + if input.is_empty() { + Some(String::new()) + } else if let Ok(value) = input.parse::() { + if value > 0 { + Some(value.to_string()) + } else { + None + } + } else { + None + } +} + +/// Filters input to only allow alphabetic characters. +/// +/// # Arguments +/// +/// - `input` - The text to filter +/// +/// # Returns +/// +/// - `Some(String)` - String containing only alphabetic characters +/// - `None` - If result would be empty (when input has no alphabetic chars) +/// +/// # Examples +/// +/// ```rust +/// use chronomancer::utils::filters::filter_alphabetic; +/// +/// assert_eq!(filter_alphabetic("Hello"), Some("Hello".to_string())); +/// assert_eq!(filter_alphabetic("Hello123"), Some("Hello".to_string())); +/// assert_eq!(filter_alphabetic(""), Some("".to_string())); +/// +/// // All non-alphabetic removed +/// assert_eq!(filter_alphabetic("123!@#"), Some("".to_string())); +/// +/// // all characters filtered out returns None +/// assert_eq!(filter_alphabetic("123!@#"), None); +/// ``` +#[must_use] +#[allow(dead_code)] +pub fn filter_alphabetic(input: &str) -> Option { + let filtered: String = input.chars().filter(|c| c.is_alphabetic()).collect(); + if filtered.is_empty() { + None + } else { + Some(filtered) + } +} + +/// Filters input to only allow alphanumeric characters. +/// +/// # Arguments +/// +/// - `input` - The text to filter +/// +/// # Returns +/// +/// - `Some(String)` - String containing only alphanumeric characters +/// +/// # Examples +/// +/// ```rust +/// use chronomancer::utils::filters::filter_alphanumeric; +/// +/// assert_eq!(filter_alphanumeric("Hello123"), Some("Hello123".to_string())); +/// assert_eq!(filter_alphanumeric("Hello 123!"), Some("Hello123".to_string())); +/// assert_eq!(filter_alphanumeric("user_name"), Some("username".to_string())); +/// +/// // all characters filtered out returns None +/// assert_eq!(filter_alphanumeric("!@#"), None); +/// ``` +#[must_use] +#[allow(dead_code)] +pub fn filter_alphanumeric(input: &str) -> Option { + let filtered: String = input.chars().filter(|c| c.is_alphanumeric()).collect(); + if filtered.is_empty() { + None + } else { + Some(filtered) + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 484975e..07b67c1 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,66 @@ +//! Utility modules for non-UI helper functions and system integration. +//! +//! This module contains reusable utilities that don't involve rendering UI +//! or handling messages. These are pure functions, data structures, and +//! system integration helpers used throughout Chronomancer. +//! +//! # Organization +//! +//! - [`database`] - Database abstractions and `SQLite` implementation +//! - [`filters`] - Text input validation and filtering functions +//! - [`resources`] - System icons and power management (D-Bus integration) +//! - [`time`] - Time unit conversion and duration formatting +//! - [`ui`] - UI spacing, sizing, and padding constants +//! +//! # Module Philosophy +//! +//! Utils are distinct from components in that they: +//! - **Don't render UI** - No `view()` methods or `Element` returns +//! - **Don't handle messages** - No message types or MVU patterns +//! - **Are reusable** - Can be used anywhere in the application +//! - **Are testable** - Pure functions or simple abstractions +//! +//! See `.github/architectural-idioms.md` for detailed guidelines on +//! when to use utils vs components. +//! +//! # Examples +//! +//! ## Text input validation +//! +//! ```rust +//! use chronomancer::utils::filters; +//! +//! // Validate positive integer input +//! if let Some(filtered) = filters::filter_positive_integer("42") { +//! println!("Valid input: {}", filtered); +//! } +//! ``` +//! +//! ## Time formatting +//! +//! ```rust +//! use chronomancer::utils::time; +//! +//! let seconds = 3600; +//! let formatted = time::format_duration(seconds); +//! assert_eq!(formatted, "1 hour"); +//! ``` +//! +//! ## System power operations +//! +//! ```rust,no_run +//! use chronomancer::utils::resources; +//! +//! # async fn example() -> anyhow::Result<()> { +//! // Suspend the system +//! resources::execute_system_suspend().await?; +//! # Ok(()) +//! # } +//! ``` +//! + pub mod database; +pub mod filters; pub mod resources; pub mod time; pub mod ui; diff --git a/src/utils/resources.rs b/src/utils/resources.rs index 2cf081f..d6c0d95 100644 --- a/src/utils/resources.rs +++ b/src/utils/resources.rs @@ -1,10 +1,112 @@ +//! System resource utilities for icons and D-Bus power management. +//! +//! This module provides utilities for: +//! - Loading system icons with consistent styling +//! - Interacting with systemd-logind for power management +//! - Managing suspend inhibitor locks +//! - Executing system power operations (suspend, shutdown, reboot, logout) +//! +//! # D-Bus Integration +//! +//! Most functions in this module interact with the systemd-logind D-Bus API +//! to control system power states. These operations require appropriate +//! permissions and may prompt the user for authentication. +//! +//! # Examples +//! +//! ## Loading a system icon +//! +//! ```rust,no_run +//! use chronomancer::utils::resources; +//! use cosmic::Element; +//! +//! #[derive(Clone)] +//! enum Message {} +//! +//! fn create_icon() -> Element<'static, Message> { +//! resources::system_icon("system-suspend-symbolic", 24) +//! } +//! ``` +//! +//! ## Preventing system suspend +//! +//! ```rust,no_run +//! use chronomancer::utils::resources; +//! +//! # async fn example() -> anyhow::Result<()> { +//! // Acquire an inhibitor lock +//! let lock = resources::acquire_suspend_inhibit( +//! "Chronomancer", +//! "User requested stay-awake mode", +//! "block" +//! ).await?; +//! +//! // System won't suspend while lock is held +//! // ... do work ... +//! +//! // Release the lock +//! resources::release_suspend_inhibit(lock); +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Executing power operations +//! +//! ```rust,no_run +//! use chronomancer::utils::resources; +//! +//! # async fn example() -> anyhow::Result<()> { +//! // Suspend the system +//! resources::execute_system_suspend().await?; +//! +//! // Shutdown the system +//! resources::execute_system_shutdown().await?; +//! +//! // Reboot the system +//! resources::execute_system_reboot().await?; +//! +//! // Logout current session +//! resources::execute_system_logout().await?; +//! # Ok(()) +//! # } +//! ``` + use anyhow::{Context, Result}; use cosmic::{Element, widget}; use std::{fs::File, os::fd::OwnedFd as StdOwnedFd}; use zbus::{Connection, Proxy, zvariant::OwnedFd}; -/// Load a system icon using `icon::from_name` +/// Loads a system icon and returns it as a cosmic [`Element`]. +/// +/// Creates an icon widget using the system icon theme. The icon is loaded +/// as a symbolic icon (monochrome) which adapts to the current theme. +/// +/// # Arguments +/// +/// - `name` - Icon name from the freedesktop.org icon naming spec or custom icon +/// - `size` - Icon size in pixels +/// +/// # Returns +/// +/// An [`Element`] containing the icon widget, ready to be used in a view. +/// +/// # Examples +/// +/// ```rust,no_run +/// use chronomancer::utils::resources; +/// use cosmic::Element; +/// +/// #[derive(Clone)] +/// enum Message {} +/// +/// // Load a system icon +/// let suspend_icon: Element = resources::system_icon("system-suspend-symbolic", 24); +/// let shutdown_icon: Element = resources::system_icon("system-shutdown-symbolic", 32); +/// +/// // Use custom application icons +/// let custom_icon: Element = resources::system_icon("io.vulpapps.Chronomancer-stay-awake", 36); +/// ``` #[must_use] pub fn system_icon(name: &str, size: u16) -> Element<'static, Message> { widget::icon::from_name(name) @@ -14,21 +116,67 @@ pub fn system_icon(name: &str, size: u16) -> Element<'static, .into() } -/// Acquire a systemd-logind suspend inhibitor lock. +/// Acquires a systemd-logind suspend inhibitor lock. +/// +/// Creates an inhibitor lock that prevents the system from suspending. The lock +/// is represented by a file descriptor - keep the returned `File` alive to +/// maintain the lock. When the `File` is dropped, the lock is automatically released. /// -/// Returns a File handle that represents the inhibitor lock. Keep this alive -/// to prevent the system from suspending. Drop it to release the lock. +/// # Inhibitor Modes +/// +/// - `"block"` - Completely prevents suspend until the lock is released +/// - `"delay"` - Delays suspend briefly to allow cleanup (typically a few seconds) /// /// # Arguments -/// * `who` - Application name (e.g., "Chronomancer") -/// * `reason` - Reason for inhibiting (e.g., "User requested stay-awake mode") -/// * `mode` - "block" to block suspend, "delay" to delay it +/// +/// - `who` - Application identifier (e.g., "Chronomancer") +/// - `reason` - Human-readable reason for the lock (shown in system logs) +/// - `mode` - Inhibitor mode: "block" or "delay" +/// +/// # Returns +/// +/// A `File` handle representing the inhibitor lock. Drop it to release the lock. /// /// # Errors /// /// Returns an error if: /// - Failed to connect to the system D-Bus -/// - The D-Bus call to Inhibit fails +/// - The D-Bus call to `Inhibit` fails (insufficient permissions, systemd not running, etc.) +/// +/// # Examples +/// +/// ```rust,no_run +/// use chronomancer::utils::resources; +/// +/// # async fn example() -> anyhow::Result<()> { +/// // Acquire a blocking inhibitor lock +/// let lock = resources::acquire_suspend_inhibit( +/// "Chronomancer", +/// "User requested stay-awake mode", +/// "block" +/// ).await?; +/// +/// // System cannot suspend while lock exists +/// println!("System suspend is now blocked"); +/// +/// // Lock is automatically released when dropped +/// drop(lock); +/// println!("System can now suspend"); +/// # Ok(()) +/// # } +/// ``` +/// +/// # D-Bus API +/// +/// This function calls the systemd-logind D-Bus method: +/// ```text +/// org.freedesktop.login1.Manager.Inhibit( +/// what: "sleep", +/// who: , +/// why: , +/// mode: +/// ) -> FileDescriptor +/// ``` pub async fn acquire_suspend_inhibit(who: &str, reason: &str, mode: &str) -> Result { let connection = Connection::system() .await @@ -51,19 +199,81 @@ pub async fn acquire_suspend_inhibit(who: &str, reason: &str, mode: &str) -> Res Ok(File::from(std_fd)) } -/// Release a suspend inhibitor lock by dropping the file handle. -/// This is just an explicit wrapper around `drop()` for clarity. +/// Releases a suspend inhibitor lock by dropping the file handle. +/// +/// This is an explicit wrapper around `drop()` for clarity and documentation. +/// Dropping the file handle closes the file descriptor, which signals systemd-logind +/// to release the inhibitor lock. +/// +/// # Arguments +/// +/// - `file` - The inhibitor lock file handle returned by [`acquire_suspend_inhibit`] +/// +/// # Examples +/// +/// ```rust,no_run +/// use chronomancer::utils::resources; +/// +/// # async fn example() -> anyhow::Result<()> { +/// let lock = resources::acquire_suspend_inhibit( +/// "Chronomancer", +/// "Processing task", +/// "block" +/// ).await?; +/// +/// // Explicitly release the lock +/// resources::release_suspend_inhibit(lock); +/// +/// // Alternatively, just let it drop: +/// // { +/// // let lock = acquire_suspend_inhibit(...).await?; +/// // // lock is dropped here automatically +/// // } +/// # Ok(()) +/// # } +/// ``` pub fn release_suspend_inhibit(file: File) { drop(file); } -/// Execute system suspend by calling the login1 D-Bus API. +/// Suspends the system to RAM (sleep mode). +/// +/// Calls the systemd-logind D-Bus API to suspend the system. This is equivalent +/// to the "Suspend" or "Sleep" option in system menus. +/// +/// **Note**: This requires appropriate permissions. The system may prompt the +/// user for authentication depending on `PolicyKit` configuration. +/// +/// # Returns +/// +/// Returns `Ok(())` if the suspend command was successfully sent. /// /// # Errors /// /// Returns an error if: -/// - Failed to connect to system bus -/// - The D-Bus call to Suspend fails +/// - Failed to connect to the system D-Bus +/// - The D-Bus call to `Suspend` fails +/// - User lacks permission to suspend the system +/// +/// # Examples +/// +/// ```rust,no_run +/// use chronomancer::utils::resources; +/// +/// # async fn example() -> anyhow::Result<()> { +/// // Suspend the system +/// resources::execute_system_suspend().await?; +/// println!("System is suspending..."); +/// # Ok(()) +/// # } +/// ``` +/// +/// # D-Bus API +/// +/// This function calls: +/// ```text +/// org.freedesktop.login1.Manager.Suspend(interactive: true) +/// ``` pub async fn execute_system_suspend() -> Result<()> { let connection = Connection::system() .await @@ -85,13 +295,47 @@ pub async fn execute_system_suspend() -> Result<()> { Ok(()) } -/// Execute system shutdown by calling the login1 D-Bus API. +/// Powers off the system. +/// +/// Calls the systemd-logind D-Bus API to shut down the system. This is equivalent +/// to the "Power Off" or "Shutdown" option in system menus. +/// +/// **Warning**: This will immediately shut down the system. Ensure all work is +/// saved before calling this function. +/// +/// **Note**: This requires appropriate permissions. The system may prompt the +/// user for authentication depending on `PolicyKit` configuration. +/// +/// # Returns +/// +/// Returns `Ok(())` if the shutdown command was successfully sent. /// /// # Errors /// /// Returns an error if: -/// - Failed to connect to system bus +/// - Failed to connect to the system D-Bus /// - The D-Bus call to `PowerOff` fails +/// - User lacks permission to shut down the system +/// +/// # Examples +/// +/// ```rust,no_run +/// use chronomancer::utils::resources; +/// +/// # async fn example() -> anyhow::Result<()> { +/// // Shut down the system +/// resources::execute_system_shutdown().await?; +/// println!("System is shutting down..."); +/// # Ok(()) +/// # } +/// ``` +/// +/// # D-Bus API +/// +/// This function calls: +/// ```text +/// org.freedesktop.login1.Manager.PowerOff(interactive: true) +/// ``` pub async fn execute_system_shutdown() -> Result<()> { let connection = Connection::system() .await @@ -113,14 +357,48 @@ pub async fn execute_system_shutdown() -> Result<()> { Ok(()) } -/// Execute a system logout by calling the login1 D-Bus API to terminate the session. +/// Logs out the current user session. +/// +/// Calls the systemd-logind D-Bus API to terminate the current user session. +/// This closes all applications and returns to the login screen. +/// +/// **Warning**: This will immediately log out. Ensure all work is saved before +/// calling this function. +/// +/// **Note**: This requires the `XDG_SESSION_ID` environment variable to be set +/// (which is normally the case in desktop sessions). +/// +/// # Returns +/// +/// Returns `Ok(())` if the logout command was successfully sent. /// /// # Errors /// /// Returns an error if: /// - The `XDG_SESSION_ID` environment variable is not set -/// - Failed to connect to system bus +/// - Failed to connect to the system D-Bus /// - The D-Bus call to `TerminateSession` fails +/// - User lacks permission to terminate the session +/// +/// # Examples +/// +/// ```rust,no_run +/// use chronomancer::utils::resources; +/// +/// # async fn example() -> anyhow::Result<()> { +/// // Log out current session +/// resources::execute_system_logout().await?; +/// println!("Logging out..."); +/// # Ok(()) +/// # } +/// ``` +/// +/// # D-Bus API +/// +/// This function calls: +/// ```text +/// org.freedesktop.login1.Manager.TerminateSession(session_id: XDG_SESSION_ID) +/// ``` pub async fn execute_system_logout() -> Result<()> { let xdg_session_id = std::env::var("XDG_SESSION_ID").context("XDG_SESSION_ID environment variable not set")?; @@ -145,13 +423,47 @@ pub async fn execute_system_logout() -> Result<()> { Ok(()) } -/// Execute system reboot by calling the login1 D-Bus API. +/// Reboots the system. +/// +/// Calls the systemd-logind D-Bus API to reboot the system. This is equivalent +/// to the "Restart" or "Reboot" option in system menus. +/// +/// **Warning**: This will immediately reboot the system. Ensure all work is +/// saved before calling this function. +/// +/// **Note**: This requires appropriate permissions. The system may prompt the +/// user for authentication depending on `PolicyKit` configuration. +/// +/// # Returns +/// +/// Returns `Ok(())` if the reboot command was successfully sent. /// /// # Errors /// /// Returns an error if: -/// - Failed to connect to system bus +/// - Failed to connect to the system D-Bus /// - The D-Bus call to `Reboot` fails +/// - User lacks permission to reboot the system +/// +/// # Examples +/// +/// ```rust,no_run +/// use chronomancer::utils::resources; +/// +/// # async fn example() -> anyhow::Result<()> { +/// // Reboot the system +/// resources::execute_system_reboot().await?; +/// println!("System is rebooting..."); +/// # Ok(()) +/// # } +/// ``` +/// +/// # D-Bus API +/// +/// This function calls: +/// ```text +/// org.freedesktop.login1.Manager.Reboot(interactive: true) +/// ``` pub async fn execute_system_reboot() -> Result<()> { let connection = Connection::system() .await diff --git a/src/utils/time.rs b/src/utils/time.rs index 6b2964c..705667a 100644 --- a/src/utils/time.rs +++ b/src/utils/time.rs @@ -1,18 +1,105 @@ +//! Time unit utilities for duration formatting and conversion. +//! +//! This module provides types and functions for working with time durations in +//! Chronomancer. It handles conversion between different time units and formatting +//! durations for display to users. +//! +//! # Examples +//! +//! ## Converting time units to seconds +//! +//! ```rust +//! use chronomancer::utils::TimeUnit; +//! +//! let minutes = TimeUnit::Minutes; +//! let seconds = minutes.to_seconds_multiplier(); +//! assert_eq!(seconds, 60); +//! +//! // Calculate total seconds for a duration +//! let duration_value = 5; +//! let total_seconds = duration_value * TimeUnit::Hours.to_seconds_multiplier(); +//! assert_eq!(total_seconds, 18000); // 5 hours = 18000 seconds +//! ``` +//! +//! ## Formatting durations for display +//! +//! ```rust +//! use chronomancer::utils::time::format_duration; +//! +//! // Displays as hours if >= 1 hour +//! assert_eq!(format_duration(3600), "1 hour"); +//! assert_eq!(format_duration(7200), "2 hours"); +//! +//! // Displays as minutes if < 1 hour +//! assert_eq!(format_duration(60), "1 minute"); +//! assert_eq!(format_duration(300), "5 minutes"); +//! ``` + 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. +/// +/// Represents the different time units that users can select when specifying +/// durations for timers and power operations. Internally, all durations are +/// stored as seconds, and these units are used for display and input. +/// +/// # Examples +/// +/// ```rust +/// use chronomancer::utils::TimeUnit; +/// +/// let unit = TimeUnit::Minutes; +/// assert_eq!(unit.to_seconds_multiplier(), 60); +/// +/// // Use with a value to calculate total seconds +/// let value = 30; // 30 minutes +/// let total_seconds = value * unit.to_seconds_multiplier(); +/// assert_eq!(total_seconds, 1800); +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TimeUnit { + /// Seconds (1 second = 1 second) Seconds, + + /// Minutes (1 minute = 60 seconds) Minutes, + + /// Hours (1 hour = 3600 seconds) Hours, + + /// Days (1 day = 86400 seconds) Days, } impl TimeUnit { - /// Returns the number of whole seconds represented by this time unit. + /// Returns the number of seconds in one unit of this time unit. + /// + /// This multiplier is used to convert a duration value to seconds. + /// For example, to convert 5 minutes to seconds: `5 * TimeUnit::Minutes.to_seconds_multiplier()` + /// + /// # Returns + /// + /// - `Seconds`: 1 + /// - `Minutes`: 60 + /// - `Hours`: 3600 + /// - `Days`: 86400 + /// + /// # Examples + /// + /// ```rust + /// use chronomancer::utils::TimeUnit; + /// + /// assert_eq!(TimeUnit::Seconds.to_seconds_multiplier(), 1); + /// assert_eq!(TimeUnit::Minutes.to_seconds_multiplier(), 60); + /// assert_eq!(TimeUnit::Hours.to_seconds_multiplier(), 3600); + /// assert_eq!(TimeUnit::Days.to_seconds_multiplier(), 86400); + /// + /// // Convert 3 hours to seconds + /// let hours = 3; + /// let seconds = hours * TimeUnit::Hours.to_seconds_multiplier(); + /// assert_eq!(seconds, 10800); + /// ``` #[must_use] pub fn to_seconds_multiplier(self) -> i32 { match self { @@ -25,7 +112,19 @@ impl TimeUnit { } impl fmt::Display for TimeUnit { - /// Localized human-readable label for the unit. + /// Formats the time unit as a localized, human-readable string. + /// + /// The display text is localized using the application's current language + /// settings via the `fl!` macro. + /// + /// # Examples + /// + /// ```rust,no_run + /// use chronomancer::utils::TimeUnit; + /// + /// let unit = TimeUnit::Minutes; + /// println!("Selected unit: {}", unit); // Prints localized "Minutes" + /// ``` fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { TimeUnit::Seconds => write!(f, "{}", fl!("seconds")), @@ -37,6 +136,39 @@ impl fmt::Display for TimeUnit { } /// Formats a duration in seconds into a human-readable string. +/// +/// Converts a duration in seconds to a friendly display format: +/// - Shows hours if the duration is >= 1 hour +/// - Shows minutes if the duration is < 1 hour +/// - Rounds down to whole units (no decimals) +/// - Properly pluralizes (1 hour vs 2 hours) +/// +/// # Arguments +/// +/// - `seconds` - Duration in seconds to format +/// +/// # Returns +/// +/// A formatted string like "1 hour", "5 minutes", etc. +/// +/// # Examples +/// +/// ```rust +/// use chronomancer::utils::time::format_duration; +/// +/// // Hours (>= 3600 seconds) +/// assert_eq!(format_duration(3600), "1 hour"); +/// assert_eq!(format_duration(7200), "2 hours"); +/// +/// // Minutes (< 3600 seconds) +/// assert_eq!(format_duration(60), "1 minute"); +/// assert_eq!(format_duration(120), "2 minutes"); +/// assert_eq!(format_duration(1800), "30 minutes"); +/// +/// // Rounds down to whole units +/// assert_eq!(format_duration(5400), "1 hour"); // 1.5 hours → 1 hour +/// assert_eq!(format_duration(30), "0 minutes"); // < 1 minute → 0 minutes +/// ``` #[must_use] pub fn format_duration(seconds: i32) -> String { let minutes = seconds / 60; diff --git a/src/utils/ui/mod.rs b/src/utils/ui/mod.rs index 9b08f09..0047208 100644 --- a/src/utils/ui/mod.rs +++ b/src/utils/ui/mod.rs @@ -1,4 +1,33 @@ -//! UI utilities module for consistent styling and layout +//! UI utilities for consistent styling, spacing, and layout. +//! +//! This module provides utilities that help maintain consistent visual design +//! across the Chronomancer application. It includes helpers for spacing, +//! sizing, and padding that integrate with the COSMIC theme system. +//! +//! # Organization +//! +//! - [`spacing`] - Spacing, sizing, and padding utilities +//! +//! # Key Types +//! +//! - [`ComponentSize`] - Standard component dimensions (icon sizes, button heights) +//! - [`Gaps`] - Semantic spacing helpers that adapt to the COSMIC theme +//! - [`Padding`] - Padding array generators for container widgets +//! +//! # Design Philosophy +//! +//! Rather than using magic numbers or importing theme values everywhere, +//! this module provides: +//! - **Semantic names** - Clear intent in code (`Gaps::s()` instead of raw pixel values) +//! - **Theme integration** - Respects user's COSMIC theme preferences +//! - **Single source of truth** - Centralized dimension constants +//! +//! # Usage +//! +//! Use `Gaps::xs()` and `Gaps::s()` for spacing between UI elements. These return +//! theme-aware spacing values. Use `Padding::standard()`, `Padding::horizontal()`, etc. +//! to generate padding arrays for containers. Use `ComponentSize` constants for +//! fixed dimensions like icon sizes and button heights. pub mod spacing; diff --git a/src/utils/ui/spacing.rs b/src/utils/ui/spacing.rs index 80c9b6a..33a0aa3 100644 --- a/src/utils/ui/spacing.rs +++ b/src/utils/ui/spacing.rs @@ -1,51 +1,143 @@ -// UI spacing and sizing standards for Chronomancer -// -// This module provides consistent spacing and sizing values across the application, -// leveraging the COSMIC theme system while providing semantic names for component sizes. -// Bascially, I was getting annoyed with the verbosity of theme imports and wanted a cleaner way. -// It's also good to have any fixed numbers in one place in any graphical application +//! UI spacing and sizing utilities for consistent visual design. +//! +//! This module provides semantic constants and helpers for spacing, sizing, and +//! padding throughout the Chronomancer UI. It wraps COSMIC theme values with +//! friendly names to reduce verbosity and ensure consistency. +//! +//! # Design Philosophy +//! +//! Rather than using magic numbers or importing theme values directly everywhere, +//! this module provides: +//! - **Semantic names** - `Gaps::s()` instead of `theme::active().cosmic().spacing.space_s` +//! - **Single source of truth** - All fixed dimensions in one place +//! - **Theme integration** - Values derive from COSMIC theme when possible +//! +//! # Usage +//! +//! Use `ComponentSize` constants for fixed dimensions (icon sizes, button heights). +//! Use `Gaps::xs()` and `Gaps::s()` for theme-aware spacing between elements. +//! Use `Padding` methods to generate padding arrays for containers. use cosmic::{cosmic_theme::Spacing, theme}; -/// Standard component sizing +/// Standard component sizing constants. +/// +/// Provides fixed dimensions for UI components that need consistent sizing +/// across the application. These values are chosen to work well with the +/// COSMIC design system. +/// +/// # Examples +/// +/// ```rust +/// use chronomancer::utils::ui::ComponentSize; +/// +/// // Get standard icon button height +/// let height = ComponentSize::ICON_BUTTON_HEIGHT; +/// assert_eq!(height, 48.0); +/// +/// // Get standard icon size +/// let icon_size = ComponentSize::ICON_SIZE; +/// assert_eq!(icon_size, 36); +/// ``` pub struct ComponentSize; impl ComponentSize { - /// Standard height for icon buttons + /// Standard height for icon buttons in pixels. + /// + /// This provides enough space for comfortable touch targets and + /// visual balance with COSMIC's design language. pub const ICON_BUTTON_HEIGHT: f32 = 48.0; - /// Standard icon size within buttons + /// Standard size for icons within buttons in pixels. + /// + /// This size works well with the button height and provides good + /// visual hierarchy. + /// + /// # Examples + /// + /// ```rust,no_run + /// use chronomancer::utils::ui::ComponentSize; + /// use cosmic::widget::icon; + /// + /// # #[derive(Clone)] enum Message {} + /// # fn example() -> cosmic::Element<'static, Message> { + /// icon::from_name("system-suspend-symbolic") + /// .size(ComponentSize::ICON_SIZE) + /// .icon() + /// .into() + /// # } + /// ``` pub const ICON_SIZE: u16 = 36; } -/// Get the current COSMIC theme spacing values +/// Retrieves the current COSMIC theme spacing values. +/// +/// Accesses the active COSMIC theme to get spacing values that respect +/// the user's theme preferences. Returns the [`Spacing`] configuration. #[must_use] pub fn cosmic_spacing() -> Spacing { theme::active().cosmic().spacing } -/// Semantic spacing helpers based on COSMIC theme +/// Semantic spacing helpers based on COSMIC theme values. +/// +/// Provides friendly names for spacing values from the COSMIC theme. Methods +/// return spacing values that automatically adapt to the user's theme preferences. +/// +/// - `xs()` - Extra small spacing for tightly related items +/// - `s()` - Small spacing for grouping related elements pub struct Gaps; impl Gaps { - /// Extra small gap - use for related items within a group + /// Extra small gap for tightly related items within a group. + /// + /// Use for items that are closely related but need some visual separation, + /// such as label/value pairs or closely related controls. #[must_use] pub fn xs() -> u16 { cosmic_spacing().space_xs } - /// Small gap - use for grouping related elements + /// Small gap for grouping related elements. + /// + /// Use for standard spacing between elements in the same logical group, + /// such as form fields or menu items. #[must_use] pub fn s() -> u16 { cosmic_spacing().space_s } } -/// Padding helpers for consistent container padding +/// Padding helpers for consistent container padding. +/// +/// Provides methods for generating padding arrays in the format expected by +/// COSMIC widgets: `[top, right, bottom, left]`. All methods return `[u16; 4]` +/// arrays where indices represent: `[0]` top, `[1]` right, `[2]` bottom, `[3]` left. pub struct Padding; impl Padding { - /// Standard padding for most components + /// Standard padding for most components. + /// + /// Applies equal padding on all four sides using the extra small spacing + /// value from the COSMIC theme. + /// + /// # Returns + /// + /// Padding array: `[xs, xs, xs, xs]` + /// + /// # Examples + /// + /// ```rust,no_run + /// use chronomancer::utils::ui::Padding; + /// use cosmic::widget::container; + /// + /// # #[derive(Clone)] enum Message {} + /// # fn example() -> cosmic::Element<'static, Message> { + /// container("content") + /// .padding(Padding::standard()) + /// .into() + /// # } + /// ``` #[must_use] #[allow(dead_code)] pub fn standard() -> [u16; 4] { @@ -53,13 +145,62 @@ impl Padding { [xs, xs, xs, xs] } - /// Horizontal padding only + /// Horizontal padding only (left and right sides). + /// + /// Useful for containers that should have padding on the sides but + /// extend to the full height vertically. + /// + /// # Arguments + /// + /// - `amount` - Padding amount in pixels for left and right sides + /// + /// # Returns + /// + /// Padding array: `[0, amount, 0, amount]` + /// + /// # Examples + /// + /// ```rust,no_run + /// use chronomancer::utils::ui::Padding; + /// use cosmic::widget::container; + /// + /// # #[derive(Clone)] enum Message {} + /// # fn example() -> cosmic::Element<'static, Message> { + /// // 24px padding on left and right, none on top/bottom + /// container("content") + /// .padding(Padding::horizontal(24)) + /// .into() + /// # } + /// ``` #[must_use] pub fn horizontal(amount: u16) -> [u16; 4] { [0, amount, 0, amount] } - /// Standard padding without bottom padding (for popups) + /// Standard padding without bottom padding. + /// + /// Applies extra small spacing on top, left, and right sides, but no + /// bottom padding. Useful for popup containers that should extend to + /// the bottom edge. + /// + /// # Returns + /// + /// Padding array: `[xs, xs, 0, xs]` + /// + /// # Examples + /// + /// ```rust,no_run + /// use chronomancer::utils::ui::Padding; + /// use cosmic::widget::container; + /// + /// # #[derive(Clone)] enum Message {} + /// # fn example() -> cosmic::Element<'static, Message> { + /// // For popup windows that extend to bottom edge + /// container("popup content") + /// .padding(Padding::no_bottom()) + /// .into() + /// # } + /// ``` #[must_use] pub fn no_bottom() -> [u16; 4] { let xs = Gaps::xs();