GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: opt-in 'band' slider style — full-range band swelling at the value
style.control.slider.style = "band" (per-app config key; default look
unchanged) replaces the track fill and thumb ball with a thin band spanning
the whole range that inflates smoothly around the value — a cosine bell
sampled as ~1px column quads, so the swell reads as one continuous surface.
Knobs: slider.band_thickness / bulge_width / bulge_height; the band wears
the thumb colors (brightens while dragging). Under the style the value maps
over the full track (no thumb inset), thumb_sphere/track_relief return None
so legacy-view hosts (ParametersBg) drop the ball and carve, and the band's
quads reach them through the plain-quad drain.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/layout.rs | 34 ++++++++++++++++++++
src/widget/input/slider.rs | 80 ++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 107 insertions(+), 7 deletions(-)
diff --git a/src/layout.rs b/src/layout.rs
index e8687e8..0c337bd 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -88,6 +88,10 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
"style.control.label.layout" => "control_label_layout",
"style.control.slider.height" => "slider_height",
"style.control.slider.corner_radius" => "slider_corner_radius",
+ "style.control.slider.style" => "slider_style",
+ "style.control.slider.band_thickness" => "slider_band_thickness",
+ "style.control.slider.bulge_width" => "slider_bulge_width",
+ "style.control.slider.bulge_height" => "slider_bulge_height",
"style.control.progressbar.height" => "progressbar_height",
"style.control.rangeslider.height" => "rangeslider_height",
"style.control.rangeslider.corner_radius" | "style.rangeslider.corner_radius" => "rangeslider_corner_radius",
@@ -1753,6 +1757,36 @@ pub fn slider_corner_radius() -> f32 {
get_style_registry().read().unwrap().get_float("slider_corner_radius").unwrap_or(4.0)
}
+/// The slider's render style: `style.control.slider.style = "band"` swaps the
+/// track/fill/thumb for a thin full-range band that inflates smoothly at the
+/// value (see `Slider::paint`). Anything else — or unset — keeps the default
+/// look, so apps opt in per-config.
+pub fn slider_band() -> bool {
+ lazy_init_style_registry();
+ get_style_registry()
+ .read()
+ .unwrap()
+ .get_string("slider_style")
+ .is_some_and(|s| s == "band")
+}
+
+/// Band-style knobs (`style.control.slider.*`): the flat band's thickness, and
+/// the bulge's half-span / peak height around the value position.
+pub fn slider_band_thickness() -> f32 {
+ lazy_init_style_registry();
+ get_style_registry().read().unwrap().get_float("slider_band_thickness").unwrap_or(2.0)
+}
+
+pub fn slider_bulge_width() -> f32 {
+ lazy_init_style_registry();
+ get_style_registry().read().unwrap().get_float("slider_bulge_width").unwrap_or(26.0)
+}
+
+pub fn slider_bulge_height() -> f32 {
+ lazy_init_style_registry();
+ get_style_registry().read().unwrap().get_float("slider_bulge_height").unwrap_or(14.0)
+}
+
pub fn set_slider_corner_radius(radius: f32) {
lazy_init_style_registry();
if let Ok(mut registry) = get_style_registry().write() {
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index f56af46..461ab5c 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -112,12 +112,25 @@ impl Slider {
}
}
+ /// The "band" style (config `style.control.slider.style = "band"`): a thin
+ /// full-range band that inflates smoothly at the value — no track fill, no
+ /// thumb ball, no carve.
+ fn band(&self) -> bool {
+ crate::layout::slider_band()
+ }
+
+ /// The width the value maps over: the full track under the band style
+ /// (there is no thumb to keep inside the ends), thumb-inset otherwise.
+ fn value_span(&self, g: &SliderGeom) -> f32 {
+ if self.band() { g.track_w } else { g.track_w - g.thumb_size }
+ }
+
/// The recessed track's carve for hosts that draw this control through the
/// legacy flat views (see `ParametersBg::reliefs`): (x, y, w, h, radius,
/// depth) over the widget's assigned `rect`, or None when the style is off.
/// The same geometry `paint` carves.
pub fn track_relief(&self, rect: Rect) -> Option<(f32, f32, f32, f32, f32, f32)> {
- if !self.recessed {
+ if !self.recessed || self.band() {
return None;
}
let g = self.geom(rect);
@@ -132,7 +145,7 @@ impl Slider {
/// it. None under the square style, whose quad thumb already reaches the
/// plain-quad view. The same geometry `paint` draws.
pub fn thumb_sphere(&self, rect: Rect) -> Option<(f32, f32, f32, [f32; 4])> {
- if crate::layout::slider_corner_radius() <= 0.0 {
+ if crate::layout::slider_corner_radius() <= 0.0 || self.band() {
return None;
}
let g = self.geom(rect);
@@ -173,6 +186,50 @@ impl Slider {
SliderGeom { x, y: rect.y, w, h: rect.height, track_x, track_w, thumb_size: rect.height * 0.9 }
}
+ /// The band style's geometry: a thin band spanning the whole track, swelling
+ /// smoothly around the value position — a cosine bell sampled as ~1px column
+ /// quads, so the swell reads as one continuous surface (the snake that
+ /// swallowed the rodent), not a marker riding a rail. Emitted as `Prim::Quad`s
+ /// so legacy plain-quad hosts (`ParametersBg::extra_quads`) carry it verbatim.
+ fn paint_band(&self, g: &SliderGeom, ctx: &mut PaintCtx) {
+ let band_t = crate::layout::slider_band_thickness().max(0.5);
+ let bulge_h = crate::layout::slider_bulge_height().clamp(band_t, g.h);
+ let bulge_w = crate::layout::slider_bulge_width().max(2.0);
+ let color = if self.dragging { colors::slider_thumb_drag() } else { colors::slider_thumb() };
+ let cy = g.y + g.h * 0.5;
+ let vx = g.track_x + self.value * g.track_w;
+
+ // Flat runs outside the bulge span.
+ let l0 = g.track_x;
+ let r1 = g.track_x + g.track_w;
+ let b0 = (vx - bulge_w).max(l0);
+ let b1 = (vx + bulge_w).min(r1);
+ if b0 > l0 {
+ ctx.quad(Rect { x: l0, y: cy - band_t * 0.5, width: b0 - l0, height: band_t }, color);
+ }
+ if r1 > b1 {
+ ctx.quad(Rect { x: b1, y: cy - band_t * 0.5, width: r1 - b1, height: band_t }, color);
+ }
+
+ // The swell: symmetric about the band's centerline. The bell is raised
+ // to a power so the flanks taper long and the crest stays plump —
+ // mid-digestion, not a triangle.
+ let span = b1 - b0;
+ if span <= 0.0 {
+ return;
+ }
+ let steps = (span.ceil() as i32).max(1);
+ let step_w = span / steps as f32;
+ for i in 0..steps {
+ let x = b0 + i as f32 * step_w;
+ let t = ((x + step_w * 0.5 - vx) / bulge_w).clamp(-1.0, 1.0);
+ let bell = 0.5 * (1.0 + (std::f32::consts::PI * t).cos());
+ let h = band_t + (bulge_h - band_t) * bell.powf(1.35);
+ // A hair of overlap between columns so AA seams can't open.
+ ctx.quad(Rect { x, y: cy - h * 0.5, width: step_w + 0.3, height: h }, color);
+ }
+ }
+
fn scaled_string(&self) -> String {
format!("{:.2}", self.min + self.value * (self.max - self.min))
}
@@ -291,12 +348,14 @@ impl Paint for Slider {
// Track. Recessed style draws no background at all — the plate below
// is the well's floor (the TextBox bare-recess look), and the carve
- // emitted after the fill defines the channel.
+ // emitted after the fill defines the channel. The band style draws
+ // neither: the band IS the whole control.
+ let band = self.band();
let track_rect = Rect { x: g.track_x, y: g.y, width: g.track_w, height: g.h };
// Carve wall width, the TextBox formula: capped against the bar height
// (the wall straddles the track boundary, intruding half its width).
let recess_t = crate::layout::bevel_width().min(g.h * 0.2);
- if !self.recessed {
+ if !self.recessed && !band {
rrect(track_rect, radius, rc, colors::slider_track(), ctx);
}
@@ -325,6 +384,13 @@ impl Paint for Slider {
ctx.text(text, rx + 8.0, crate::layout::align_text_y(g.y, g.h, 12.0, 0.0), 12.0, [0xee, 0xee, 0xf0]);
}
+ // Band style: the full-range band with its value swell replaces fill,
+ // carve, and thumb outright.
+ if band {
+ self.paint_band(&g, ctx);
+ return;
+ }
+
// Fill up to the thumb center. Recessed style insets the fill onto the
// well's flat floor (past the wall's inner half-span), so the liquid
// sits in the well instead of climbing its walls.
@@ -458,7 +524,7 @@ impl Input for Slider {
}
match state {
ElementState::Pressed => {
- let thumb_x = g.track_x + self.value * (g.track_w - g.thumb_size);
+ let thumb_x = g.track_x + self.value * self.value_span(&g);
if *px >= g.track_x && *px <= g.track_x + g.track_w && *py >= g.y && *py <= g.y + g.h {
self.dragging = true;
self.drag_offset = px - thumb_x;
@@ -555,12 +621,12 @@ impl Input for Slider {
fn drag_begin(&mut self, px: f32, _py: f32, rect: Rect) {
self.dragging = true;
let g = self.geom(rect);
- let thumb_x = g.track_x + self.value * (g.track_w - g.thumb_size);
+ let thumb_x = g.track_x + self.value * self.value_span(&g);
self.drag_offset = px - thumb_x;
}
fn drag_update(&mut self, px: f32, _py: f32, rect: Rect) -> bool {
let g = self.geom(rect);
- let range = g.track_w - g.thumb_size;
+ let range = self.value_span(&g);
if range > 0.0 {
let new_val = ((px - self.drag_offset - g.track_x) / range).clamp(0.0, 1.0);
return self.set_value_marking(new_val);