status bar
git clone https://git.lucas.co/cce-status-interface.git
src/config.rs (15.2K)
1 //! Config access: cached KDL config lookup, color/font/dimension readers,
2 //! and resolution of the ccectl binary.
3 //!
4 //! Every key is read by an explicit JSON pointer — the canonical nesting in
5 //! config.kdl. A key at a non-canonical location simply does not resolve.
6 //! (The legacy fuzzy search that split snake_case keys across nesting was
7 //! deleted after a release of quiet fallback-warning logs.)
8 //!
9 //! Color space: text colors stay **raw sRGB** — they end up as `[u8; 3]` for
10 //! cosmic-text (see `StyledLabel`), which expects sRGB. Quad/box colors are
11 //! converted with [`cce_ui::color::srgb_to_linear`] because the Vulkan pipeline
12 //! samples them in linear space.
13
14 pub(crate) fn get_cached_config() -> serde_json::Value {
15 cce_ui::config::cached_config()
16 }
17
18 fn cfg_f32(pointer: &str) -> Option<f32> {
19 get_cached_config().pointer(pointer).and_then(|v| v.as_f64()).map(|n| n as f32)
20 }
21
22 fn cfg_string(pointer: &str) -> Option<String> {
23 get_cached_config().pointer(pointer).and_then(|v| v.as_str()).map(|s| s.to_string())
24 }
25
26 /// A text color: raw sRGB RGB with alpha forced to 1.0 (cosmic-text consumes
27 /// text colors as sRGB `[u8; 3]`; alpha is not carried by the text path).
28 pub(crate) fn text_color_from(val: &serde_json::Value, pointer: &str) -> Option<[f32; 4]> {
29 let s = val.pointer(pointer)?.as_str()?;
30 let [r, g, b, _] = cce_ui::color::parse_hex_rgba(s)?;
31 Some([r, g, b, 1.0])
32 }
33
34 /// A quad/box color: RGB converted sRGB→linear for the Vulkan pipeline, alpha
35 /// kept raw.
36 pub(crate) fn quad_color_from(val: &serde_json::Value, pointer: &str) -> Option<[f32; 4]> {
37 let s = val.pointer(pointer)?.as_str()?;
38 cce_ui::color::parse_hex_rgba_linear(s)
39 }
40
41 fn cfg_text_color(pointer: &str) -> Option<[f32; 4]> {
42 text_color_from(&get_cached_config(), pointer)
43 }
44
45 fn cfg_quad_color(pointer: &str) -> Option<[f32; 4]> {
46 quad_color_from(&get_cached_config(), pointer)
47 }
48
49 pub(crate) fn read_normal_color_from_config() -> Option<[f32; 4]> {
50 // App-native `module { text_color }` first (a TEXT color — raw sRGB, per
51 // the color-space split in CLAUDE.md), then the shared status key.
52 cfg_text_color("/module/text_color")
53 .or_else(|| cfg_text_color("/style/status/normal_color"))
54 }
55
56 pub(crate) fn read_disabled_color_from_config() -> Option<[f32; 4]> {
57 cfg_text_color("/style/status/disabled_color")
58 }
59
60 pub(crate) fn read_status_font_from_config() -> String {
61 // App-native `module { font }` first (a family; an embedded size ranks
62 // below module { font_size } in the size chain), then the shared key.
63 if let Some(font_str) = cfg_string("/module/font") {
64 return font_str;
65 }
66 if let Some(font_str) = cfg_string("/style/status/font") {
67 return font_str;
68 }
69
70 // The third rung used to be fontconfig's `status-interface` alias — a cce
71 // invention squatting in fontconfig's family namespace, and one this
72 // precedence chain had already demoted to a last resort. It is gone along
73 // with the settings app's Fonts page; the DE's families now live in the
74 // shared config's `fonts { }` block.
75 cce_ui::layout::read_preferred_fonts().0
76 }
77
78 pub(crate) fn read_status_height_from_config() -> f32 {
79 // App-native `module { height }` first — the compositor reads the same
80 // key for the arrange pass's segment height and reserved strip, so the
81 // two sides always agree — then the shared layout { bar_height }.
82 cfg_f32("/module/height")
83 .or_else(|| cfg_f32("/layout/bar_height"))
84 .unwrap_or(28.0)
85 }
86
87 pub(crate) fn read_status_font_size_from_config() -> f32 {
88 // App-native `module { font_size }` wins over everything — including the
89 // size embedded in the shared font string ("Chivo Mono 14"), which stays
90 // the fallback along with the shared status_font_size key.
91 if let Some(size) = cfg_f32("/module/font_size") {
92 return size;
93 }
94 if let Some(font_str) = cfg_string("/module/font") {
95 let (_, parsed_size) = cce_ui::layout::parse_font_string(&font_str);
96 if let Some(size) = parsed_size {
97 return size;
98 }
99 }
100 if let Some(font_str) = cfg_string("/style/status/font") {
101 let (_, parsed_size) = cce_ui::layout::parse_font_string(&font_str);
102 if let Some(size) = parsed_size {
103 return size;
104 }
105 }
106
107 cfg_f32("/style/status/font_size").unwrap_or(11.0)
108 }
109
110 pub(crate) fn read_status_padding_from_config() -> f32 {
111 // App-native `module { padding }` first (the text inset inside each
112 // module box — bar-side only), then the shared status key.
113 cfg_f32("/module/padding")
114 .or_else(|| cfg_f32("/style/status/padding"))
115 .unwrap_or(8.0)
116 }
117
118 pub(crate) fn read_status_module_spacing_from_config() -> f32 {
119 // App-native `module { spacing }` first (the same value the compositor
120 // reads for the gap BETWEEN segments — this reader only matters for the
121 // intra-surface layout of a multi-module bar), then the shared key.
122 cfg_f32("/module/spacing")
123 .or_else(|| cfg_f32("/style/status/module_spacing"))
124 .unwrap_or(8.0)
125 }
126
127
128 /// The DE-wide light angle, canonical at `window_manager { light_source_position }`.
129 /// Radians normally; a value above 2π is taken as legacy degrees (e.g. `135`)
130 /// and converted. (This unifies the two previous readers, one of which only
131 /// degree-converted integer values.)
132 pub(crate) fn light_source_position_from(val: &serde_json::Value) -> f32 {
133 let raw = val
134 .pointer("/window_manager/light_source_position")
135 .and_then(|v| v.as_f64())
136 .map(|f| f as f32)
137 .unwrap_or(2.356_194_5); // 135°, the compositor default
138 if raw > 2.0 * std::f32::consts::PI {
139 raw.to_radians()
140 } else {
141 raw
142 }
143 }
144
145 pub(crate) fn read_light_source_position_from_config() -> f32 {
146 light_source_position_from(&get_cached_config())
147 }
148
149 pub(crate) fn read_status_background_blur_from_config() -> f32 {
150 cfg_f32("/style/status/background_blur").unwrap_or(0.0)
151 }
152
153 pub(crate) fn read_status_box_background_color_from_config() -> Option<[f32; 4]> {
154 // App-native `module { background_color }` first (a quad color — the
155 // sRGB→linear conversion applies, per the color-space split in
156 // CLAUDE.md), then the shared status key, then the built-in default.
157 let mut color = cfg_quad_color("/module/background_color")
158 .or_else(|| cfg_quad_color("/style/status/background_color"))
159 .unwrap_or_else(|| {
160 let c = cce_ui::color::srgb_to_linear(0x15 as f32 / 255.0);
161 let b = cce_ui::color::srgb_to_linear(0x20 as f32 / 255.0);
162 [c, c, b, 0.9]
163 });
164
165 let blur = read_status_background_blur_from_config();
166
167 // Scale RGB by (1.0 - blur) to apply tint factor while keeping alpha as full opacity for the blur shader
168 color[0] *= 1.0 - blur;
169 color[1] *= 1.0 - blur;
170 color[2] *= 1.0 - blur;
171
172 Some(color)
173 }
174
175 /// `module { droplet "sag=0.45 belly=0.75 gleam=1.2 ..." }` — the water-drop
176 /// module style ([`cce_ui::scene::paint::Prim::Droplet`]). The key's PRESENCE
177 /// turns the style on (an empty string takes every default); its value is
178 /// whitespace-separated `k=v` pairs onto [`DropletSpec`]'s fields, in the DE's
179 /// spec-string idiom. Unknown keys warn and are skipped, so a typo shows up in
180 /// the log instead of silently reverting one knob.
181 pub(crate) fn read_droplet_from_config() -> Option<cce_ui::scene::paint::DropletSpec> {
182 // The parser is shared with the compositor (whose scenefx droplet node
183 // refracts the backdrop behind each segment from the same spec).
184 Some(cce_ui::scene::paint::DropletSpec::parse(&cfg_string("/module/droplet")?))
185 }
186
187 /// `module { text_raise }` — lifts module text above vertical center by this
188 /// many logical px (negative lowers it). Bar-side only; every module's text
189 /// baseline funnels through `centered_text_y`, which subtracts this.
190 pub(crate) fn read_text_raise_from_config() -> f32 {
191 cfg_f32("/module/text_raise").unwrap_or(0.0)
192 }
193
194 /// `module { icon_size }` — the glyph height, logical px, for the modules
195 /// that read out as a cce-icons glyph beside their value (cpu, memory,
196 /// brightness, volume, battery). Default `TRAY_ICON_SIZE`: the readout
197 /// glyphs and the tray icons share the strip and read as one set only at
198 /// one size (bar height − 6 was tried first and read a size larger).
199 pub(crate) fn read_icon_size_from_config() -> f32 {
200 cfg_f32("/module/icon_size").unwrap_or(TRAY_ICON_SIZE).max(4.0)
201 }
202
203 /// The tray's icon size, logical px — fixed, and what the readout glyphs
204 /// default to.
205 pub(crate) const TRAY_ICON_SIZE: f32 = 16.0;
206
207 /// `module { icon_font_size }` — the size of the number beside a glyph.
208 /// Defaults to the module font size, so the readouts match the clock.
209 pub(crate) fn read_icon_font_size_from_config(font_size: f32) -> f32 {
210 cfg_f32("/module/icon_font_size").unwrap_or(font_size).max(1.0)
211 }
212
213 /// `module { icon_gap }` — logical px between a glyph and its number
214 /// (default 4). Narrower than `icon_spacing` so each pair reads as one.
215 pub(crate) fn read_icon_gap_from_config() -> f32 {
216 cfg_f32("/module/icon_gap").unwrap_or(4.0).max(0.0)
217 }
218
219 /// `module { icon_spacing }` — logical px between readouts sharing a bubble
220 /// (the `stats` segment). Defaults to the gap between segments, so the
221 /// combined bubble is spaced like the strip it replaced.
222 pub(crate) fn read_icon_spacing_from_config() -> f32 {
223 cfg_f32("/module/icon_spacing")
224 .unwrap_or_else(read_status_module_spacing_from_config)
225 .max(0.0)
226 }
227
228 /// `module { icon_weight }` — OpenType weight of the number beside a glyph
229 /// (700 = bold); unset leaves the face's regular, like every other label.
230 pub(crate) fn read_icon_weight_from_config() -> Option<u16> {
231 cfg_f32("/module/icon_weight").map(|w| w.clamp(1.0, 1000.0) as u16)
232 }
233
234 /// `module { icon_alpha }` — opacity 0-1 of the glyph (default 1). The
235 /// glyph is tinted the number's color, so this is the one lever for making
236 /// it read lighter than the digits.
237 pub(crate) fn read_icon_alpha_from_config() -> f32 {
238 cfg_f32("/module/icon_alpha").unwrap_or(1.0).clamp(0.0, 1.0)
239 }
240
241 pub(crate) fn read_status_box_corner_radius_from_config() -> f32 {
242 // App-native ONLY (~/.config/cce/cce-status-interface/config.kdl,
243 // merged over the shared config by cce-ui): `module { corner_radius }`.
244 // Unlike the other module styling keys there is deliberately no shared
245 // `style { status ... }` fallback — the radius is purely bar-side
246 // cosmetics (the old `status_box_corner_radius` rung was removed).
247 cfg_f32("/module/corner_radius").unwrap_or(4.0)
248 }
249
250 /// Bevel treatment for the module boxes.
251 #[derive(Clone, Copy, PartialEq, Debug)]
252 pub(crate) enum StatusBoxBevel {
253 /// A lit plate lip — the box rises out of the bar.
254 Raised,
255 /// A carved recess rim — the box sinks into the bar.
256 Inset,
257 }
258
259 /// `style { status box_bevel="raised"|"inset" }`; absent, `"none"`, or any
260 /// other value keeps the flat boxes.
261 pub(crate) fn read_status_box_bevel_from_config() -> Option<StatusBoxBevel> {
262 match cfg_string("/style/status/box_bevel")?.to_ascii_lowercase().as_str() {
263 "raised" => Some(StatusBoxBevel::Raised),
264 "inset" => Some(StatusBoxBevel::Inset),
265 _ => None,
266 }
267 }
268
269 /// `style { status box_bevel_depth=(f64)N }` — the roll width of the bevel lip
270 /// in logical px. The DE-wide `bevel_width` (~9px) is window-scale; module
271 /// boxes in a ~24px bar want a much tighter lip.
272 pub(crate) fn read_status_box_bevel_depth_from_config() -> f32 {
273 cfg_f32("/style/status/box_bevel_depth").unwrap_or(3.0)
274 }
275
276 pub(crate) fn get_ccectl_cmd() -> String {
277 if let Ok(home) = std::env::var("HOME") {
278 let path = format!("{}/.local/bin/ccectl", home);
279 if std::path::Path::new(&path).exists() {
280 return path;
281 }
282 }
283 "ccectl".to_string()
284 }
285
286 #[cfg(test)]
287 mod tests {
288 use super::*;
289
290 fn assert_rgba_close(actual: [f32; 4], expected: [f32; 4]) {
291 for i in 0..4 {
292 assert!(
293 (actual[i] - expected[i]).abs() < 1e-3,
294 "channel {} differs: actual {:?} vs expected {:?}",
295 i,
296 actual,
297 expected
298 );
299 }
300 }
301
302 fn parse_kdl(content: &str) -> serde_json::Value {
303 cce_ui::config::parse_kdl_to_json(content)
304 }
305
306 #[test]
307 fn test_status_config() {
308 let val = parse_kdl(
309 r##"
310 style {
311 status normal_color=(color)"#ccccd8" background_color=(color)"#151520e6" background_blur=(f64)0.8 font="Berkeley Mono 14"
312 }
313 "##,
314 );
315
316 assert_eq!(
317 val.pointer("/style/status/background_color").and_then(|v| v.as_str()),
318 Some("#151520e6")
319 );
320 assert_eq!(
321 val.pointer("/style/status/background_blur").and_then(|v| v.as_f64()),
322 Some(0.8)
323 );
324 assert_eq!(
325 val.pointer("/style/status/font").and_then(|v| v.as_str()),
326 Some("Berkeley Mono 14")
327 );
328 }
329
330 #[test]
331 fn test_box_bevel_config() {
332 let val = parse_kdl(
333 r##"
334 style {
335 status box_bevel="raised" box_bevel_depth=(f64)2.5
336 }
337 "##,
338 );
339 assert_eq!(
340 val.pointer("/style/status/box_bevel").and_then(|v| v.as_str()),
341 Some("raised")
342 );
343 assert_eq!(
344 val.pointer("/style/status/box_bevel_depth").and_then(|v| v.as_f64()),
345 Some(2.5)
346 );
347 }
348
349 // --- pointer-only lookup ---
350
351 #[test]
352 fn non_canonical_locations_do_not_resolve() {
353 // A key parked somewhere other than its canonical nesting is simply
354 // absent — the fuzzy search that used to find these is gone.
355 let val = serde_json::json!({"stray": {"status_normal_color": "#222222"}});
356 assert!(text_color_from(&val, "/style/status/normal_color").is_none());
357 }
358
359 // --- light_source_position ---
360
361 #[test]
362 fn light_source_position_canonical_and_units() {
363 // Canonical location, radians as-is.
364 let val = parse_kdl("window_manager {\n light_source_position (f64)2.5\n}");
365 assert!((light_source_position_from(&val) - 2.5).abs() < 1e-6);
366
367 // A value above 2π is legacy degrees.
368 let val = parse_kdl("window_manager {\n light_source_position (f64)135.0\n}");
369 assert!((light_source_position_from(&val) - 135.0f32.to_radians()).abs() < 1e-6);
370
371 // Absent: the compositor's 135° default.
372 let val = serde_json::json!({});
373 assert!((light_source_position_from(&val) - 2.356_194_5).abs() < 1e-6);
374 }
375
376 // --- color space (spec: text = raw sRGB, quads = linearized) ---
377
378 #[test]
379 fn text_colors_stay_srgb_and_quad_colors_are_linearized() {
380 let val = parse_kdl(
381 r##"
382 style {
383 status normal_color=(color)"#808080" background_color=(color)"#80808080"
384 }
385 "##,
386 );
387 let raw = 128.0f32 / 255.0;
388 let linear = cce_ui::color::srgb_to_linear(raw);
389
390 // Text color: raw sRGB, alpha forced to 1.0 (cosmic-text takes sRGB u8).
391 let text = text_color_from(&val, "/style/status/normal_color").unwrap();
392 assert_rgba_close(text, [raw, raw, raw, 1.0]);
393
394 // Quad color: RGB linearized for the Vulkan pipeline, alpha raw.
395 let quad = quad_color_from(&val, "/style/status/background_color").unwrap();
396 assert_rgba_close(quad, [linear, linear, linear, raw]);
397 }
398 }