Skip to main content

wlr_capture/
theme.rs

1//! Colour & font theme. Sensible generic-dark defaults, overridable from
2//! `~/.config/wlr-chooser/theme.toml` (or `$XDG_CONFIG_HOME/wlr-chooser/theme.toml`).
3//!
4//! Colour keys are `#rrggbb` / `#rrggbbaa`; font keys configure the UI font:
5//!
6//! ```toml
7//! accent = "#89b4fa"
8//! screen-accent = "#89b4fa"
9//! window-accent = "#cba6f7"
10//!
11//! font = "JetBrains Mono"   # UI font family (resolved via fontconfig)
12//! # font-path = "/path/to/Font.ttf"   # …or a direct file
13//! # cjk-font = "Noto Sans CJK JP"     # CJK fallback (else auto-detected)
14//! font-size = 15.0
15//! ```
16//!
17//! Rendering CJK text (Japanese/Chinese/Korean) needs a CJK font installed; one
18//! is auto-detected and used as a fallback.
19
20use egui::Color32;
21use serde::Deserialize;
22
23/// Colours and fonts for the overlay UI, with generic-dark defaults (see [`Default`])
24/// overridable from `theme.toml`.
25#[derive(Clone)]
26pub struct Theme {
27    /// Dimmed overlay drawn behind the card (lock mode).
28    pub backdrop: Color32,
29    /// Window background (no-lock mode).
30    pub bg: Color32,
31    /// The centred card.
32    pub card: Color32,
33    /// Tile background.
34    pub tile: Color32,
35    /// Tile background when hovered.
36    pub tile_hover: Color32,
37    /// Tile background when selected.
38    pub tile_selected: Color32,
39    /// Thumbnail letterbox area.
40    pub thumb: Color32,
41    /// Labels.
42    pub text: Color32,
43    /// Placeholders and secondary text.
44    pub text_dim: Color32,
45    /// General accent (selection, focus).
46    pub accent: Color32,
47    /// Outline and glyph for OUTPUT tiles.
48    pub screen_accent: Color32,
49    /// Outline for WINDOW tiles.
50    pub window_accent: Color32,
51
52    /// UI font family (resolved via fontconfig).
53    pub font: Option<String>,
54    /// …or a direct font file.
55    pub font_path: Option<String>,
56    /// CJK fallback family (else auto-detected).
57    pub cjk_font: Option<String>,
58    /// Base UI text size in points.
59    pub font_size: Option<f32>,
60}
61
62impl Default for Theme {
63    fn default() -> Self {
64        let c = |r, g, b| Color32::from_rgb(r, g, b);
65        Self {
66            backdrop: Color32::from_rgba_unmultiplied(0, 0, 0, 140),
67            bg: c(0x1e, 0x21, 0x27),
68            card: c(0x21, 0x25, 0x2d),
69            tile: c(0x18, 0x1b, 0x22),
70            tile_hover: c(0x26, 0x2b, 0x33),
71            tile_selected: c(0x3b, 0x42, 0x52),
72            thumb: c(0x12, 0x14, 0x1a),
73            text: c(0xd8, 0xde, 0xe9),
74            text_dim: c(0x7a, 0x82, 0x90),
75            accent: c(0x88, 0xc0, 0xd0),
76            screen_accent: c(0x81, 0xa1, 0xc1), // blue — screens
77            window_accent: c(0xb4, 0x8e, 0xad), // purple — windows
78            font: None,
79            font_path: None,
80            cjk_font: None,
81            font_size: None,
82        }
83    }
84}
85
86#[derive(Deserialize, Default)]
87#[serde(rename_all = "kebab-case", default)]
88struct Raw {
89    backdrop: Option<String>,
90    bg: Option<String>,
91    card: Option<String>,
92    tile: Option<String>,
93    tile_hover: Option<String>,
94    tile_selected: Option<String>,
95    thumb: Option<String>,
96    text: Option<String>,
97    text_dim: Option<String>,
98    accent: Option<String>,
99    screen_accent: Option<String>,
100    window_accent: Option<String>,
101    font: Option<String>,
102    font_path: Option<String>,
103    cjk_font: Option<String>,
104    font_size: Option<f32>,
105}
106
107impl Theme {
108    /// Load the theme, applying any overrides from the user config.
109    pub fn load() -> Self {
110        let mut t = Theme::default();
111        let Some(raw) = config_path()
112            .and_then(|p| std::fs::read_to_string(p).ok())
113            .and_then(|s| toml::from_str::<Raw>(&s).ok())
114        else {
115            return t;
116        };
117        let set = |dst: &mut Color32, src: &Option<String>| {
118            if let Some(c) = src.as_deref().and_then(parse_hex) {
119                *dst = c;
120            }
121        };
122        set(&mut t.backdrop, &raw.backdrop);
123        set(&mut t.bg, &raw.bg);
124        set(&mut t.card, &raw.card);
125        set(&mut t.tile, &raw.tile);
126        set(&mut t.tile_hover, &raw.tile_hover);
127        set(&mut t.tile_selected, &raw.tile_selected);
128        set(&mut t.thumb, &raw.thumb);
129        set(&mut t.text, &raw.text);
130        set(&mut t.text_dim, &raw.text_dim);
131        set(&mut t.accent, &raw.accent);
132        set(&mut t.screen_accent, &raw.screen_accent);
133        set(&mut t.window_accent, &raw.window_accent);
134        t.font = raw.font;
135        t.font_path = raw.font_path;
136        t.cjk_font = raw.cjk_font;
137        t.font_size = raw.font_size;
138        t
139    }
140
141    /// Apply the palette to egui's global visuals (panels, widgets, selection…).
142    pub fn apply(&self, ctx: &egui::Context) {
143        let mut v = egui::Visuals::dark();
144        v.panel_fill = self.bg;
145        v.window_fill = self.card;
146        v.extreme_bg_color = self.thumb;
147        v.override_text_color = Some(self.text);
148        v.selection.bg_fill = self.accent.gamma_multiply(0.4);
149        v.selection.stroke = egui::Stroke::new(1.0, self.accent);
150        v.hyperlink_color = self.accent;
151        v.widgets.hovered.bg_fill = self.tile_hover;
152        v.widgets.active.bg_fill = self.tile_selected;
153        ctx.set_visuals(v);
154
155        self.install_fonts(ctx);
156        if let Some(sz) = self.font_size {
157            ctx.global_style_mut(|s| {
158                use egui::{FontFamily, FontId, TextStyle};
159                let prop = FontFamily::Proportional;
160                s.text_styles
161                    .insert(TextStyle::Body, FontId::new(sz, prop.clone()));
162                s.text_styles
163                    .insert(TextStyle::Button, FontId::new(sz, prop.clone()));
164                s.text_styles
165                    .insert(TextStyle::Small, FontId::new(sz * 0.85, prop.clone()));
166                s.text_styles
167                    .insert(TextStyle::Heading, FontId::new(sz * 1.4, prop));
168                s.text_styles
169                    .insert(TextStyle::Monospace, FontId::new(sz, FontFamily::Monospace));
170            });
171        }
172    }
173
174    /// Build egui's font set: the configured UI font first (if any), then egui's
175    /// defaults, then a CJK fallback (so Japanese/Chinese/Korean render when a CJK
176    /// font is installed).
177    fn install_fonts(&self, ctx: &egui::Context) {
178        let mut fonts = egui::FontDefinitions::default();
179        let mut db = fontdb::Database::new();
180        db.load_system_fonts();
181
182        // Primary UI font: explicit file, or a family resolved via fontconfig.
183        let primary = self
184            .font_path
185            .as_deref()
186            .and_then(read_font_file)
187            .or_else(|| self.font.as_deref().and_then(|f| load_family(&db, f)));
188        if let Some(data) = primary {
189            fonts.font_data.insert("ui".into(), data.into());
190            for fam in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
191                fonts
192                    .families
193                    .entry(fam)
194                    .or_default()
195                    .insert(0, "ui".into());
196            }
197        }
198
199        // CJK fallback: configured family, else the first common one installed.
200        let cjk = self
201            .cjk_font
202            .as_deref()
203            .and_then(|f| load_family(&db, f))
204            .or_else(|| CJK_FAMILIES.iter().find_map(|f| load_family(&db, f)));
205        if let Some(data) = cjk {
206            fonts.font_data.insert("cjk".into(), data.into());
207            for fam in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
208                fonts.families.entry(fam).or_default().push("cjk".into());
209            }
210        }
211
212        ctx.set_fonts(fonts);
213    }
214}
215
216/// Common CJK font families to try when none is configured.
217const CJK_FAMILIES: &[&str] = &[
218    "Noto Sans CJK JP",
219    "Noto Sans CJK SC",
220    "Noto Sans CJK KR",
221    "Source Han Sans",
222    "Sarasa Gothic",
223    "WenQuanYi Zen Hei",
224];
225
226fn read_font_file(path: &str) -> Option<egui::FontData> {
227    let bytes = std::fs::read(path).ok()?;
228    Some(egui::FontData::from_owned(bytes))
229}
230
231/// Resolve a font family name to its data (handles `.ttc` face indices).
232fn load_family(db: &fontdb::Database, family: &str) -> Option<egui::FontData> {
233    let query = fontdb::Query {
234        families: &[fontdb::Family::Name(family)],
235        ..Default::default()
236    };
237    let id = db.query(&query)?;
238    db.with_face_data(id, |bytes, index| {
239        let mut data = egui::FontData::from_owned(bytes.to_vec());
240        data.index = index;
241        data
242    })
243}
244
245fn config_path() -> Option<std::path::PathBuf> {
246    let base = std::env::var_os("XDG_CONFIG_HOME")
247        .map(std::path::PathBuf::from)
248        .or_else(|| {
249            std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
250        })?;
251    Some(base.join("wlr-chooser").join("theme.toml"))
252}
253
254/// Parse `#rgb`, `#rrggbb` or `#rrggbbaa`.
255fn parse_hex(s: &str) -> Option<Color32> {
256    let h = s.trim().strip_prefix('#')?;
257    let n = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
258    match h.len() {
259        6 => Some(Color32::from_rgb(n(0)?, n(2)?, n(4)?)),
260        8 => Some(Color32::from_rgba_unmultiplied(n(0)?, n(2)?, n(4)?, n(6)?)),
261        3 => {
262            let d = |i: usize| {
263                let v = u8::from_str_radix(&h[i..i + 1], 16).ok()?;
264                Some(v * 17)
265            };
266            Some(Color32::from_rgb(d(0)?, d(1)?, d(2)?))
267        }
268        _ => None,
269    }
270}