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

commitc5477daa62efb2d41f31e6e2dd62eed567d9329b
parentb4999eaa83
authorLucas Galante <[email protected]>
date2026-09-01 08:24
fix: partially clipped message cards render cut, not culled

The list's ScrollRegion copy is deleted for the toolkit's
cce_ui::widget::ScrollRegion (cce-ui@764d9e7), whose get_item_draw_y
returns every card INTERSECTING the viewport. The card pass (button
walk + unread dots) now runs under a PaintCtx clip on the list
viewport, and the four hand-emitted row labels carry viewport bounds
(the shared labels drain is boundless — an edge card's text bled into
the menubar without them). Clicking an edge card's visible sliver
selects it, since the buttons now carry their true rects.

Verified in a shadow session with a seeded 15-message store:
before, scrolling left blank bands at both viewport edges where
partial cards vanished; after, edge cards render cut and a sliver
click selects the right message.

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

 src/main.rs          |  93 ++++++++++------
 src/scroll_region.rs | 304 ---------------------------------------------------
 2 files changed, 62 insertions(+), 335 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 1c7da2c..e70c6bb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,8 +1,7 @@
-mod scroll_region;
 /// Embedded WPE WebKit for the HTML mail view — see src/wpe/mod.rs.
 #[cfg(feature = "wpe")]
 mod wpe;
-use scroll_region::ScrollRegion;
+use cce_ui::widget::ScrollRegion;
 use wayland_client::QueueHandle;
 use cce_ui::cosmic_text::FontSystem;
 use cce_ui::engine::{Application, CursorIcon, EngineState, LogicalPosition, LogicalSize, WindowSettings};
@@ -3387,6 +3386,18 @@ impl ClearEmailApp {
                 })
                 .collect();
 
+            // Row labels carry the list-viewport bounds, unlike the other
+            // hand-emitted labels: `get_item_draw_y` returns PARTIALLY visible
+            // cards (the toolkit ScrollRegion's intersection contract), so an
+            // edge card's text must be cut at the viewport instead of bleeding
+            // into the menubar above or the frame below. Emitted straight into
+            // `pc` — the shared `labels` drain is boundless.
+            let list_bounds = Some([
+                list_x,
+                self.email_list.viewport_y,
+                list_x + list_w,
+                self.email_list.viewport_y + self.email_list.viewport_h,
+            ]);
             for (idx, email) in filtered.iter().enumerate() {
                 if let Some(draw_y) = self.email_list.get_item_draw_y(idx, 0.0) {
                     // Sender — recipient on sent rows (every sent mail is
@@ -3396,45 +3407,53 @@ impl ClearEmailApp {
                     } else {
                         email.from.clone()
                     };
-                    labels.push(TextLabel {
-                        text: ellipsize(&row_head, fit(21.0)),
-                        x: list_x + 20.0,
-                        y: draw_y + 6.0,
-                        font_size: 11.0,
-                        color: if !email.read { [0xff, 0xff, 0xff] } else { [0xb0, 0xb0, 0xb8] },
-                    });
+                    pc.text_with(
+                        ellipsize(&row_head, fit(21.0)),
+                        list_x + 20.0,
+                        draw_y + 6.0,
+                        11.0,
+                        if !email.read { [0xff, 0xff, 0xff] } else { [0xb0, 0xb0, 0xb8] },
+                        None,
+                        list_bounds,
+                    );
 
                     // Date — right-aligned inside the row, clear of the scrollbar strip
                     let date_w = TextLabel::estimate_width(&email.date, 9.0);
-                    labels.push(TextLabel {
-                        text: email.date.clone(),
-                        x: list_x + list_w - 14.0 - date_w,
-                        y: draw_y + 7.0,
-                        font_size: 9.0,
-                        color: [0x70, 0x70, 0x75],
-                    });
+                    pc.text_with(
+                        email.date.clone(),
+                        list_x + list_w - 14.0 - date_w,
+                        draw_y + 7.0,
+                        9.0,
+                        [0x70, 0x70, 0x75],
+                        None,
+                        list_bounds,
+                    );
 
                     // Subject
-                    labels.push(TextLabel {
-                        text: ellipsize(&email.subject, fit(29.0)),
-                        x: list_x + 20.0,
-                        y: draw_y + 20.0,
-                        font_size: 10.0,
-                        color: if !email.read { [0x3a, 0x9a, 0xff] } else { [0x83, 0x83, 0x8a] },
-                    });
+                    pc.text_with(
+                        ellipsize(&email.subject, fit(29.0)),
+                        list_x + 20.0,
+                        draw_y + 20.0,
+                        10.0,
+                        if !email.read { [0x3a, 0x9a, 0xff] } else { [0x83, 0x83, 0x8a] },
+                        None,
+                        list_bounds,
+                    );
 
                     // Snippet — collapse ALL whitespace: CRLF bodies leave bare '\r'
                     // after a plain '\n' replace, and the renderer treats it as a
                     // line break, bleeding preview lines into the next row.
                     let snippet_raw = email.body.split_whitespace().collect::<Vec<_>>().join(" ");
                     let snippet = ellipsize(&snippet_raw, fit(37.0));
-                    labels.push(TextLabel {
-                        text: snippet,
-                        x: list_x + 20.0,
-                        y: draw_y + 34.0,
-                        font_size: 9.0,
-                        color: [0x60, 0x60, 0x65],
-                    });
+                    pc.text_with(
+                        snippet,
+                        list_x + 20.0,
+                        draw_y + 34.0,
+                        9.0,
+                        [0x60, 0x60, 0x65],
+                        None,
+                        list_bounds,
+                    );
                 }
             }
         }
