simple_editor/
theme.rs

1//! Match the user's Windows theme: light/dark from the registry, accent colour from DWM.
2//! Bare, flat, Windows-Forms-ish visuals; DaVinci-like layout comes from the panels, not styling.
3
4use eframe::egui::{self, Color32, CornerRadius, Theme, Visuals};
5use serde::{Deserialize, Serialize};
6use std::sync::OnceLock;
7
8fn reg_dword(root: winreg::HKEY, path: &str, name: &str) -> Option<u32> {
9    winreg::RegKey::predef(root).open_subkey(path).ok()?.get_value::<u32, _>(name).ok()
10}
11
12/// Windows accent colour (falls back to the default blue).
13pub fn system_accent() -> Color32 {
14    static ACCENT: OnceLock<Color32> = OnceLock::new();
15    *ACCENT.get_or_init(|| {
16        reg_dword(winreg::enums::HKEY_CURRENT_USER, r"Software\Microsoft\Windows\DWM", "AccentColor")
17            .map(|v| Color32::from_rgb((v & 0xff) as u8, ((v >> 8) & 0xff) as u8, ((v >> 16) & 0xff) as u8))
18            .unwrap_or(Color32::from_rgb(0, 120, 212))
19    })
20}
21
22/// Colours for custom-painted widgets (timeline, preview, waveforms).
23#[derive(Clone, Copy, Debug)]
24pub struct Palette {
25    pub accent: Color32,
26    pub bg: Color32,
27    pub panel: Color32,
28    pub header: Color32,
29    pub border: Color32,
30    pub text: Color32,
31    pub text_dim: Color32,
32    pub clip_video: Color32,
33    pub clip_audio: Color32,
34    pub clip_image: Color32,
35    pub clip_text: Color32,
36    pub clip_sequence: Color32,
37    pub clip_shape: Color32,
38    pub clip_adjust: Color32,
39    pub waveform: Color32,
40    pub playhead: Color32,
41    pub in_out: Color32,
42    pub keyframe: Color32,
43    pub selection: Color32,
44}
45
46impl Palette {
47    pub fn new(dark: bool, accent: Color32) -> Self {
48        let g = |v: u8| Color32::from_gray(v);
49        if dark {
50            Self {
51                accent,
52                bg: g(28),
53                panel: g(38),
54                header: g(46),
55                border: g(62),
56                text: g(225),
57                text_dim: g(150),
58                clip_video: Color32::from_rgb(62, 98, 142),
59                clip_audio: Color32::from_rgb(56, 120, 84),
60                clip_image: Color32::from_rgb(110, 84, 140),
61                clip_text: Color32::from_rgb(150, 112, 58),
62                clip_sequence: Color32::from_rgb(60, 130, 140),
63                clip_shape: Color32::from_rgb(120, 100, 60),
64                clip_adjust: Color32::from_rgb(96, 96, 110),
65                waveform: Color32::from_rgb(150, 215, 170),
66                playhead: Color32::from_rgb(235, 70, 70),
67                in_out: accent,
68                keyframe: Color32::from_rgb(255, 200, 60),
69                selection: accent,
70            }
71        } else {
72            Self {
73                accent,
74                bg: g(250),
75                panel: g(240),
76                header: g(228),
77                border: g(200),
78                text: g(20),
79                text_dim: g(100),
80                clip_video: Color32::from_rgb(150, 180, 220),
81                clip_audio: Color32::from_rgb(150, 205, 170),
82                clip_image: Color32::from_rgb(190, 170, 215),
83                clip_text: Color32::from_rgb(225, 195, 140),
84                clip_sequence: Color32::from_rgb(150, 205, 215),
85                clip_shape: Color32::from_rgb(225, 210, 160),
86                clip_adjust: Color32::from_rgb(190, 190, 205),
87                waveform: Color32::from_rgb(30, 110, 60),
88                playhead: Color32::from_rgb(220, 40, 40),
89                in_out: accent,
90                keyframe: Color32::from_rgb(200, 140, 0),
91                selection: accent,
92            }
93        }
94    }
95    pub fn clip_color(&self, kind: crate::model::ClipKind) -> Color32 {
96        use crate::model::ClipKind::*;
97        match kind {
98            Video => self.clip_video,
99            Audio => self.clip_audio,
100            Image => self.clip_image,
101            Text => self.clip_text,
102            Sequence => self.clip_sequence,
103            Shape => self.clip_shape,
104            Adjustment => self.clip_adjust,
105        }
106    }
107}
108
109/// Settings-window override for `Palette`'s hand-tuned colours ("Appearance" tab). `mode` picks the
110/// base ("system" = today's Windows-derived behaviour, untouched; "light"/"dark" force the base;
111/// "custom" keeps following Windows but every field below can still override it); each colour is
112/// `None` until the user turns it on, so an untouched `PaletteOverride` changes nothing.
113#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
114#[serde(default)]
115pub struct PaletteOverride {
116    pub mode: String,
117    pub background: Option<[u8; 3]>,
118    pub panel: Option<[u8; 3]>,
119    pub header: Option<[u8; 3]>,
120    pub border: Option<[u8; 3]>,
121    pub text: Option<[u8; 3]>,
122    pub text_dim: Option<[u8; 3]>,
123    pub accent: Option<[u8; 3]>,
124    pub selection: Option<[u8; 3]>,
125    pub keyframe: Option<[u8; 3]>,
126    pub waveform: Option<[u8; 3]>,
127}
128
129/// `palette(ctx)` layered with a user override. "system" mode (the default, everything `None`) is
130/// exactly `palette(ctx)` — the round-trip the Settings window's "Reset to system" button restores.
131pub fn palette_with(ctx: &egui::Context, ov: &PaletteOverride) -> Palette {
132    let dark = match ov.mode.as_str() {
133        "light" => false,
134        "dark" => true,
135        "custom" => ctx.style().visuals.dark_mode,
136        _ => return palette(ctx),
137    };
138    let rgb = |c: [u8; 3]| Color32::from_rgb(c[0], c[1], c[2]);
139    let mut p = Palette::new(dark, ov.accent.map(rgb).unwrap_or_else(system_accent));
140    if let Some(c) = ov.background {
141        p.bg = rgb(c);
142    }
143    if let Some(c) = ov.panel {
144        p.panel = rgb(c);
145    }
146    if let Some(c) = ov.header {
147        p.header = rgb(c);
148    }
149    if let Some(c) = ov.border {
150        p.border = rgb(c);
151    }
152    if let Some(c) = ov.text {
153        p.text = rgb(c);
154    }
155    if let Some(c) = ov.text_dim {
156        p.text_dim = rgb(c);
157    }
158    if let Some(c) = ov.selection {
159        p.selection = rgb(c);
160    }
161    if let Some(c) = ov.keyframe {
162        p.keyframe = rgb(c);
163    }
164    if let Some(c) = ov.waveform {
165        p.waveform = rgb(c);
166    }
167    p
168}
169
170fn visuals(dark: bool, accent: Color32) -> Visuals {
171    let mut v = if dark { Visuals::dark() } else { Visuals::light() };
172    let r = CornerRadius::same(2);
173    v.window_corner_radius = r;
174    v.menu_corner_radius = r;
175    for w in [
176        &mut v.widgets.noninteractive,
177        &mut v.widgets.inactive,
178        &mut v.widgets.hovered,
179        &mut v.widgets.active,
180        &mut v.widgets.open,
181    ] {
182        w.corner_radius = r;
183    }
184    v.selection.bg_fill = if dark { accent.linear_multiply(0.6) } else { accent.linear_multiply(0.35) };
185    v.selection.stroke.color = accent;
186    v.hyperlink_color = accent;
187    v.widgets.active.bg_fill = accent;
188    v.widgets.active.weak_bg_fill = accent;
189    v.widgets.hovered.bg_stroke.color = accent;
190    v.slider_trailing_fill = true;
191    v
192}
193
194/// egui's bundled fonts with the Windows UI font (Segoe UI) in front, so text matches the OS.
195fn fonts() -> egui::FontDefinitions {
196    let mut f = egui::FontDefinitions::default();
197    let dir =
198        std::path::PathBuf::from(std::env::var_os("WINDIR").unwrap_or_else(|| r"C:\Windows".into())).join("Fonts");
199    if let Ok(bytes) = std::fs::read(dir.join("segoeui.ttf")) {
200        f.font_data.insert("Segoe UI".into(), std::sync::Arc::new(egui::FontData::from_owned(bytes)));
201        f.families.entry(egui::FontFamily::Proportional).or_default().insert(0, "Segoe UI".into());
202    }
203    f
204}
205
206/// Apply the theme preference ("system" | "dark" | "light") to the egui context.
207pub fn apply(ctx: &egui::Context, pref: &str) {
208    // set_fonts is a no-op when unchanged; egui's own Ctrl+=/Ctrl+-/Ctrl+0 UI zoom would hijack the
209    // timeline zoom hotkeys (and persist across runs)
210    ctx.set_fonts(fonts());
211    ctx.options_mut(|o| o.zoom_with_keyboard = false);
212    let accent = system_accent();
213    ctx.set_visuals_of(Theme::Dark, visuals(true, accent));
214    ctx.set_visuals_of(Theme::Light, visuals(false, accent));
215    let theme = match pref {
216        "dark" => egui::ThemePreference::Dark,
217        "light" => egui::ThemePreference::Light,
218        _ => egui::ThemePreference::System,
219    };
220    ctx.set_theme(theme);
221    ctx.style_mut(|s| {
222        s.spacing.item_spacing = egui::vec2(6.0, 4.0);
223        s.spacing.button_padding = egui::vec2(6.0, 2.0);
224    });
225}
226
227/// Palette for the theme currently in effect.
228pub fn palette(ctx: &egui::Context) -> Palette {
229    Palette::new(ctx.style().visuals.dark_mode, system_accent())
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn override_round_trips_and_falls_through() {
238        let ctx = egui::Context::default();
239        // unset (the default): falls through to today's system-derived palette exactly.
240        let derived = palette_with(&ctx, &PaletteOverride::default());
241        let sys = palette(&ctx);
242        assert_eq!(derived.bg, sys.bg);
243        assert_eq!(derived.accent, sys.accent);
244
245        // custom: only the fields turned on differ; the rest still tracks the (overridden) base.
246        let mut ov = PaletteOverride { mode: "custom".into(), ..Default::default() };
247        ov.background = Some([10, 20, 30]);
248        ov.accent = Some([200, 30, 40]);
249        let p = palette_with(&ctx, &ov);
250        assert_eq!(p.bg, Color32::from_rgb(10, 20, 30));
251        assert_eq!(p.accent, Color32::from_rgb(200, 30, 40));
252        assert_eq!(p.panel, Palette::new(ctx.style().visuals.dark_mode, Color32::from_rgb(200, 30, 40)).panel);
253
254        // what Settings::save()/load() actually persists.
255        let json = serde_json::to_string(&ov).unwrap();
256        let back: PaletteOverride = serde_json::from_str(&json).unwrap();
257        assert_eq!(back, ov);
258    }
259}