1use egui::Context;
14use serde::{Deserialize, Serialize};
15
16pub mod feel;
17pub mod keymap;
18pub mod metrics;
19pub mod oklch;
20pub mod palette;
21pub mod platform;
22pub mod policy;
23pub mod scroll;
24pub mod typography;
25
26pub use feel::{
27 apply_focus_ring, elevation_shadow, elevation_shadow_params, focus_ring_visual, native_feel, publish_native,
28 reveal_on_hover, ElevationShadow, FocusRingVisual,
29};
30pub use keymap::{Action, KeyMap, keymap, publish_keymap};
31pub use metrics::Metrics;
32pub use oklch::{Oklch, contrast_ratio, relative_luminance};
33pub use palette::Palette;
34pub use platform::{FocusRing, NativeFeel, Platform, WindowControls};
35pub use policy::{effects_policy, publish_effects, EffectsPolicy, FocusSpec, Motion, PerfConfig, SurfaceSpec, ThemeMode};
36pub use scroll::{ScrollSpec, ScrollVisibility};
37pub use typography::{Typography, UiFont};
38
39#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
43pub struct Theme {
44 pub name: String,
45 pub mode: ThemeMode,
46 pub palette: Palette,
47 pub typography: Typography,
48 pub metrics: Metrics,
49 pub scroll: ScrollSpec,
50 pub keymap: KeyMap,
51 pub focus: FocusSpec,
52 pub surface: SurfaceSpec,
53 pub motion: Motion,
54 pub effects: EffectsPolicy,
55 pub perf: PerfConfig,
56 pub native: NativeFeel,
60}
61
62impl Default for Theme {
65 fn default() -> Self {
66 Theme::windows_dark()
67 }
68}
69
70impl Theme {
71 pub fn windows_dark() -> Self {
75 Self {
76 name: "windows-dark".into(),
77 mode: ThemeMode::Dark,
78 palette: presets::windows_dark_palette(),
79 typography: Typography::default().with_font(UiFont::SegoeUi),
80 metrics: Metrics::windows(),
81 scroll: ScrollSpec::windows(),
82 keymap: KeyMap::windows(),
83 focus: FocusSpec::default(),
84 surface: SurfaceSpec::Frosted { blur_radius: 12.0, tint: [20, 22, 30, 160] },
90 motion: Motion::windows(),
91 effects: EffectsPolicy::Full,
92 perf: PerfConfig::default(),
93 native: NativeFeel::windows(),
94 }
95 }
96
97 pub fn windows_light() -> Self {
99 Self {
100 name: "windows-light".into(),
101 mode: ThemeMode::Light,
102 palette: presets::windows_light_palette(),
103 ..Theme::windows_dark()
104 }
105 }
106
107 pub fn windows() -> Self {
109 Theme::windows_dark()
110 }
111
112 pub fn macos_dark() -> Self {
114 Self {
115 name: "macos-dark".into(),
116 mode: ThemeMode::Dark,
117 palette: presets::macos_dark_palette(),
118 typography: Typography::default().with_font(UiFont::SanFrancisco),
119 metrics: Metrics::macos(),
120 scroll: ScrollSpec::macos(),
121 keymap: KeyMap::macos(),
122 focus: FocusSpec::default(),
123 surface: SurfaceSpec::Frosted { blur_radius: 16.0, tint: [22, 22, 26, 130] },
126 motion: Motion::macos(),
127 effects: EffectsPolicy::Full,
128 perf: PerfConfig::default(),
129 native: NativeFeel::macos(),
130 }
131 }
132
133 pub fn macos_light() -> Self {
135 Self {
136 name: "macos-light".into(),
137 mode: ThemeMode::Light,
138 palette: presets::macos_light_palette(),
139 ..Theme::macos_dark()
140 }
141 }
142
143 pub fn macos() -> Self {
145 Theme::macos_dark()
146 }
147
148 pub fn device() -> Self {
151 Self {
152 name: "device".into(),
153 mode: ThemeMode::Dark,
154 palette: presets::device_palette(),
155 typography: Typography::default().with_font(UiFont::System),
156 metrics: Metrics::device(),
157 scroll: ScrollSpec::device(),
158 keymap: KeyMap::device(),
159 focus: FocusSpec { hints_enabled: false, revolver_enabled: false, ..FocusSpec::default() },
160 surface: SurfaceSpec::Opaque,
161 motion: Motion { duration: 0.0, fast: 0.0, curve: crate::effects::Curve::Linear },
163 effects: EffectsPolicy::None,
164 perf: PerfConfig { prefer_wgpu: true, ..PerfConfig::default() },
165 native: NativeFeel::neutral(),
167 }
168 }
169
170 pub fn neutral() -> Self {
176 Self {
177 name: "neutral-dark".into(),
178 mode: ThemeMode::Dark,
179 palette: presets::windows_dark_palette(),
180 typography: Typography::default().with_font(UiFont::System),
181 metrics: Metrics::windows(),
182 scroll: ScrollSpec::windows(),
183 keymap: KeyMap::windows(),
184 focus: FocusSpec::default(),
185 surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [20, 22, 28, 150] },
187 motion: Motion::neutral(),
188 effects: EffectsPolicy::Full,
189 perf: PerfConfig::default(),
190 native: NativeFeel::neutral(),
191 }
192 }
193
194 pub fn skade_vinter() -> Self {
201 Self {
202 name: "skade_vinter".into(),
203 mode: ThemeMode::Dark,
204 palette: presets::skade_vinter_palette(),
205 typography: Typography::default().with_font(UiFont::System),
206 metrics: Metrics::windows(),
207 scroll: ScrollSpec::windows(),
208 keymap: KeyMap::windows(),
209 focus: FocusSpec::default(),
210 surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [180, 210, 235, 90] },
212 motion: Motion::default(),
213 effects: EffectsPolicy::Full,
214 perf: PerfConfig::default(),
215 native: NativeFeel::neutral(),
216 }
217 }
218
219 pub fn nordisk_dark() -> Self {
225 Self {
226 name: "nordisk-dark".into(),
227 mode: ThemeMode::Dark,
228 palette: presets::nordisk_dark_palette(),
229 typography: Typography::default().with_font(UiFont::System),
230 metrics: Metrics::nordisk(),
231 scroll: ScrollSpec::windows(),
232 keymap: KeyMap::windows(),
233 focus: FocusSpec::default(),
234 surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [16, 22, 32, 150] },
237 motion: Motion::neutral(),
238 effects: EffectsPolicy::Full,
239 perf: PerfConfig::default(),
240 native: NativeFeel::neutral(),
241 }
242 }
243
244 pub fn nordisk_light() -> Self {
246 Self {
247 name: "nordisk-light".into(),
248 mode: ThemeMode::Light,
249 palette: presets::nordisk_light_palette(),
250 surface: SurfaceSpec::Frosted { blur_radius: 14.0, tint: [235, 240, 248, 120] },
252 ..Theme::nordisk_dark()
253 }
254 }
255
256 pub fn nordisk() -> Self {
258 Theme::nordisk_dark()
259 }
260
261 pub fn from_os(os: egui::os::OperatingSystem) -> Self {
266 use egui::os::OperatingSystem as Os;
267 match os {
268 Os::Mac | Os::IOS => Theme::macos_dark(),
269 Os::Windows => Theme::windows_dark(),
270 _ => Theme::windows_dark(),
272 }
273 }
274
275 pub fn for_platform(p: Platform) -> Self {
280 match p {
281 Platform::Mac => Theme::macos_dark(),
282 Platform::Windows => Theme::windows_dark(),
283 Platform::Neutral => Theme::neutral(),
284 }
285 }
286
287 pub fn for_detected_platform() -> Self {
291 Theme::for_platform(Platform::detect())
292 }
293
294 pub const PRESETS: &'static [fn() -> Theme] = &[
296 Theme::windows_light,
297 Theme::windows_dark,
298 Theme::macos_light,
299 Theme::macos_dark,
300 Theme::device,
301 Theme::skade_vinter,
302 Theme::neutral,
303 Theme::nordisk_light,
304 Theme::nordisk_dark,
305 ];
306
307 pub fn preset_names() -> Vec<String> {
308 Self::PRESETS.iter().map(|c| c().name).collect()
309 }
310
311 pub fn by_name(name: &str) -> Option<Theme> {
312 let norm = |s: &str| s.to_ascii_lowercase().replace([' ', '_'], "-");
313 let want = norm(name);
314 Self::PRESETS.iter().map(|c| c()).find(|t| norm(&t.name) == want)
315 }
316
317 pub fn with_effects(mut self, e: EffectsPolicy) -> Self {
320 self.effects = e;
321 self
322 }
323 pub fn with_keymap(mut self, k: KeyMap) -> Self {
324 self.keymap = k;
325 self
326 }
327 pub fn with_focus(mut self, f: FocusSpec) -> Self {
328 self.focus = f;
329 self
330 }
331 pub fn with_surface(mut self, s: SurfaceSpec) -> Self {
332 self.surface = s;
333 self
334 }
335 pub fn with_name(mut self, n: impl Into<String>) -> Self {
336 self.name = n.into();
337 self
338 }
339
340 pub fn is_dark(&self) -> bool {
342 match self.mode {
343 ThemeMode::Dark => true,
344 ThemeMode::Light => false,
345 ThemeMode::FollowSystem => self.palette.dark,
346 }
347 }
348
349 pub fn egui_style(&self) -> egui::Style {
352 let mut style = egui::Style::default();
353
354 let mut visuals = self.palette.to_visuals(self.metrics.corner_radius);
356 visuals.window_corner_radius = egui::CornerRadius::same(self.metrics.window_corner_radius);
357 visuals.menu_corner_radius = egui::CornerRadius::same(self.metrics.menu_corner_radius);
358 style.visuals = visuals;
359
360 let sp = &mut style.spacing;
362 sp.item_spacing = self.metrics.item_spacing_vec();
363 sp.button_padding = self.metrics.button_padding_vec();
364 sp.window_margin = self.metrics.window_margin_m();
365 sp.menu_margin = self.metrics.menu_margin_m();
366 sp.interact_size = self.metrics.interact_size_vec();
367 sp.indent = self.metrics.indent;
368 sp.slider_width = self.metrics.slider_width;
369 sp.icon_width = self.metrics.icon_width;
370 sp.scroll = self.scroll.to_scroll_style();
371
372 style.text_styles = self.typography.text_styles();
374
375 style
376 }
377
378 pub fn apply(&self, ctx: &Context) {
383 let os = if self.name.starts_with("macos") {
386 egui::os::OperatingSystem::Mac
387 } else {
388 egui::os::OperatingSystem::Windows
389 };
390 ctx.set_os(os);
391
392 ctx.set_global_style(self.egui_style());
393
394 crate::theme::publish_palette(ctx, self.to_legacy_palette());
399
400 keymap::publish_keymap(ctx, self.keymap.clone());
402
403 policy::publish_effects(ctx, self.effects);
406
407 feel::publish_native(ctx, self.native);
411 }
412
413 pub fn to_legacy_palette(&self) -> crate::Theme {
418 let p = &self.palette;
419 let name: &'static str = match self.name.as_str() {
420 "windows-dark" => "windows-dark",
421 "windows-light" => "windows-light",
422 "macos-dark" => "macos-dark",
423 "macos-light" => "macos-light",
424 "device" => "device",
425 "skade_vinter" => "skade_vinter",
426 "neutral-dark" => "neutral-dark",
427 "nordisk-dark" => "nordisk-dark",
428 "nordisk-light" => "nordisk-light",
429 _ => "look",
430 };
431 crate::Theme {
432 name,
433 bg: p.surface.to_color32(),
434 node_fill: p.surface_container.to_color32(),
435 node_stroke: p.outline.to_color32(),
436 edge: p.outline.with_chroma_scale(0.6).to_color32(),
437 text: p.on_surface.to_color32(),
438 text_dim: p.on_surface_dim.to_color32(),
439 accent: p.accent.to_color32(),
440 point: p.primary.to_color32(),
441 panel_bg: p.surface_container.to_color32(),
442 panel_stroke: p.outline.to_color32(),
443 glow: p.glow.to_color32(),
444 }
445 }
446}
447
448mod presets;
451
452#[cfg(test)]
453mod tests;