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

commitfe58cc820a8eafa993e7cc3349bd8e7376fc9735
parent9bb70662e1
authorLucas Galante <[email protected]>
date2026-08-21 14:04
feat: draggable list/detail split

The 325/310/300 literal family pinned the email list at 300px. The split
is now one value — list_w — with everything derived through split_geom():
separator, detail x, row/search widths, date right-alignment, wheel and
hover routing, chip hit-tests. Row-label ellipsize budgets scale with the
band, so a wider list shows whole addresses and longer snippets.

Drag the separator (±4px grab band below the bar, same slop as the
scrollbar drags it mirrors); the width clamps so neither pane collapses
(list ≥180, detail ≥220) and persists to the cce-mail-split.txt sidecar
on release, following the account sidecar's pattern. A narrow window
re-clamps at use without clobbering the stored preference.

Shadow-verified: drag both directions, both clamps, row clicks at the
new width, detail pane re-anchoring, and restore across a restart.

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

 src/main.rs | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++---------
 1 file changed, 100 insertions(+), 17 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 039631c..134193c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -211,6 +211,11 @@ struct ClearEmailApp {
     detail_hovered: bool,
     body_sb_dragging: bool,
     body_sb_drag_offset: f32,
+    /// Width of the email-list band (the rows), user-draggable via the
+    /// list/detail separator. The stored preference survives narrow windows
+    /// un-clobbered — [`Self::split_geom`] clamps at use, not here.
+    list_w: f32,
+    split_dragging: bool,
     compose_open: bool,
     compose_title: String,
     /// When the most recent IMAP sync was spawned — folder switches re-sync
@@ -395,6 +400,25 @@ fn save_selected_account_email(email: &str) {
     let _ = std::fs::write(selected_account_path(), email);
 }
 
+/// Sidecar remembering the dragged list/detail split, same pattern as the
+/// account sidecar above (config.kdl stays the user's file).
+fn split_path() -> std::path::PathBuf {
+    cce_ui::config::cce_config_dir().join("cce-mail-split.txt")
+}
+
+fn load_list_w() -> Option<f32> {
+    std::fs::read_to_string(split_path())
+        .ok()?
+        .trim()
+        .parse::<f32>()
+        .ok()
+        .filter(|w| (LIST_W_MIN..=4000.0).contains(w))
+}
+
+fn save_list_w(w: f32) {
+    let _ = std::fs::write(split_path(), format!("{:.0}", w));
+}
+
 fn get_account_emails_path(email: &str) -> std::path::PathBuf {
     let p = cce_ui::config::cce_config_dir();
     if !p.exists() {
@@ -431,6 +455,20 @@ fn save_emails_for_account(email: &str, emails: &[Email]) {
 /// Menubar height; all chrome below the bar offsets by this.
 const MENUBAR_H: f32 = 36.0;
 
+// List/detail split geometry. The list band starts at LIST_X; the separator
+// line sits LIST_SEP_GAP after the band and the detail pane LIST_DETAIL_GAP
+// after the separator. `list_w` (the band width) is the one draggable value —
+// everything else derives from it through `split_geom`.
+const LIST_X: f32 = 10.0;
+const LIST_SEP_GAP: f32 = 10.0;
+const LIST_DETAIL_GAP: f32 = 15.0;
+const LIST_W_DEFAULT: f32 = 300.0;
+const LIST_W_MIN: f32 = 180.0;
+/// The detail pane never gets squeezed below this by a drag or a narrow window.
+const DETAIL_W_MIN: f32 = 220.0;
+/// Half-width of the separator's grab band (±, matching ScrollRegion's slop).
+const SPLIT_GRAB_SLOP: f32 = 4.0;
+
 // Compose modal geometry. One source of truth: the background quads, the
 // input rects, the labels, the chip row and the outside-click test all
 // derive from these — the old duplicated 500.0/420.0 literals meant growing
@@ -1868,6 +1906,18 @@ impl ClearEmailApp {
         }
     }
 
+    /// The list/detail split, clamped to the current window: (list band
+    /// width, separator x, detail pane x). The single source for paint,
+    /// layout, and every hit-test — the stored `list_w` preference is never
+    /// mutated by a window resize, only re-clamped here.
+    fn split_geom(&self) -> (f32, f32, f32) {
+        let max_w = (self.width as f32 - LIST_X - LIST_SEP_GAP - LIST_DETAIL_GAP - DETAIL_W_MIN)
+            .max(LIST_W_MIN);
+        let lw = self.list_w.clamp(LIST_W_MIN, max_w);
+        let sep = LIST_X + lw + LIST_SEP_GAP;
+        (lw, sep, sep + LIST_DETAIL_GAP)
+    }
+
     /// Detail-pane body scrollbar geometry, mirroring `ScrollRegion::scrollbar_geom`:
     /// (sb_x, track_y, sb_w, track_h, thumb_y, thumb_h). None when the body fits
     /// (no scrollbar drawn). The single source for display_list and the drag path.
@@ -1929,9 +1979,11 @@ impl ClearEmailApp {
         let w_f32 = self.width as f32;
         let h_f32 = self.height as f32;
 
-        let list_x = 10.0;
-        let detail_x = list_x + 325.0;
-        let separator_x = list_x + 310.0;
+        let list_x = LIST_X;
+        let (list_w, separator_x, detail_x) = self.split_geom();
+        // Row-label char budgets scale with the band; the bases are the
+        // hand-tuned counts at the 300px default.
+        let fit = |base: f32| (base * list_w / LIST_W_DEFAULT) as usize;
 
         // Widget text rides along with chrome in display_list's paint_root_into
         // walk — only app-composed labels are emitted here.
@@ -1974,7 +2026,7 @@ impl ClearEmailApp {
                         email.from.clone()
                     };
                     labels.push(TextLabel {
-                        text: ellipsize(&row_head, 21),
+                        text: ellipsize(&row_head, fit(21.0)),
                         x: list_x + 20.0,
                         y: draw_y + 6.0,
                         font_size: 11.0,
@@ -1985,7 +2037,7 @@ impl ClearEmailApp {
                     let date_w = TextLabel::estimate_width(&email.date, 9.0);
                     labels.push(TextLabel {
                         text: email.date.clone(),
-                        x: list_x + 300.0 - 14.0 - date_w,
+                        x: list_x + list_w - 14.0 - date_w,
                         y: draw_y + 7.0,
                         font_size: 9.0,
                         color: [0x70, 0x70, 0x75],
@@ -1993,7 +2045,7 @@ impl ClearEmailApp {
 
                     // Subject
                     labels.push(TextLabel {
-                        text: ellipsize(&email.subject, 29),
+                        text: ellipsize(&email.subject, fit(29.0)),
                         x: list_x + 20.0,
                         y: draw_y + 20.0,
                         font_size: 10.0,
@@ -2004,7 +2056,7 @@ impl ClearEmailApp {
                     // 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, 37);
+                    let snippet = ellipsize(&snippet_raw, fit(37.0));
                     labels.push(TextLabel {
                         text: snippet,
                         x: list_x + 20.0,
@@ -2289,6 +2341,8 @@ impl Application for ClearEmailApp {
             detail_hovered: false,
             body_sb_dragging: false,
             body_sb_drag_offset: 0.0,
+            list_w: load_list_w().unwrap_or(LIST_W_DEFAULT),
+            split_dragging: false,
             compose_open,
             compose_title,
             status_message: None,
@@ -2751,9 +2805,8 @@ impl Application for ClearEmailApp {
             Folder::Trash => "trash",
         };
 
-        let list_x = 10.0;
-        let detail_x = list_x + 325.0;
-        let separator_x = list_x + 310.0;
+        let list_x = LIST_X;
+        let (list_w, separator_x, detail_x) = self.split_geom();
         let detail_panel_x = separator_x + 1.0;
 
         if self.needs_rebuild || size_changed {
@@ -2799,7 +2852,7 @@ impl Application for ClearEmailApp {
             self.btn_compose.set_rect(list_x, 15.0 + MENUBAR_H, 26.0, 26.0);
 
             // Search box and Scrolling list
-            self.search_box.set_rect(list_x + 36.0, 15.0 + MENUBAR_H, 264.0, 26.0);
+            self.search_box.set_rect(list_x + 36.0, 15.0 + MENUBAR_H, list_w - 36.0, 26.0);
 
             // Get filtered emails count for bounds setup
             let list_count = self.emails.iter()
@@ -2817,7 +2870,7 @@ impl Application for ClearEmailApp {
                 })
                 .count();
 
-            self.email_list.set_rect(list_x, 55.0 + MENUBAR_H, 300.0, h_f32 - 70.0 - MENUBAR_H);
+            self.email_list.set_rect(list_x, 55.0 + MENUBAR_H, list_w, h_f32 - 70.0 - MENUBAR_H);
             self.email_list.update_bounds(list_count, 55.0 + MENUBAR_H, h_f32 - 70.0 - MENUBAR_H);
 
             if self.email_buttons.len() != list_count {
@@ -2872,7 +2925,7 @@ impl Application for ClearEmailApp {
             for (idx, &(_email_id, is_selected)) in filtered_email_ids.iter().enumerate() {
                 self.email_buttons[idx].selected = is_selected;
                 if let Some(draw_y) = self.email_list.get_item_draw_y(idx, 0.0) {
-                    self.email_buttons[idx].set_rect(list_x, draw_y, 300.0, 54.0);
+                    self.email_buttons[idx].set_rect(list_x, draw_y, list_w, 54.0);
                 } else {
                     self.email_buttons[idx].set_rect(-9999.0, -9999.0, 0.0, 0.0);
                 }
@@ -3152,7 +3205,23 @@ impl Application for ClearEmailApp {
             }
         }
 
+        // Active split drag: the separator follows the pointer, clamped so
+        // neither pane collapses. Clamp into the stored value (not just at
+        // paint) so the release persists what the user actually sees.
+        if !self.compose_open && self.split_dragging {
+            let max_w = (self.width as f32 - LIST_X - LIST_SEP_GAP - LIST_DETAIL_GAP - DETAIL_W_MIN)
+                .max(LIST_W_MIN);
+            let new_w = (px - LIST_X - LIST_SEP_GAP).clamp(LIST_W_MIN, max_w);
+            if (new_w - self.list_w).abs() > 0.5 {
+                self.list_w = new_w;
+                changed = true;
+            }
+        }
+
         // Routed dispatch (6bd shrink): one Event per widget root through the router.
+        // separator_x is read before ctx: split_geom(&self) cannot run while
+        // ui_context is mutably borrowed.
+        let separator_x = self.split_geom().1;
         let mv = cce_ui::widget::Event::PointerMove { x: px, y: py, local_x: px, local_y: py };
         let ctx = &mut self.ui_context;
 
@@ -3178,7 +3247,7 @@ 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 > 10.0 + 310.0;
+            self.detail_hovered = px > separator_x;
 
             for btn in &mut self.email_buttons {
                 if btn.rect().0 > -9000.0 {
@@ -3233,17 +3302,26 @@ impl Application for ClearEmailApp {
                     // A fresh press always supersedes a stale drag — a lost
                     // release must not leave the thumb glued to the pointer.
                     self.body_sb_dragging = false;
+                    self.split_dragging = false;
+                    // Grab the list/detail separator (±slop, below the bar).
+                    let separator_x = self.split_geom().1;
+                    if (px - separator_x).abs() <= SPLIT_GRAB_SLOP && py > MENUBAR_H {
+                        self.split_dragging = true;
+                        *needs_rebuild = true;
+                        self.needs_rebuild = true;
+                        return None;
+                    }
                     if self.selected_email_id.is_some() && self.body_sb_press(px, py) {
                         changed = true;
                     }
                     // Server-attachment chips: hit-test against the same
                     // rects the paint pass laid out.
+                    let detail_x = self.split_geom().2;
                     if let Some(email) = self
                         .selected_email_id
                         .and_then(|id| self.emails.iter().find(|e| e.id == id))
                     {
                         if !email.remote_attachments.is_empty() {
-                            let detail_x = 10.0 + 325.0;
                             let hit = detail_chip_rects(&email.remote_attachments, detail_x)
                                 .iter()
                                 .position(|&(cx, cy, cw, ch)| {
@@ -3260,6 +3338,11 @@ impl Application for ClearEmailApp {
                     if std::mem::take(&mut self.body_sb_dragging) {
                         changed = true;
                     }
+                    if std::mem::take(&mut self.split_dragging) {
+                        // Persist the split where the drag left it.
+                        save_list_w(self.list_w);
+                        changed = true;
+                    }
                 }
             }
         }
@@ -3555,8 +3638,9 @@ impl Application for ClearEmailApp {
         let px = pos.x as f32;
         let py = pos.y as f32;
 
+        let separator_x = self.split_geom().1;
         if !self.compose_open {
-            if px >= 66.0 && px <= 366.0 {
+            if px < separator_x {
                 if self.email_list.wheel(delta, px, py) {
                     changed = true;
                 }
@@ -3565,7 +3649,6 @@ impl Application for ClearEmailApp {
 
         // Detail-pane body scroll.
         if !self.compose_open && self.selected_email_id.is_some() {
-            let separator_x = 10.0 + 310.0;
             if px > separator_x {
                 let dy = match delta {
                     MouseScrollDelta::LineDelta(_, y) => -y * 24.0,