diff --git a/issues/032-preferences-window.md b/issues/032-preferences-window.md new file mode 100644 index 0000000..4a1989e --- /dev/null +++ b/issues/032-preferences-window.md @@ -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 diff --git a/settings.html b/settings.html new file mode 100644 index 0000000..62dac55 --- /dev/null +++ b/settings.html @@ -0,0 +1,12 @@ + + + + + + Preferences + + +
+ + + diff --git a/src-tauri/src/app/menu.rs b/src-tauri/src/app/menu.rs index 7fff372..9aa406e 100644 --- a/src-tauri/src/app/menu.rs +++ b/src-tauri/src/app/menu.rs @@ -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(handle: &AppHandle) -> tauri::Result> { + 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()?; @@ -70,6 +77,9 @@ pub fn handle_event(app: &AppHandle, event: MenuEvent) { } } SHOW_DEBUG_OVERLAY_ID => toggle_debug_overlay(app), + OPEN_PREFS_ID => { + super::open_settings_window(app); + } _ => {} } } diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index cededc4..429f381 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -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; @@ -28,6 +28,8 @@ pub const PROPOSALS_CHANGED_EVENT: &str = "proposals-changed"; pub struct MonitorsState(pub Vec); pub struct DisplayConfigState(pub Arc>); pub struct SettingsState(pub Arc>); +pub struct SettingsPathState(pub std::path::PathBuf); +pub struct DebugOverlayState(pub Arc>); pub fn run() { let settings_path = paths::settings_file().expect("settings path"); @@ -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); @@ -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. @@ -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, @@ -187,6 +193,30 @@ fn now_unix_secs() -> i64 { time::OffsetDateTime::now_utc().unix_timestamp() } +pub fn open_settings_window(app: &AppHandle) { + 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), diff --git a/src-tauri/src/app/tray.rs b/src-tauri/src/app/tray.rs index 235a13e..3aef314 100644 --- a/src-tauri/src/app/tray.rs +++ b/src-tauri/src/app/tray.rs @@ -1,26 +1,20 @@ -use std::path::PathBuf; use std::sync::Arc; -use adhd_ranch_domain::{cap_state, Focus, Settings, Widget}; -use adhd_ranch_storage::{write_settings, FocusStore}; +use adhd_ranch_domain::{cap_state, Focus, Settings}; +use adhd_ranch_storage::FocusStore; use tauri::image::Image; -use tauri::menu::{ - CheckMenuItemBuilder, IsMenuItem, Menu, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder, -}; +use tauri::menu::{IsMenuItem, Menu, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder}; use tauri::tray::{TrayIcon, TrayIconBuilder}; use tauri::{AppHandle, Emitter, Manager, Wry}; -use super::{DisplayConfigState, MonitorsState, SettingsState}; -use crate::display::DisplayManagerState; +use super::SettingsState; const QUIT_ID: &str = "tray-quit"; const NO_FOCUSES_ID: &str = "tray-no-focuses"; const NEW_FOCUS_ID: &str = "tray-new-focus"; const GATHER_PIGS_ID: &str = "tray-gather-pigs"; const DELETE_PREFIX: &str = "tray-delete-"; -const DISPLAY_PREFIX: &str = "tray-display-"; -const TRAY_ALWAYS_ON_TOP_ID: &str = "tray-always-on-top"; -const TRAY_CONFIRM_DELETE_ID: &str = "tray-confirm-delete"; +const TRAY_OPEN_PREFS_ID: &str = "tray-open-prefs"; #[cfg(debug_assertions)] const DEVTOOLS_ID: &str = "tray-devtools"; @@ -28,7 +22,6 @@ pub fn setup( app: &AppHandle, store: Arc, settings: Settings, - settings_path: PathBuf, ) -> tauri::Result> { let focuses = match store.list() { Ok(f) => f, @@ -40,7 +33,6 @@ pub fn setup( let menu = build_menu(app, &focuses)?; let over_cap = cap_state(&focuses, settings.caps).any_over(); - let settings_path_for_handler = settings_path.clone(); let mut builder = TrayIconBuilder::with_id("main-tray") .menu(&menu) .show_menu_on_left_click(true) @@ -49,7 +41,11 @@ pub fn setup( #[cfg(debug_assertions)] if id == DEVTOOLS_ID { if let Some(win) = app.get_webview_window("overlay-0") { - win.open_devtools(); + if win.is_devtools_open() { + win.close_devtools(); + } else { + win.open_devtools(); + } } return; } @@ -64,24 +60,12 @@ pub fn setup( let _ = win.show(); let _ = win.set_focus(); } - } else if id == TRAY_ALWAYS_ON_TOP_ID { - let app_handle = app.clone(); - let path = settings_path_for_handler.clone(); - std::thread::spawn(move || handle_always_on_top_toggle(app_handle, path)); - } else if id == TRAY_CONFIRM_DELETE_ID { - let app_handle = app.clone(); - let path = settings_path_for_handler.clone(); - std::thread::spawn(move || handle_confirm_delete_toggle(app_handle, path)); + } else if id == TRAY_OPEN_PREFS_ID { + super::open_settings_window(app); } else if let Some(focus_id) = id.strip_prefix(DELETE_PREFIX) { let focus_id = focus_id.to_string(); let app_handle = app.clone(); std::thread::spawn(move || handle_delete(app_handle, focus_id)); - } else if let Some(idx_str) = id.strip_prefix(DISPLAY_PREFIX) { - if let Ok(idx) = idx_str.parse::() { - let app_handle = app.clone(); - let path = settings_path_for_handler.clone(); - std::thread::spawn(move || handle_display_toggle(app_handle, idx, path)); - } } }); @@ -128,15 +112,7 @@ pub fn rebuild_handler( }) } -/// Threshold: more monitors nest under a "Displays" submenu inside Settings. -const DISPLAY_SUBMENU_MIN_COUNT: usize = 4; - fn build_menu(handle: &AppHandle, focuses: &[Focus]) -> tauri::Result> { - let widget = handle - .try_state::() - .and_then(|s| s.0.lock().ok().map(|s| s.widget)) - .unwrap_or_default(); - let mut items: Vec>> = Vec::new(); let gather = MenuItemBuilder::with_id(GATHER_PIGS_ID, "Gather Pigs").build(handle)?; @@ -166,7 +142,8 @@ fn build_menu(handle: &AppHandle, focuses: &[Focus]) -> tauri::Result, focuses: &[Focus]) -> tauri::Result, - widget: &Widget, -) -> tauri::Result> { - let always_on_top = CheckMenuItemBuilder::with_id(TRAY_ALWAYS_ON_TOP_ID, "Always on Top") - .checked(widget.always_on_top) - .build(handle)?; - let confirm_delete = - CheckMenuItemBuilder::with_id(TRAY_CONFIRM_DELETE_ID, "Confirm Before Delete") - .checked(widget.confirm_delete) - .build(handle)?; - - let window_sub = SubmenuBuilder::new(handle, "Window") - .item(&always_on_top) - .item(&confirm_delete) - .build()?; - - let display_items = build_display_setting_items(handle)?; - - let mut settings_sub = SubmenuBuilder::new(handle, "Settings").item(&window_sub); - - for di in &display_items { - settings_sub = settings_sub.item(di.as_ref()); - } - - settings_sub.build() -} - -fn build_display_setting_items( - handle: &AppHandle, -) -> tauri::Result>>> { - let Some(monitors_state) = handle.try_state::() else { - return Ok(Vec::new()); - }; - if monitors_state.0.is_empty() { - return Ok(Vec::new()); - } - - let enabled_indices = handle - .try_state::() - .and_then(|s| s.0.lock().ok().map(|c| c.enabled_indices.clone())) - .unwrap_or_else(|| vec![0]); - - let monitors = &monitors_state.0; - let mut out: Vec>> = Vec::new(); - - if monitors.len() >= DISPLAY_SUBMENU_MIN_COUNT { - let mut display_sub = SubmenuBuilder::new(handle, "Displays"); - for (idx, monitor) in monitors.iter().enumerate() { - let checked = enabled_indices.contains(&idx); - let item = - CheckMenuItemBuilder::with_id(format!("{DISPLAY_PREFIX}{idx}"), &monitor.label) - .checked(checked) - .build(handle)?; - display_sub = display_sub.item(&item); - } - out.push(Box::new(display_sub.build()?)); - } else { - for (idx, monitor) in monitors.iter().enumerate() { - let checked = enabled_indices.contains(&idx); - let item = - CheckMenuItemBuilder::with_id(format!("{DISPLAY_PREFIX}{idx}"), &monitor.label) - .checked(checked) - .build(handle)?; - out.push(Box::new(item)); - } - } - - Ok(out) -} - -fn handle_always_on_top_toggle(app: AppHandle, settings_path: PathBuf) { - let new_val = { - let Some(state) = app.try_state::() else { - return; - }; - let Ok(mut s) = state.0.lock() else { return }; - s.widget.always_on_top = !s.widget.always_on_top; - let v = s.widget.always_on_top; - // Persist under lock so the applied value and persisted value are always in sync. - if let Err(e) = write_settings(&settings_path, &s) { - log::error!("tray: failed to persist settings: {e}"); - } - v - }; - - if let Some(win) = app.get_webview_window("overlay-0") { - super::window_always_on_top::apply(&win, new_val); - } - - rebuild_tray_menu(&app); -} - -fn handle_confirm_delete_toggle(app: AppHandle, settings_path: PathBuf) { - { - let Some(state) = app.try_state::() else { - return; - }; - let Ok(mut s) = state.0.lock() else { return }; - s.widget.confirm_delete = !s.widget.confirm_delete; - } - - persist_settings(&app, &settings_path); - rebuild_tray_menu(&app); -} - fn handle_delete(app: AppHandle, focus_id: String) { let confirm = app .try_state::() @@ -327,67 +198,7 @@ fn handle_delete(app: AppHandle, focus_id: String) { } } -fn handle_display_toggle(app: AppHandle, idx: usize, settings_path: PathBuf) { - let Some(display_state) = app.try_state::() else { - return; - }; - let Some(monitors_state) = app.try_state::() else { - return; - }; - let Some(overlay_state) = app.try_state::() else { - return; - }; - - if idx >= monitors_state.0.len() { - return; - } - - let new_config = { - let Ok(mut config) = display_state.0.lock() else { - return; - }; - if config.enabled_indices.contains(&idx) { - config.enabled_indices.retain(|&i| i != idx); - } else { - config.enabled_indices.push(idx); - config.enabled_indices.sort_unstable(); - } - config.clone() - }; - - // Sync DisplayConfig change into SettingsState so persist_settings writes the full config. - if let Some(state) = app.try_state::() { - if let Ok(mut s) = state.0.lock() { - s.displays = new_config.clone(); - } - } - - persist_settings(&app, &settings_path); - - let display_mgr = Arc::clone(&overlay_state.0); - let monitors = monitors_state.0.clone(); - let config_for_main = new_config.clone(); - let app_for_main = app.clone(); - if let Err(e) = app.run_on_main_thread(move || { - display_mgr.apply(&app_for_main, &monitors, &config_for_main); - }) { - log::error!("tray: run_on_main_thread failed: {e}"); - } - - rebuild_tray_menu(&app); -} - -fn persist_settings(app: &AppHandle, settings_path: &std::path::Path) { - let Some(state) = app.try_state::() else { - return; - }; - let Ok(s) = state.0.lock() else { return }; - if let Err(e) = write_settings(settings_path, &s) { - log::error!("tray: failed to persist settings: {e}"); - } -} - -fn rebuild_tray_menu(app: &AppHandle) { +pub fn rebuild_tray_menu(app: &AppHandle) { let focuses = app .try_state::() .and_then(|s| s.0.list_focuses().ok()) diff --git a/src-tauri/src/ui_bridge/mod.rs b/src-tauri/src/ui_bridge/mod.rs index 8b6d782..dfe7219 100644 --- a/src-tauri/src/ui_bridge/mod.rs +++ b/src-tauri/src/ui_bridge/mod.rs @@ -5,13 +5,15 @@ use adhd_ranch_commands::{ CommandError, Commands, CreateFocusInput, CreatedFocus, CreatedProposal, DecisionOutcome, ProposalEdit, }; -use adhd_ranch_domain::{Caps, Focus, Proposal}; +use adhd_ranch_domain::{Caps, Focus, Proposal, Settings}; +use adhd_ranch_storage::write_settings; -use tauri::State; +use tauri::{AppHandle, Emitter, Manager, State, Wry}; use adhd_ranch_domain::{PigRect, RectUpdater}; use crate::api::Health; +use crate::app::{DebugOverlayState, SettingsPathState, SettingsState}; pub struct CommandsState(pub Arc); pub struct PigHitState(pub Arc); @@ -146,3 +148,118 @@ pub fn update_pig_rects( pub fn set_pig_drag_active(active: bool, state: State<'_, DragLockState>) { state.0.store(active, Ordering::Relaxed); } + +#[tauri::command] +pub fn get_settings(state: State<'_, SettingsState>) -> Settings { + state.0.lock().map(|s| s.clone()).unwrap_or_default() +} + +#[tauri::command] +pub fn update_settings( + settings: Settings, + app: AppHandle, + state: State<'_, SettingsState>, + path_state: State<'_, SettingsPathState>, +) -> Result<(), String> { + let old_displays = { + let Ok(mut s) = state.0.lock() else { + return Err("settings lock poisoned".to_string()); + }; + let old = s.displays.clone(); + *s = settings.clone(); + old + }; + + write_settings(&path_state.0, &settings).map_err(|e| format!("persist settings: {e}"))?; + + let monitors_count = app + .try_state::() + .map(|s| s.0.len()) + .unwrap_or(1); + for i in 0..monitors_count { + if let Some(w) = app.get_webview_window(&format!("overlay-{i}")) { + crate::app::window_always_on_top::apply(&w, settings.widget.always_on_top); + } + } + + if settings.displays != old_displays { + if let Some(display_state) = app.try_state::() { + match display_state.0.lock() { + Ok(mut config) => *config = settings.displays.clone(), + Err(e) => log::error!("update_settings: display config lock poisoned: {e}"), + } + } + if let Some(overlay_state) = app.try_state::() { + if let Some(monitors_state) = app.try_state::() { + let display_mgr = Arc::clone(&overlay_state.0); + let monitors = monitors_state.0.clone(); + let config = settings.displays.clone(); + let app_clone = app.clone(); + if let Err(e) = app.run_on_main_thread(move || { + display_mgr.apply(&app_clone, &monitors, &config); + }) { + log::error!("update_settings: run_on_main_thread failed: {e}"); + } + } + } + } + + crate::app::tray::rebuild_tray_menu(&app); + + Ok(()) +} + +#[derive(serde::Serialize)] +pub struct MonitorInfo { + pub idx: usize, + pub label: String, +} + +#[tauri::command] +pub fn get_monitors(app: AppHandle) -> Vec { + app.try_state::() + .map(|s| { + s.0.iter() + .enumerate() + .map(|(i, m)| MonitorInfo { + idx: i, + label: m.label.clone(), + }) + .collect() + }) + .unwrap_or_default() +} + +#[tauri::command] +pub fn get_debug_overlay(state: State<'_, DebugOverlayState>) -> bool { + state.0.lock().map(|v| *v).unwrap_or(false) +} + +#[tauri::command] +pub fn set_debug_overlay(enabled: bool, app: AppHandle, state: State<'_, DebugOverlayState>) { + let Ok(mut v) = state.0.lock() else { return }; + *v = enabled; + let _ = app.emit("debug-overlay-toggle", enabled); +} + +#[tauri::command] +pub fn toggle_devtools(app: AppHandle) { + if let Some(win) = app.get_webview_window("overlay-0") { + #[cfg(debug_assertions)] + if win.is_devtools_open() { + win.close_devtools(); + } else { + win.open_devtools(); + } + let _ = win; + } +} + +#[tauri::command] +pub fn get_devtools_open(app: AppHandle) -> bool { + #[cfg(debug_assertions)] + if let Some(win) = app.get_webview_window("overlay-0") { + return win.is_devtools_open(); + } + false +} diff --git a/src/api/debug.ts b/src/api/debug.ts new file mode 100644 index 0000000..ea6c134 --- /dev/null +++ b/src/api/debug.ts @@ -0,0 +1,17 @@ +import { invoke } from "@tauri-apps/api/core"; + +export async function getDebugOverlay(): Promise { + return invoke("get_debug_overlay"); +} + +export async function setDebugOverlay(enabled: boolean): Promise { + return invoke("set_debug_overlay", { enabled }); +} + +export async function getDevtoolsOpen(): Promise { + return invoke("get_devtools_open"); +} + +export async function toggleDevtools(): Promise { + return invoke("toggle_devtools"); +} diff --git a/src/api/monitors.ts b/src/api/monitors.ts new file mode 100644 index 0000000..02d1f7c --- /dev/null +++ b/src/api/monitors.ts @@ -0,0 +1,6 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { MonitorInfo } from "../types/monitor"; + +export async function getMonitors(): Promise { + return invoke("get_monitors"); +} diff --git a/src/api/settings.ts b/src/api/settings.ts new file mode 100644 index 0000000..e6f6bfe --- /dev/null +++ b/src/api/settings.ts @@ -0,0 +1,10 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { Settings } from "../types/settings"; + +export async function getSettings(): Promise { + return invoke("get_settings"); +} + +export async function updateSettings(settings: Settings): Promise { + return invoke("update_settings", { settings }); +} diff --git a/src/components/SettingsWindow.tsx b/src/components/SettingsWindow.tsx new file mode 100644 index 0000000..2df8745 --- /dev/null +++ b/src/components/SettingsWindow.tsx @@ -0,0 +1,173 @@ +import type React from "react"; +import type { MonitorInfo } from "../types/monitor"; +import type { Settings } from "../types/settings"; + +interface SettingsWindowProps { + readonly settings: Settings | null; + readonly monitors: MonitorInfo[]; + readonly debugOverlay: boolean; + readonly devtoolsOpen: boolean; + readonly onUpdate: (next: Settings) => void; + readonly onSetDebugOverlay: (enabled: boolean) => void; + readonly onToggleDevtools: () => void; + readonly containerRef: React.RefObject; +} + +interface ToggleRowProps { + readonly label: string; + readonly checked: boolean; + readonly onChange: (v: boolean) => void; +} + +function ToggleRow({ label, checked, onChange }: ToggleRowProps) { + return ( + + ); +} + +interface NumberRowProps { + readonly label: string; + readonly value: number; + readonly min: number; + readonly max: number; + readonly onChange: (v: number) => void; +} + +function NumberRow({ label, value, min, max, onChange }: NumberRowProps) { + return ( + + ); +} + +function toggleDisplayIndex(indices: readonly number[], idx: number): number[] { + const next = indices.includes(idx) + ? indices.filter((i) => i !== idx) + : [...indices, idx].sort((a, b) => a - b); + return next.length > 0 ? next : [idx]; +} + +export function SettingsWindow({ + settings, + monitors, + debugOverlay, + devtoolsOpen, + onUpdate, + onSetDebugOverlay, + onToggleDevtools, + containerRef, +}: SettingsWindowProps) { + return ( +
} className="settings-window"> + {settings && ( + <> +
+

General

+ + onUpdate({ ...settings, caps: { ...settings.caps, max_focuses: v } }) + } + /> + + onUpdate({ ...settings, caps: { ...settings.caps, max_tasks_per_focus: v } }) + } + /> +
+ +
+

