git.lucas.co / cce-mail
mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git

commit9701dc4c470e569c0cce51800c08d7db29fe2ae6
parent9e6eab7264
authorLucas Galante <[email protected]>
date2026-09-19 00:06
Both bars: behind a frosted plate, fading to the fore

The panes are now transparent AND blurred — their fill carries the
blur-behind sentinel (a negative alpha), so shader2d samples the frosted
backdrop under them. That is what makes a bar parked underneath read as
behind the pane instead of as a stripe painted across it, which is what
the last change removed the sunk copy to avoid.

So both bars are drawn twice: a behind copy at full strength under the
plate, and a fore copy over the content at the activity's fade. The
detail pane's bar also moves to its plate's centre line, as the list's
did, and stops reserving a text lane — it rides over the body the way
the list's rides over its rows, so the inner padding is a true 20 on the
right as well.

Ordering matters and cost a debugging pass: only the FIRST blur plate in
a frame takes a backdrop snapshot, the second reusing it, so a bar drawn
between the two plates was frosted over a backdrop that predated it and
came out invisible. Both bars now go down ahead of both plates.

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

 src/main.rs | 122 ++++++++++++++++++++++++++++++++++++++++--------------------
 1 file changed, 82 insertions(+), 40 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 7c3a8ac..b76ddc6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -615,6 +615,18 @@ fn pane_gap() -> f32 {
     cce_ui::layout::root_plate_gap()
 }
 
+/// The pane plates' fill: the DE's list colour carrying the blur-behind
+/// sentinel — a NEGATIVE alpha, which is how shader2d is told to sample the
+/// frosted backdrop beneath this surface rather than compositing flatly over
+/// it. The plate is then transparent AND blurred, which is what lets a
+/// scrollbar parked underneath read as genuinely behind it instead of as a
+/// stripe drawn through it.
+fn pane_fill() -> [f32; 4] {
+    let mut c = cce_ui::color::list_bg_color();
+    c[3] = -c[3].abs();
+    c
+}
+
 /// The padding INSIDE a pane plate, between its rim and its content
 /// (`style.surface.plate.padding`, DE-wide — the pane rung's own value, 20
 /// here, where the root rung's [`pane_pad`] is 12). Uniform on all four
@@ -3019,23 +3031,58 @@ impl ClearEmailApp {
         self.detail_pane_geom().1 + pane_inner_pad()
     }
 
-    /// The body scrollbar's lane inside the plate: (x, width). Independent of
-    /// whether the bar is currently needed, so the text column keeps its width
-    /// and does not reflow the moment the body grows past the pane.
+    /// The body scrollbar's lane: (x, width), on the plate's CENTRE line, as
+    /// the list's bar rides its plate's.
     fn detail_sb_lane(&self) -> (f32, f32) {
         let (px, _, pw, _) = self.detail_pane_geom();
         // Page-level bar width (the settings page look), as `parameters_bg`
         // and the settings pages widen theirs.
         let sb_w = cce_ui::layout::scrollbar_width() * 1.6;
-        (px + pw - sb_w - cce_ui::layout::scrollbar_inset(), sb_w)
+        (px + (pw - sb_w) / 2.0, sb_w)
     }
 
-    /// Width of the detail pane's text column: from the content's left edge to
-    /// a gutter short of the scrollbar lane.
+    /// Width of the detail pane's text column: the whole plate inside its
+    /// padding. No lane is reserved for the bar any more — it rides over the
+    /// text the way the list's rides over its rows, and is only in front
+    /// while scrolling.
     fn detail_content_w(&self) -> f32 {
+        let (px, _, pw, _) = self.detail_pane_geom();
         let (_, _, detail_x) = self.split_geom();
-        let (sb_x, _) = self.detail_sb_lane();
-        (sb_x - 6.0 - detail_x).max(100.0)
+        ((px + pw - pane_inner_pad()) - detail_x).max(100.0)
+    }
+
+    /// The detail pane's bar as pills at `alpha`. Emitted TWICE a frame: once
+    /// under the plate at full strength (the copy seen through the frost),
+    /// once over the body as the fore copy fades in and out.
+    fn push_body_scrollbar(&self, pc: &mut cce_ui::scene::paint::PaintCtx, alpha: f32) {
+        let a = alpha.clamp(0.0, 1.0);
+        if a <= 0.001 {
+            return;
+        }
+        let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.body_scrollbar_geom()
+        else {
+            return;
+        };
+        let dim = |mut c: [f32; 4]| {
+            c[3] *= a;
+            c
+        };
+        use cce_ui::scene::layout::Rect;
+        let all = (true, true, true, true);
+        // Pills, as `ScrollRegion::push_scrollbar_prims` draws them: radius is
+        // half the shorter extent.
+        pc.rounded_rect(
+            Rect { x: sb_x, y: track_y, width: sb_w, height: track_h },
+            sb_w.min(track_h) * 0.5,
+            all,
+            dim(cce_ui::color::scrollbar_track_color()),
+        );
+        pc.rounded_rect(
+            Rect { x: sb_x, y: thumb_y, width: sb_w, height: thumb_h },
+            sb_w.min(thumb_h) * 0.5,
+            all,
+            dim(cce_ui::color::scrollbar_thumb_color()),
+        );
     }
 
     /// Move the selection `delta` rows through the list as it is displayed,
