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
63 changes: 63 additions & 0 deletions issues/032-preferences-window.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 032 — Preferences window

## Parent PRD

PRD.md §FR7 (configuration)

## What to build

Native Tauri webview window opened via Tray "Settings…" item (no submenu) or `Cmd-,`.
Window is created on demand, destroyed on close, recreated on next open (not pre-configured in `tauri.conf.json`).

### Sections

| Section | Controls |
|---------|---------|
| General | Max focuses (1–10), Max tasks per focus (1–20) |
| Displays | Per-monitor toggle (moved from tray into Preferences window) |
| Widget | Always on Top, Confirm Before Delete |
| Alerts | System Notifications |
| Debug | Debug Overlay off by default; toggled here (ephemeral — emits `debug-overlay-toggle`, not persisted) |

### Backend

- `SettingsPathState(PathBuf)` in `app/mod.rs` — managed state for settings file path
- `get_settings` command — reads `SettingsState`, returns `Settings`
- `update_settings(settings: Settings)` command:
1. Writes `SettingsState`
2. Persists via `write_settings`
3. Applies `always_on_top` to all `overlay-N` windows
4. Rebuilds tray menu

### Frontend

- `settings.html` + `src/settings.tsx` — entry point (mirrors `new-focus` pattern)
- `src/types/settings.ts` — TypeScript mirror of `Settings` struct
- `src/api/settings.ts` — `getSettings()` + `updateSettings(settings)`
- `src/hooks/useSettingsWindow.ts` — loads settings, calls `updateSettings` on each change
- `src/components/SettingsWindow.tsx` — sections + row-toggle layout

### Tray + menu

- `tray.rs` — single "Settings…" item (no submenu) that opens Preferences window on demand
- `menu.rs` — "Preferences…" with `Cmd-,` in app menu
- `vite.config.ts` — `settings` Rollup input

## Completion promise

`Cmd-,` or Tray "Settings…" opens a Preferences window where all settings (including display toggles) are editable. Changes persist immediately. Window is ephemeral — destroyed on close.

## Acceptance criteria

- [ ] `Cmd-,` opens Preferences window
- [ ] Tray "Settings…" (single item, no submenu) opens Preferences window
- [ ] All sections render with correct current values, including Displays (per-monitor toggles)
- [ ] Changing caps, widget, or alerts persists to `settings.yaml` immediately
- [ ] `always_on_top` change takes effect on overlay windows without restart
- [ ] Debug overlay is off by default; toggle emits `debug-overlay-toggle` and is ephemeral
- [ ] Closing Preferences destroys the window; reopening recreates it
- [ ] `task check` green

## Blocked by

None
12 changes: 12 additions & 0 deletions settings.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Preferences</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/settings.tsx"></script>
</body>
</html>
10 changes: 10 additions & 0 deletions src-tauri/src/app/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ use tauri::{AppHandle, Manager, Runtime};
pub const SHOW_RANCH_ID: &str = "show-ranch";
pub const CLOSE_WINDOW_ID: &str = "close-window";
pub const SHOW_DEBUG_OVERLAY_ID: &str = "show-debug-overlay";
pub const OPEN_PREFS_ID: &str = "open-preferences";
const MAIN_WINDOW: &str = "overlay-0";

pub fn build<R: Runtime>(handle: &AppHandle<R>) -> tauri::Result<Menu<R>> {
let prefs_item = MenuItemBuilder::with_id(OPEN_PREFS_ID, "Preferences…")
.accelerator("CmdOrCtrl+,")
.build(handle)?;

let app_submenu = SubmenuBuilder::new(handle, "Adhd Ranch")
.item(&PredefinedMenuItem::about(handle, None, None)?)
.separator()
.item(&prefs_item)
.separator()
.item(&PredefinedMenuItem::quit(handle, None)?)
.build()?;

Expand Down Expand Up @@ -70,6 +77,9 @@ pub fn handle_event<R: Runtime>(app: &AppHandle<R>, event: MenuEvent) {
}
}
SHOW_DEBUG_OVERLAY_ID => toggle_debug_overlay(app),
OPEN_PREFS_ID => {
super::open_settings_window(app);
}
_ => {}
}
}
Expand Down
44 changes: 37 additions & 7 deletions src-tauri/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use adhd_ranch_storage::{
watch_path, DecisionLog, FocusStore, FocusWatcher, JsonlDecisionLog, JsonlProposalQueue,
MarkdownFocusStore, ProposalQueue,
};
use tauri::{AppHandle, Emitter, Manager};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use time::format_description::well_known::Rfc3339;

use crate::ui_bridge;
Expand All @@ -28,6 +28,8 @@ pub const PROPOSALS_CHANGED_EVENT: &str = "proposals-changed";
pub struct MonitorsState(pub Vec<LogicalMonitor>);
pub struct DisplayConfigState(pub Arc<Mutex<DisplayConfig>>);
pub struct SettingsState(pub Arc<Mutex<Settings>>);
pub struct SettingsPathState(pub std::path::PathBuf);
pub struct DebugOverlayState(pub Arc<Mutex<bool>>);

pub fn run() {
let settings_path = paths::settings_file().expect("settings path");
Expand All @@ -51,6 +53,13 @@ pub fn run() {
ui_bridge::get_caps,
ui_bridge::update_pig_rects,
ui_bridge::set_pig_drag_active,
ui_bridge::get_settings,
ui_bridge::update_settings,
ui_bridge::get_monitors,
ui_bridge::get_debug_overlay,
ui_bridge::set_debug_overlay,
ui_bridge::toggle_devtools,
ui_bridge::get_devtools_open,
])
.menu(menu::build);

Expand Down Expand Up @@ -112,6 +121,8 @@ pub fn run() {
display_config.clone(),
))));
app.manage(SettingsState(Arc::new(Mutex::new(settings.clone()))));
app.manage(SettingsPathState(settings_path.clone()));
app.manage(DebugOverlayState(Arc::new(Mutex::new(false))));

// DisplayManager must be managed before windows are shown so invoke
// calls from React can find PigHitState immediately.
Expand All @@ -123,12 +134,7 @@ pub fn run() {
app.manage(DisplayManagerState(Arc::clone(&display_svc)));
display_svc.apply(app.handle(), &monitor_infos, &display_config);

let tray_icon = tray::setup(
app.handle(),
store.clone(),
settings.clone(),
settings_path.clone(),
)?;
let tray_icon = tray::setup(app.handle(), store.clone(), settings.clone())?;

let focuses_watcher = install_change_handlers(
&focuses_root,
Expand Down Expand Up @@ -187,6 +193,30 @@ fn now_unix_secs() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp()
}

pub fn open_settings_window<R: tauri::Runtime>(app: &AppHandle<R>) {
if let Some(win) = app.get_webview_window("settings") {
if win.is_visible().unwrap_or(false) {
let _ = win.set_focus();
return;
}
// Label still registered but window is closed — destroy to free it
let _ = win.destroy();
}
match WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into()))
.title("Preferences")
.inner_size(380.0, 300.0)
.min_inner_size(380.0, 100.0)
.decorations(true)
.resizable(false)
.build()
{
Ok(win) => {
let _ = win.show();
}
Err(e) => log::error!("open_settings_window: {e}"),
}
}

fn load_settings(path: &std::path::Path) -> Settings {
match std::fs::read_to_string(path) {
Ok(raw) => Settings::parse_yaml(&raw),
Expand Down
Loading
Loading