git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

commit4efe94eca4d2c503f51a1bd395cdcf20b2ee2aea
parent75f9a09c78
authorLucas Galante <[email protected]>
date2026-08-24 13:21
feat: droplet module style — module { droplet } spec string

Module boxes (and the expanded in-surface menu box, which the drop
grows into) render as cce-ui's new water-droplet material
(Prim::Droplet, shader mode 10) when the app config carries
module { droplet "k=v ..." } — presence enables it, an empty string
takes every DropletSpec default, and the k=v pairs map onto the spec's
fields with warn-and-skip on unknown keys. Drops inset 1px from the
surface bottom so the belly silhouette and its AA feather stay inside
the buffer. The window module's no-focus chip keeps the rounded-box
look for now.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/config.rs | 36 ++++++++++++++++++++++
 src/main.rs   | 99 ++++++++++++++++++++++++++++++++++++++++-------------------
 2 files changed, 104 insertions(+), 31 deletions(-)

diff --git a/src/config.rs b/src/config.rs
index da487b3..9a9ce1e 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -172,6 +172,42 @@ pub(crate) fn read_status_box_background_color_from_config() -> Option<[f32; 4]>
     Some(color)
 }
 
+/// `module { droplet "sag=0.45 belly=0.75 gleam=1.2 ..." }` — the water-drop
+/// module style ([`cce_ui::scene::paint::Prim::Droplet`]). The key's PRESENCE
+/// turns the style on (an empty string takes every default); its value is
+/// whitespace-separated `k=v` pairs onto [`DropletSpec`]'s fields, in the DE's
+/// spec-string idiom. Unknown keys warn and are skipped, so a typo shows up in
+/// the log instead of silently reverting one knob.
+pub(crate) fn read_droplet_from_config() -> Option<cce_ui::scene::paint::DropletSpec> {
+    let raw = cfg_string("/module/droplet")?;
+    let mut spec = cce_ui::scene::paint::DropletSpec::default();
+    for tok in raw.split_whitespace() {
+        let Some((key, val)) = tok.split_once('=') else {
+            log::warn!("module.droplet: token '{}' is not k=v — skipped", tok);
+            continue;
+        };
+        let Ok(v) = val.parse::<f32>() else {
+            log::warn!("module.droplet: '{}' has a non-numeric value — skipped", tok);
+            continue;
+        };
+        match key {
+            "sag" => spec.sag = v,
+            "belly" => spec.belly = v,
+            "belly_w" => spec.belly_w = v,
+            "blend" => spec.blend = v,
+            "sheet_r" => spec.sheet_r = v,
+            "clarity" => spec.clarity = v,
+            "dome" => spec.dome = v,
+            "band" => spec.band = v,
+            "gleam" => spec.gleam = v,
+            "shine" => spec.shine = v,
+            "rim" => spec.rim = v,
+            _ => log::warn!("module.droplet: unknown key '{}' — skipped", key),
+        }
+    }
+    Some(spec)
+}
+
 pub(crate) fn read_status_box_corner_radius_from_config() -> f32 {
     // App-native ONLY (~/.config/cce/cce-status-interface/config.kdl,
     // merged over the shared config by cce-ui): `module { corner_radius }`.
diff --git a/src/main.rs b/src/main.rs
index 91bd209..8f2bae3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -255,6 +255,11 @@ struct StatusApp {
     rects: Vec<RectWidget>,
     overlay_rects: Vec<RectWidget>,
     rounded_boxes: Vec<RoundedBox>,
+    /// Module boxes drawn as water droplets instead of `rounded_boxes` entries
+    /// when `module { droplet }` is configured — (x, y, w, h, color), painted
+    /// first so module content sits on the drop.
+    droplet_boxes: Vec<(f32, f32, f32, f32, [f32; 4])>,
+    droplet: Option<cce_ui::scene::paint::DropletSpec>,
     text_prims: Vec<TextPrim>,
 
     scale_factor: f64,
@@ -360,6 +365,8 @@ impl StatusApp {
         let status_box_radius = read_status_box_corner_radius_from_config();
         self.box_bevel = read_status_box_bevel_from_config();
         self.box_bevel_depth = read_status_box_bevel_depth_from_config();
+        self.droplet = read_droplet_from_config();
+        self.droplet_boxes.clear();
 
         self.status_bar.set_rect(0.0, 0.0, self.width as f32, self.height as f32);
         // The surface itself is transparent: every StatusApp is a single
@@ -395,16 +402,23 @@ impl StatusApp {
                 // the module box GROWS into the menu, it doesn't sit atop it.
                 if !module.has_custom_background(&self.title) && self.context_menu.is_none() {
                     if let Some(color) = box_bg_color {
-                        self.rounded_boxes.push(RoundedBox {
-                            x: left_x,
-                            y: 0.0,
-                            w,
-                            h: bar_h,
-                            radius: status_box_radius,
-                            color,
-                            corners: if self.selected_module_name.is_some() { (true, true, true, true) } else { (false, false, true, true) },
-                            border: None,
-                        });
+                        if self.droplet.is_some() {
+                            // 1px inset from the surface bottom: the belly's
+                            // silhouette (and its AA feather) must live inside
+                            // the buffer, or the drop bottom cuts off flat.
+                            self.droplet_boxes.push((left_x, 0.0, w, bar_h - 1.0, color));
+                        } else {
+                            self.rounded_boxes.push(RoundedBox {
+                                x: left_x,
+                                y: 0.0,
+                                w,
+                                h: bar_h,
+                                radius: status_box_radius,
+                                color,
+                                corners: if self.selected_module_name.is_some() { (true, true, true, true) } else { (false, false, true, true) },
+                                border: None,
+                            });
+                        }
                     }
                 }
 
@@ -473,16 +487,21 @@ impl StatusApp {
                 // box for the unified expanded box.
                 if !module.has_custom_background(&self.title) && self.context_menu.is_none() {
                     if let Some(color) = box_bg_color {
-                        self.rounded_boxes.push(RoundedBox {
-                            x: right_x,
-                            y: 0.0,
-                            w,
-                            h: bar_h,
-                            radius: status_box_radius,
-                            color,
-                            corners: if self.selected_module_name.is_some() { (true, true, true, true) } else { (false, false, true, true) },
-                            border: None,
-                        });
+                        if self.droplet.is_some() {
+                            // Same 1px bottom inset as the left loop.
+                            self.droplet_boxes.push((right_x, 0.0, w, bar_h - 1.0, color));
+                        } else {
+                            self.rounded_boxes.push(RoundedBox {
+                                x: right_x,
+                                y: 0.0,
+                                w,
+                                h: bar_h,
+                                radius: status_box_radius,
+                                color,
+                                corners: if self.selected_module_name.is_some() { (true, true, true, true) } else { (false, false, true, true) },
+                                border: None,
+                            });
+                        }
                     }
                 }
 
@@ -669,17 +688,25 @@ impl StatusApp {
                     // the strip band and the menu — the module box literally
                     // grows into the menu. Inserted at the front so the
                     // module's strip content (tray icons, labels)
-                    // renders on top of its band.
-                    self.rounded_boxes.insert(0, RoundedBox {
-                        x: plate_x,
-                        y: 0.0,
-                        w: anim_w,
-                        h: bar_h + reveal_h,
-                        radius: status_box_radius.max(4.0),
-                        color: box_bg_color.unwrap_or([0.055, 0.055, 0.075, 0.97]),
-                        corners: (true, true, true, true),
-                        border: None,
-                    });
+                    // renders on top of its band. In droplet style the drop
+                    // itself grows: the belly follows the expanding bottom
+                    // edge, which is the metaball merge the smin silhouette
+                    // gives for free.
+                    let menu_color = box_bg_color.unwrap_or([0.055, 0.055, 0.075, 0.97]);
+                    if self.droplet.is_some() {
+                        self.droplet_boxes.push((plate_x, 0.0, anim_w, bar_h + reveal_h - 1.0, menu_color));
+                    } else {
+                        self.rounded_boxes.insert(0, RoundedBox {
+                            x: plate_x,
+                            y: 0.0,
+                            w: anim_w,
+                            h: bar_h + reveal_h,
+                            radius: status_box_radius.max(4.0),
+                            color: menu_color,
+                            corners: (true, true, true, true),
+                            border: None,
+                        });
+                    }
 
                     let text_u8 = [
                         (normal_color[0] * 255.0) as u8,
@@ -1087,6 +1114,8 @@ impl cce_ui::engine::Application for StatusApp {
             rects: Vec::new(),
             overlay_rects: Vec::new(),
             rounded_boxes: Vec::new(),
+            droplet_boxes: Vec::new(),
+            droplet: None,
             text_prims: Vec::new(),
             scale_factor: 1.0,
             width: if selected_module.is_some() { 120 } else { 1920 },
@@ -1306,6 +1335,14 @@ impl cce_ui::engine::Application for StatusApp {
         }
         let mut pc = cce_ui::scene::paint::PaintCtx::new();
 
+        // Droplet-style module boxes paint first, so any remaining rounded
+        // boxes (module-internal chips) and all content sit on the drops.
+        if let Some(spec) = self.droplet {
+            for &(x, y, w, h, color) in &self.droplet_boxes {
+                pc.droplet(Rect { x, y, width: w, height: h }, color, spec);
+            }
+        }
+
         for rb in &self.rounded_boxes {
             let rect = Rect { x: rb.x, y: rb.y, width: rb.w, height: rb.h };
             // Same positional corner→radius mapping the RoundedRect prim uses.