@@ -4794,11 +4841,29 @@ impl Application for ClearEmailApp {
         // cards span its full width — does.
         {
             let (px, py, pw, ph) = self.detail_pane_geom();
+            // BOTH panes' bars go down here, before EITHER plate, each at
+            // full strength: a frosted plate laid over one is what dims and
+            // blurs it, and that is what reads as the bar being behind the
+            // pane rather than drawn across it.
+            //
+            // The list's belongs to the block further down, with its own
+            // plate, and sat there until it came out invisible. A blur plate
+            // samples the frame so far, but only the FIRST one in a frame
+            // takes that snapshot — the second reuses it (the renderer's
+            // consecutive-blur-plate optimisation, which a non-blur draw
+            // between them did not defeat here). So the detail plate was
+            // frosting over a backdrop from before the list's bar existed.
+            // Emitting both bars ahead of both plates puts them in whichever
+            // snapshot gets taken. The two panes do not overlap, so each
+            // plate carrying the other's bar in its sampled backdrop costs
+            // nothing.
+            self.push_body_scrollbar(&mut *quads.pc, 1.0);
+            self.email_list.push_scrollbar_prims(&mut *quads.pc);
             quads.pc.rounded_rect(
                 cce_ui::scene::layout::Rect { x: px, y: py, width: pw, height: ph },
                 cce_ui::layout::list_corner_radius(),
                 (true, true, true, true),
-                cce_ui::color::list_bg_color(),
+                pane_fill(),
             );
         }
 
@@ -4808,16 +4873,8 @@ impl Application for ClearEmailApp {
             cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.search_box, &mut *quads.pc);
         }
         {
-            // No sunk layer is emitted here. A frameless sink-behind region
-            // leaves the layering to its host, and the host's only other
-            // option — the bar under the plate fill — does not hide it: this
-            // plate is translucent, so a sunk bar reads straight through the
-            // rows as a permanent stripe, which riding the centre line made
-            // impossible to miss. Behind the list means out of sight, so the
-            // bar is drawn only once it rises (`push_prims`, after the rows),
-            // the same treatment the detail pane's own bar already gets. The
-            // opaque-plate hosts that DO show a sunk bar through are what
-            // `push_scrollbar_prims` exists for.
+            // The list's behind copy is NOT emitted here — it goes down with
+            // the detail pane's, ahead of both plates; see there.
             // The list is a plate, so it wears the DE's list radius rather
             // than square corners — `ScrollRegion`'s own framed paint draws
             // its surface at exactly this getter.
@@ -4830,7 +4887,7 @@ impl Application for ClearEmailApp {
                 },
                 cce_ui::layout::list_corner_radius(),
                 (true, true, true, true),
-                cce_ui::color::list_bg_color(),
+                pane_fill(),
             );
         }
 
@@ -4969,26 +5026,11 @@ impl Application for ClearEmailApp {
                         // scroll shows it, 0.7s of quiet hides it (the window
                         // bg is opaque — no plate for a sunk layer to show
                         // through, so sunk is simply not drawn).
-                        if self.body_sb_activity.raised() {
-                            if let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.body_scrollbar_geom() {
-                                // Pills, as `ScrollRegion::push_scrollbar_prims`
-                                // draws them: radius is half the shorter extent.
-                                use cce_ui::scene::layout::Rect;
-                                let all = (true, true, true, true);
-                                quads.pc.rounded_rect(
-                                    Rect { x: sb_x, y: track_y, width: sb_w, height: track_h },
-                                    sb_w.min(track_h) * 0.5,
-                                    all,
-                                    cce_ui::color::scrollbar_track_color(),
-                                );
-                                quads.pc.rounded_rect(
-                                    Rect { x: sb_x, y: thumb_y, width: sb_w, height: thumb_h },
-                                    sb_w.min(thumb_h) * 0.5,
-                                    all,
-                                    cce_ui::color::scrollbar_thumb_color(),
-                                );
-                            }
-                        }
+                        // The fore copy, faded in over the body. Driven by the
+                        // activity's fade rather than its latch, so it keeps
+                        // drawing all the way out instead of being cut off the
+                        // instant the bar stops counting as raised.
+                        self.push_body_scrollbar(&mut *quads.pc, self.body_sb_activity.fade());
 
                         quads.pc.text_boxed(
                             email.body.clone(),