git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit83a3ca03a365a98c02b3c5d0be1e5061cff5212e
parentf3497bbd32
authorLucas Galante <[email protected]>
date2026-08-24 11:23
relief: the flat-path bridge carves a TextBox's well too

`render_widget` offered only a Dropdown's flush inset trough through
`RenderTarget::inset_plate`, so on a flat host every text field rendered as
bare text: a recessed TextBox with the DE's transparent face draws NO border
and NO background (`paint`'s `bare` branch) — the carve is its entire
decoration, and the carve is exactly what `all_quads` cannot carry. That is
why the settings app's Browser page had a naked Homepage line sitting under
two decorated dropdowns.

A TextBox's chrome is a WELL, not a trough — a trough is a seam about the
boundary with the face left level, a well drops the whole interior — so this
is a second hook, `RenderTarget::recess`, not the existing one at another
rect. Its default is a no-op rather than a fill: the face is transparent by
design (the host surface IS the well floor), so a host that cannot carve has
nothing truthful to draw, and a solid box would paint every field a slab it
never had. Only a host that overrides it changes (today: the settings app).

The geometry comes from the widget: `TextBox::well` is now the single source
both `paint` and the bridge read, so the flat host's well cannot drift from
the real one through label layout, the side-label inset, or the focus tint.

Verified in a headless shadow session against the pixels, not the prim
stream: the Homepage row's top edge goes flat (9,25,13) → lit lip (69,73,70)
→ shadowed inner wall (5,17,8), the recess profile, while the dropdowns keep
their trough profile (dark 6,19,9 → bright 130,131,130) — the two carves stay
distinct. Focused, the same edge lights cyan (39,86,84) against the warm
counter-shadow (93,12,5): `recess_tinted` through the new hook.

cce-files is untouched by this — its host is its own `PageContent`, which
takes the no-op default, so its app-side `relief_recessed` compensations
stay the only carve there (measured: byte-identical well profile before and
after, 105/77/65/71).

 src/layout.rs                | 24 ++++++++++++++++++++++++
 src/scene/paint.rs           |  8 ++++++++
 src/widget/input/text_box.rs | 44 +++++++++++++++++++++++++++++++++++---------
 3 files changed, 67 insertions(+), 9 deletions(-)

diff --git a/src/layout.rs b/src/layout.rs
index 3d003f0..f54795c 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -3476,6 +3476,17 @@ pub trait RenderTarget {
     fn inset_plate(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, _depth: f32) {
         self.rect_with_radius(color, x, y, w, h, radius);
     }
+    /// A sunken well ([`crate::scene::paint::PaintCtx::recess`]) — the carve a
+    /// TextBox leaves, offered here for the same reason as `inset_plate`: the
+    /// legacy `all_quads` stream carries no relief prims, so a flat-path host
+    /// never sees it. `tint` is the focus accent (`recess_tinted`).
+    ///
+    /// The default is deliberately a NO-OP, not a fill: a recessed control's
+    /// face is transparent by design (the host surface IS the well floor), so
+    /// the carve is the entire decoration — a host that can't carve has
+    /// nothing truthful to draw, and a solid box here would paint every text
+    /// field a flat slab it never had.
+    fn recess(&mut self, _x: f32, _y: f32, _w: f32, _h: f32, _radius: f32, _depth: f32, _tint: Option<[f32; 3]>) {}
     /// Whether this host renders sections as sunken wells (the designer idiom).
     /// `SectionContext` then lays the title out left-aligned over its tab box
     /// instead of centered on the top border.
@@ -3571,6 +3582,19 @@ pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut
         );
     }
 
