GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(layout)!: wells carve inside their rect; strategies place blocks (label row + control)
Two more sources of uneven spacing, both systemic:
- A recess, boss or trough wall straddled the rect edge it was given, half its
depth outside, so a well painted past its widget's box and the gap beside a
well read up to half a depth smaller than beside a raised plate — 3.5px
under a 64px pad where the layout said 8. `layout::carve_inside` returns the
rect inset by half the depth with the radii reduced to keep the outer
silhouette, and every carve site uses it: the sliders' shared labeled well
(and their `track_relief` bridge), TextBox's `well`, ColorSelector's
`field_relief`, ProgressBar, UsageBar, KeybindRecorder, Trackpad,
ButtonStrip, FontSelector, Spinbox, Dropdown (trigger and popover), Toggle's
carves (faces keep the full pill), MenuBar and StatusBar (the one wall inside
the bar), Breadcrumb, Button's inset face, TreeList, Ramp, the relief
scrollbar, the context menu plate. Fills that sat "on the floor past the
wall's inner half" now sit past the whole wall.
- Placing content boxes at the cursor put a carve-out tab (Slider, RangeSlider,
Slider2D, Dropdown) 4px under the control above it: the tab makes the label
part of the silhouette. A strategy now places BLOCKS — the label row
(`label_lead`, reserved for every child of a labeled container) then the
content — with `CONTROL_GAP` between blocks in both axes; a row's controls
stay level, and a tab sits a full gap from its neighbour.
Also: a labeled well whose throat beside the tab is too short for the fillet
and a run of wall (a narrow slider, the Slider2D pad) gives the tab the whole
width instead of a notch; the tab's crossfade extension is capped at the fillet
radius so a deep wall no longer runs on as a stub; Slider2D is wide enough for
its tab and a filleted throat.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
src/layout.rs | 40 +++++++++++-------
src/widget/container/breadcrumb.rs | 6 ++-
src/widget/container/container_layout.rs | 72 ++++++++++++++++++--------------
src/widget/container/menu.rs | 10 +++--
src/widget/container/scroll_box.rs | 8 ++--
src/widget/container/treelist.rs | 3 +-
src/widget/core.rs | 3 +-
src/widget/display/progress_bar.rs | 5 ++-
src/widget/display/status_bar.rs | 4 +-
src/widget/display/usage_bar.rs | 5 ++-
src/widget/input/button.rs | 3 +-
src/widget/input/button_strip.rs | 6 ++-
src/widget/input/checkbox.rs | 12 ++++--
src/widget/input/color_selector.rs | 7 +++-
src/widget/input/dropdown.rs | 18 +++++++-
src/widget/input/font_selector.rs | 3 +-
src/widget/input/keybind_recorder.rs | 6 +--
src/widget/input/ramp.rs | 3 +-
src/widget/input/slider.rs | 36 ++++++++++++----
src/widget/input/slider2d.rs | 11 +++--
src/widget/input/spinbox.rs | 4 +-
src/widget/input/text_box.rs | 3 +-
src/widget/input/trackpad.rs | 3 +-
src/widget/mod.rs | 5 ++-
24 files changed, 183 insertions(+), 93 deletions(-)
diff --git a/src/layout.rs b/src/layout.rs
index fd4410e..fe3d94c 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -293,13 +293,10 @@ static SECTION_PADDING: RwLock<f32> = RwLock::new(8.0);
pub const DEFAULT_CONTROL_HEIGHT: f32 = 24.0;
/// The one gap between controls — one control height — in BOTH axes: what every
-/// layout strategy's `Default` puts between children's content boxes and around
-/// them, and what the legacy row builders advance by. A detached label hangs in
-/// the gap above its control (a strategy places content boxes; see
-/// `WidgetHost::label_strip`), which is why the gap is a control height and not
-/// a few pixels: it holds a label strip (font + margin) with room to spare, so
-/// the space between two controls reads the same whether or not a label sits in
-/// it, and the same as the space beside them.
+/// layout strategy's `Default` puts between children's blocks (a detached label
+/// and the control below it, see `WidgetHost::label_strip`) and around them, and
+/// what the legacy row builders advance by. One number, one module, so the space
+/// beside a control and the space below it read the same.
pub const CONTROL_GAP: f32 = DEFAULT_CONTROL_HEIGHT;
/// The one inset from a control's edge to its text: the field text of a TextBox,
@@ -308,6 +305,22 @@ pub const CONTROL_GAP: f32 = DEFAULT_CONTROL_HEIGHT;
/// their text up because they all use this.
pub const CONTROL_TEXT_INSET: f32 = 8.0;
+/// A carve that stays INSIDE its rect. A recess, boss or trough wall straddles the
+/// rect edge it is given — half its depth outside — so a well carved at a widget's
+/// rect edge painted past the widget's box, and the gap beside a well read up to
+/// half a depth smaller than the gap beside a raised plate (whose roll is inside).
+/// Every widget carves the rect this returns instead: inset by half the depth, the
+/// radii reduced by the same so the OUTER silhouette keeps the configured radius.
+/// The widget's footprint is then its rect, and the gap is the gap.
+pub fn carve_inside(rect: crate::scene::layout::Rect, radii: crate::scene::paint::Radii, depth: f32) -> (crate::scene::layout::Rect, crate::scene::paint::Radii) {
+ let g = depth * 0.5;
+ let r = |r: f32| if r > 0.0 { (r - g).max(0.0) } else { 0.0 };
+ (
+ crate::scene::layout::Rect { x: rect.x + g, y: rect.y + g, width: (rect.width - depth).max(0.0), height: (rect.height - depth).max(0.0) },
+ (r(radii.0), r(radii.1), r(radii.2), r(radii.3)),
+ )
+}
+
/// The one inset from a control's left edge to its detached label above it — the
/// x offset the adapter draws the label at, and the tab hugging that label in the
/// carve-out compositions (Slider, Dropdown, RangeSlider). Every labeled control
@@ -4732,11 +4745,10 @@ impl LayoutStrategy for FlexLayout {
fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn crate::widget::WidgetHost + 'static)], ctx: &mut crate::context::UiContext) -> f32 {
let (cur_x, cur_y) = (x, y);
- // Content boxes: a detached label hangs above its control in the gap (or
- // this lead row of headroom), so labeled and unlabeled children line up
- // by content and the gap between controls is the gap.
+ // Blocks: the label row (`label_lead`) above every child's content, the
+ // gap between blocks, so a row's controls are level and a carve-out tab
+ // sits a full gap from its neighbour.
let lead = crate::widget::container::container_layout::label_lead(children);
- let (cur_x, cur_y) = (cur_x, cur_y + lead);
match self.direction {
FlexDirection::Row => {
let mut cur_x = cur_x;
@@ -4747,7 +4759,7 @@ impl LayoutStrategy for FlexLayout {
let child_h = crate::widget::container::container_layout::content_height(child);
let use_h = if child_h > 0.0 { child_h } else { h };
child.layout(
- crate::widget::Point { x: cur_x, y: cur_y },
+ crate::widget::Point { x: cur_x, y: cur_y + lead },
crate::widget::LayoutConstraints::new(child_w, child_w, use_h, use_h),
ctx,
);
@@ -4764,11 +4776,11 @@ impl LayoutStrategy for FlexLayout {
let child_h = crate::widget::container::container_layout::content_height(child);
let use_h = if child_h > 0.0 { child_h } else { 44.0 };
child.layout(
- crate::widget::Point { x, y: cur_y },
+ crate::widget::Point { x, y: cur_y + lead },
crate::widget::LayoutConstraints::new(w, w, use_h, use_h),
ctx,
);
- cur_y += use_h + self.spacing;
+ cur_y += lead + use_h + self.spacing;
}
}
(cur_y - y).max(0.0)
diff --git a/src/widget/container/breadcrumb.rs b/src/widget/container/breadcrumb.rs
index 0f8dacc..f6018f0 100644
--- a/src/widget/container/breadcrumb.rs
+++ b/src/widget/container/breadcrumb.rs
@@ -353,10 +353,12 @@ impl Paint for Breadcrumb {
c[3] = -(c[3] * Self::RAISED_FACE_OPACITY);
ctx.bevel(run_rect, (r, r, r, r), c, depth);
} else {
- ctx.boss(run_rect, (r, r, r, r), depth);
+ let (plateau, radii) = crate::layout::carve_inside(run_rect, (r, r, r, r), depth);
+ ctx.boss(plateau, radii, depth);
}
} else {
- ctx.inset_plate(run_rect, (r, r, r, r), face, depth);
+ let (trough, radii) = crate::layout::carve_inside(run_rect, (r, r, r, r), depth);
+ ctx.inset_plate(trough, radii, face, depth);
}
for (a, b) in self.seams(rect) {
ctx.groove(a, b, Self::SEAM_WIDTH, depth, run_rect);
diff --git a/src/widget/container/container_layout.rs b/src/widget/container/container_layout.rs
index b2e1b12..ffa61e8 100644
--- a/src/widget/container/container_layout.rs
+++ b/src/widget/container/container_layout.rs
@@ -116,9 +116,12 @@ pub fn content_height(child: &dyn WidgetHost) -> f32 {
child.preferred_height().unwrap_or(child.rect().3 - child.label_strip())
}
-/// The headroom a strategy leaves above its first row so the first row's detached
-/// labels have somewhere to hang: the tallest label strip among the children (every
-/// later row's labels hang in the gap). Zero when nothing is labeled.
+/// The label row a strategy reserves above EVERY child's content: the tallest
+/// detached-label strip among the children, zero when nothing is labeled. A block
+/// is label + control; the strategies place blocks and put the gap between them,
+/// in both axes, so a carve-out tab (which makes the label part of a control's
+/// silhouette) sits a full gap from its neighbour. Reserving the row for every
+/// child — an unlabeled one leaves it empty — keeps a row's controls level.
pub fn label_lead(children: &[*mut (dyn WidgetHost + 'static)]) -> f32 {
children.iter().map(|&c| unsafe { (*c).label_strip() }).fold(0.0, f32::max)
}
@@ -164,10 +167,10 @@ impl crate::layout::LayoutStrategy for VerticalLayout {
fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn WidgetHost + 'static)], ctx: &mut UiContext) -> f32 {
let left_x = x + self.padding_x;
let available_w = (w - 2.0 * self.padding_x).max(1.0);
- // Content boxes: a child's detached label hangs above its content in the
- // gap before it (the first row's in this lead), so mixed children line up
- // by content and the space between controls is the gap.
- let mut current_y = y + self.padding_y + label_lead(children);
+ // Blocks: the label row (`label_lead`) then the content, the gap between
+ // blocks.
+ let lead = label_lead(children);
+ let mut current_y = y + self.padding_y;
for &child_ptr in children {
unsafe {
@@ -175,26 +178,27 @@ impl crate::layout::LayoutStrategy for VerticalLayout {
let ch = content_height(child);
let use_h = if ch > 0.0 { ch } else { 44.0 };
child.layout(
- Point { x: left_x, y: current_y },
+ Point { x: left_x, y: current_y + lead },
LayoutConstraints::new(available_w, available_w, use_h, use_h),
ctx,
);
- current_y += use_h + self.spacing;
+ current_y += lead + use_h + self.spacing;
}
}
(current_y - y).max(0.0)
}
fn measure(&self, constraints: LayoutConstraints, children: &[*mut (dyn WidgetHost + 'static)], ctx: &UiContext) -> Size {
- let mut total_h = self.padding_y * 2.0 + label_lead(children);
+ let mut total_h = self.padding_y * 2.0;
let mut max_w = 0.0f32;
let spacing = self.spacing;
+ let lead = label_lead(children);
for (i, &child_ptr) in children.iter().enumerate() {
unsafe {
let size = (*child_ptr).measure(constraints, ctx);
max_w = max_w.max(size.width);
- total_h += size.height;
+ total_h += lead + size.height;
if i > 0 {
total_h += spacing;
}
@@ -272,9 +276,9 @@ impl crate::layout::LayoutStrategy for GridLayout {
let available_w = (w - 2.0 * self.padding_x - total_gap).max(1.0);
let col_w = available_w / cols as f32;
- // Content boxes (see VerticalLayout): labels hang in the gaps, the first
- // row's in the lead.
- let mut col_heights = vec![y + self.padding_y + label_lead(children); cols];
+ // Blocks (see VerticalLayout): the label row, then the content.
+ let lead = label_lead(children);
+ let mut col_heights = vec![y + self.padding_y; cols];
for &child_ptr in children {
unsafe {
@@ -294,11 +298,11 @@ impl crate::layout::LayoutStrategy for GridLayout {
let cx = x + self.padding_x + min_col as f32 * (col_w + self.gap);
let cy = col_heights[min_col];
child.layout(
- Point { x: cx, y: cy },
+ Point { x: cx, y: cy + lead },
LayoutConstraints::new(col_w, col_w, use_h, use_h),
ctx,
);
- col_heights[min_col] += use_h + self.gap;
+ col_heights[min_col] += lead + use_h + self.gap;
}
}
@@ -308,7 +312,8 @@ impl crate::layout::LayoutStrategy for GridLayout {
fn measure(&self, constraints: LayoutConstraints, children: &[*mut (dyn WidgetHost + 'static)], ctx: &UiContext) -> Size {
let cols = self.columns.max(1);
- let mut col_heights = vec![self.padding_y + label_lead(children); cols];
+ let lead = label_lead(children);
+ let mut col_heights = vec![self.padding_y; cols];
let total_gap = self.gap * (cols - 1) as f32;
let available_w = (constraints.max_width - 2.0 * self.padding_x - total_gap).max(1.0);
let col_w = available_w / cols as f32;
@@ -326,7 +331,7 @@ impl crate::layout::LayoutStrategy for GridLayout {
min_col = i;
}
}
- col_heights[min_col] += size.height + self.gap;
+ col_heights[min_col] += lead + size.height + self.gap;
}
}
@@ -460,7 +465,7 @@ impl crate::layout::LayoutStrategy for ColumnsLayout {
let total_padding = self.padding_x * 2.0;
let available_w = (w - total_padding - total_spacing).max(1.0);
let col_w = available_w / count as f32;
- // Content boxes: the columns' labels hang in the lead above them.
+ // Blocks: the label row above the columns' content.
let lead = label_lead(children);
let use_h = (h - 2.0 * self.padding_y - lead).max(1.0);
let start_y = y + self.padding_y + lead;
@@ -605,9 +610,9 @@ impl crate::layout::LayoutStrategy for MosaicLayout {
let total_padding_x = self.padding_x * 2.0;
let available_w = (w - total_padding_x).max(1.0);
- // Content boxes (see VerticalLayout): labels hang in the gaps, the first
- // row's in the lead.
- let mut packer = Packer::new(x + self.padding_x, y + self.padding_y + label_lead(children), available_w, self.gap);
+ // Blocks (see VerticalLayout): each packed as label row + content.
+ let lead = label_lead(children);
+ let mut packer = Packer::new(x + self.padding_x, y + self.padding_y, available_w, self.gap);
for &child_ptr in children {
unsafe {
@@ -617,9 +622,9 @@ impl crate::layout::LayoutStrategy for MosaicLayout {
let child_h = content_height(child);
let use_h = if child_h > 0.0 { child_h } else { 44.0 };
- let (px, py) = packer.pack(child_w, use_h);
+ let (px, py) = packer.pack(child_w, lead + use_h);
child.layout(
- Point { x: px, y: py },
+ Point { x: px, y: py + lead },
LayoutConstraints::new(child_w.min(available_w), child_w.min(available_w), use_h, use_h),
ctx,
);
@@ -637,12 +642,13 @@ impl crate::layout::LayoutStrategy for MosaicLayout {
let total_padding_x = self.padding_x * 2.0;
let available_w = (constraints.max_width - total_padding_x).max(1.0);
- let mut packer = Packer::new(self.padding_x, self.padding_y + label_lead(children), available_w, self.gap);
+ let lead = label_lead(children);
+ let mut packer = Packer::new(self.padding_x, self.padding_y, available_w, self.gap);
for &child_ptr in children {
unsafe {
let size = (*child_ptr).measure(constraints, ctx);
- packer.pack(size.width, size.height);
+ packer.pack(size.width, lead + size.height);
}
}
@@ -694,7 +700,7 @@ impl crate::layout::LayoutStrategy for ReverseMosaicLayout {
let available_w = (w - total_padding_x).max(1.0);
let lead = label_lead(children);
- let mut packer = Packer::new(self.padding_x, self.padding_y + lead, available_w, self.gap);
+ let mut packer = Packer::new(self.padding_x, self.padding_y, available_w, self.gap);
let mut temp_positions = Vec::with_capacity(count);
for &child_ptr in children {
@@ -705,8 +711,10 @@ impl crate::layout::LayoutStrategy for ReverseMosaicLayout {
let child_h = content_height(child);
let use_h = if child_h > 0.0 { child_h } else { 44.0 };
- let (px, py) = packer.pack(child_w, use_h);
- temp_positions.push((px, py, child_w, use_h));
+ // The block (label row + content) is packed; the content lands
+ // `lead` below its top, scaled with the rest.
+ let (px, py) = packer.pack(child_w, lead + use_h);
+ temp_positions.push((px, py, child_w, lead + use_h));
}
}
@@ -726,7 +734,7 @@ impl crate::layout::LayoutStrategy for ReverseMosaicLayout {
let src_h = (y_max - y_min).max(1.0);
let dst_w = available_w;
- let dst_h = (h - 2.0 * self.padding_y - lead).max(1.0);
+ let dst_h = (h - 2.0 * self.padding_y).max(1.0);
let scale_x = dst_w / src_w;
let scale_y = dst_h / src_h;
@@ -737,9 +745,9 @@ impl crate::layout::LayoutStrategy for ReverseMosaicLayout {
let (px, py, pw, ph) = temp_positions[i];
let new_x = x + self.padding_x + (px - x_min) * scale_x;
- let new_y = y + self.padding_y + lead + (py - y_min) * scale_y;
+ let new_y = y + self.padding_y + (py - y_min) * scale_y + lead * scale_y;
let new_w = pw * scale_x;
- let new_h = ph * scale_y;
+ let new_h = (ph - lead) * scale_y;
child.layout(
Point { x: new_x, y: new_y },
diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index ad6191e..63a101e 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -479,13 +479,16 @@ impl Paint for MenuBar {
// — carving there too would cut a second lip into the same pixels. One wall
// straddling the boundary intrudes only half its width, so the cap is looser
// than the trough's.
+ // The wall stays inside the bar (`layout::carve_inside`).
let depth = crate::layout::bar_wall_width().min(rect.height * 0.6);
- ctx.recess_edges(rect, (0.0, 0.0, 0.0, 0.0), depth, (false, false, true, false));
+ let bar = Rect { height: rect.height - depth * 0.5, ..rect };
+ ctx.recess_edges(bar, (0.0, 0.0, 0.0, 0.0), depth, (false, false, true, false));
} else {
// Inset from the plate edge: a real trough, walled all round, its corners
// rounded by the roll itself.
let depth = crate::layout::bar_wall_width().min(rect.height * 0.4);
- ctx.recess(rect, (depth, depth, depth, depth), depth);
+ let (well, radii) = crate::layout::carve_inside(rect, (depth, depth, depth, depth), depth);
+ ctx.recess(well, radii, depth);
}
} else {
// Background: always the plain quad — the rounded-against-parent variant required a
@@ -506,7 +509,8 @@ impl Paint for MenuBar {
let trough = Rect { x: r.0, y: r.1 + (r.3 - trough_h) / 2.0, width: r.2, height: trough_h };
let radius = crate::layout::dropdown_corner_radius();
let depth = crate::layout::bevel_width().min(trough_h * 0.2);
- ctx.inset_plate(trough, (radius, radius, radius, radius), [0.0; 4], depth);
+ let (trough, radii) = crate::layout::carve_inside(trough, (radius, radius, radius, radius), depth);
+ ctx.inset_plate(trough, radii, [0.0; 4], depth);
if let Some(c) = fill {
ctx.rounded_rect(trough, radius, (true, true, true, true), c);
}
diff --git a/src/widget/container/scroll_box.rs b/src/widget/container/scroll_box.rs
index 8f4d2c8..f9175ab 100644
--- a/src/widget/container/scroll_box.rs
+++ b/src/widget/container/scroll_box.rs
@@ -43,11 +43,9 @@ pub fn paint_relief_scrollbar(
let depth = crate::layout::bevel_width().min(sb_w * 0.35);
let radii = (r, r, r, r);
use crate::scene::layout::Rect;
- pc.recess(
- Rect { x: sb_x, y: sb_track_y, width: sb_w, height: sb_track_h },
- radii,
- depth,
- );
+ let (track, track_radii) =
+ crate::layout::carve_inside(Rect { x: sb_x, y: sb_track_y, width: sb_w, height: sb_track_h }, radii, depth);
+ pc.recess(track, track_radii, depth);
pc.bevel(
Rect { x: sb_x, y: thumb_y, width: sb_w, height: thumb_h },
radii,
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index 29f2e1d..94a1014 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -957,8 +957,7 @@ impl Paint for TreeList {
// (the retired legacy_focus_highlight overlay).
if crate::layout::control_relief() {
let depth = crate::layout::bevel_width().min(h * 0.2);
- let well = Rect { x, y, width: w, height: h };
- let radii = (radius, radius, radius, radius);
+ let (well, radii) = crate::layout::carve_inside(Rect { x, y, width: w, height: h }, (radius, radius, radius, radius), depth);
if self.focused {
let hc = crate::color::highlight_primary_color();
pc.recess_tinted(well, radii, depth, [hc[0], hc[1], hc[2]]);
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 7c9655a..93834a1 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -505,7 +505,8 @@ pub mod context_menu {
frosted[3] = -crate::color::menu_opacity();
ctx.plate(rect, (r, r, r, r), frosted, depth);
} else {
- ctx.boss(rect, (r, r, r, r), depth);
+ let (plateau, radii) = crate::layout::carve_inside(rect, (r, r, r, r), depth);
+ ctx.boss(plateau, radii, depth);
}
if let Some(h_idx) = self.hovered_item {
diff --git a/src/widget/display/progress_bar.rs b/src/widget/display/progress_bar.rs
index 487bb7b..8280af6 100644
--- a/src/widget/display/progress_bar.rs
+++ b/src/widget/display/progress_bar.rs
@@ -54,7 +54,7 @@ impl Paint for ProgressBar {
// floor (past the wall's inner half-span), the carve comes after it so
// the walls' shading modulates what they cross.
let depth = crate::layout::bevel_width().min(rect.height * 0.2);
- let inset = depth * 0.5;
+ let inset = depth;
let floor = Rect { x: rect.x + inset, y: rect.y + inset, width: rect.width - 2.0 * inset, height: rect.height - 2.0 * inset };
let fill_w = floor.width * self.value.clamp(0.0, 1.0);
if fill_w > 0.0 {
@@ -65,7 +65,8 @@ impl Paint for ProgressBar {
colors::progress_fill(),
);
}
- ctx.recess(rect, (radius, radius, radius, radius), depth);
+ let (well, radii) = crate::layout::carve_inside(rect, (radius, radius, radius, radius), depth);
+ ctx.recess(well, radii, depth);
return;
}
// Track.
diff --git a/src/widget/display/status_bar.rs b/src/widget/display/status_bar.rs
index 54d0fbc..a3f8b10 100644
--- a/src/widget/display/status_bar.rs
+++ b/src/widget/display/status_bar.rs
@@ -143,7 +143,9 @@ impl Paint for StatusBar {
// Capped against the bar's own height so a deep DE-wide roll can't swallow it
// (a single wall straddling the boundary intrudes only half its width).
let depth = crate::layout::bar_wall_width().min(rect.height * 0.6);
- ctx.recess_edges(rect, (0.0, 0.0, 0.0, 0.0), depth, (true, false, false, false));
+ // The wall stays inside the bar (`layout::carve_inside`).
+ let bar = Rect { y: rect.y + depth * 0.5, height: rect.height - depth * 0.5, ..rect };
+ ctx.recess_edges(bar, (0.0, 0.0, 0.0, 0.0), depth, (true, false, false, false));
} else {
// Always the plain background quad — the rounded-against-parent variant required a
// root plate parent, which no longer exists.
diff --git a/src/widget/display/usage_bar.rs b/src/widget/display/usage_bar.rs
index c7e4a68..03d0019 100644
--- a/src/widget/display/usage_bar.rs
+++ b/src/widget/display/usage_bar.rs
@@ -66,13 +66,14 @@ impl Paint for UsageBar {
// the carve, rounded like the sliders' tracks.
let radius = crate::layout::slider_corner_radius();
let depth = crate::layout::bevel_width().min(rect.height * 0.2);
- let inset = depth * 0.5;
+ let inset = depth;
let floor = Rect { x: rect.x + inset, y: rect.y + inset, width: rect.width - 2.0 * inset, height: rect.height - 2.0 * inset };
let fill_w = floor.width * self.value;
if fill_w > 0.0 {
ctx.rounded_rect(Rect { width: fill_w, ..floor }, radius.min(floor.height / 2.0), (true, true, true, true), self.fill_color);
}
- ctx.recess(rect, (radius, radius, radius, radius), depth);
+ let (well, radii) = crate::layout::carve_inside(rect, (radius, radius, radius, radius), depth);
+ ctx.recess(well, radii, depth);
return;
}
ctx.quad(rect, self.bg_color);
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index b2a3ee2..21d06d0 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -379,7 +379,8 @@ impl Paint for Button {
// surfaces, and the edges-only groove would stack a permanent carved
// ring on every idle row of a list.
if let Some((face, r, depth, c)) = self.inset_face(rect) {
- ctx.inset_plate(face, (r, r, r, r), c, depth);
+ let (trough, radii) = crate::layout::carve_inside(face, (r, r, r, r), depth);
+ ctx.inset_plate(trough, radii, c, depth);
} else {
// ListRow also skips the border idiom below: it draws the border
// color as a FULL rect with the fill inset over it, which only
diff --git a/src/widget/input/button_strip.rs b/src/widget/input/button_strip.rs
index b2f977a..54d8451 100644
--- a/src/widget/input/button_strip.rs
+++ b/src/widget/input/button_strip.rs
@@ -351,7 +351,8 @@ impl crate::widget::Paint for ButtonStrip {
let depth = if self.recessed {
let short = if self.vertical { sw } else { sh };
let depth = crate::layout::bevel_width().min(short * 0.2);
- pc.recess(Rect { x: sx, y: sy, width: sw, height: sh }, (radius, radius, radius, radius), depth);
+ let (well, radii) = crate::layout::carve_inside(Rect { x: sx, y: sy, width: sw, height: sh }, (radius, radius, radius, radius), depth);
+ pc.recess(well, radii, depth);
depth
} else {
0.0
@@ -377,7 +378,8 @@ impl crate::widget::Paint for ButtonStrip {
let inset = depth * 0.5;
let plateau = Rect { x: r.0 + inset, y: r.1 + inset, width: (r.2 - 2.0 * inset).max(0.0), height: (r.3 - 2.0 * inset).max(0.0) };
let pr = (radius - inset).max(0.0);
- pc.boss(plateau, (pr, pr, pr, pr), depth);
+ let (plateau, radii) = crate::layout::carve_inside(plateau, (pr, pr, pr, pr), depth);
+ pc.boss(plateau, radii, depth);
}
if self.vertical {
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 5f986b9..9c75778 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -324,13 +324,14 @@ impl Toggle {
let radius = crate::layout::toggle_corner_radius();
let depth = crate::layout::bevel_width().min(rect.height * 0.2);
if let Some(btn) = self.slide_button(rect) {
+ let (btn, radii) = crate::layout::carve_inside(btn, (radius, radius, radius, radius), depth);
return vec![ReliefCarve {
kind: CarveKind::Trough,
x: btn.x,
y: btn.y,
w: btn.width,
h: btn.height,
- radii: (radius, radius, radius, radius),
+ radii,
depth,
edges: (true, true, true, true),
}];
@@ -338,7 +339,12 @@ impl Toggle {
if !self.raised {
return Vec::new();
}
- self.rocker_reliefs(rect)
+ // The carves stay inside the pill (`layout::carve_inside`): the halves are
+ // cut from the inset rect (the hinge keeps the pill's centre line), the
+ // faces (`flat_faces`) keep the full one — the walls shade over their edges.
+ let (inset, radii) = crate::layout::carve_inside(rect, (radius, radius, radius, radius), depth);
+ let r = radii.0;
+ self.rocker_reliefs(inset)
.into_iter()
.map(|(half, radii, walls, raised)| ReliefCarve {
kind: if raised { CarveKind::Boss } else { CarveKind::Recess { tint: None } },
@@ -346,7 +352,7 @@ impl Toggle {
y: half.y,
w: half.width,
h: half.height,
- radii,
+ radii: (radii.0.min(r), radii.1.min(r), radii.2.min(r), radii.3.min(r)),
depth,
edges: walls,
})
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index 26b7936..6af8d99 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -119,7 +119,12 @@ impl ColorSelector {
let field_w = rect.width * 0.65;
let radius = crate::layout::textbox_corner_radius();
let depth = crate::layout::bevel_width().min(well_h * 0.2);
- Some((rect.x, rect.y, field_w, well_h, radius, depth))
+ let (well, radii) = crate::layout::carve_inside(
+ Rect { x: rect.x, y: rect.y, width: field_w, height: well_h },
+ (radius, radius, radius, radius),
+ depth,
+ );
+ Some((well.x, well.y, well.width, well.height, radii.0, depth))
}
}
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 59857e6..a9a2582 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -475,6 +475,17 @@ impl Dropdown {
}
// Flush inset plate: groove ring down, beveled lip back up, face
// level with the surface (transparent raw fill = edges only).
+ // Carved INSIDE the trigger's rect (`layout::carve_inside`): the
+ // groove's outer edge lands on the rect, so everything below —
+ // which outsets the ring by half the depth — starts from the rect
+ // inset by that much, radii reduced to keep the outer silhouette.
+ let (inner, r4t) = crate::layout::carve_inside(
+ Rect { x, y, width: w, height: visual_h },
+ (r4[0], r4[1], r4[2], r4[3]),
+ depth,
+ );
+ let (x, y, w, visual_h) = (inner.x, inner.y, inner.width, inner.height);
+ let r4 = [r4t.0, r4t.1, r4t.2, r4t.3];
let face = if raw_bg[3] > 0.001 { bg_color } else { [0.0; 4] };
let strip = self.label_top();
if strip > 0.0 {
@@ -1027,7 +1038,12 @@ impl Paint for Dropdown {
};
if self.raised {
let depth = crate::layout::bevel_width().min(rect.height * 0.2);
- pc.inset_plate(face, ux, uy, uw, uh, radius, depth);
+ let (t, tr) = crate::layout::carve_inside(
+ crate::scene::layout::Rect { x: ux, y: uy, width: uw, height: uh },
+ (radius, radius, radius, radius),
+ depth,
+ );
+ pc.inset_plate(face, t.x, t.y, t.width, t.height, tr.0, depth);
} else {
pc.rect_with_radius(self.border_color(), ux, uy, uw, uh, radius);
pc.rect_with_radius(face, ux + 1.0, uy + 1.0, uw - 2.0, uh - 2.0, (radius - 1.0).max(0.0));
diff --git a/src/widget/input/font_selector.rs b/src/widget/input/font_selector.rs
index 3af17ec..30a7a78 100644
--- a/src/widget/input/font_selector.rs
+++ b/src/widget/input/font_selector.rs
@@ -158,7 +158,8 @@ impl Paint for FontSelector {
// The closed-dropdown chrome: a flush inset trough with a transparent
// face, the state fill rounded to sit inside it.
let depth = crate::layout::bevel_width().min(rect.height * 0.2);
- ctx.inset_plate(rect, (r, r, r, r), [0.0; 4], depth);
+ let (trough, radii) = crate::layout::carve_inside(rect, (r, r, r, r), depth);
+ ctx.inset_plate(trough, radii, [0.0; 4], depth);
let wash = if self.pressed {
Some(colors::button_press_color())
} else if self.hovered {
diff --git a/src/widget/input/keybind_recorder.rs b/src/widget/input/keybind_recorder.rs
index 8cf55db..c8e3b4f 100644
--- a/src/widget/input/keybind_recorder.rs
+++ b/src/widget/input/keybind_recorder.rs
@@ -69,12 +69,12 @@ impl Paint for KeybindRecorder {
if self.recessed {
let radius = crate::layout::textbox_corner_radius();
let depth = crate::layout::bevel_width().min(rect.height * 0.2);
- let radii = (radius, radius, radius, radius);
+ let (well, radii) = crate::layout::carve_inside(rect, (radius, radius, radius, radius), depth);
if self.recording {
let hc = crate::color::highlight_primary_color();
- ctx.recess_tinted(rect, radii, depth, [hc[0], hc[1], hc[2]]);
+ ctx.recess_tinted(well, radii, depth, [hc[0], hc[1], hc[2]]);
} else {
- ctx.recess(rect, radii, depth);
+ ctx.recess(well, radii, depth);
}
self.paint_text(rect, ctx);
return;
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index 2f4e205..1914c13 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -1340,7 +1340,8 @@ impl Paint for Ramp {
pc.border(graph, radii, [0.0; 4], [0.0, 0.0, 0.0, a], t);
}
let depth = crate::layout::bevel_width().min(graph.height * 0.2);
- pc.recess(graph, radii, depth);
+ let (well, radii) = crate::layout::carve_inside(graph, radii, depth);
+ pc.recess(well, radii, depth);
if !self.controls_collapsed {
let dummy = UiContext::new();
self.preset_dropdown.paint_self(&dummy, pc);
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index ed065c5..90c44e8 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -151,7 +151,12 @@ impl Slider {
let g = self.geom(rect);
let radius = crate::layout::slider_corner_radius();
let depth = crate::layout::bevel_width().min(g.h * 0.2);
- Some((g.track_x, g.y, g.track_w, g.h, radius, depth))
+ let (well, radii) = crate::layout::carve_inside(
+ Rect { x: g.track_x, y: g.y, width: g.track_w, height: g.h },
+ (radius, radius, radius, radius),
+ depth,
+ );
+ Some((well.x, well.y, well.width, well.height, radii.0, depth))
}
/// The thumb knob's circle (cx, cy, radius, color) for hosts that draw this
@@ -521,12 +526,12 @@ impl Paint for Slider {
}
// 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.
+ // well's flat floor (past the wall, which is carved inside the track), so
+ // the liquid sits in the well instead of climbing its walls.
let thumb_x = g.track_x + self.value * (g.track_w - g.thumb_size);
if let Some(fill_color) = colors::slider_fill() {
let (fx, fy, fmax_w, fh) = if self.recessed {
- let inset = recess_t * 0.5;
+ let inset = recess_t;
(g.track_x + inset, g.y + inset, g.track_w - 2.0 * inset, g.h - 2.0 * inset)
} else {
(g.track_x, g.y, g.track_w, g.h)
@@ -869,6 +874,11 @@ impl Adapted<RangeSlider> {
/// left-only bridge carries the left wall across the fillet span. Unlabeled, one
/// plain recess.
pub(crate) fn carve_labeled_well(ctx: &mut PaintCtx, track: Rect, strip: f32, label_w: f32, radius: f32, depth: f32) {
+ // Carve INSIDE the track (`layout::carve_inside`): every piece derives from
+ // this rect, so the whole composition — tab, fillet, bridge — moves in with it
+ // and the outer walls land on the track's edges.
+ let (track, radii) = crate::layout::carve_inside(track, (radius, radius, radius, radius), depth);
+ let radius = radii.0;
let track_end = track.x + track.width;
if strip > 0.0 {
// Labeled: the label sits in a CARVE-OUT tab, the section-
@@ -877,18 +887,28 @@ pub(crate) fn carve_labeled_well(ctx: &mut PaintCtx, track: Rect, strip: f32, la
// track's well; the well's top wall picks up right of the
// tab's throat.
let inset = crate::layout::DETACHED_LABEL_INSET; // the label's x offset
- let tab_w = (label_w + 2.0 * inset).max(2.0 * radius + 8.0).min(track.width);
+ let fr = 6.0_f32.min(strip * 0.5);
+ let mut tab_w = (label_w + 2.0 * inset).max(2.0 * radius + 8.0).min(track.width);
+ // A throat too short for the fillet and a run of top wall past it reads
+ // as a notch beside the tab: then the tab spans the whole track (the
+ // Slider2D's pad, a narrow slider) and the well's top wall is the tab's.
+ if track.width - tab_w < 2.0 * fr + 4.0 {
+ tab_w = track.width;
+ }
let tab_r = track.x + tab_w;
// The labeled-Dropdown composition: pieces extend `depth`
// past interior seams (host-fade crossfade), the tab's right
// wall ends at the fillet's vertical tangent (or it ghosts
// through the arc), and a left-only bridge carries the left
// wall across the fillet span.
- let fr = 6.0_f32.min(strip * 0.5);
- let filleted = track_end - tab_r > fr + 4.0;
+ let filleted = track_end - tab_r > 2.0 * fr + 4.0;
let tab_bottom = if filleted { track.y - fr } else { track.y };
+ // The tab's crossfade extension past its bottom seam is capped at the
+ // fillet radius: a deep wall (a tall well's) otherwise ran on below the
+ // well's top edge as a stub beside the fillet.
+ let ext = if filleted { depth.min(fr) } else { depth };
ctx.recess_edges(
- Rect { x: track.x, y: track.y - strip, width: tab_w, height: tab_bottom - (track.y - strip) + depth },
+ Rect { x: track.x, y: track.y - strip, width: tab_w, height: tab_bottom - (track.y - strip) + ext },
(radius, radius.min(strip * 0.5), 0.0, 0.0),
depth,
(true, true, false, true),
diff --git a/src/widget/input/slider2d.rs b/src/widget/input/slider2d.rs
index 0fdba74..a413114 100644
--- a/src/widget/input/slider2d.rs
+++ b/src/widget/input/slider2d.rs
@@ -92,11 +92,16 @@ impl Layout for Slider2D {
}
- /// A 64px pad, or wide enough for its label's carve-out tab (the tab is clipped
- /// to the pad and the label spilled past a 64px one).
+ /// A 64px pad, or wide enough for its label's carve-out tab plus a filleted
+ /// throat beside it (the tab is clipped to the pad; the label spilled past a
+ /// 64px one, and a throat too short for the fillet reads as a notch).
fn intrinsic_size(&self) -> Option<Size> {
let label_w = crate::widget::input::slider::detached_label_width(&self.label);
- let tab_w = if label_w > 0.0 { label_w + 2.0 * crate::layout::DETACHED_LABEL_INSET + 16.0 } else { 0.0 };
+ let tab_w = if label_w > 0.0 {
+ label_w + 2.0 * crate::layout::DETACHED_LABEL_INSET + 24.0 + crate::layout::bevel_width()
+ } else {
+ 0.0
+ };
Some(Size::new(64.0_f32.max(tab_w), 64.0))
}
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 8a7522a..b2a60a5 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -169,7 +169,9 @@ impl Spinbox {
}
let radius = crate::layout::spinbox_corner_radius();
let depth = crate::layout::bevel_width().min(g.h * 0.2);
- let well = (Rect { x: g.x, y: g.y, width: g.w, height: g.h }, radius, depth);
+ let (well_rect, well_radii) =
+ crate::layout::carve_inside(Rect { x: g.x, y: g.y, width: g.w, height: g.h }, (radius, radius, radius, radius), depth);
+ let well = (well_rect, well_radii.0, depth);
if g.btn_w <= 0.0 || g.btn_h <= 0.0 {
return Some((well, None));
}
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index d22bdae..cc29a14 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1219,11 +1219,12 @@ impl TextBox {
height: self.rect.height - top,
};
let depth = crate::layout::bevel_width().min(well.height * 0.2);
+ let (well, radii) = crate::layout::carve_inside(well, (radius, radius, radius, radius), depth);
let tint = self.editing.then(|| {
let hc = crate::color::highlight_primary_color();
[hc[0], hc[1], hc[2]]
});
- Some((well, radius, depth, tint))
+ Some((well, radii.0, depth, tint))
}
}
diff --git a/src/widget/input/trackpad.rs b/src/widget/input/trackpad.rs
index ab411a0..8f21ffd 100644
--- a/src/widget/input/trackpad.rs
+++ b/src/widget/input/trackpad.rs
@@ -122,7 +122,8 @@ impl Paint for Trackpad {
let radius = crate::layout::textbox_corner_radius();
let depth = crate::layout::bevel_width().min(visual_h * 0.2);
ctx.rounded_rect(area, radius, (true, true, true, true), [0.0, 0.0, 0.0, 0.18]);
- ctx.recess(area, (radius, radius, radius, radius), depth);
+ let (well, radii) = crate::layout::carve_inside(area, (radius, radius, radius, radius), depth);
+ ctx.recess(well, radii, depth);
} else {
// 1. Background
ctx.quad(area, [0.11, 0.11, 0.16, 0.85]);
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 0b93659..53c503d 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -209,8 +209,9 @@ pub trait WidgetHost {
/// The height of the detached-label strip above this widget's content: zero for
/// unlabeled and inline-label widgets. A widget's occupied rect is its content plus
/// this strip, whichever legacy convention its `set_rect` follows; `layout` lands
- /// the content at the origin and the strip above it, in the gap a strategy leaves
- /// between rows (`layout::CONTROL_GAP` holds one).
+ /// the content at the origin and the strip above it. A strategy reserves that
+ /// row above every child's content (`container_layout::label_lead`) and puts
+ /// `layout::CONTROL_GAP` between the blocks.
fn label_strip(&self) -> f32 { 0.0 }
fn mark_dirty(&mut self, ctx: &mut UiContext) {