diff --git a/index.html b/index.html index ff93803bb..3d0c8aaf3 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ + Tauri + React + Typescript diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 350844423..ddb75cff4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -572,6 +572,8 @@ dependencies = [ "git2", "ignore", "libc", + "objc2-app-kit", + "objc2-foundation", "portable-pty", "reqwest", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2a12fcabb..35548c275 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,3 +42,7 @@ tauri-plugin-updater = "2" cpal = "0.15" whisper-rs = "0.12" sha2 = "0.10" + +[target."cfg(target_os = \"macos\")".dependencies] +objc2-app-kit = { version = "0.3", features = ["NSAppearance", "NSResponder", "NSWindow"] } +objc2-foundation = { version = "0.3", features = ["NSString"] } diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 21ffa92ac..541f88acc 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1,3 +1,4 @@ +#[allow(dead_code)] #[path = "../backend/mod.rs"] mod backend; #[path = "../codex_home.rs"] @@ -8,6 +9,7 @@ mod codex_config; mod rules; #[path = "../storage.rs"] mod storage; +#[allow(dead_code)] #[path = "../types.rs"] mod types; @@ -42,6 +44,7 @@ struct DaemonEventSink { #[derive(Clone)] enum DaemonEvent { AppServer(AppServerEvent), + #[allow(dead_code)] TerminalOutput(TerminalOutput), } @@ -1088,13 +1091,6 @@ fn parse_optional_u32(value: &Value, key: &str) -> Option { } } -fn parse_optional_bool(value: &Value, key: &str) -> Option { - match value { - Value::Object(map) => map.get(key).and_then(|value| value.as_bool()), - _ => None, - } -} - fn parse_optional_string_array(value: &Value, key: &str) -> Option> { match value { Value::Object(map) => map.get(key).and_then(|value| value.as_array()).map(|items| { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f11f02b2e..dc3b23dda 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod rules; mod settings; mod state; mod terminal; +mod window; mod storage; mod types; mod utils; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 5a576ae40..9bce0693f 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,12 +1,16 @@ -use tauri::State; +use tauri::{State, Window}; use crate::codex_config; use crate::state::AppState; use crate::storage::write_settings; use crate::types::AppSettings; +use crate::window; #[tauri::command] -pub(crate) async fn get_app_settings(state: State<'_, AppState>) -> Result { +pub(crate) async fn get_app_settings( + state: State<'_, AppState>, + window: Window, +) -> Result { let mut settings = state.app_settings.lock().await.clone(); if let Ok(Some(collab_enabled)) = codex_config::read_collab_enabled() { settings.experimental_collab_enabled = collab_enabled; @@ -17,6 +21,7 @@ pub(crate) async fn get_app_settings(state: State<'_, AppState>) -> Result) -> Result, + window: Window, ) -> Result { let _ = codex_config::write_collab_enabled(settings.experimental_collab_enabled); let _ = codex_config::write_steer_enabled(settings.experimental_steer_enabled); @@ -31,5 +37,6 @@ pub(crate) async fn update_app_settings( write_settings(&state.settings_path, &settings)?; let mut current = state.app_settings.lock().await; *current = settings.clone(); + let _ = window::apply_window_appearance(&window, settings.theme.as_str()); Ok(settings) } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index be9127f1c..0f391c499 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -262,6 +262,8 @@ pub(crate) struct AppSettings { pub(crate) last_composer_reasoning_effort: Option, #[serde(default = "default_ui_scale", rename = "uiScale")] pub(crate) ui_scale: f64, + #[serde(default = "default_theme", rename = "theme")] + pub(crate) theme: String, #[serde( default = "default_notification_sounds_enabled", rename = "notificationSoundsEnabled" @@ -325,6 +327,10 @@ fn default_ui_scale() -> f64 { 1.0 } +fn default_theme() -> String { + "system".to_string() +} + fn default_composer_model_shortcut() -> Option { Some("cmd+shift+m".to_string()) } @@ -383,6 +389,7 @@ impl Default for AppSettings { last_composer_model_id: None, last_composer_reasoning_effort: None, ui_scale: 1.0, + theme: default_theme(), notification_sounds_enabled: true, experimental_collab_enabled: false, experimental_steer_enabled: false, @@ -425,6 +432,7 @@ mod tests { assert!(settings.last_composer_model_id.is_none()); assert!(settings.last_composer_reasoning_effort.is_none()); assert!((settings.ui_scale - 1.0).abs() < f64::EPSILON); + assert_eq!(settings.theme, "system"); assert!(settings.notification_sounds_enabled); assert!(!settings.experimental_steer_enabled); assert!(!settings.dictation_enabled); diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs new file mode 100644 index 000000000..17b2bf1d6 --- /dev/null +++ b/src-tauri/src/window.rs @@ -0,0 +1,53 @@ +use tauri::{Theme, Window}; + +#[cfg(target_os = "macos")] +fn apply_macos_window_appearance(window: &Window, theme: &str) -> Result<(), String> { + use objc2_app_kit::{ + NSAppearance, NSAppearanceCustomization, NSAppearanceNameAqua, + NSAppearanceNameDarkAqua, NSWindow, + }; + + let ns_window = window + .ns_window() + .map_err(|error| error.to_string())?; + let ns_window: &NSWindow = unsafe { &*ns_window.cast() }; + + if theme == "system" { + ns_window.setAppearance(None); + return Ok(()); + } + + let appearance_name = unsafe { + if theme == "dark" { + NSAppearanceNameDarkAqua + } else { + NSAppearanceNameAqua + } + }; + let appearance = + NSAppearance::appearanceNamed(appearance_name).ok_or("NSAppearance missing")?; + ns_window.setAppearance(Some(&appearance)); + Ok(()) +} + +pub(crate) fn apply_window_appearance(window: &Window, theme: &str) -> Result<(), String> { + let next_theme = match theme { + "light" => Some(Theme::Light), + "dark" => Some(Theme::Dark), + _ => None, + }; + let _ = window.set_theme(next_theme); + + #[cfg(target_os = "macos")] + { + let window_handle = window.clone(); + let theme_value = theme.to_string(); + window + .run_on_main_thread(move || { + let _ = apply_macos_window_appearance(&window_handle, theme_value.as_str()); + }) + .map_err(|error| error.to_string())?; + } + + Ok(()) +} diff --git a/src/App.tsx b/src/App.tsx index 5a28a6e31..e3e8c7421 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,7 @@ import { useResizablePanels } from "./features/layout/hooks/useResizablePanels"; import { useLayoutMode } from "./features/layout/hooks/useLayoutMode"; import { useSidebarToggles } from "./features/layout/hooks/useSidebarToggles"; import { useTransparencyPreference } from "./features/layout/hooks/useTransparencyPreference"; +import { useThemePreference } from "./features/layout/hooks/useThemePreference"; import { useWindowLabel } from "./features/layout/hooks/useWindowLabel"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { @@ -107,6 +108,7 @@ function MainApp() { doctor, isLoading: appSettingsLoading } = useAppSettings(); + useThemePreference(appSettings.theme); const dictationModel = useDictationModel(appSettings.dictationModelId); const { state: dictationState, diff --git a/src/features/layout/hooks/useThemePreference.ts b/src/features/layout/hooks/useThemePreference.ts new file mode 100644 index 000000000..bfdf94c27 --- /dev/null +++ b/src/features/layout/hooks/useThemePreference.ts @@ -0,0 +1,13 @@ +import { useEffect } from "react"; +import type { ThemePreference } from "../../../types"; + +export function useThemePreference(theme: ThemePreference) { + useEffect(() => { + const root = document.documentElement; + if (theme === "system") { + delete root.dataset.theme; + return; + } + root.dataset.theme = theme; + }, [theme]); +} diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 14152e463..8199adb13 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -718,6 +718,26 @@ export function SettingsView({
Adjust how the window renders backgrounds and effects.
+
+ + +
Reduce transparency
diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 85a48882c..a660fd7e7 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -3,6 +3,8 @@ import type { AppSettings } from "../../../types"; import { getAppSettings, runCodexDoctor, updateAppSettings } from "../../../services/tauri"; import { clampUiScale, UI_SCALE_DEFAULT } from "../../../utils/uiScale"; +const allowedThemes = new Set(["system", "light", "dark"]); + const defaultSettings: AppSettings = { codexBin: null, backendMode: "local", @@ -15,6 +17,7 @@ const defaultSettings: AppSettings = { lastComposerModelId: null, lastComposerReasoningEffort: null, uiScale: UI_SCALE_DEFAULT, + theme: "system", notificationSoundsEnabled: true, experimentalCollabEnabled: false, experimentalSteerEnabled: false, @@ -30,6 +33,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, uiScale: clampUiScale(settings.uiScale), + theme: allowedThemes.has(settings.theme) ? settings.theme : "system", }; } diff --git a/src/styles/base.css b/src/styles/base.css index 13f12b09a..211ff4efa 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -70,6 +70,10 @@ --ui-scale: 1; } +:root[data-theme="dark"] { + color-scheme: dark; +} + .app.reduced-transparency { --surface-sidebar: rgba(18, 18, 18, 0.92); --surface-topbar: rgba(10, 14, 20, 0.94); @@ -95,8 +99,90 @@ --surface-popover: rgba(16, 20, 30, 0.995); } - @media (prefers-color-scheme: light) { - :root { +:root[data-theme="light"] { + color-scheme: light; + --text-primary: #1a1d24; + --text-strong: #0e1118; + --text-emphasis: rgba(17, 20, 28, 0.9); + --text-stronger: rgba(17, 20, 28, 0.85); + --text-quiet: rgba(17, 20, 28, 0.75); + --text-muted: rgba(17, 20, 28, 0.7); + --text-subtle: rgba(17, 20, 28, 0.6); + --text-faint: rgba(17, 20, 28, 0.5); + --text-fainter: rgba(17, 20, 28, 0.45); + --text-dim: rgba(17, 20, 28, 0.35); + --surface-sidebar: rgba(246, 247, 250, 0.82); + --surface-topbar: rgba(250, 251, 253, 0.9); + --surface-right-panel: rgba(245, 247, 250, 0.82); + --surface-composer: rgba(250, 251, 253, 0.9); + --surface-messages: rgba(238, 241, 246, 0.9); + --surface-card: rgba(255, 255, 255, 0.72); + --surface-card-strong: rgba(255, 255, 255, 0.92); + --surface-card-muted: rgba(255, 255, 255, 0.7); + --surface-item: rgba(255, 255, 255, 0.6); + --surface-control: rgba(15, 23, 36, 0.08); + --surface-control-hover: rgba(15, 23, 36, 0.12); + --surface-control-disabled: rgba(15, 23, 36, 0.05); + --surface-hover: rgba(15, 23, 36, 0.06); + --surface-active: rgba(77, 153, 255, 0.18); + --surface-approval: rgba(246, 248, 252, 0.92); + --surface-debug: rgba(242, 244, 248, 0.9); + --surface-command: rgba(245, 247, 250, 0.95); + --surface-diff-card: rgba(240, 243, 248, 0.92); + --surface-bubble: rgba(255, 255, 255, 0.9); + --surface-bubble-user: rgba(77, 153, 255, 0.22); + --surface-context-core: rgba(255, 255, 255, 0.9); + --surface-popover: rgba(255, 255, 255, 0.99); + --surface-review: rgba(255, 170, 210, 0.25); + --border-review: rgba(200, 90, 140, 0.45); + --surface-review-active: rgba(255, 170, 210, 0.32); + --text-review-active: rgba(120, 30, 70, 0.9); + --surface-review-done: rgba(140, 235, 200, 0.35); + --text-review-done: rgba(20, 90, 60, 0.9); + --border-subtle: rgba(15, 23, 36, 0.08); + --border-muted: rgba(15, 23, 36, 0.06); + --border-strong: rgba(15, 23, 36, 0.14); + --border-stronger: rgba(15, 23, 36, 0.18); + --border-quiet: rgba(15, 23, 36, 0.2); + --border-accent: rgba(77, 153, 255, 0.5); + --border-accent-soft: rgba(77, 153, 255, 0.28); + --text-accent: rgba(45, 93, 170, 0.7); + --shadow-accent: rgba(90, 140, 210, 0.18); + --status-success: rgba(30, 155, 110, 0.9); + --status-warning: rgba(215, 120, 20, 0.9); + --status-error: rgba(200, 45, 45, 0.9); + --status-unknown: rgba(17, 20, 28, 0.25); + --select-caret: rgba(15, 23, 36, 0.45); +} + +:root[data-theme="light"] .app.reduced-transparency { + --surface-sidebar: rgba(240, 242, 247, 0.98); + --surface-topbar: rgba(244, 246, 250, 0.98); + --surface-right-panel: rgba(242, 244, 248, 0.98); + --surface-composer: rgba(244, 246, 250, 0.98); + --surface-messages: rgba(240, 242, 247, 0.98); + --surface-card: rgba(255, 255, 255, 0.96); + --surface-card-strong: rgba(255, 255, 255, 0.98); + --surface-card-muted: rgba(252, 253, 255, 0.96); + --surface-item: rgba(250, 251, 253, 0.96); + --surface-control: rgba(15, 23, 36, 0.12); + --surface-control-hover: rgba(15, 23, 36, 0.18); + --surface-control-disabled: rgba(15, 23, 36, 0.08); + --surface-hover: rgba(15, 23, 36, 0.1); + --surface-active: rgba(77, 153, 255, 0.22); + --surface-approval: rgba(248, 249, 252, 0.98); + --surface-debug: rgba(246, 248, 252, 0.98); + --surface-command: rgba(250, 251, 253, 0.98); + --surface-diff-card: rgba(244, 246, 250, 0.98); + --surface-bubble: rgba(255, 255, 255, 0.98); + --surface-bubble-user: rgba(77, 153, 255, 0.28); + --surface-context-core: rgba(255, 255, 255, 0.98); + --surface-popover: rgba(255, 255, 255, 0.995); +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + color-scheme: light; --text-primary: #1a1d24; --text-strong: #0e1118; --text-emphasis: rgba(17, 20, 28, 0.9); @@ -118,23 +204,23 @@ --surface-item: rgba(255, 255, 255, 0.6); --surface-control: rgba(15, 23, 36, 0.08); --surface-control-hover: rgba(15, 23, 36, 0.12); - --surface-control-disabled: rgba(15, 23, 36, 0.05); - --surface-hover: rgba(15, 23, 36, 0.06); - --surface-active: rgba(77, 153, 255, 0.18); + --surface-control-disabled: rgba(15, 23, 36, 0.05); + --surface-hover: rgba(15, 23, 36, 0.06); + --surface-active: rgba(77, 153, 255, 0.18); --surface-approval: rgba(246, 248, 252, 0.92); --surface-debug: rgba(242, 244, 248, 0.9); --surface-command: rgba(245, 247, 250, 0.95); --surface-diff-card: rgba(240, 243, 248, 0.92); - --surface-bubble: rgba(255, 255, 255, 0.9); - --surface-bubble-user: rgba(77, 153, 255, 0.22); - --surface-context-core: rgba(255, 255, 255, 0.9); - --surface-popover: rgba(255, 255, 255, 0.99); - --surface-review: rgba(255, 170, 210, 0.25); - --border-review: rgba(200, 90, 140, 0.45); - --surface-review-active: rgba(255, 170, 210, 0.32); - --text-review-active: rgba(120, 30, 70, 0.9); - --surface-review-done: rgba(140, 235, 200, 0.35); - --text-review-done: rgba(20, 90, 60, 0.9); + --surface-bubble: rgba(255, 255, 255, 0.9); + --surface-bubble-user: rgba(77, 153, 255, 0.22); + --surface-context-core: rgba(255, 255, 255, 0.9); + --surface-popover: rgba(255, 255, 255, 0.99); + --surface-review: rgba(255, 170, 210, 0.25); + --border-review: rgba(200, 90, 140, 0.45); + --surface-review-active: rgba(255, 170, 210, 0.32); + --text-review-active: rgba(120, 30, 70, 0.9); + --surface-review-done: rgba(140, 235, 200, 0.35); + --text-review-done: rgba(20, 90, 60, 0.9); --border-subtle: rgba(15, 23, 36, 0.08); --border-muted: rgba(15, 23, 36, 0.06); --border-strong: rgba(15, 23, 36, 0.14); @@ -142,16 +228,16 @@ --border-quiet: rgba(15, 23, 36, 0.2); --border-accent: rgba(77, 153, 255, 0.5); --border-accent-soft: rgba(77, 153, 255, 0.28); - --text-accent: rgba(45, 93, 170, 0.7); - --shadow-accent: rgba(90, 140, 210, 0.18); - --status-success: rgba(30, 155, 110, 0.9); + --text-accent: rgba(45, 93, 170, 0.7); + --shadow-accent: rgba(90, 140, 210, 0.18); + --status-success: rgba(30, 155, 110, 0.9); --status-warning: rgba(215, 120, 20, 0.9); --status-error: rgba(200, 45, 45, 0.9); --status-unknown: rgba(17, 20, 28, 0.25); - --select-caret: rgba(15, 23, 36, 0.45); + --select-caret: rgba(15, 23, 36, 0.45); } - .app.reduced-transparency { + :root:not([data-theme]) .app.reduced-transparency { --surface-sidebar: rgba(240, 242, 247, 0.98); --surface-topbar: rgba(244, 246, 250, 0.98); --surface-right-panel: rgba(242, 244, 248, 0.98); diff --git a/src/styles/composer.css b/src/styles/composer.css index 3795fa842..452cdba2b 100644 --- a/src/styles/composer.css +++ b/src/styles/composer.css @@ -198,13 +198,22 @@ position: relative; } +:root[data-theme="light"] .composer-action { + border-color: var(--border-strong); + color: var(--text-strong); +} + +:root[data-theme="light"] .composer-action:hover { + color: var(--text-strong); +} + @media (prefers-color-scheme: light) { - .composer-action { + :root:not([data-theme]) .composer-action { border-color: var(--border-strong); color: var(--text-strong); } - .composer-action:hover { + :root:not([data-theme]) .composer-action:hover { color: var(--text-strong); } } diff --git a/src/styles/messages.css b/src/styles/messages.css index 1da7dd190..3a740c715 100644 --- a/src/styles/messages.css +++ b/src/styles/messages.css @@ -73,8 +73,13 @@ animation: working-shimmer 2.2s ease-in-out infinite; } +:root[data-theme="light"] .working-text { + color: var(--text-muted); + background: none; +} + @media (prefers-color-scheme: light) { - .working-text { + :root:not([data-theme]) .working-text { color: var(--text-muted); background: none; } diff --git a/src/types.ts b/src/types.ts index aa2d28529..444584dc5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,6 +71,7 @@ export type ReviewTarget = export type AccessMode = "read-only" | "current" | "full-access"; export type BackendMode = "local" | "remote"; +export type ThemePreference = "system" | "light" | "dark"; export type AppSettings = { codexBin: string | null; @@ -84,6 +85,7 @@ export type AppSettings = { lastComposerModelId: string | null; lastComposerReasoningEffort: string | null; uiScale: number; + theme: ThemePreference; notificationSoundsEnabled: boolean; experimentalCollabEnabled: boolean; experimentalSteerEnabled: boolean;