diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 1ff5ae7..2c5b4bc 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -279,10 +279,7 @@ fn options_page(app: &mut WordApp, ui: &mut Ui) { app.session.author = n; } }); - let mut dark = app.ui.dark; - if ui.checkbox(&mut dark, tl!("Dark mode")).changed() { - let _ = app.run("ui.dark", json!({"value": dark})); - } + theme_picker(app, ui); ui.checkbox(&mut app.autosave, tl!("AutoSave documents you have saved in WordCraft")); let mut dark_page = app.session.view.dark_mode; if ui @@ -308,6 +305,21 @@ fn options_page(app: &mut WordApp, ui: &mut Ui) { ui.label(tl!("Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel.")); } +/// File ▸ Options ▸ Interface theme: Light, Dark, or follow the system's appearance (#115). +fn theme_picker(app: &mut WordApp, ui: &mut Ui) { + use crate::theme::Appearance; + ui.horizontal(|ui| { + ui.label(tl!("Interface theme:")); + egui::ComboBox::from_id_salt("interface_theme").selected_text(tl!(app.ui.theme.label())).width(220.0).show_ui(ui, |ui| { + for a in Appearance::ALL { + if ui.selectable_label(app.ui.theme == a, tl!(a.label())).clicked() { + let _ = app.run("ui.theme", json!({"value": a.code()})); + } + } + }); + }); +} + /// File ▸ Options ▸ Interface language: follow the system (the default) or pick one (#8). fn language_picker(app: &mut WordApp, ui: &mut Ui) { use crate::i18n::{AUTO, Lang}; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4858ea7..fdb5bd8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -164,7 +164,6 @@ Customize Quick Access Toolbar Personalizar la barra de herramientas de acceso rápido Cut Cortar Dark Mode Modo oscuro - Dark mode Modo oscuro Dashed Discontinua Data Datos Date & Time Fecha y hora @@ -893,3 +892,9 @@ Opening print dialog… Abriendo el cuadro de diálogo de impresión… Print failed: {error} Error al imprimir: {error} Couldn't open {path}: {error} No se pudo abrir {path}: {error} + Interface theme: Tema de la interfaz: + Interface\nTheme Tema de la\ninterfaz + Interface Theme Tema de la interfaz + Light Claro + Dark Oscuro + Use system setting Usar la configuración del sistema diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dc5999f..f486551 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -157,7 +157,6 @@ Customize Quick Access Toolbar クイック アクセス ツール バーのユーザー設定 Cut 切り取り Dark Mode ダーク モード - Dark mode ダーク モード Dashed 破線 Data データ Date & Time 日付と時刻 @@ -876,3 +875,9 @@ Opening print dialog… 印刷ダイアログを開いています… Print failed: {error} 印刷できませんでした: {error} Couldn't open {path}: {error} {path} を開けませんでした: {error} + Interface theme: 表示テーマ: + Interface\nTheme 表示テーマ + Interface Theme 表示テーマ + Light ライト + Dark ダーク + Use system setting システム設定を使用 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c6c6bea..9dfc016 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -161,7 +161,6 @@ Customize Quick Access Toolbar Personalizar barra de acesso rápido Cut Recortar Dark Mode Modo escuro - Dark mode Modo escuro Dashed Tracejada Data Dados Date & Time Data e hora @@ -888,3 +887,9 @@ Opening print dialog… Abrindo a caixa de diálogo de impressão… Print failed: {error} Falha na impressão: {error} Couldn't open {path}: {error} Não foi possível abrir {path}: {error} + Interface theme: Tema da interface: + Interface\nTheme Tema da\ninterface + Interface Theme Tema da interface + Light Claro + Dark Escuro + Use system setting Usar a configuração do sistema diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f356a63..25b9a4d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -161,7 +161,6 @@ Customize Quick Access Toolbar Налаштувати панель швидкого доступу Cut Вирізати Dark Mode Темний режим - Dark mode Темний режим Dashed Штрихова Data Дані Date & Time Дата й час @@ -888,3 +887,9 @@ Opening print dialog… Відкриття діалогу друку… Print failed: {error} Не вдалося надрукувати: {error} Couldn't open {path}: {error} Не вдалося відкрити {path}: {error} + Interface theme: Тема інтерфейсу: + Interface\nTheme Тема\nінтерфейсу + Interface Theme Тема інтерфейсу + Light Світла + Dark Темна + Use system setting Як у системі diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 01154be..9e4856d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -168,7 +168,6 @@ Customize Quick Access Toolbar 自定义快速访问工具栏 Cut 剪切 Dark Mode 深色模式 - Dark mode 深色模式 Dashed 虚线 Data 数据 Date & Time 日期和时间 @@ -887,3 +886,9 @@ Opening print dialog… 正在打开打印对话框… Print failed: {error} 打印失败:{error} Couldn't open {path}: {error} 无法打开 {path}:{error} + Interface theme: 界面主题: + Interface\nTheme 界面主题 + Interface Theme 界面主题 + Light 浅色 + Dark 深色 + Use system setting 使用系统设置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a1e6e2b..9adadea 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -158,7 +158,6 @@ Customize Quick Access Toolbar 自訂快速存取工具列 Cut 剪下 Dark Mode 深色模式 - Dark mode 深色模式 Dashed 虛線 Data 資料 Date & Time 日期及時間 @@ -877,3 +876,9 @@ Opening print dialog… 正在開啟列印對話方塊… Print failed: {error} 列印失敗:{error} Couldn't open {path}: {error} 無法開啟 {path}:{error} + Interface theme: 介面主題: + Interface\nTheme 介面主題 + Interface Theme 介面主題 + Light 淺色 + Dark 深色 + Use system setting 使用系統設定 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 8ff68a9..2491375 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -874,6 +874,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.fcircle(10.0, 10.0, 7.0, c); pen.fcircle(13.0, 8.0, 6.0, Color32::from_rgba_unmultiplied(255, 255, 255, 230)); } + "interfaceTheme" => { + // A window, its right half dark: light, dark or both (follow the system). + pen.frect(10.0, 3.5, 17.5, 16.5, c); + pen.rect(2.5, 3.5, 17.5, 16.5, c); + pen.line_c(&[(2.5, 6.5), (10.0, 6.5)], a); + } "newWindow" => { pen.rect(2.5, 5.0, 13.0, 15.0, c); pen.rect(7.0, 2.5, 17.5, 12.5, a); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f882858..d9a44fb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -76,7 +76,11 @@ pub struct UiState { pub backstage_page: String, pub ribbon_collapsed: bool, pub recent: Vec, - pub dark: bool, + /// Interface theme: light, dark, or follow the OS appearance (#115). + pub theme: theme::Appearance, + /// The dark-mode switch `ui.json` held before `theme` (#115): read once to migrate, never written. + #[serde(rename = "dark", skip_serializing)] + pub(crate) legacy_dark: Option, pub nav_tab: String, pub show_discord: bool, /// User name (File › Options) for comments and tracked changes; empty keeps the default. @@ -105,7 +109,8 @@ impl Default for UiState { backstage_page: "home".into(), ribbon_collapsed: false, recent: Vec::new(), - dark: false, + theme: theme::Appearance::default(), + legacy_dark: None, nav_tab: "headings".into(), show_discord: true, author: String::new(), @@ -244,6 +249,10 @@ impl WordApp { pub fn apply_prefs(&mut self, ui: UiState) { self.ui = ui; self.ui.backstage = false; + // A `ui.json` from before the theme setting: its dark-mode switch picks Dark or Light. + if let Some(dark) = self.ui.legacy_dark.take() { + self.ui.theme = if dark { theme::Appearance::Dark } else { theme::Appearance::Light }; + } // The session owns the name from here on; `prefs` copies it back when saving. let author = std::mem::take(&mut self.ui.author); if !author.trim().is_empty() { @@ -426,6 +435,11 @@ impl WordApp { } } + /// Whether the interface theme setting currently resolves to dark (`System` asks the OS). + pub fn ui_is_dark(&self) -> bool { + self.ui.theme.is_dark(self.ctx.as_ref().and_then(egui::Context::system_theme)) + } + /// Commands that live in the UI layer. fn ui_command(&mut self, id: &str, p: &Value) -> Option> { let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -468,8 +482,20 @@ impl WordApp { json!({"collapsed": self.ui.ribbon_collapsed}) } "ui.dark" => { - self.ui.dark = p.get("value").and_then(Value::as_bool).unwrap_or(!self.ui.dark); - json!({"dark": self.ui.dark}) + // A manual Light/Dark switch (toggles what is shown when no value is given). + let dark = p.get("value").and_then(Value::as_bool).unwrap_or(!self.ui_is_dark()); + self.ui.theme = if dark { theme::Appearance::Dark } else { theme::Appearance::Light }; + json!({"dark": dark, "theme": self.ui.theme.code()}) + } + "ui.theme" => { + // `light`, `dark` or `system` (follow the OS appearance); no value reads it. + if let Some(v) = s("value") { + match theme::Appearance::from_code(v) { + Some(a) => self.ui.theme = a, + None => return Some(Err(format!("unknown theme {v:?}; use light, dark or system"))), + } + } + json!({"theme": self.ui.theme.code(), "dark": self.ui_is_dark()}) } "ui.language" => { // `auto` (follow the system) or a language code; anything else is an error. @@ -631,7 +657,9 @@ impl WordApp { ctx.options_mut(|o| o.zoom_with_keyboard = false); self.styled = true; } - let dark = self.ui.dark || self.session.view.dark_mode; + // Re-checked every frame, so `System` follows an OS appearance change live (egui reports it + // and repaints). + let dark = self.ui.theme.is_dark(ctx.system_theme()) || self.session.view.dark_mode; if self.applied_dark != Some(dark) { theme::apply(ctx, &if dark { theme::Tokens::dark() } else { theme::Tokens::light() }); self.applied_dark = Some(dark); @@ -1710,10 +1738,48 @@ mod tests { let default = a.session.author.clone(); a.apply_prefs(serde_json::from_str(r#"{"dark": true, "backstage": true}"#).unwrap()); assert_eq!(a.session.author, default); - assert!(a.ui.dark); + assert_eq!(a.ui.theme, theme::Appearance::Dark); assert!(!a.ui.backstage); } + /// #115: the old dark-mode switch migrates to Light/Dark; a saved System stays System, and the + /// old key is never written back. + #[test] + fn interface_theme_migrates_and_survives_a_restart() { + use theme::Appearance; + for (saved, want) in [(r#"{"dark": false}"#, Appearance::Light), (r#"{"dark": true}"#, Appearance::Dark), ("{}", Appearance::Light)] { + let mut a = app(); + a.apply_prefs(serde_json::from_str(saved).unwrap()); + assert_eq!(a.ui.theme, want, "{saved}"); + } + let mut a = app(); + a.run("ui.theme", json!({"value": "System"})).unwrap(); + let saved = serde_json::to_string(&a.prefs()).unwrap(); + assert!(saved.contains(r#""theme":"system""#) && !saved.contains(r#""dark":"#), "{saved}"); + let mut b = app(); + b.apply_prefs(serde_json::from_str(&saved).unwrap()); + assert_eq!(b.ui.theme, Appearance::System); + // An unknown saved value keeps the rest of the preferences. + let odd: UiState = serde_json::from_str(r#"{"theme": "sepia", "tab": "Insert"}"#).unwrap(); + assert_eq!((odd.theme, odd.tab.as_str()), (Appearance::Light, "Insert")); + } + + /// #115: System follows the OS appearance (light when unknown); the manual choices ignore it. + #[test] + fn interface_theme_resolves_against_the_os_appearance() { + use egui::Theme::{Dark, Light}; + use theme::Appearance; + let cases = [(Appearance::System, None, false), (Appearance::System, Some(Light), false), (Appearance::System, Some(Dark), true)]; + for (setting, os, dark) in cases.into_iter().chain([(Appearance::Light, Some(Dark), false), (Appearance::Dark, Some(Light), true)]) { + assert_eq!(setting.is_dark(os), dark, "{setting:?} with the OS at {os:?}"); + } + let mut a = app(); + assert!(a.run("ui.theme", json!({"value": "purple"})).is_err()); + assert_eq!(a.run("ui.theme", json!({"value": "dark"})).unwrap()["theme"], "dark"); + assert_eq!(a.run("ui.dark", json!({})).unwrap()["theme"], "light", "ui.dark toggles the manual choice"); + assert_eq!(a.run("ui.theme", json!({})).unwrap()["theme"], "light"); + } + #[test] fn word_count_setting_survives_a_restart() { let mut a = app(); @@ -1729,7 +1795,7 @@ mod tests { fn prefs_without_editing_keep_its_defaults() { let mut a = app(); a.apply_prefs(serde_json::from_str(r#"{"tab": "Insert", "dark": true}"#).unwrap()); - assert_eq!((a.ui.tab.as_str(), a.ui.dark), ("Insert", true)); + assert_eq!((a.ui.tab.as_str(), a.ui.theme), ("Insert", theme::Appearance::Dark)); assert!(a.session.prefs.count_notes, "text boxes and notes count by default"); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index fcf9ded..7e592df 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1015,6 +1015,14 @@ fn view(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Dark Mode", None, app, |ui, app| { big(ui, app, "darkMode", "Switch\nModes", "view.darkMode", json!({}), false); + menu_button(ui, app, "interfaceTheme", Some("Interface\nTheme"), "Interface Theme", true, |ui, app| { + for a in crate::theme::Appearance::ALL { + if ui.add(egui::Button::selectable(app.ui.theme == a, tl!(a.label())).min_size(vec2(200.0, 0.0))).clicked() { + let _ = app.run("ui.theme", json!({"value": a.code()})); + ui.close(); + } + } + }); }); group(ui, "Window", None, app, |ui, app| { big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 52d5ba6..6bde325 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -8,6 +8,67 @@ use egui::{Align2, Color32, CornerRadius, FontData, FontDefinitions, FontFamily, pub const APP_COLOR: Color32 = Color32::from_rgb(0x3B, 0x5B, 0xDB); pub const APP_INK: Color32 = Color32::from_rgb(0x2B, 0x47, 0xB5); +/// The interface theme setting (File › Options › General, View › Dark Mode): a fixed light or +/// dark palette, or `System`, which follows the OS light/dark appearance live (#115). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(from = "String", into = "String")] +pub enum Appearance { + #[default] + Light, + Dark, + System, +} + +impl Appearance { + /// In menu order. + pub const ALL: [Appearance; 3] = [Appearance::System, Appearance::Light, Appearance::Dark]; + + /// The value used in `ui.json` and by the `ui.theme` command. + pub fn code(self) -> &'static str { + match self { + Appearance::Light => "light", + Appearance::Dark => "dark", + Appearance::System => "system", + } + } + + pub fn from_code(code: &str) -> Option { + Appearance::ALL.into_iter().find(|a| a.code().eq_ignore_ascii_case(code.trim())) + } + + /// The English label (translated with `tl!` where shown). + pub fn label(self) -> &'static str { + match self { + Appearance::Light => "Light", + Appearance::Dark => "Dark", + Appearance::System => "Use system setting", + } + } + + /// Whether the interface is dark, given the OS appearance egui reports (`Context::system_theme`). + /// `System` falls back to light when the OS reports none. + pub fn is_dark(self, system: Option) -> bool { + match self { + Appearance::Light => false, + Appearance::Dark => true, + Appearance::System => system == Some(egui::Theme::Dark), + } + } +} + +/// An unknown saved value reads as the default rather than discarding the whole `ui.json`. +impl From for Appearance { + fn from(s: String) -> Self { + Appearance::from_code(&s).unwrap_or_default() + } +} + +impl From for String { + fn from(a: Appearance) -> Self { + a.code().to_string() + } +} + /// Every colour the UI uses; widgets never hard-code colours. #[derive(Clone, Copy, Debug)] pub struct Tokens { @@ -301,6 +362,9 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) { v.widgets.open.weak_bg_fill = t.pressed; v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 40 }) }; v.window_shadow = egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 50 }) }; + // Pin egui's own theme to ours: left on its default (follow the OS), egui would swap to its + // other, unstyled light/dark style the moment the OS appearance changed. + ctx.set_theme(if t.dark { egui::Theme::Dark } else { egui::Theme::Light }); ctx.set_visuals(v); ctx.global_style_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 4.0); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 431224f..27aea7a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"}`)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, perf |