Widget

+ + onUpdate({ ...settings, widget: { ...settings.widget, always_on_top: v } }) + } + /> + + onUpdate({ ...settings, widget: { ...settings.widget, confirm_delete: v } }) + } + /> +
+ + {monitors.length > 0 && ( +
+

Displays

+ {monitors.map((m) => ( + + onUpdate({ + ...settings, + displays: { + enabled_indices: toggleDisplayIndex( + settings.displays.enabled_indices, + m.idx, + ), + }, + }) + } + /> + ))} +
+ )} + +
+

Alerts

+ + onUpdate({ + ...settings, + alerts: { ...settings.alerts, system_notifications: v }, + }) + } + /> +
+ + )} + +
+

Debug

+ + {import.meta.env.DEV && ( + onToggleDevtools()} /> + )} +
+
+ ); +} diff --git a/src/hooks/useDebugOverlay.ts b/src/hooks/useDebugOverlay.ts index 63553fc..e166fb7 100644 --- a/src/hooks/useDebugOverlay.ts +++ b/src/hooks/useDebugOverlay.ts @@ -14,7 +14,7 @@ export interface DebugOverlayState { } export function useDebugOverlay(): DebugOverlayState { - const [visible, setVisible] = useState(import.meta.env.DEV); + const [visible, setVisible] = useState(false); useEffect(() => { const unsub = subscribeDebugOverlay(setVisible); diff --git a/src/hooks/useSettingsWindow.ts b/src/hooks/useSettingsWindow.ts new file mode 100644 index 0000000..67e624f --- /dev/null +++ b/src/hooks/useSettingsWindow.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from "react"; +import { getDebugOverlay, getDevtoolsOpen, setDebugOverlay, toggleDevtools } from "../api/debug"; +import { getMonitors } from "../api/monitors"; +import { getSettings, updateSettings } from "../api/settings"; +import type { MonitorInfo } from "../types/monitor"; +import type { Settings } from "../types/settings"; + +export interface SettingsWindowState { + settings: Settings | null; + monitors: MonitorInfo[]; + debugOverlay: boolean; + devtoolsOpen: boolean; + update: (next: Settings) => void; + setDebugOverlayEnabled: (enabled: boolean) => void; + toggleDevtoolsOpen: () => void; +} + +export function useSettingsWindow(): SettingsWindowState { + const [settings, setSettings] = useState(null); + const [monitors, setMonitors] = useState([]); + const [debugOverlay, setDebugOverlayState] = useState(false); // real state loaded from backend on mount + const [devtoolsOpen, setDevtoolsOpen] = useState(false); + + useEffect(() => { + getSettings().then(setSettings).catch(console.error); + getMonitors().then(setMonitors).catch(console.error); + getDebugOverlay().then(setDebugOverlayState).catch(console.error); + getDevtoolsOpen().then(setDevtoolsOpen).catch(console.error); + }, []); + + const update = useCallback((next: Settings) => { + setSettings(next); + updateSettings(next).catch(console.error); + }, []); + + const setDebugOverlayEnabled = useCallback((enabled: boolean) => { + setDebugOverlayState(enabled); + setDebugOverlay(enabled).catch(console.error); + }, []); + + const toggleDevtoolsOpen = useCallback(() => { + setDevtoolsOpen((prev) => !prev); + toggleDevtools().catch(console.error); + }, []); + + return { + settings, + monitors, + debugOverlay, + devtoolsOpen, + update, + setDebugOverlayEnabled, + toggleDevtoolsOpen, + }; +} diff --git a/src/settings.tsx b/src/settings.tsx new file mode 100644 index 0000000..e31b6b8 --- /dev/null +++ b/src/settings.tsx @@ -0,0 +1,53 @@ +import { LogicalSize, getCurrentWindow } from "@tauri-apps/api/window"; +import React, { useEffect, useRef } from "react"; +import ReactDOM from "react-dom/client"; +import { SettingsWindow } from "./components/SettingsWindow"; +import { useSettingsWindow } from "./hooks/useSettingsWindow"; +import "./styles.css"; + +function SettingsApp() { + const containerRef = useRef(null); + const { + settings, + monitors, + debugOverlay, + devtoolsOpen, + update, + setDebugOverlayEnabled, + toggleDevtoolsOpen, + } = useSettingsWindow(); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const win = getCurrentWindow(); + const observer = new ResizeObserver(() => { + const h = Math.ceil(el.getBoundingClientRect().height); + win.setSize(new LogicalSize(380, h)).catch(console.error); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( + + ); +} + +const rootEl = document.getElementById("root"); +if (!rootEl) throw new Error("missing #root"); + +ReactDOM.createRoot(rootEl).render( + + + , +); diff --git a/src/styles.css b/src/styles.css index 2898352..dbd1f03 100644 --- a/src/styles.css +++ b/src/styles.css @@ -526,3 +526,69 @@ body, .pig-detail-add-task:focus { border-color: rgba(255, 255, 255, 0.3); } + +.settings-window { + display: flex; + flex-direction: column; + background: #16161a; + color: #f5f5f7; + padding: 12px 16px 16px; + box-sizing: border-box; +} + +.settings-section { + margin-bottom: 12px; +} + +.settings-section:last-child { + margin-bottom: 0; +} + +.settings-section-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + opacity: 0.4; + margin: 0 0 4px; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + cursor: pointer; +} + +.settings-row:last-child { + border-bottom: none; +} + +.settings-row-label { + font-size: 12px; +} + +.settings-toggle { + cursor: pointer; + width: 14px; + height: 14px; + accent-color: #636ae8; +} + +.settings-number { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 5px; + color: #f5f5f7; + font-size: 12px; + padding: 2px 5px; + width: 48px; + text-align: right; + outline: none; +} + +.settings-number:focus { + border-color: rgba(255, 255, 255, 0.4); +} diff --git a/src/types/monitor.ts b/src/types/monitor.ts new file mode 100644 index 0000000..8968410 --- /dev/null +++ b/src/types/monitor.ts @@ -0,0 +1,4 @@ +export interface MonitorInfo { + readonly idx: number; + readonly label: string; +} diff --git a/src/types/settings.ts b/src/types/settings.ts new file mode 100644 index 0000000..dd27f36 --- /dev/null +++ b/src/types/settings.ts @@ -0,0 +1,24 @@ +export interface Caps { + readonly max_focuses: number; + readonly max_tasks_per_focus: number; +} + +export interface Alerts { + readonly system_notifications: boolean; +} + +export interface Widget { + readonly always_on_top: boolean; + readonly confirm_delete: boolean; +} + +export interface DisplayConfig { + readonly enabled_indices: readonly number[]; +} + +export interface Settings { + readonly caps: Caps; + readonly alerts: Alerts; + readonly widget: Widget; + readonly displays: DisplayConfig; +} diff --git a/vite.config.ts b/vite.config.ts index 44b7c9c..7d165df 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ input: { main: resolve(__dirname, "index.html"), newFocus: resolve(__dirname, "new-focus.html"), + settings: resolve(__dirname, "settings.html"), }, }, },