Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
you don't want it.
- **Six layouts** — mascot/graph beside, above, or below the tiles, or tiles
only — all in a proper settings window.
- **Four themes** — Charcoal (default), Midnight, warm Paper, and cool Mist.
Choose one in Settings → Appearance; it applies to both windows and is
remembered between launches.

## Showcase

Expand Down
16 changes: 12 additions & 4 deletions settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@
</head>
<body>
<main>
<header class="page-header">
<h1>Settings</h1>
<p>Widget appearance and preferences.</p>
</header>
<section>
<h2>Appearance</h2>
<div class="options" id="opt-theme" role="group" aria-label="Theme"></div>
</section>
<section>
<h2>Widget</h2>
<div class="field">
<span class="label">Layout</span>
<div class="options" id="opt-layout"></div>
<div class="options" id="opt-layout" role="group" aria-label="Layout"></div>
</div>
<div class="field">
<span class="label">Size</span>
<div class="options" id="opt-size"></div>
<div class="options" id="opt-size" role="group" aria-label="Size"></div>
<p class="hint" id="size-hint">Or drag the widget's bottom-right grip to any size.</p>
</div>
<div class="field">
<span class="label">Mascot</span>
<div class="options" id="opt-mascot"></div>
<div class="options" id="opt-mascot" role="group" aria-label="Mascot"></div>
</div>
<div class="field">
<span class="label">Limits</span>
Expand All @@ -33,7 +41,7 @@ <h2>Widget</h2>
<section>
<h2>Tray icon</h2>
<div class="field">
<div class="options" id="opt-tray"></div>
<div class="options" id="opt-tray" role="group" aria-label="Tray icon"></div>
</div>
<p class="hint">Ring shows a colour-coded gauge; Text shows the 5h percentage.</p>
</section>
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 20 additions & 3 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use tauri_plugin_autostart::ManagerExt;

use crate::history::{HistoryLog, RawSample, Sample};
use crate::poller::RefreshSignal;
use crate::state::{AppState, BetaFeatures, Layout, Mascot, Size, TrayStyle};
use crate::state::{AppState, BetaFeatures, Layout, Mascot, Size, Theme, TrayStyle};

