status bar
git clone https://git.lucas.co/cce-status-interface.git
src/config.rs (17K)
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 /// `module { text_contrast }` — adaptive contrast strength (0 = off, the
242 /// default; 1 = full). The compositor's per-segment `backdrop` measurement
243 /// drives the scrim through it: the ground darkens only as far as a backdrop
244 /// the configured text color cannot carry demands, and its strength tracks
245 /// how badly the text is losing. Bar-side only.
246 ///
247 /// On its own it makes the scrim appear only when the backdrop earns it;
248 /// alongside `module { text_scrim }` it deepens that resting ground.
249 pub(crate) fn read_text_contrast_from_config() -> f32 {
250 cfg_f32("/module/text_contrast").unwrap_or(0.0).clamp(0.0, 1.0)
251 }
252
253 /// `module { text_scrim }` — opacity 0-1 of a dark feathered pool drawn
254 /// inside each module box, beneath everything the module paints (0 = off,
255 /// the default). It darkens the ground the glyphs sit on rather than
256 /// decorating the letterforms, which is what survives a busy backdrop
257 /// without putting a rim on every letterform.
258 ///
259 /// The DE's one text-contrast treatment, and `module { text_contrast }`
260 /// applies on top: the scrim rests at this opacity and deepens toward
261 /// opaque as the measured backdrop demands more.
262 pub(crate) fn read_text_scrim_from_config() -> f32 {
263 cfg_f32("/module/text_scrim").unwrap_or(0.0).clamp(0.0, 1.0)
264 }
265
266 /// `module { text_scrim_feather }` — how far, in logical px, the scrim fades
267 /// out from its solid core (default: a quarter of the box's height, which
268 /// keeps the whole gradient inside the box at any bar height).
269 ///
270 /// The feather is drawn OUTSIDE the core rect, so the core is inset by this
271 /// much: feather and inset are the same number, and the pool reaches the
272 /// box's edge exactly.
273 pub(crate) fn read_text_scrim_feather_from_config() -> Option<f32> {
274 cfg_f32("/module/text_scrim_feather").map(|v| v.max(0.0))
275 }
276
277 pub(crate) fn read_status_box_corner_radius_from_config() -> f32 {
278 // App-native ONLY (~/.config/cce/cce-status-interface/config.kdl,
279 // merged over the shared config by cce-ui): `module { corner_radius }`.
280 // Unlike the other module styling keys there is deliberately no shared
281 // `style { status ... }` fallback — the radius is purely bar-side
282 // cosmetics (the old `status_box_corner_radius` rung was removed).
283 cfg_f32("/module/corner_radius").unwrap_or(4.0)
284 }
285
286 /// Bevel treatment for the module boxes.
287 #[derive(Clone, Copy, PartialEq, Debug)]
288 pub(crate) enum StatusBoxBevel {
289 /// A lit plate lip — the box rises out of the bar.
290 Raised,
291 /// A carved recess rim — the box sinks into the bar.
292 Inset,
293 }
294
295 /// `style { status box_bevel="raised"|"inset" }`; absent, `"none"`, or any
296 /// other value keeps the flat boxes.
297 pub(crate) fn read_status_box_bevel_from_config() -> Option<StatusBoxBevel> {
298 match cfg_string("/style/status/box_bevel")?.to_ascii_lowercase().as_str() {
299 "raised" => Some(StatusBoxBevel::Raised),
300 "inset" => Some(StatusBoxBevel::Inset),
301 _ => None,
302 }
303 }
304
305 /// `style { status box_bevel_depth=(f64)N }` — the roll width of the bevel lip
306 /// in logical px. The DE-wide `bevel_width` (~9px) is window-scale; module
307 /// boxes in a ~24px bar want a much tighter lip.
308 pub(crate) fn read_status_box_bevel_depth_from_config() -> f32 {
309 cfg_f32("/style/status/box_bevel_depth").unwrap_or(3.0)
310 }
311
312 pub(crate) fn get_ccectl_cmd() -> String {
313 if let Ok(home) = std::env::var("HOME") {
314 let path = format!("{}/.local/bin/ccectl", home);
315 if std::path::Path::new(&path).exists() {
316 return path;
317 }
318 }
319 "ccectl".to_string()
320 }
321
322 #[cfg(test)]
323 mod tests {
324 use super::*;
325
326 fn assert_rgba_close(actual: [f32; 4], expected: [f32; 4]) {
327 for i in 0..4 {
328 assert!(
329 (actual[i] - expected[i]).abs() < 1e-3,
330 "channel {} differs: actual {:?} vs expected {:?}",
331 i,
332 actual,
333 expected
334 );
335 }
336 }
337
338 fn parse_kdl(content: &str) -> serde_json::Value {
339 cce_ui::config::parse_kdl_to_json(content)
340 }
341
342 #[test]
343 fn test_status_config() {
344 let val = parse_kdl(
345 r##"
346 style {
347 status normal_color=(color)"#ccccd8" background_color=(color)"#151520e6" background_blur=(f64)0.8 font="Berkeley Mono 14"
348 }
349 "##,
350 );
351
352 assert_eq!(
353 val.pointer("/style/status/background_color").and_then(|v| v.as_str()),
354 Some("#151520e6")
355 );
356 assert_eq!(
357 val.pointer("/style/status/background_blur").and_then(|v| v.as_f64()),
358 Some(0.8)
359 );
360 assert_eq!(
361 val.pointer("/style/status/font").and_then(|v| v.as_str()),
362 Some("Berkeley Mono 14")
363 );
364 }
365
366 #[test]
367 fn test_box_bevel_config() {
368 let val = parse_kdl(
369 r##"
370 style {
371 status box_bevel="raised" box_bevel_depth=(f64)2.5
372 }
373 "##,
374 );
375 assert_eq!(
376 val.pointer("/style/status/box_bevel").and_then(|v| v.as_str()),
377 Some("raised")
378 );
379 assert_eq!(
380 val.pointer("/style/status/box_bevel_depth").and_then(|v| v.as_f64()),
381 Some(2.5)
382 );
383 }
384
385 // --- pointer-only lookup ---
386
387 #[test]
388 fn non_canonical_locations_do_not_resolve() {
389 // A key parked somewhere other than its canonical nesting is simply
390 // absent — the fuzzy search that used to find these is gone.
391 let val = serde_json::json!({"stray": {"status_normal_color": "#222222"}});
392 assert!(text_color_from(&val, "/style/status/normal_color").is_none());
393 }
394
395 // --- light_source_position ---
396
397 #[test]
398 fn light_source_position_canonical_and_units() {
399 // Canonical location, radians as-is.
400 let val = parse_kdl("window_manager {\n light_source_position (f64)2.5\n}");
401 assert!((light_source_position_from(&val) - 2.5).abs() < 1e-6);
402
403 // A value above 2π is legacy degrees.
404 let val = parse_kdl("window_manager {\n light_source_position (f64)135.0\n}");
405 assert!((light_source_position_from(&val) - 135.0f32.to_radians()).abs() < 1e-6);
406
407 // Absent: the compositor's 135° default.
408 let val = serde_json::json!({});
409 assert!((light_source_position_from(&val) - 2.356_194_5).abs() < 1e-6);
410 }
411
412 // --- color space (spec: text = raw sRGB, quads = linearized) ---
413
414 #[test]
415 fn text_colors_stay_srgb_and_quad_colors_are_linearized() {
416 let val = parse_kdl(
417 r##"
418 style {
419 status normal_color=(color)"#808080" background_color=(color)"#80808080"
420 }
421 "##,
422 );
423 let raw = 128.0f32 / 255.0;
424 let linear = cce_ui::color::srgb_to_linear(raw);
425
426 // Text color: raw sRGB, alpha forced to 1.0 (cosmic-text takes sRGB u8).
427 let text = text_color_from(&val, "/style/status/normal_color").unwrap();
428 assert_rgba_close(text, [raw, raw, raw, 1.0]);
429
430 // Quad color: RGB linearized for the Vulkan pipeline, alpha raw.
431 let quad = quad_color_from(&val, "/style/status/background_color").unwrap();
432 assert_rgba_close(quad, [linear, linear, linear, raw]);
433 }
434 }