+    // The same gap for the TextBox, whose chrome is a WELL rather than a
+    // trough (`TextBox::well` — the geometry it carves in `paint`, asked for
+    // here so the two cannot drift). A recessed box with the DE's transparent
+    // face draws no border or background at all — the carve is the only thing
+    // marking the field — so on a flat host the row rendered as bare text.
+    if control_relief() {
+        if let Some(tb) = w.as_any().downcast_ref::<crate::widget::TextBox>() {
+            if let Some((well, radius, depth, tint)) = tb.well() {
+                pc.recess(well.x, well.y, well.width, well.height, radius, depth, tint);
+            }
+        }
+    }
+
     for (qx, qy, qw, qh, qc) in w.all_quads(ctx) {
         let extra_corners = (
             corners.0 && qx <= wx + 1.5 && qy <= wy + 1.5,
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 3c285ca..19bd99e 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -881,6 +881,14 @@ impl crate::layout::RenderTarget for PaintCtx {
     fn inset_plate(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32) {
         PaintCtx::inset_plate(self, Rect { x, y, width: w, height: h }, (radius, radius, radius, radius), color, depth);
     }
+    fn recess(&mut self, x: f32, y: f32, w: f32, h: f32, radius: f32, depth: f32, tint: Option<[f32; 3]>) {
+        let rect = Rect { x, y, width: w, height: h };
+        let radii = (radius, radius, radius, radius);
+        match tint {
+            Some(t) => PaintCtx::recess_tinted(self, rect, radii, depth, t),
+            None => PaintCtx::recess(self, rect, radii, depth),
+        }
+    }
 }
 
 #[cfg(test)]
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 1d648e9..9cac127 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1012,6 +1012,36 @@ impl TextBox {
         }
         labels
     }
+
+    /// The well this TextBox carves, as `(rect, corner radius, depth, focus
+    /// tint)` — `None` when it draws no relief at all (square-cornered legacy
+    /// geometry, `control_relief` off, or a box that draws no background).
+    ///
+    /// The SINGLE source for that geometry: `paint` carves it here, and the
+    /// flat-path bridge in `layout::render_widget` re-offers the same rect
+    /// through [`crate::layout::RenderTarget::recess`] for hosts that consume
+    /// `all_quads` and so never see the carve. A second copy of this math in
+    /// the bridge is exactly how the two would drift apart.
+    pub fn well(&self) -> Option<(Rect, f32, f32, Option<[f32; 3]>)> {
+        let radius = crate::layout::textbox_corner_radius();
+        if radius <= 0.0 || !self.recessed || !self.draw_bg_border {
+            return None;
+        }
+        let top = self.label_top();
+        let label_x = side_offset(&self.label);
+        let well = Rect {
+            x: self.rect.x + label_x,
+            y: self.rect.y + top,
+            width: self.rect.width - label_x,
+            height: self.rect.height - top,
+        };
+        let depth = crate::layout::bevel_width().min(well.height * 0.2);
+        let tint = self.editing.then(|| {
+            let hc = crate::color::highlight_primary_color();
+            [hc[0], hc[1], hc[2]]
+        });
+        Some((well, radius, depth, tint))
+    }
 }
 
 impl Adapted<TextBox> {
@@ -1326,17 +1356,13 @@ impl Paint for TextBox {
                         bg_color,
                     );
                 }
-                if self.recessed {
+                if let Some((well, r, depth, tint)) = self.well() {
                     // Focus lights the well's rim in the highlight accent (with
                     // the shader's complementary shadow) — the TreeList treatment.
-                    let depth = crate::layout::bevel_width().min(visual_h * 0.2);
-                    let well = Rect { x, y: self.rect.y + top, width: w, height: visual_h };
-                    let radii = (radius, radius, radius, radius);
-                    if self.editing {
-                        let hc = crate::color::highlight_primary_color();
-                        ctx.recess_tinted(well, radii, depth, [hc[0], hc[1], hc[2]]);
-                    } else {
-                        ctx.recess(well, radii, depth);
+                    let radii = (r, r, r, r);
+                    match tint {
+                        Some(t) => ctx.recess_tinted(well, radii, depth, t),
+                        None => ctx.recess(well, radii, depth),
                     }
                 }
             }