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

commit9edd2373396866d5380f76cb226f82b28f5b6644
parent4d62b5fcaf
authorLucas Galante <[email protected]>
date2026-07-23 11:14
feat: detail-pane body scrolling (wheel + keys + scrollbar)

Long wrapped bodies overflowed the pane bottom with no way to read the
rest. The pane now keeps a body_scroll offset:

- content height measured via engine::get_text_buffer_laid_out with the
  same wrap width/metrics the renderer uses, so the clamp and the thumb
  track the real wrapped height (End lands flush on the last line)
- wheel over the pane scrolls (24px/line, pixel deltas passthrough);
  ArrowUp/Down, PageUp/Down, Home/End work hover-scoped like
  ScrollRegion's keyboard path
- passive scrollbar track+thumb (ScrollRegion colors) when overflowing
- offset resets on select/folder/search/account changes; boxed text
  draws at 170 - scroll with pane-rect bounds doing the clipping

Live-verified with a 40-line injected mail: Home/PageDown/End screenshots
show correct windows, clean top-edge clipping, thumb tracking.

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

 src/main.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 96 insertions(+), 2 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 7d5bcd7..27aa318 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -140,6 +140,12 @@ struct ClearEmailApp {
     emails: Vec<Email>,
     current_folder: Folder,
     selected_email_id: Option<usize>,
+    /// Detail-pane body scroll offset (logical px) and the measured height of
+    /// the wrapped body text, refreshed each display_list; hover scopes the
+    /// wheel/keyboard scrolling to the pane.
+    body_scroll: f32,
+    body_content_h: f32,
+    detail_hovered: bool,
     compose_open: bool,
     status_message: Option<(String, f32)>, // (message, timer)
     sender: calloop::channel::Sender<AppMessage>,
@@ -1291,6 +1297,9 @@ impl Application for ClearEmailApp {
             emails,
             current_folder: Folder::Inbox,
             selected_email_id: None,
+            body_scroll: 0.0,
+            body_content_h: 0.0,
+            detail_hovered: false,
             compose_open: false,
             status_message: None,
             sender: _sender.clone(),
@@ -1320,11 +1329,13 @@ impl Application for ClearEmailApp {
                 self.current_folder = f;
                 self.selected_email_id = None;
                 self.email_list.set_scroll_y(0.0);
+                self.body_scroll = 0.0;
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
             AppMessage::SelectEmail(id) => {
                 self.selected_email_id = Some(id);
+                self.body_scroll = 0.0;
                 if let Some(email) = self.emails.iter_mut().find(|e| e.id == id) {
                     if !email.read {
                         email.read = true;
@@ -1337,6 +1348,7 @@ impl Application for ClearEmailApp {
             AppMessage::SearchChanged => {
                 self.selected_email_id = None;
                 self.email_list.set_scroll_y(0.0);
+                self.body_scroll = 0.0;
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
@@ -1454,6 +1466,7 @@ impl Application for ClearEmailApp {
                 }
                 self.selected_email_id = None;
                 self.email_list.set_scroll_y(0.0);
+                self.body_scroll = 0.0;
                 *needs_rebuild = true;
                 self.needs_rebuild = true;
             }
@@ -1981,10 +1994,42 @@ impl Application for ClearEmailApp {
                     if let Some(email) = self.emails.iter().find(|e| e.id == selected_id) {
                         let body_w = (w_f32 - (detail_x + 15.0)).max(100.0);
                         let body_h = (h_f32 - 190.0).max(100.0);
+                        let line_h = 12.0 * 1.4; // get_text_buffer_laid_out's placed-text metric
+
+                        // Measure with the exact shaping the renderer will use, so the
+                        // scroll clamp and the thumb track the real wrapped height.
+                        let (buffer, _) = cce_ui::engine::get_text_buffer_laid_out(
+                            &mut self.font_system,
+                            &email.body,
+                            12.0,
+                            Some("sans-serif"),
+                            cce_ui::scene::paint::TextAttrs::default(),
+                            cce_ui::scene::paint::TextLayout {
+                                wrap_width: Some(body_w),
+                                box_height: 1.0e6,
+                                align_h: cce_ui::scene::paint::AlignH::Left,
+                                align_v: cce_ui::scene::paint::AlignV::Top,
+                            },
+                        );
+                        let content_h = buffer.layout_runs().count() as f32 * line_h;
+                        self.body_content_h = content_h;
+                        let max_scroll = (content_h - body_h).max(0.0);
+                        self.body_scroll = self.body_scroll.clamp(0.0, max_scroll);
+
+                        // Scrollbar (ScrollRegion's colors) when the body overflows.
+                        if max_scroll > 0.0 {
+                            let sb_w = cce_ui::layout::scrollbar_width();
+                            let sb_x = w_f32 - sb_w - 4.0;
+                            let thumb_h = (body_h * body_h / content_h).clamp(20.0, body_h);
+                            let thumb_y = 170.0 + (self.body_scroll / max_scroll) * (body_h - thumb_h);
+                            quads.push((sb_x, 170.0, sb_w, body_h, cce_ui::color::scrollbar_track_color()));
+                            quads.push((sb_x, thumb_y, sb_w, thumb_h, cce_ui::color::scrollbar_thumb_color()));
+                        }
+
                         quads.pc.text_boxed(
                             email.body.clone(),
                             detail_x,
-                            170.0,
+                            170.0 - self.body_scroll,
                             12.0,
                             [0xc8, 0xc8, 0xd0],
                             Some("sans-serif".to_string()),
@@ -1992,7 +2037,7 @@ impl Application for ClearEmailApp {
                             cce_ui::scene::paint::TextAttrs::default(),
                             cce_ui::scene::paint::TextLayout {
                                 wrap_width: Some(body_w),
-                                box_height: body_h,
+                                box_height: content_h.max(body_h),
                                 align_h: cce_ui::scene::paint::AlignH::Left,
                                 align_v: cce_ui::scene::paint::AlignV::Top,
                             },
@@ -2104,6 +2149,8 @@ impl Application for ClearEmailApp {
                 if ctx.propagate_event(&mv, self.search_box.id()) { changed = true; }
             }
             if self.email_list.cursor_moved(px, py) { changed = true; }
+            // Hover scope for the detail-pane body scroll (wheel + keys).
+            self.detail_hovered = px > self.paginator.sidebar_w() + 10.0 + 310.0;
 
             for btn in &mut self.email_buttons {
                 if btn.rect().0 > -9000.0 {
@@ -2414,6 +2461,28 @@ impl Application for ClearEmailApp {
             }
         }
 
+        // Detail-pane body scroll.
+        if !self.compose_open
+            && !self.account_dialog_open
+            && self.current_folder != Folder::Accounts
+            && self.selected_email_id.is_some()
+        {
+            let separator_x = self.paginator.sidebar_w() + 10.0 + 310.0;
+            if px > separator_x {
+                let dy = match delta {
+                    MouseScrollDelta::LineDelta(_, y) => -y * 24.0,
+                    MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
+                };
+                let body_h = (self.height as f32 - 190.0).max(100.0);
+                let max = (self.body_content_h - body_h).max(0.0);
+                let old = self.body_scroll;
+                self.body_scroll = (self.body_scroll + dy).clamp(0.0, max);
+                if (self.body_scroll - old).abs() > 0.01 {
+                    changed = true;
+                }
+            }
+        }
+
         if changed {
             *needs_rebuild = true;
             self.needs_rebuild = true;
@@ -2491,6 +2560,31 @@ impl Application for ClearEmailApp {
                 }
             }
 
+            // Detail-pane body scroll, hover-scoped like ScrollRegion's keyboard path.
+            if !handled
+                && self.detail_hovered
+                && self.selected_email_id.is_some()
+                && self.current_folder != Folder::Accounts
+                && !self.search_box.editing
+                && event.state == ElementState::Pressed
+            {
+                let body_h = (self.height as f32 - 190.0).max(100.0);
+                let max = (self.body_content_h - body_h).max(0.0);
+                let old = self.body_scroll;
+                match &event.logical_key {
+                    Key::Named(cce_ui::widget::NamedKey::ArrowDown) => self.body_scroll = (self.body_scroll + 24.0).min(max),
+                    Key::Named(cce_ui::widget::NamedKey::ArrowUp) => self.body_scroll = (self.body_scroll - 24.0).max(0.0),
+                    Key::Named(cce_ui::widget::NamedKey::PageDown) => self.body_scroll = (self.body_scroll + body_h).min(max),
+                    Key::Named(cce_ui::widget::NamedKey::PageUp) => self.body_scroll = (self.body_scroll - body_h).max(0.0),
+                    Key::Named(cce_ui::widget::NamedKey::Home) => self.body_scroll = 0.0,
+                    Key::Named(cce_ui::widget::NamedKey::End) => self.body_scroll = max,
+                    _ => {}
+                }
+                if (self.body_scroll - old).abs() > 0.01 {
+                    handled = true;
+                }
+            }
+
             if !handled && self.current_folder != Folder::Accounts && self.search_box.editing {
                 if ctx.propagate_event(&kev, self.search_box.id()) {
                     handled = true;