Skip to content

feat(state,ui): theme preference persistence + live switching - #79

Merged
thewrz merged 4 commits into
mainfrom
feat/theme-persistence
May 12, 2026
Merged

feat(state,ui): theme preference persistence + live switching#79
thewrz merged 4 commits into
mainfrom
feat/theme-persistence

Conversation

@thewrz

@thewrz thewrz commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Closes #69.

What

  • Theme enum gains System variant + serde derives + setting_index/from_setting_index helpers
  • AppConfig.theme: Theme field added with #[serde(default)] — existing config.json files without the field load as Dark
  • HonkHonk::theme() now maps from self.config.theme instead of hardcoding Theme::Dark
  • All hardcoded theme::Theme::Dark in view_main, view() SlotManager arm, and view_settings call replaced with self.config.theme
  • Message::ThemeChanged(Theme) added; handler updates config.theme and saves to disk
  • SettingId::Theme wired into SETTINGS_REGISTRY as Radio(&["Light", "Dark", "System"])
  • render_setting_row gains a Radio arm rendering pill-buttons (active = ink-inverted)
  • get_setting_value and setting_message handle Theme
  • view_appearance_section now renders registry rows instead of empty column

System theme

System maps to Dark colors for now (the same palette as Dark). Portal-based DE color scheme detection (#TBD) will refine this in a future sub-MVP.

Backward compat

#[serde(default)] on AppConfig.theme means any existing config.json without the field deserializes cleanly with Theme::Dark.

Test plan

  • cargo test — 94/94 pass
  • cargo clippy -- -D warnings — zero warnings
  • Launch app → Settings → Appearance → click "Light" → UI flips to light mode immediately
  • Restart app → light mode persists (check ~/.config/honkhonk/config.json for "theme": "Light")
  • Click "Dark" → flips back; "System" → Dark colors (until portal detection lands)
  • Old config.json without theme key loads as Dark (backward compat)

Summary by CodeRabbit

  • New Features
    • Appearance setting with Light, Dark, and System options added to Settings (radio controls); UI now respects the selected theme and applies it across views.
  • Bug Fixes
    • Theme changes update app state and are persisted; save failures are logged.
  • Tests
    • Unit tests added to verify theme selection dispatch and persistence.

Review Change Stack

- Add System variant to Theme enum (maps to Dark colors; portal detection TBD)
- Derive Serialize/Deserialize on Theme; add setting_index/from_setting_index helpers
- Add theme field to AppConfig with #[serde(default)] for backward compat
- Remove hardcoded Theme::Dark from HonkHonk::theme() and all view functions
- Add Message::ThemeChanged(Theme) + update handler that saves config
- Wire SettingId::Theme into SETTINGS_REGISTRY as Radio([Light, Dark, System])
- Add Radio arm to render_setting_row (pill buttons, active = inverted ink)
- Wire get_setting_value + setting_message for Theme
- Expand view_appearance_section to render registry rows
- 94/94 tests pass
@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 877b59fb-6e74-4ec0-be27-8e892db85191

📥 Commits

Reviewing files that changed from the base of the PR and between cf006de and 38bf905.

📒 Files selected for processing (1)
  • src/app.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/app.rs

📝 Walkthrough

Walkthrough

Adds serializable Theme (Light/Dark/System), stores theme in AppConfig, registers a Theme radio in SETTINGS_REGISTRY, wires settings UI to emit ThemeChanged, and applies/persists the chosen theme across app views.

Changes

Theme Persistence and Live Switching

Layer / File(s) Summary
Theme enum with serialization and index mapping
src/ui/theme.rs
Theme enum expands from Dark/Light to include System variant. Serde derives added for serialization. Methods setting_index(), from_setting_index(), and updated is_dark() map theme variants to numeric indices and treat System as dark. Color palette methods branch on Dark|System instead of Dark alone.
AppConfig with theme field and tests
src/state/config.rs
AppConfig struct adds theme: Theme field with #[serde(default)]. Default initializes to Theme::Dark. Round-trip serialization and save/load tests updated to include theme.
Settings registry, radio control, and message mapping
src/settings/mod.rs, src/ui/settings.rs
SETTINGS_REGISTRY registers Theme entry with Appearance category and Radio control. render_setting_row gains ControlType::Radio rendering; get_setting_value returns theme.setting_index(); setting_message maps Theme + index to Message::ThemeChanged. view_appearance_section now builds rows from the registry.
App message, update handler, and theme application
src/app.rs
Message enum adds ThemeChanged(Theme) variant. HonkHonk::update handles ThemeChanged, updates config.theme, and persists via config.save() (errors logged). HonkHonk::theme() maps config.theme to iced::Theme. Views use config.theme instead of a hardcoded Dark. Unit test verifies ThemeChanged updates config.

Sequence Diagram

sequenceDiagram
  participant User
  participant SettingsUI as Settings UI
  participant GetValue as get_setting_value
  participant SetMsg as setting_message
  participant AppUpdate as app update
  participant Config as AppConfig
  participant ThemeMethod as app theme method
  participant Views as Render Views
  User->>SettingsUI: click theme option
  SettingsUI->>GetValue: current theme index
  GetValue->>Config: read config theme
  GetValue-->>SettingsUI: index value
  SettingsUI->>SettingsUI: render highlighted
  User->>SettingsUI: select new theme
  SettingsUI->>SetMsg: setting and index
  SetMsg-->>AppUpdate: ThemeChanged message
  AppUpdate->>Config: save new theme
  Views->>ThemeMethod: request iced theme
  ThemeMethod->>Config: read config theme
  ThemeMethod-->>Views: mapped iced theme
  Views->>Views: render with new theme
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hopped through code to save your light,
Dark, Light, or System — chosen right.
A radio click, a message sent,
Config saved, and views repaint.
Small rabbit dance — the UI's bright!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding theme preference persistence and live switching capability.
Linked Issues check ✅ Passed All primary coding objectives from issue #69 are met: Theme serialization, AppConfig field, theme mapping, ThemeChanged message with save handler, SETTINGS_REGISTRY entry, and UI wiring.
Out of Scope Changes check ✅ Passed All changes are scoped to theme persistence and live switching. No unrelated features or refactorings outside the stated objectives are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/theme-persistence

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/ui/settings.rs (1)

237-250: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unhandled setting mappings should not fall back to RescanLibrary.

Line 249 can trigger a real rescan when a new setting is added but not wired yet. That’s a risky default action for an “unhandled” path.

Safer fallback pattern
// src/app.rs (Message enum)
+    NoOp,

// src/app.rs (update match)
+            Message::NoOp => Task::none(),

// src/ui/settings.rs
 pub fn setting_message(id: SettingId, value: SettingValue) -> Message {
     match (id, value) {
         (SettingId::RescanLibrary, _) => Message::RescanLibrary,
         (SettingId::Theme, SettingValue::Index(i)) => {
             Message::ThemeChanged(crate::ui::theme::Theme::from_setting_index(i))
         }
         other => {
             debug_assert!(
                 false,
                 "setting_message: unhandled ({:?}) — add an arm here when wiring a backend",
                 other
             );
-            Message::RescanLibrary
+            Message::NoOp
         }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ui/settings.rs` around lines 237 - 250, The match fallback in
setting_message currently returns Message::RescanLibrary for unhandled cases,
which can trigger a real rescan; change the fallback arm (the `other` pattern in
setting_message) to return a safe no-op message instead (e.g., add and return
Message::NoOp or Message::UnhandledSetting) and keep the debug_assert to notify
during development; update any consumers if needed to handle the new no-op
variant and avoid performing side-effects for unwired SettingId values.
🧹 Nitpick comments (1)
src/state/config.rs (1)

21-23: ⚡ Quick win

Add an explicit legacy-config test for missing theme field.

#[serde(default)] is the compatibility guarantee here; a focused test prevents regressions if serde attributes change later.

Proposed test addition
 #[test]
 fn default_config_has_expected_values() {
     let config = AppConfig::default();
     assert_eq!(config.volume, 0.85);
     assert_eq!(config.window_width, 900);
     assert_eq!(config.window_height, 600);
+    assert_eq!(config.theme, Theme::Dark);
 }
+
+#[test]
+fn deserialize_legacy_config_without_theme_defaults_to_dark() {
+    let legacy = r#"
+    {
+      "sound_directories": ["/tmp/sounds"],
+      "volume": 0.5,
+      "window_width": 1024,
+      "window_height": 768
+    }
+    "#;
+
+    let parsed: AppConfig = serde_json::from_str(legacy).unwrap();
+    assert_eq!(parsed.theme, Theme::Dark);
+}

Also applies to: 143-208

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/state/config.rs` around lines 21 - 23, Add a focused legacy-config test
that ensures deserializing a config JSON without the theme field still yields
the default Theme via serde(default): create a test (e.g., in the module tests
for src/state/config.rs) that deserializes a JSON string missing "theme" into
your Config struct and assert config.theme == Theme::default() (or matches the
known default variant); include similar tests for the other serde(default)
fields referenced around lines 143-208 to prevent regressions. Ensure the test
imports Config and Theme and uses the same serde deserialization path your code
uses so it verifies the compatibility guarantee.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app.rs`:
- Around line 586-592: In update(), avoid mutating self.config in place for
Message::ThemeChanged; instead construct a new Config instance (copying existing
fields with the new theme), compare it to the current self.config and only
assign and call self.config.save() when the theme actually changed; reference
the Message::ThemeChanged match arm, the update() method, and the
self.config.save() call to locate where to replace the in-place mutation with an
immutable new-config assignment and guarded save.

---

Outside diff comments:
In `@src/ui/settings.rs`:
- Around line 237-250: The match fallback in setting_message currently returns
Message::RescanLibrary for unhandled cases, which can trigger a real rescan;
change the fallback arm (the `other` pattern in setting_message) to return a
safe no-op message instead (e.g., add and return Message::NoOp or
Message::UnhandledSetting) and keep the debug_assert to notify during
development; update any consumers if needed to handle the new no-op variant and
avoid performing side-effects for unwired SettingId values.

---

Nitpick comments:
In `@src/state/config.rs`:
- Around line 21-23: Add a focused legacy-config test that ensures deserializing
a config JSON without the theme field still yields the default Theme via
serde(default): create a test (e.g., in the module tests for
src/state/config.rs) that deserializes a JSON string missing "theme" into your
Config struct and assert config.theme == Theme::default() (or matches the known
default variant); include similar tests for the other serde(default) fields
referenced around lines 143-208 to prevent regressions. Ensure the test imports
Config and Theme and uses the same serde deserialization path your code uses so
it verifies the compatibility guarantee.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 19fd690a-3e8e-48d3-b80f-c9dac7fede30

📥 Commits

Reviewing files that changed from the base of the PR and between 29c5e6d and 8a2630c.

📒 Files selected for processing (5)
  • src/app.rs
  • src/settings/mod.rs
  • src/state/config.rs
  • src/ui/settings.rs
  • src/ui/theme.rs

Comment thread src/app.rs
@thewrz

thewrz commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed CodeRabbit suggestion in 38bf905: ThemeChanged now constructs a new AppConfig via struct update syntax (..self.config.clone()) and guards the save with theme != t — no disk write when the selection hasn't changed. Aligns with CLAUDE.md immutable-patterns rule.

@thewrz
thewrz merged commit e169248 into main May 12, 2026
8 checks passed
@thewrz
thewrz deleted the feat/theme-persistence branch May 12, 2026 13:44
@thewrz thewrz added this to HonkHonk Jun 21, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in HonkHonk Jun 21, 2026
@github-project-automation github-project-automation Bot moved this from Backlog to Done in HonkHonk Jun 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

feat(state,ui): theme preference persistence + live switching

1 participant