@@ -4601,7 +4620,18 @@ impl Application for ClearEmailApp {
             quads.extend(list_quads);
         }
 
-        // Visible List Item Buttons
+        // Visible List Item Buttons — `get_item_draw_y` returns PARTIALLY
+        // visible cards too (the toolkit ScrollRegion's intersection
+        // contract), so the whole card pass runs under the list viewport clip:
+        // an edge card renders cut by the clip instead of vanishing. The
+        // unread dots ride the same PaintCtx (the tuple sink forwards to it),
+        // so the clip covers them too.
+        quads.pc.push_clip(cce_ui::scene::layout::Rect {
+            x: self.email_list.x,
+            y: self.email_list.viewport_y,
+            width: self.email_list.w,
+            height: self.email_list.viewport_h,
+        });
         for idx in 0..filtered.len() {
             if self.email_list.get_item_draw_y(idx, 0.0).is_some() {
                 cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.email_buttons[idx], &mut *quads.pc);
@@ -4614,6 +4644,7 @@ impl Application for ClearEmailApp {
                 }
             }
         }
+        quads.pc.pop_clip();
         // Scrollbar after the rows so the thumb rides on top of them instead of
         // peeking through the inter-row gaps.
         {
diff --git a/src/scroll_region.rs b/src/scroll_region.rs
deleted file mode 100644
index 3fad1e3..0000000
--- a/src/scroll_region.rs
+++ /dev/null
@@ -1,304 +0,0 @@
-//! App-owned scroll region replacing the dissolved `List` embedded base (the Phase 6q
-//! ScrollRegion, ported from cce-system-interface). The email list was a pure scroll
-//! frame (`List` with `columns: None`, whose visuals were just its internal ScrollBox):
-//! the rows are drawn by the app at `get_item_draw_y` positions, so the widget
-//! contributed only the flat background, the scrollbar, the scroll/virtualization math,
-//! and wheel/drag input. `push_quads` reproduces `ScrollBox::extra_quads` verbatim
-//! (flat `list_bg_color` fill + track + thumb — no border, no rounding).
-
-use cce_ui::widget::{ElementState, Key, KeyEvent, MouseScrollDelta, NamedKey};
-
-#[derive(Debug, Clone)]
-pub struct ScrollRegion {
-    pub x: f32,
-    pub y: f32,
-    pub w: f32,
-    pub h: f32,
-    /// Row height, with `List::new`'s silent adjustment to `max(item_height, list_font + 14)`.
-    pub item_height: f32,
-    pub item_gap: f32,
-    pub scroll_y: f32,
-    pub content_h: f32,
-    pub viewport_y: f32,
-    pub viewport_h: f32,
-    pub dragging: bool,
-    drag_offset_y: f32,
-    pub hovered: bool,
-    /// Local stand-in for the legacy global focus flag (`ScrollBox::focus()` on any press
-    /// inside the frame): set on a press that hits the region, cleared on one that misses.
-    pub focused: bool,
-}
-
-impl ScrollRegion {
-    pub fn new(item_height: f32, item_gap: f32) -> Self {
-        let (_, font_size) = cce_ui::layout::list_font_parsed();
-        Self {
-            x: 0.0,
-            y: 0.0,
-            w: 0.0,
-            h: 0.0,
-            item_height: item_height.max(font_size + 14.0),
-            item_gap,
-            scroll_y: 0.0,
-            content_h: 0.0,
-            viewport_y: 0.0,
-            viewport_h: 0.0,
-            dragging: false,
-            drag_offset_y: 0.0,
-            hovered: false,
-            focused: false,
-        }
-    }
-
-    pub fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
-        self.x = x;
-        self.y = y;
-        self.w = w;
-        self.h = h;
-    }
-
-    /// The `List::update_bounds` count math: `content_h = count * (item_height + gap) + 4`.
-    pub fn update_bounds(&mut self, count: usize, viewport_y: f32, viewport_h: f32) {
-        self.content_h = count as f32 * (self.item_height + self.item_gap) + 4.0;
-        self.viewport_y = viewport_y;
-        self.viewport_h = viewport_h;
-        self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
-    }
-
-    pub fn set_scroll_y(&mut self, val: f32) {
-        self.scroll_y = val;
-    }
-
-    fn max_scroll(&self) -> f32 {
-        (self.content_h - self.viewport_h).max(0.0)
-    }
-
-    pub fn hit(&self, px: f32, py: f32) -> bool {
-        px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
-    }
-
-    /// Row virtualization (`List::get_item_draw_y`): screen y for row `idx`, or `None`
-    /// when the row isn't fully inside the viewport.
-    pub fn get_item_draw_y(&self, idx: usize, offset: f32) -> Option<f32> {
-        let virtual_y = idx as f32 * (self.item_height + self.item_gap) + offset;
-        let draw_y = self.viewport_y + virtual_y - self.scroll_y;
-        if draw_y >= self.viewport_y - 1.0
-            && draw_y + self.item_height <= self.viewport_y + self.viewport_h + 1.0
-        {
-            Some(draw_y)
-        } else {
-            None
-        }
-    }
-
-    /// Scrollbar geometry (`ScrollBox::extra_quads`): (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h).
-    fn scrollbar_geom(&self) -> (f32, f32, f32, f32, f32, f32) {
-        let sb_w = cce_ui::layout::scrollbar_width();
-        let sb_x = self.x + self.w - sb_w - 4.0;
-        let track_h = self.viewport_h - 8.0;
-        let track_y = self.viewport_y + 4.0;
-        let visible_ratio = self.viewport_h / self.content_h.max(1.0);
-        let thumb_h = if track_h <= 20.0 {
-            track_h
-        } else {
-            (track_h * visible_ratio).clamp(20.0, track_h)
-        };
-        let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
-        let thumb_y = track_y + scroll_ratio * (track_h - thumb_h);
-        (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)
-    }
-
-    fn hit_scrollbar(&self, px: f32, py: f32) -> bool {
-        if self.content_h <= self.viewport_h {
-            return false;
-        }
-        let (sb_x, track_y, sb_w, track_h, _, _) = self.scrollbar_geom();
-        px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
-    }
-
-    /// Left press: scrollbar thumb grab or track jump (`ScrollBox::mouse_input`), plus the
-    /// press-inside focus / press-outside unfocus bookkeeping. Returns true only when the
-    /// scrollbar consumed the press — a press on the rows falls through to them.
-    pub fn press(&mut self, px: f32, py: f32) -> bool {
-        self.focused = self.hit(px, py);
-        if !self.hit_scrollbar(px, py) {
-            self.dragging = false;
-            return false;
-        }
-        self.dragging = true;
-        let (_, track_y, _, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
-        let click_offset = py - thumb_y;
-        if click_offset >= 0.0 && click_offset <= thumb_h {
-            self.drag_offset_y = click_offset;
-        } else {
-            self.drag_offset_y = thumb_h / 2.0;
-            let target = py - self.drag_offset_y;
-            let ratio = if track_h - thumb_h > 0.0 {
-                ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
-            } else {
-                0.0
-            };
-            self.scroll_y = ratio * self.max_scroll();
-        }
-        true
-    }
-
-    /// Returns whether a thumb drag was in progress (the caller's redraw signal).
-    pub fn release(&mut self) -> bool {
-        std::mem::take(&mut self.dragging)
-    }
-
-    fn drag_move(&mut self, py: f32) -> bool {
-        let (_, track_y, _, track_h, _, thumb_h) = self.scrollbar_geom();
-        let target = py - self.drag_offset_y;
-        let ratio = if track_h - thumb_h > 0.0 {
-            ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0)
-        } else {
-            0.0
-        };
-        let old = self.scroll_y;
-        self.scroll_y = ratio * self.max_scroll();
-        (self.scroll_y - old).abs() > 0.01
-    }
-
-    /// Pointer-move bookkeeping: forwards to an active thumb drag (returns true so the host
-    /// treats it as a high-priority drag override), else just tracks hover for the border
-    /// tint and the keyboard scope.
-    pub fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
-        self.hovered = self.hit(px, py);
-        if self.dragging {
-            self.drag_move(py);
-            return true;
-        }
-        false
-    }
-
-    pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
-        if !self.hit(px, py) {
-            return false;
-        }
-        let dy = match delta {
-            MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
-            MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
-        };
-        let old = self.scroll_y;
-        self.scroll_y = (self.scroll_y + dy).clamp(0.0, self.max_scroll());
-        (self.scroll_y - old).abs() > 0.01
-    }
-
-    /// Hover/focus-scoped keyboard scrolling (`ScrollBox::keyboard_input` reached the boxes
-    /// when focused or hovered; the dissolved region keeps both via its local flags).
-    pub fn keyboard(&mut self, event: &KeyEvent) -> bool {
-        if (!self.hovered && !self.focused) || event.state != ElementState::Pressed {
-            return false;
-        }
-        let max = self.max_scroll();
-        let old = self.scroll_y;
-        if event.ctrl {
-            match &event.logical_key {
-                Key::Character(c) if c == "n" || c == "N" => self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max),
-                Key::Character(c) if c == "p" || c == "P" => self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max),
-                _ => return false,
-            }
-        } else {
-            match &event.logical_key {
-                Key::Named(NamedKey::ArrowDown) => self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max),
-                Key::Named(NamedKey::ArrowUp) => self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max),
-                Key::Named(NamedKey::PageDown) => self.scroll_y = (self.scroll_y + self.viewport_h).clamp(0.0, max),
-                Key::Named(NamedKey::PageUp) => self.scroll_y = (self.scroll_y - self.viewport_h).clamp(0.0, max),
-                Key::Named(NamedKey::Home) => self.scroll_y = 0.0,
-                Key::Named(NamedKey::End) => self.scroll_y = max,
-                _ => return false,
-            }
-        }
-        (self.scroll_y - old).abs() > 0.01
-    }
-
-    /// The region's flat background (the legacy `ScrollBox::extra_quads` fill).
-    /// The scrollbar is split into [`push_scrollbar_quads`](Self::push_scrollbar_quads)
-    /// so the host can emit it AFTER the rows — drawn together, the rows paint over
-    /// the thumb and it peeks through the inter-row gaps as dotted segments.
-    pub fn push_quads(&self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
-        quads.push((self.x, self.y, self.w, self.h, cce_ui::color::list_bg_color()));
-    }
-
-    /// Scrollbar track + thumb when the content overflows; emit after the rows.
-    pub fn push_scrollbar_quads(&self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
-        if self.content_h > self.viewport_h {
-            let (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h) = self.scrollbar_geom();
-            quads.push((sb_x, track_y, sb_w, track_h, cce_ui::color::scrollbar_track_color()));
-            quads.push((sb_x, thumb_y, sb_w, thumb_h, cce_ui::color::scrollbar_thumb_color()));
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    fn region() -> ScrollRegion {
-        // item_height clamps to list_font + 14, so pick one comfortably above any config.
-        let mut r = ScrollRegion::new(40.0, 4.0);
-        r.set_rect(10.0, 20.0, 200.0, 100.0);
-        r
-    }
-
-    #[test]
-    fn wheel_scrolls_and_clamps() {
-        let mut r = region();
-        r.update_bounds(10, 20.0, 100.0); // content_h = 444 > 100
-        assert!(r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
-        assert_eq!(r.scroll_y, 48.0);
-        assert!(!r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 500.0, 50.0)); // miss
-        r.wheel(&MouseScrollDelta::LineDelta(0.0, -100.0), 50.0, 50.0);
-        assert_eq!(r.scroll_y, 344.0); // clamped to max_scroll
-    }
-
-    #[test]
-    fn virtualization_matches_list_math() {
-        let mut r = region();
-        r.update_bounds(10, 20.0, 100.0);
-        r.set_scroll_y(0.0);
-        // Row 0 at viewport_y + 0*(44) + 4 = 24; fits (24 + 40 <= 121).
-        assert_eq!(r.get_item_draw_y(0, 4.0), Some(24.0));
-        // Row 2 at 20 + 92 - 0 = 112; 112 + 40 > 121 → culled.
-        assert!(r.get_item_draw_y(2, 4.0).is_none());
-    }
-
-    #[test]
-    fn press_focuses_and_grabs_only_scrollbar() {
-        let mut r = region();
-        r.update_bounds(10, 20.0, 100.0);
-        // Press in the rows area: focused, not dragging, falls through.
-        assert!(!r.press(50.0, 50.0));
-        assert!(r.focused && !r.dragging);
-        // Press on the scrollbar strip (x + w - sb_w - 4 ± 4): consumed.
-        let sb_x = 10.0 + 200.0 - cce_ui::layout::scrollbar_width() - 4.0;
-        assert!(r.press(sb_x + 1.0, 50.0));
-        assert!(r.dragging);
-        assert!(r.release());
-        // Press outside: unfocuses.
-        assert!(!r.press(500.0, 500.0));
-        assert!(!r.focused);
-    }
-
-    #[test]
-    fn keyboard_is_hover_or_focus_scoped() {
-        let mut r = region();
-        r.update_bounds(10, 20.0, 100.0);
-        let down = KeyEvent {
-            state: ElementState::Pressed,
-            logical_key: Key::Named(NamedKey::ArrowDown),
-            text: None,
-            repeat: false,
-            ctrl: false,
-            shift: false,
-            alt: false,
-        };
-        assert!(!r.keyboard(&down)); // neither hovered nor focused
-        r.cursor_moved(50.0, 50.0);
-        assert!(r.hovered);
-        assert!(r.keyboard(&down));
-        assert_eq!(r.scroll_y, 24.0);
-    }
-}