Add a System interface theme that follows the OS appearance (#115) (#249)

The interface theme is now a three-way choice: Use system setting,
Light or Dark, in File > Options > General and in a new Interface Theme
menu in View > Dark Mode. System follows the light/dark appearance egui
reports (winit on macOS, Windows and Linux; prefers-color-scheme on the
web) at launch and live, and falls back to light when the OS reports
none. egui's own theme is now pinned to ours, so an OS change no longer
swaps in egui's unstyled style.

Saved preferences migrate: the old "dark" switch becomes Dark or Light
and is never written again. Light stays the default for new installs.
New ui.theme command (light/dark/system); ui.dark still sets Light/Dark.
The dark page (View > Switch Modes) is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 18:18:50 +02:00 committed by GitHub
parent e53b618584
commit cc64f3df2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 204 additions and 18 deletions

View File

@ -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 <port>` 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};

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 システム設定を使用

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 Як у системі

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -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 使用系统设置

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 使用系統設定

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -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);

View File

@ -76,7 +76,11 @@ pub struct UiState {
pub backstage_page: String,
pub ribbon_collapsed: bool,
pub recent: Vec<String>,
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<bool>,
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<Result<Value, String>> {
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");
}

View File

@ -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);

View File

@ -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> {
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<egui::Theme>) -> 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<String> for Appearance {
fn from(s: String) -> Self {
Appearance::from_code(&s).unwrap_or_default()
}
}
impl From<Appearance> 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);

View File

@ -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 |