#[tauri::command]
pub fn get_state(state: State<'_, AppState>) -> serde_json::Value {
Expand All @@ -16,6 +16,7 @@ pub fn get_state(state: State<'_, AppState>) -> serde_json::Value {
"customScale": s.custom_scale,
"mascot": s.mascot,
"trayStyle": s.tray_style,
"theme": s.theme,
"workDays": s.work_days,
"probeFallback": s.probe_fallback,
"hiddenLimits": s.hidden_limits,
Expand Down Expand Up @@ -62,6 +63,20 @@ pub fn set_tray_style(app: AppHandle, style: String) {
}
}

#[tauri::command]
pub fn set_theme(app: AppHandle, theme: String) {
let Some(theme) = Theme::from_id(&theme) else {
return;
};
app.state::<AppState>().0.lock().unwrap().theme = theme;
if let Some(win) = app.get_webview_window("settings") {
let _ = win.set_theme(Some(theme.window_theme()));
let _ = win.set_background_color(Some(theme.background_color()));
}
crate::state::save(&app);
crate::tray::emit_state(&app);
}

#[tauri::command]
pub fn set_work_days(app: AppHandle, days: Vec<bool>) {
let Ok(days) = <[bool; 7]>::try_from(days) else {
Expand Down Expand Up @@ -192,6 +207,7 @@ pub fn show_settings(app: &AppHandle) {
// the event loop safely on every platform.
let app = app.clone();
tauri::async_runtime::spawn(async move {
let theme = app.state::<AppState>().0.lock().unwrap().theme;
let _ = tauri::WebviewWindowBuilder::new(
&app,
"settings",
Expand All @@ -202,12 +218,13 @@ pub fn show_settings(app: &AppHandle) {
.resizable(false)
.maximizable(false)
.minimizable(false)
// Born hidden, and black underneath: the page shows itself once its
// Born hidden, with the theme underneath: the page shows itself once its
// first render has landed (settings.ts), but the webview can still
// surface before its first paint — a native background in the page's
// colour keeps that moment invisible instead of a white flash.
.visible(false)
.background_color(tauri::window::Color(0, 0, 0, 255))
.theme(Some(theme.window_theme()))
.background_color(theme.background_color())
.build();
});
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ pub fn run() {
commands::set_layout,
commands::set_size,
commands::set_tray_style,
commands::set_theme,
commands::set_work_days,
commands::set_hidden_limits,
commands::set_probe_fallback,
Expand Down
83 changes: 83 additions & 0 deletions src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,52 @@ impl TrayStyle {
}
}

/// Shared appearance for the widget and settings window.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Theme {
#[default]
Charcoal,
Midnight,
Paper,
Mist,
}

impl Theme {
pub const ALL: [Theme; 4] = [Theme::Charcoal, Theme::Midnight, Theme::Paper, Theme::Mist];

pub fn id(self) -> &'static str {
match self {
Theme::Charcoal => "charcoal",
Theme::Midnight => "midnight",
Theme::Paper => "paper",
Theme::Mist => "mist",
}
}

pub fn from_id(id: &str) -> Option<Theme> {
Self::ALL.into_iter().find(|theme| theme.id() == id)
}

pub fn window_theme(self) -> tauri::Theme {
match self {
Theme::Charcoal | Theme::Midnight => tauri::Theme::Dark,
Theme::Paper | Theme::Mist => tauri::Theme::Light,
}
}

/// Native background before the settings webview paints; matches --bg in theme.css.
pub fn background_color(self) -> tauri::window::Color {
let (r, g, b) = match self {
Theme::Charcoal => (0x14, 0x15, 0x16),
Theme::Midnight => (0x05, 0x05, 0x05),
Theme::Paper => (0xf0, 0xec, 0xe4),
Theme::Mist => (0xe9, 0xef, 0xf4),
};
tauri::window::Color(r, g, b, 255)
}
}

/// Which weekdays count as "work days", indexed Sun..Sat to match the
/// frontend's `Date.getDay()`. Unchecked days hold the 7-day prediction flat
/// (no usage expected), so the dotted line doesn't extrapolate across them.
Expand Down Expand Up @@ -207,6 +253,7 @@ pub struct PersistedState {
pub custom_scale: Option<f64>,
pub mascot: Mascot,
pub tray_style: TrayStyle,
pub theme: Theme,
/// All-true by default; a plain derive would flatten the whole prediction.
#[serde(default = "all_work_days")]
pub work_days: [bool; 7],
Expand Down Expand Up @@ -247,6 +294,7 @@ impl Default for PersistedState {
custom_scale: None,
mascot: Mascot::default(),
tray_style: TrayStyle::default(),
theme: Theme::default(),
work_days: all_work_days(),
probe_fallback: true,
hidden_limits: Vec::new(),
Expand Down Expand Up @@ -465,6 +513,38 @@ mod tests {
assert_eq!(TrayStyle::default(), TrayStyle::Ring);
}

#[test]
fn every_theme_survives_a_state_save_and_reload() {
for theme in Theme::ALL {
assert_eq!(Theme::from_id(theme.id()), Some(theme));
let state = PersistedState {
theme,
pin: true,
layout: Layout::TilesRow,
..Default::default()
};
let json = serde_json::to_string(&state).unwrap();
let restored: PersistedState = serde_json::from_str(&json).unwrap();
assert_eq!(restored.theme, theme);
assert!(restored.pin);
assert_eq!(restored.layout, Layout::TilesRow);
}
assert_eq!(Theme::from_id("unknown"), None);
}

#[test]
fn upgrading_an_older_state_defaults_theme_without_resetting_preferences() {
let state: PersistedState = serde_json::from_str(
r#"{"pin":true,"layout":"tiles-column","size":"large","hiddenLimits":["weekly_all"]}"#,
)
.unwrap();
assert_eq!(state.theme, Theme::Charcoal);
assert!(state.pin);
assert_eq!(state.layout, Layout::TilesColumn);
assert_eq!(state.size, Size::Large);
assert_eq!(state.hidden_limits, vec!["weekly_all"]);
}

#[test]
fn persisted_state_fills_missing_fields_with_defaults() {
// The poller writes partial state early on; load() must tolerate it.
Expand All @@ -474,6 +554,7 @@ mod tests {
assert_eq!(s.size, Size::Small);
assert_eq!(s.mascot, Mascot::Clawd);
assert_eq!(s.tray_style, TrayStyle::Ring);
assert_eq!(s.theme, Theme::Charcoal);
// All-true, not the [false; 7] a plain field default would give —
// otherwise an old state.json (no workDays key) flattens the prediction.
assert_eq!(s.work_days, [true; 7]);
Expand Down Expand Up @@ -506,6 +587,7 @@ mod tests {
custom_scale: Some(1.1),
mascot: Mascot::Axolotl,
tray_style: TrayStyle::Text,
theme: Theme::Paper,
work_days: [true, false, true, true, true, true, false],
probe_fallback: true,
hidden_limits: vec!["weekly_scoped:fable".into()],
Expand All @@ -524,6 +606,7 @@ mod tests {
assert_eq!(back.custom_scale, original.custom_scale);
assert_eq!(back.mascot, original.mascot);
assert_eq!(back.tray_style, original.tray_style);
assert_eq!(back.theme, original.theme);
assert_eq!(back.work_days, original.work_days);
assert_eq!(back.probe_fallback, original.probe_fallback);
assert_eq!(back.window.unwrap().x, 12.0);
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ pub fn emit_state(app: &AppHandle) {
"customScale": s.custom_scale,
"mascot": s.mascot,
"trayStyle": s.tray_style,
"theme": s.theme,
"workDays": s.work_days,
"probeFallback": s.probe_fallback,
"hiddenLimits": s.hidden_limits,
Expand Down
5 changes: 5 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ export type Size = "small" | "medium" | "large";
/** Mirrors the Rust `TrayStyle` enum (state.rs). */
export type TrayStyle = "ring" | "text";

/** Shared widget/settings palette; mirrors the Rust `Theme` enum. */
export type Theme = "charcoal" | "midnight" | "paper" | "mist";

/** Names of the opt-in feature previews being trialled; none right now. */
export type BetaFlag = never;
/** Opt-in feature previews, one boolean per flag; mirrors the Rust
Expand All @@ -78,6 +81,7 @@ export interface Preferences {
customScale: number | null;
mascot: Mascot;
trayStyle: TrayStyle;
theme: Theme;
/** Which weekdays the 7-day prediction ramps, indexed Sun..Sat. */
workDays: boolean[];
/** Whether a failing usage endpoint may fall back to the 1-token probe. */
Expand All @@ -102,6 +106,7 @@ export const setMascot = (mascot: Mascot) => invoke<void>("set_mascot", { mascot
export const setLayout = (layout: Layout) => invoke<void>("set_layout", { layout });
export const setSize = (size: Size) => invoke<void>("set_size", { size });
export const setTrayStyle = (style: TrayStyle) => invoke<void>("set_tray_style", { style });
export const setTheme = (theme: Theme) => invoke<void>("set_theme", { theme });
export const setWorkDays = (days: boolean[]) => invoke<void>("set_work_days", { days });
export const setHiddenLimits = (ids: string[]) => invoke<void>("set_hidden_limits", { ids });
export const setProbeFallback = (enabled: boolean) =>
Expand Down
Loading
Loading