git.lucas.co / cce-status-interface
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 }