ui: standardise system appearance modes and theme previews

This commit is contained in:
Luniiya 2026-10-10 02:14:55 +02:00
parent 7b6c134472
commit 77fa159f49
17 changed files with 697 additions and 6 deletions

1
Cargo.lock generated
View File

@ -1684,6 +1684,7 @@ dependencies = [
"sys-locale",
"winit",
"winresource",
"zbus",
]
[[package]]

View File

@ -36,6 +36,11 @@ objc2 = "0.6"
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
winit = { version = "0.30", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
# Settings ▸ Appearance ▸ Sync with system: the XDG desktop portal's colour scheme. The zbus the
# portal file dialogs (rfd) and accesskit already use.
zbus = "5.19"
# Icon + VERSIONINFO for filmcraft.exe (build.rs); only needed when building on Windows.
[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"

View File

@ -0,0 +1,188 @@
//! The system's light or dark appearance for Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with
//! system, on Linux desktops where winit reports none (Wayland compositors send no theme).
//!
//! The XDG desktop portal is read once (`org.freedesktop.portal.Settings.Read` of
//! `org.freedesktop.appearance` / `color-scheme`), then its `SettingChanged` signal is followed on a
//! worker thread, which wakes the UI only when the value changes. There is no polling. Without a
//! portal answer, `gsettings` runs once at start-up, by absolute path. Elsewhere the host returns no
//! service and the UI uses what egui reports.
#[cfg(target_os = "linux")]
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
#[cfg(target_os = "linux")]
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
decode(portal_code(value)?)
}
/// The `u32` inside a portal reply. `Read` returns a variant, and some portals nest another
/// variant inside it.
#[cfg(target_os = "linux")]
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
let mut value: zbus::zvariant::Value<'_> = value.into();
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
other => return u32::try_from(other).ok(),
}
}
None
}
/// The portal's `color-scheme`: 0 no preference, 1 prefer dark, 2 prefer light.
#[cfg(target_os = "linux")]
fn decode(code: u32) -> Option<egui::Theme> {
match code {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
/// One-shot fallback for desktops without the settings portal: runs once, by absolute path.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
if !output.status.success() {
return None;
}
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
}
#[cfg(target_os = "linux")]
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn code_of(theme: Option<egui::Theme>) -> u8 {
match theme {
Some(egui::Theme::Dark) => 1,
Some(egui::Theme::Light) => 2,
None => 0,
}
}
/// Publish initial and subsequent answers alike: the initial Read may finish after the UI's
/// bounded startup wait, so it must also wake an already-created context.
#[cfg(target_os = "linux")]
fn publish(value: &std::sync::atomic::AtomicU8, code: u8, repaint: impl FnOnce()) {
if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code {
repaint();
}
}
/// The host hook (`HostHooks::system_theme`), or `None` where egui's own report is used.
pub fn service() -> Option<filmcraft_ui_egui::SystemThemeFn> {
#[cfg(target_os = "linux")]
{
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, OnceLock};
let value = Arc::new(AtomicU8::new(0));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
let (ready, wait) = std::sync::mpsc::channel();
let worker = move || {
let proxy = portal_proxy();
// Subscribe before Read so a change during startup cannot be missed.
let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok());
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
publish(&worker_value, code_of(initial), || {
if let Some(ctx) = worker_wake.get() {
ctx.request_repaint();
}
});
let _ = ready.send(());
let Some(signals) = signals else { return };
// Sleeps in the signal iterator: no polling.
for message in signals {
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
continue;
};
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
let code = code_of(portal_code(changed).and_then(decode));
publish(&worker_value, code, || {
if let Some(ctx) = worker_wake.get() {
ctx.request_repaint();
}
});
}
};
let spawned = std::thread::Builder::new().name("appearance-portal".into()).spawn(move || {
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(worker)).is_err() {
log::warn!("the system appearance watcher stopped; Sync with system keeps the last value");
}
});
if spawned.is_err() {
return None;
}
// The first reading, so the first frame already shows the right theme (bounded wait).
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
Some(Box::new(move |ctx: &egui::Context| {
let _ = wake.set(ctx.clone());
decode(u32::from(value.load(Ordering::Relaxed)))
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
#[test]
fn initial_and_signal_answers_repaint_only_on_changes() {
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
let repaints = AtomicUsize::new(0);
let value = AtomicU8::new(0);
let repaint = || {
repaints.fetch_add(1, Ordering::Relaxed);
};
// A late initial Read takes the same publication path as later signals.
super::publish(&value, 2, repaint);
assert_eq!(repaints.load(Ordering::Relaxed), 1);
super::publish(&value, 2, repaint);
assert_eq!(repaints.load(Ordering::Relaxed), 1);
super::publish(&value, 1, repaint);
assert_eq!(repaints.load(Ordering::Relaxed), 2);
assert_eq!(value.load(Ordering::Relaxed), 1);
}
#[cfg(target_os = "linux")]
#[test]
fn portal_and_gsettings_values_decode() {
assert_eq!(super::decode(0), None);
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
assert_eq!(super::decode(2), Some(egui::Theme::Light));
assert_eq!(super::decode(9), None);
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
let plain = zbus::zvariant::OwnedValue::from(1u32);
assert_eq!(super::portal_code(plain), Some(1));
let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap();
assert_eq!(super::portal_code(wrong), None);
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
assert_eq!(super::parse_gtk_scheme(""), None);
assert_eq!((super::code_of(None), super::code_of(Some(egui::Theme::Dark)), super::code_of(Some(egui::Theme::Light))), (0, 1, 2));
}
}

View File

@ -23,6 +23,7 @@
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
mod app_nap;
mod appearance;
mod args;
mod audio;
mod audio_in;
@ -229,6 +230,9 @@ fn main() -> eframe::Result {
app.hooks.open_path = Some(Box::new(open_path));
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
// Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose
// compositor reports no theme to winit (no polling: see appearance.rs).
app.hooks.system_theme = appearance::service();
app.hooks.raise_without_focus = Some(Box::new(|| {
window_raise::raise_without_focus();
}));

View File

@ -455,6 +455,11 @@ impl Preferences {
settings::sanitize(&mut v, &Self::default().to_value());
let mut p: Preferences = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?;
p.clamp();
// Old clients that set the single Color Theme still pick the theme they name.
if key == "appearance.colorTheme" {
let theme = p.appearance.color_theme.clone();
p.appearance.select_theme(&theme);
}
*self = p;
Ok(())
}

View File

@ -88,8 +88,15 @@ impl Default for GeneralPrefs {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AppearancePrefs {
/// "Color Theme": `darkest` (default) | `dark` | `light`.
/// Legacy single "Color Theme": `darkest` | `dark` | `light`. Setting it (old settings files,
/// automation, View ▸ Appearance) selects that theme and fixes the mode to its family.
pub color_theme: String,
/// "Appearance Mode": `auto` (follow the operating system) | `dark` (default) | `light`.
pub appearance_mode: String,
/// The theme shown in dark mode: `darkest` (default) | `dark`.
pub dark_theme: String,
/// The theme shown in light mode: `light`.
pub light_theme: String,
/// "Accessible color contrast": brighter secondary text and borders.
pub accessible_contrast: bool,
/// Highlight (accent) colour of selections, focus and primary buttons (`#rrggbb`).
@ -98,7 +105,48 @@ pub struct AppearancePrefs {
impl Default for AppearancePrefs {
fn default() -> Self {
Self { color_theme: "darkest".into(), accessible_contrast: false, highlight_color: DEFAULT_HIGHLIGHT.into() }
Self {
color_theme: "darkest".into(),
appearance_mode: "dark".into(),
dark_theme: "darkest".into(),
light_theme: "light".into(),
accessible_contrast: false,
highlight_color: DEFAULT_HIGHLIGHT.into(),
}
}
}
impl AppearancePrefs {
/// The theme to show (`darkest` | `dark` | `light`): the light or dark theme of the mode. Auto
/// follows `system_light` (the operating system's appearance) and is dark without an answer.
pub fn shown_theme(&self, system_light: Option<bool>) -> &str {
let light = match self.appearance_mode.as_str() {
"light" => true,
"auto" => system_light == Some(true),
_ => false,
};
if light { &self.light_theme } else { &self.dark_theme }
}
/// Select `theme` (a Color Theme value) as the theme of its family and fix the mode to that
/// family. Unknown names are ignored.
pub fn select_theme(&mut self, theme: &str) {
if LIGHT_THEMES.iter().any(|(v, _)| *v == theme) {
self.light_theme = theme.into();
self.appearance_mode = "light".into();
} else if DARK_THEMES.iter().any(|(v, _)| *v == theme) {
self.dark_theme = theme.into();
self.appearance_mode = "dark".into();
}
}
/// The mode after this one in the header button's cycle: Auto, Light, Dark, Auto…
pub fn next_mode(&self) -> &'static str {
match self.appearance_mode.as_str() {
"auto" => "light",
"light" => "dark",
_ => "auto",
}
}
}
@ -607,6 +655,9 @@ const OPENING: &[(&str, &str)] = &[("showOpenDialog", "Show Open Dialog"), ("sho
const BIN_OPEN: &[(&str, &str)] = &[("openInPlace", "Open in place"), ("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")];
const PROJECT_OPEN: &[(&str, &str)] = &[("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")];
const THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark"), ("light", "Light")];
pub const APPEARANCE_MODES: &[(&str, &str)] = &[("auto", "Sync with system"), ("dark", "Dark"), ("light", "Light")];
pub const DARK_THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark")];
pub const LIGHT_THEMES: &[(&str, &str)] = &[("light", "Light")];
const MIXDOWN: &[(&str, &str)] = &[("front", "Front Only"), ("frontRear", "Front + Rear"), ("frontLfe", "Front + LFE"), ("frontRearLfe", "Front + Rear + LFE")];
const AUDITION: &[(&str, &str)] = &[("scratch", "Scratch disk location for Captured Audio"), ("nextToMedia", "Next to original media files")];
const BUFFERS: &[(&str, &str)] = &[("64", "64"), ("128", "128"), ("256", "256"), ("512", "512"), ("1024", "1024"), ("2048", "2048"), ("4096", "4096")];
@ -714,6 +765,12 @@ static CATEGORIES: &[Category] = &[
id: "appearance",
title: "Appearance",
rows: &[
// Drawn by the dialog as the Appearance Mode selector above the light and dark theme
// cards; the fields below keep their keys, validation and `prefs.schema` entries.
Row::Custom("appearanceModes"),
f("appearance.appearanceMode", "Appearance Mode", Kind::Choice(APPEARANCE_MODES), true),
f("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
f("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
f("appearance.colorTheme", "Color Theme", Kind::Choice(THEMES), true),
b("appearance.accessibleContrast", "Accessible color contrast", true),
f("appearance.highlightColor", "Highlight Color", Kind::Color, true),
@ -1133,6 +1190,18 @@ pub fn migrate(v: &mut Value) {
// pre/postroll; v2 stores them as reals, which serde reads either way.
m.insert("version".into(), json!(2));
}
// Files from before appearance modes had one Color Theme: keep showing it, as a fixed Dark or
// Light mode, instead of switching established users to another theme or to Auto.
if let Some(a) = m.get_mut("appearance").and_then(Value::as_object_mut)
&& !a.contains_key("appearanceMode")
&& let Some(theme) = a.get("colorTheme").and_then(Value::as_str).map(str::to_string)
{
let mut p = AppearancePrefs::default();
p.select_theme(&theme);
a.insert("appearanceMode".into(), json!(p.appearance_mode));
a.insert("darkTheme".into(), json!(p.dark_theme));
a.insert("lightTheme".into(), json!(p.light_theme));
}
}
impl crate::autosave::Preferences {

View File

@ -551,3 +551,59 @@ fn hardware_decoding_setting_drives_the_decoder_switch() {
assert!(filmcraft_codecs::hw::hardware_decoding());
assert!(settings::field("playback.hardwareDecoding").is_some_and(|f| f.wired && matches!(f.kind, Kind::Choice(_))));
}
/// New users keep the dark Darkest theme (following the system is opt-in); a file saved before
/// appearance modes keeps showing its Color Theme as a fixed Dark or Light mode.
#[test]
fn appearance_defaults_and_legacy_theme_migrate() {
let a = Preferences::default().appearance;
assert_eq!((a.appearance_mode.as_str(), a.dark_theme.as_str(), a.light_theme.as_str()), ("dark", "darkest", "light"));
assert_eq!(a.shown_theme(Some(true)), "darkest", "Dark mode ignores a light system");
let dir = tmp_dir("prefs-appearance-migrate");
let path = dir.join("preferences.json");
for (theme, mode, dark, light) in [("darkest", "dark", "darkest", "light"), ("dark", "dark", "dark", "light"), ("light", "light", "darkest", "light")] {
std::fs::write(&path, json!({"version": 2, "appearance": {"colorTheme": theme}}).to_string()).unwrap();
let a = Preferences::load(&path).appearance;
assert_eq!((a.appearance_mode.as_str(), a.dark_theme.as_str(), a.light_theme.as_str()), (mode, dark, light), "{theme}");
assert_eq!(a.shown_theme(None), theme);
}
// a v1 file without an appearance section, and a hostile theme, get the defaults
std::fs::write(&path, r#"{"autoSave":{"enabled":true}}"#).unwrap();
assert_eq!(Preferences::load(&path).appearance, settings::AppearancePrefs::default());
std::fs::write(&path, r#"{"appearance":{"colorTheme":"neon","appearanceMode":7,"darkTheme":"light"}}"#).unwrap();
assert_eq!(Preferences::load(&path).appearance, settings::AppearancePrefs::default());
// a file with a mode keeps it
std::fs::write(&path, r#"{"appearance":{"colorTheme":"darkest","appearanceMode":"auto","lightTheme":"light","darkTheme":"dark"}}"#).unwrap();
let a = Preferences::load(&path).appearance;
assert_eq!((a.appearance_mode.as_str(), a.shown_theme(Some(true)), a.shown_theme(Some(false)), a.shown_theme(None)), ("auto", "light", "dark", "dark"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn appearance_choices_validate_and_legacy_theme_still_selects() {
let mut s = Session::default();
s.execute("prefs.set", json!({"values": {"appearance.appearanceMode": "auto", "appearance.darkTheme": "dark"}})).unwrap();
assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.dark_theme.as_str()), ("auto", "dark"));
for (key, value) in [
("appearance.darkTheme", json!("light")),
("appearance.lightTheme", json!("darkest")),
("appearance.appearanceMode", json!("system")),
("appearance.appearanceMode", json!(1)),
] {
assert!(s.execute("prefs.set", json!({"key": key, "value": value})).is_err(), "{key} {value}");
}
// the single Color Theme of older clients selects that theme and fixes the mode to its family
set(&mut s, "appearance.colorTheme", json!("light"));
assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.light_theme.as_str()), ("light", "light"));
assert_eq!(s.prefs.appearance.dark_theme, "dark", "the dark choice is kept");
set(&mut s, "appearance.colorTheme", json!("darkest"));
assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.dark_theme.as_str()), ("dark", "darkest"));
// the header button's cycle
let mut a = settings::AppearancePrefs::default();
let mut seen = Vec::new();
for _ in 0..4 {
a.appearance_mode = a.next_mode().into();
seen.push(a.appearance_mode.clone());
}
assert_eq!(seen, ["auto", "light", "dark", "auto"]);
}

View File

@ -100,6 +100,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("quick-export-toggled"), true));
}
crate::panels::export_mode::quick_export(app, ui.ctx(), pos2(qx - 340.0, rect.max.y + 4.0));
// Appearance Mode: click for the next one (Auto, Light, Dark); the icon shows the current one.
let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() {
"auto" => (Icon::Monitor, tl!("Sync with system")),
"light" => (Icon::Sun, tl!("Light")),
_ => (Icon::Moon, tl!("Dark")),
};
let tip = format!("{}: {mode}", tl!("Appearance Mode"));
if btn(ui, icon, "appearance", &tip, app).clicked() {
crate::panels::settings::cycle_appearance(app, ui.ctx());
}
let ws_resp = btn(ui, Icon::Workspaces, "workspaces", tl!("Workspaces"), app);
// workspace name (caps)
let ws = crate::i18n::t(&app.ui.workspace).to_uppercase();

View File

@ -3480,3 +3480,11 @@
New Sequence Nueva secuencia
Tracks Pistas
tracks pistas
# System appearance modes and theme cards.
Active Activo
Appearance Mode Modo de apariencia
Dark Theme Tema oscuro
Light Theme Tema claro
Next Appearance Mode Siguiente modo de apariencia
Sync with system Sincronizar con el sistema

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

View File

@ -93,3 +93,6 @@
OK OK
Type Tool 文字ツール
Vertical Type Tool 縦書き文字ツール
# System appearance mode menu command.
Next Appearance Mode 次の外観モード

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

View File

@ -352,3 +352,6 @@
Zoom In Ampliar
Zoom Out Reduzir
Zoom to Sequence Zoom para a sequência
# System appearance mode menu command.
Next Appearance Mode Próximo modo de aparência

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

View File

@ -114,6 +114,10 @@ pub enum Icon {
Network,
/// Media Browser: recent directories (a clock face).
Clock,
/// Appearance Mode: Auto (a display on a stand), Light (a sun), Dark (a crescent moon).
Monitor,
Sun,
Moon,
}
pub struct Pen16<'a> {
@ -725,6 +729,31 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
pen.circle(8.0, 8.0, 6.0);
pen.line(&[(8.0, 4.5), (8.0, 8.0), (10.5, 9.5)]);
}
Monitor => {
pen.rect(2.0, 2.5, 14.0, 11.0);
pen.line(&[(8.0, 11.0), (8.0, 13.5)]);
pen.line(&[(5.0, 13.5), (11.0, 13.5)]);
}
Sun => {
pen.circle(8.0, 8.0, 2.8);
for i in 0..8 {
let a = (i as f32 * 45.0).to_radians();
let (c, s) = (a.cos(), a.sin());
pen.line(&[(8.0 + 4.6 * c, 8.0 + 4.6 * s), (8.0 + 6.2 * c, 8.0 + 6.2 * s)]);
}
}
Moon => {
// The outer circle's arc through the bottom left, back along a smaller circle that
// bites the top right out of it.
let arc = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| {
(0..=12).map(move |i| {
let a = (a0 + (a1 - a0) * i as f32 / 12.0).to_radians();
(cx + r * a.cos(), cy + r * a.sin())
})
};
let pts: Vec<(f32, f32)> = arc(8.0, 8.5, 5.5, 8.56, 261.44).chain(arc(11.2, 5.3, 4.6, 209.12, 60.88).skip(1)).collect();
pen.closed(&pts);
}
}
}
@ -759,6 +788,9 @@ mod tests {
use egui::epaint::{ColorMode, Shape};
const ALL: &[Icon] = &[
Monitor,
Sun,
Moon,
Selection,
TrackSelectFwd,
TrackSelectBack,

View File

@ -131,8 +131,15 @@ pub struct HostHooks {
/// Open a file in its default application, or (`true`) reveal it in the file manager (Edit ▸
/// Edit Original, Help ▸ Reveal Log Files).
pub open_path: Option<Box<dyn FnMut(&str, bool) -> Result<(), String>>>,
/// The operating system's light or dark appearance when egui cannot report it (Linux desktops
/// whose Wayland compositor sends no theme to winit). Without it, or without an answer, Auto
/// uses `egui::Context::system_theme`.
pub system_theme: Option<SystemThemeFn>,
}
/// Reads the system appearance (Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system).
pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
/// The command a [`HostHooks::pick_file_for_relink`] caller runs with the chosen file, for hosts
/// that can only run it later.
#[derive(Clone, Debug)]
@ -506,6 +513,18 @@ impl FilmcraftApp {
self.ui.dark = k != ThemeKind::Light;
}
/// The system appearance: the host's reading, else what the windowing toolkit reports.
pub fn system_theme(&self, ctx: &egui::Context) -> Option<egui::Theme> {
self.hooks.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme())
}
/// The theme Settings ▸ Appearance selects: the light or dark theme of the Appearance Mode,
/// Auto following the system (dark when it gives no answer).
pub fn appearance_kind(&self, ctx: &egui::Context) -> ThemeKind {
let light = self.system_theme(ctx).map(|t| t == egui::Theme::Light);
ThemeKind::from_pref(self.session.prefs.appearance.shown_theme(light))
}
fn apply_tooltips(&self, ctx: &egui::Context) {
// Settings ▸ General ▸ Show Tool Tips
let delay = if self.session.prefs.general.show_tool_tips { 0.5 } else { 1.0e9 };
@ -515,13 +534,22 @@ impl FilmcraftApp {
/// Make the UI follow the settings after they change (theme, tooltips, frame cache budget,
/// play after rendering, audio device).
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
// Appearance Mode ▸ Sync with system: follow the system while running. The host reports
// changes as they happen (no polling) and wakes the UI only when the value changes.
if self.applied_prefs.is_some() {
let k = self.appearance_kind(ctx);
if k != self.tokens.kind {
self.set_theme(ctx, k);
}
}
if self.applied_prefs.as_ref() == Some(&self.session.prefs) {
return;
}
let p = self.session.prefs.clone();
let prev = self.applied_prefs.take();
if prev.as_ref().is_none_or(|q| q.appearance != p.appearance || q.general.show_tool_tips != p.general.show_tool_tips) {
self.set_theme(ctx, ThemeKind::from_pref(&p.appearance.color_theme));
let k = self.appearance_kind(ctx);
self.set_theme(ctx, k);
}
if prev.as_ref().is_none_or(|q| q.general.interface_language != p.general.interface_language) {
let language = match i18n::Language::parse(&p.general.interface_language) {

View File

@ -93,6 +93,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None),
uic!("view.theme.medium", "Medium", ["View", "Appearance"], None),
uic!("view.theme.light", "Light", ["View", "Appearance"], None),
uic!("view.appearanceMode.next", "Next Appearance Mode", ["View", "Appearance"], None),
uic!("window.workspace.editing", "Editing", ["Window", "Workspaces"], Some("Alt+Shift+1")),
uic!("window.workspace.assembly", "Assembly", ["Window", "Workspaces"], Some("Alt+Shift+2")),
uic!("window.workspace.color", "Color", ["Window", "Workspaces"], Some("Alt+Shift+3")),
@ -478,6 +479,10 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
}
_ => {}
}
if id == "view.appearanceMode.next" {
crate::panels::settings::cycle_appearance(app, ctx);
return Ok(json!({"appearanceMode": app.session.prefs.appearance.appearance_mode}));
}
if let Some(th) = id.strip_prefix("view.theme.") {
let k = crate::theme::ThemeKind::from_name(th).ok_or("unknown theme")?;
crate::panels::settings::set_theme(app, ctx, k);

View File

@ -79,6 +79,192 @@ pub fn set_theme(app: &mut FilmcraftApp, ctx: &egui::Context, k: crate::theme::T
app.set_theme(ctx, k);
}
/// The header's appearance button and View ▸ Appearance ▸ Next Appearance Mode: Auto, Light, Dark,
/// then Auto again. The light and dark theme choices are kept.
pub fn cycle_appearance(app: &mut FilmcraftApp, ctx: &egui::Context) {
let next = app.session.prefs.appearance.next_mode();
if let Err(e) = app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": next})) {
app.ui.status = e.to_string();
}
app.apply_prefs(ctx);
}
/// Keys the Appearance page draws as the mode selector and theme cards instead of plain fields.
const APPEARANCE_KEYS: &[&str] = &["appearance.appearanceMode", "appearance.lightTheme", "appearance.darkTheme", "appearance.colorTheme"];
/// A small FilmCraft editor painted with `t`'s colours: the header with the Import / Edit / Export
/// tabs, the Source and Program monitors, the Project panel, the tools, the Timeline with video and
/// audio clips and the playhead, and the audio meters (the Editing workspace).
fn theme_preview(ui: &mut Ui, t: &Tokens, width: f32) {
use egui::pos2;
let (rect, _) = ui.allocate_exact_size(vec2(width, 128.0), Sense::hover());
let p = ui.painter_at(rect);
let bar = |r: Rect, w: f32, c: Color32| p.rect_filled(Rect::from_min_size(r.min, vec2(w, 2.0)), 1.0, c);
p.rect_filled(rect, 3.0, t.app_bg);
// header: home, the three mode tabs (Edit active, underlined), the title, quick actions
let header = Rect::from_min_size(rect.min, vec2(rect.width(), 12.0));
p.rect_filled(header, CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 }, t.header_bg);
p.rect_filled(Rect::from_center_size(pos2(header.left() + 7.0, header.center().y), vec2(4.0, 4.0)), 1.0, t.text_dim);
for (i, w) in [9.0, 6.0, 9.0].into_iter().enumerate() {
let x = header.left() + 14.0 + i as f32 * 13.0;
let c = if i == 1 { t.tab_text_active } else { t.tab_text };
bar(Rect::from_min_size(pos2(x, header.center().y - 1.0), vec2(w, 2.0)), w, c);
if i == 1 {
p.rect_filled(Rect::from_min_size(pos2(x, header.bottom() - 2.5), vec2(w, 1.0)), 0.0, c);
}
}
bar(Rect::from_min_size(pos2(header.center().x - 16.0, header.center().y - 1.0), vec2(32.0, 2.0)), 32.0, t.tab_text_active);
for i in 0..4 {
p.rect_filled(Rect::from_center_size(pos2(header.right() - 7.0 - i as f32 * 8.0, header.center().y), vec2(4.0, 4.0)), 1.0, t.icon);
}
let body = Rect::from_min_max(pos2(rect.left() + 2.0, header.bottom() + 2.0), rect.max - vec2(2.0, 2.0));
let split_y = body.top() + body.height() * 0.5;
let panel = |r: Rect, tab_w: f32| {
p.rect_filled(r, 2.0, t.panel_bg);
bar(Rect::from_min_size(r.min + vec2(4.0, 3.5), vec2(tab_w, 2.0)), tab_w, t.tab_text_active);
Rect::from_min_max(r.min + vec2(3.0, 9.0), r.max - vec2(3.0, 3.0))
};
// Source monitor (left) and Program monitor (right)
let source = panel(Rect::from_min_max(body.min, pos2(body.left() + body.width() * 0.36, split_y - 1.0)), 14.0);
p.rect_filled(source, 0.0, t.monitor_bg);
p.rect_filled(source.shrink2(vec2(6.0, 8.0)), 0.0, t.field_bg);
let program = panel(Rect::from_min_max(pos2(body.left() + body.width() * 0.36 + 2.0, body.top()), pos2(body.right(), split_y - 1.0)), 16.0);
p.rect_filled(program, 0.0, t.monitor_bg);
let frame = Rect::from_center_size(program.center() - vec2(0.0, 3.0), vec2((program.height() - 9.0) * 16.0 / 9.0, program.height() - 9.0));
p.rect_filled(frame, 0.0, Color32::from_rgb(0x2c, 0x4a, 0x2e));
p.rect_filled(Rect::from_min_max(pos2(frame.left(), frame.bottom() - frame.height() * 0.3), frame.max), 0.0, Color32::from_rgb(0x4a, 0x3c, 0x30));
p.circle_filled(pos2(frame.left() + frame.width() * 0.62, frame.center().y + 2.0), frame.height() * 0.26, Color32::from_rgb(0xc8, 0x2a, 0x26));
bar(Rect::from_min_size(pos2(program.left() + 2.0, program.bottom() - 3.0), vec2(14.0, 2.0)), 14.0, t.timecode);
// Project panel, tools, Timeline, meters
let low = split_y + 1.0;
let project = panel(Rect::from_min_max(pos2(body.left(), low), pos2(body.left() + body.width() * 0.24, body.bottom())), 12.0);
for row in 0..2 {
for col in 0..2 {
let cell =
Rect::from_min_size(project.min + vec2(col as f32 * (project.width() / 2.0), 2.0 + row as f32 * 18.0), vec2(project.width() / 2.0 - 3.0, 11.0));
p.rect_filled(cell, 1.0, if (row + col) % 2 == 0 { t.field_bg } else { t.row_selected });
bar(Rect::from_min_size(cell.left_bottom() + vec2(0.0, 2.0), vec2(cell.width() * 0.7, 2.0)), cell.width() * 0.7, t.text_faint);
}
}
let tools = Rect::from_min_max(pos2(project.right() + 5.0, low), pos2(project.right() + 15.0, body.bottom()));
p.rect_filled(tools, 2.0, t.panel_bg);
for i in 0..5 {
let c = if i == 0 { t.accent } else { t.icon };
p.rect_filled(Rect::from_center_size(pos2(tools.center().x, tools.top() + 7.0 + i as f32 * 9.0), vec2(5.0, 5.0)), 1.0, c);
}
let meters = Rect::from_min_max(pos2(body.right() - 12.0, low), pos2(body.right(), body.bottom()));
p.rect_filled(meters, 2.0, t.panel_bg);
for (i, h) in [0.55, 0.62].into_iter().enumerate() {
let x = meters.left() + 3.0 + i as f32 * 4.0;
p.rect_filled(
Rect::from_min_max(pos2(x, meters.bottom() - 3.0 - (meters.height() - 6.0) * h), pos2(x + 2.0, meters.bottom() - 3.0)),
0.0,
t.render_green,
);
}
let tl = Rect::from_min_max(pos2(tools.right() + 2.0, low), pos2(meters.left() - 2.0, body.bottom()));
p.rect_filled(tl, 2.0, t.tl_bg);
bar(Rect::from_min_size(tl.min + vec2(4.0, 3.5), vec2(14.0, 2.0)), 14.0, t.tab_text_active);
bar(Rect::from_min_size(tl.min + vec2(4.0, 9.0), vec2(16.0, 2.0)), 16.0, t.timecode);
let heads_w = 22.0;
let ruler = Rect::from_min_max(pos2(tl.left() + heads_w, tl.top() + 7.0), pos2(tl.right() - 2.0, tl.top() + 13.0));
p.rect_filled(ruler, 0.0, t.tl_ruler_bg);
let mut x = ruler.left() + 2.0;
while x < ruler.right() {
p.line_segment([pos2(x, ruler.bottom() - 2.0), pos2(x, ruler.bottom())], Stroke::new(1.0, t.tl_ruler_tick));
x += 6.0;
}
let video = Label::Iris.rgb();
let audio = Label::Caribbean.rgb();
let music = Label::Forest.rgb();
let track_h = ((tl.bottom() - ruler.bottom() - 3.0) / 4.0).max(4.0);
for i in 0..4 {
let y = ruler.bottom() + 1.0 + i as f32 * track_h;
let head = Rect::from_min_size(pos2(tl.left() + 2.0, y), vec2(heads_w - 3.0, track_h - 1.0));
p.rect_filled(head, 1.0, t.tl_header_bg);
p.rect_filled(Rect::from_min_size(head.min + vec2(2.0, 1.5), vec2(5.0, track_h - 4.0)), 1.0, t.accent);
let lane = Rect::from_min_max(pos2(ruler.left(), y), pos2(ruler.right(), y + track_h - 1.0));
p.rect_filled(lane, 0.0, if i % 2 == 0 { t.tl_track_bg } else { t.tl_track_bg_alt });
let clips: &[(f32, f32)] = match i {
0 => &[(0.55, 0.75)],
1 | 2 => &[(0.0, 0.22), (0.23, 0.48), (0.49, 0.7), (0.71, 0.9)],
_ => &[(0.0, 0.9)],
};
let c = match i {
0 | 1 => video,
2 => audio,
_ => music,
};
for (a, b) in clips {
let r = Rect::from_min_max(pos2(lane.left() + lane.width() * a, lane.top() + 0.5), pos2(lane.left() + lane.width() * b, lane.bottom() - 0.5));
p.rect_filled(r, 1.0, Color32::from_rgb(c[0], c[1], c[2]).gamma_multiply(0.85));
}
}
let ph = ruler.left() + ruler.width() * 0.3;
p.line_segment([pos2(ph, ruler.top()), pos2(ph, tl.bottom() - 2.0)], Stroke::new(1.0, t.playhead));
}
/// One theme card of the Appearance page: title, "Active" while the mode shows it, the preview and
/// a radio button per theme (automation ids `settings.<key>.<value>`).
#[allow(clippy::too_many_arguments)]
fn theme_card(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key: &str, title: &str, opts: &[(&str, &str)], active: bool, width: f32) {
let t = app.tokens;
let current = choice_text(get(&d.values, key));
let shown = opts.iter().find(|(v, _)| *v == current).or(opts.first()).map_or("darkest", |(v, _)| v);
let highlight = settings::parse_hex(&choice_text(get(&d.values, "appearance.highlightColor")));
let contrast = get(&d.values, "appearance.accessibleContrast").as_bool().unwrap_or(false);
let look = Tokens::for_kind(crate::theme::ThemeKind::from_pref(shown)).with_appearance(highlight, contrast);
let r = Frame::new()
.fill(t.field_bg)
.stroke(Stroke::new(1.0, if active { t.accent } else { t.field_border }))
.corner_radius(CornerRadius::same(6))
.inner_margin(Margin::same(10))
.show(ui, |ui| {
ui.vertical(|ui| {
ui.set_width(width - 20.0);
ui.horizontal(|ui| {
ui.label(RichText::new(crate::i18n::t(title)).size(12.5).strong());
if active {
ui.label(RichText::new(tl!("Active")).size(11.0).color(t.accent));
}
});
ui.add_space(4.0);
theme_preview(ui, &look, width - 20.0);
ui.add_space(4.0);
for (v, l) in opts {
let r = ui.radio(current == *v, RichText::new(crate::i18n::t(l)).size(12.5));
app.auto.add(&format!("settings.{key}.{v}"), r.rect, l);
if r.clicked() {
put(&mut d.values, key, json!(v));
}
}
});
});
app.auto.add(&format!("settings.{key}"), r.response.rect, title);
}
/// Settings ▸ Appearance: the Appearance Mode selector above the light and dark theme cards.
fn appearance_modes(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) {
let key = "appearance.appearanceMode";
let mode = choice_text(get(&d.values, key));
ui.horizontal(|ui| {
field_label(ui, "Appearance Mode");
if let Some(v) = combo(app, ui, key, &mode, &static_opts(settings::APPEARANCE_MODES), 200.0) {
put(&mut d.values, key, json!(v));
}
});
let mode = choice_text(get(&d.values, key));
let light_active = mode == "light" || (mode == "auto" && app.system_theme(ui.ctx()) == Some(egui::Theme::Light));
ui.add_space(6.0);
let width = ((ui.available_width() - 12.0) / 2.0).max(200.0);
ui.horizontal_top(|ui| {
theme_card(app, ui, d, "appearance.lightTheme", "Light Theme", settings::LIGHT_THEMES, light_active, width);
ui.add_space(4.0);
theme_card(app, ui, d, "appearance.darkTheme", "Dark Theme", settings::DARK_THEMES, !light_active, width);
});
ui.add_space(6.0);
}
/// Patch the open dialog's draft (`ui.set {"settings": {"page": "trim", "values": {key: value}}}`).
pub fn patch(app: &mut FilmcraftApp, p: &Value) -> Result<(), String> {
let d = app.ui.settings.as_mut().ok_or("the Settings dialog is not open")?;
@ -422,6 +608,7 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str
),
);
}
"appearanceModes" => appearance_modes(app, ui, d),
"autoSaveStatus" => {
let status = app.session.execute("file.autoSaveStatus", json!({})).unwrap_or_default();
if let Some(dir) = status["autoSaveDir"].as_str() {
@ -467,6 +654,7 @@ fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row]
continue;
}
match r {
Row::Field(f) if APPEARANCE_KEYS.contains(&f.key) => continue,
Row::Field(f) => draw_field(app, ui, d, f),
Row::Group(title, inner) => group(ui, &t, title, |ui| rows(app, ui, d, inner)),
Row::Note(text) => note(ui, &t, text),
@ -595,6 +783,66 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::ThemeKind;
#[test]
fn appearance_button_cycles_modes_without_losing_theme_choices() {
let ctx = egui::Context::default();
let mut app = FilmcraftApp::new(filmcraft_engine::Session::default());
app.apply_prefs(&ctx);
assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("dark", ThemeKind::Dark), "new users keep Darkest");
app.session.execute("prefs.set", json!({"values": {"appearance.darkTheme": "dark"}})).unwrap();
app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark)));
for (mode, shown) in [("auto", ThemeKind::Medium), ("light", ThemeKind::Light), ("dark", ThemeKind::Medium), ("auto", ThemeKind::Medium)] {
cycle_appearance(&mut app, &ctx);
assert_eq!(app.session.prefs.appearance.appearance_mode, mode);
assert_eq!(app.tokens.kind, shown, "{mode}");
assert_eq!((app.session.prefs.appearance.dark_theme.as_str(), app.session.prefs.appearance.light_theme.as_str()), ("dark", "light"));
}
// View ▸ Appearance picks a theme and fixes the mode to its family
set_theme(&mut app, &ctx, ThemeKind::Light);
assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("light", ThemeKind::Light));
set_theme(&mut app, &ctx, ThemeKind::Dark);
assert_eq!((app.session.prefs.appearance.appearance_mode.as_str(), app.tokens.kind), ("dark", ThemeKind::Dark));
assert_eq!(app.session.prefs.appearance.dark_theme, "darkest");
}
/// Auto follows the host's reading of the system appearance as it changes, and falls back to
/// dark without an answer.
#[test]
fn auto_follows_a_stubbed_system_theme() {
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
let ctx = egui::Context::default();
let mut app = FilmcraftApp::new(filmcraft_engine::Session::default());
let system = Arc::new(AtomicU8::new(2));
let read = system.clone();
app.hooks.system_theme = Some(Box::new(move |_| match read.load(Ordering::Relaxed) {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}));
app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "auto"})).unwrap();
app.apply_prefs(&ctx);
assert_eq!(app.tokens.kind, ThemeKind::Light);
assert!(!app.ui.dark);
assert_eq!(ctx.theme(), egui::Theme::Light, "egui's style follows FilmCraft's choice");
system.store(1, Ordering::Relaxed);
app.apply_prefs(&ctx);
assert_eq!(app.tokens.kind, ThemeKind::Dark);
assert_eq!(ctx.theme(), egui::Theme::Dark);
system.store(2, Ordering::Relaxed);
app.apply_prefs(&ctx);
assert_eq!(app.tokens.kind, ThemeKind::Light);
system.store(0, Ordering::Relaxed);
app.apply_prefs(&ctx);
assert_eq!(app.tokens.kind, ThemeKind::Dark, "no answer: dark");
// a fixed mode ignores the system
app.session.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "dark"})).unwrap();
system.store(2, Ordering::Relaxed);
app.apply_prefs(&ctx);
assert_eq!(app.tokens.kind, ThemeKind::Dark);
}
#[test]
fn diff_lists_changed_leaves() {

View File

@ -299,7 +299,11 @@ fn add_craft_fonts(fonts: &mut FontDefinitions) {
}
pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
let mut v = if t.kind == ThemeKind::Light { Visuals::light() } else { Visuals::dark() };
let light = t.kind == ThemeKind::Light;
// FilmCraft decides between light and dark itself (Settings ▸ Appearance ▸ Appearance Mode), so
// egui must not switch to its other built-in style when the system appearance changes.
ctx.set_theme(if light { egui::Theme::Light } else { egui::Theme::Dark });
let mut v = if light { Visuals::light() } else { Visuals::dark() };
v.panel_fill = t.panel_bg;
v.window_fill = t.panel_bg;
v.extreme_bg_color = t.field_bg;

View File

@ -250,17 +250,39 @@ fn appearance_labels_and_tooltips_take_effect() {
let mut d = Driver::demo();
assert_eq!(d.inspect()["ui"]["dark"], true);
d.menu("app.settings.appearance");
d.click("settings.appearance.colorTheme");
d.click("settings.appearance.colorTheme.light");
// the Appearance Mode selector above the light and dark theme cards
assert!(d.has("settings.appearance.lightTheme.light") && d.has("settings.appearance.darkTheme.dark"));
assert!(!d.has("settings.appearance.colorTheme"), "the single Color Theme is not shown");
// Each radio belongs below its preview, even though the cards are laid out side by side.
let elements = d.ok("ui.elements", json!({"prefix": "settings.appearance."}));
let top = |id: &str| elements.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap()["rect"][1].as_f64().unwrap();
for (card, radio) in
[("settings.appearance.lightTheme", "settings.appearance.lightTheme.light"), ("settings.appearance.darkTheme", "settings.appearance.darkTheme.darkest")]
{
assert!(top(radio) > top(card) + 128.0, "{radio} sits below its preview");
}
d.click("settings.appearance.darkTheme.dark");
d.click("settings.appearance.appearanceMode");
d.click("settings.appearance.appearanceMode.light");
d.ok("ui.set", json!({"settings": {"values": {"appearance.highlightColor": "#e0457b"}}}));
d.click("settings.ok");
d.frames(2);
assert_eq!(d.inspect()["ui"]["dark"], false, "Light theme applied");
assert_eq!((d.pref("appearance.appearanceMode"), d.pref("appearance.darkTheme")), (json!("light"), json!("dark")));
// the header button cycles Auto, Light, Dark
d.click("header.appearance");
assert_eq!(d.pref("appearance.appearanceMode"), "dark");
assert_eq!(d.harness.state().tokens.kind, filmcraft_ui_egui::theme::ThemeKind::Medium, "the saved dark theme");
d.click("header.appearance");
assert_eq!(d.pref("appearance.appearanceMode"), "auto");
d.menu("view.appearanceMode.next");
assert_eq!(d.pref("appearance.appearanceMode"), "light");
assert_eq!(d.pref("appearance.highlightColor"), "#e0457b");
assert_eq!(d.harness.state().tokens.accent, egui::Color32::from_rgb(0xe0, 0x45, 0x7b));
// View ▸ Appearance writes the setting
d.menu("view.theme.dark");
assert_eq!(d.pref("appearance.colorTheme"), "darkest");
assert_eq!((d.pref("appearance.appearanceMode"), d.pref("appearance.darkTheme")), (json!("dark"), json!("darkest")));
assert_eq!(d.inspect()["ui"]["dark"], true);
// label names show in Edit ▸ Label