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