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

commita83b2a59f97c988f5cac455240e2d24d44780412
parenta7a6193f77
authorLucas Galante <[email protected]>
date2026-07-27 15:52
feat: sidebar dissolves into the menubar

- folder selection is the bar's right-aligned title dropdown (the
  designer pane-switcher idiom): title shows the current folder with the
  inbox unread count folded in (the old tab badge), context options
  Inbox/Sent/Trash
- Accounts is a plain button pinned at a fixed right offset (anchoring
  to the title would drift as the folder name changes length)
- the vertical Paginator, its sidebar panel, and the tab badge are gone;
  the list pane starts at the window edge with [+][search] as its header
- routing order matters twice: the Accounts button routes BEFORE the
  menubar (the bar consumes any press in its band), and the menubar
  before everything else (dropdown must not click through); context
  changes poll take_context_change() ahead of menu_click()
- the detail action-bar strip starts below the menubar — at y=0 it
  tinted through the recessed bar on the right half only

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

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

diff --git a/src/main.rs b/src/main.rs
index 8c02d45..ef077b6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5,7 +5,7 @@ use glyphon::FontSystem;
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::widget::{
     MouseButton, ElementState, MouseScrollDelta, KeyEvent, WidgetHost,
-    TextBox, Button, TextLabel, Key, Paginator, PageSelector, MenuController, MenuBar
+    TextBox, Button, TextLabel, Key, MenuController, MenuBar
 };
 use cce_ui::context::UiContext;
 use native_tls::TlsConnector;
@@ -106,7 +106,7 @@ struct ClearEmailApp {
     // Navigation / Sidebar
     btn_compose: cce_ui::widget::Adapted<cce_ui::widget::Button>,
     menubar: cce_ui::widget::Adapted<MenuBar>,
-    paginator: cce_ui::widget::Adapted<Paginator>,
+    btn_accounts: cce_ui::widget::Adapted<cce_ui::widget::Button>,
 
     // Search and List View
     search_box: cce_ui::widget::Adapted<TextBox>,
@@ -1190,6 +1190,8 @@ impl ClearEmailApp {
         self.ui_context.register_widget(id, ptr);
         let (id, ptr) = (self.menubar.id(), self.menubar.as_ptr_mut());
         self.ui_context.register_widget(id, ptr);
+        let (id, ptr) = (self.btn_accounts.id(), self.btn_accounts.as_ptr_mut());
+        self.ui_context.register_widget(id, ptr);
         let (id, ptr) = (self.btn_compose_send.id(), self.btn_compose_send.as_ptr_mut());
         self.ui_context.register_widget(id, ptr);
         let (id, ptr) = (self.btn_compose_cancel.id(), self.btn_compose_cancel.as_ptr_mut());
@@ -1290,29 +1292,12 @@ impl ClearEmailApp {
         let w_f32 = self.width as f32;
         let h_f32 = self.height as f32;
 
-        let sidebar_w = self.paginator.sidebar_w();
-        let list_x = sidebar_w + 10.0;
+        let list_x = 10.0;
         let detail_x = list_x + 325.0;
         let separator_x = list_x + 310.0;
-        let tab_w = 46.0;
-        let margin_x = (sidebar_w - tab_w) / 2.0;
-
-        // Sidebar button/tab text now rides along with their chrome in display_list's
-        // paint_root_into walk — only app-composed labels are emitted here.
 
-        // Sidebar Folder Badges
-        let inbox_unread = self.emails.iter().filter(|e| e.folder == "inbox" && !e.read).count();
-        if inbox_unread > 0 {
-            let badge_text = inbox_unread.to_string();
-            let est_w = TextLabel::estimate_width(&badge_text, 10.0);
-            labels.push(TextLabel {
-                text: badge_text,
-                x: margin_x + (tab_w - est_w) / 2.0,
-                y: 114.0 + MENUBAR_H, // centers in the badge pill (by + 20 + ~2)
-                font_size: 10.0,
-                color: [0xff, 0xff, 0xff],
-            });
-        }
+        // Widget text rides along with chrome in display_list's paint_root_into
+        // walk — only app-composed labels are emitted here.
 
         // 3. Email List Labels / Accounts list labels.
         // Skipped while a modal is up: text always renders above geometry, and these
@@ -1538,17 +1523,17 @@ impl Application for ClearEmailApp {
         cce_ui::scale::set_scale_factor(1.0);
         let btn_compose = Button::new(10.0, 15.0, 26.0, 26.0).with_label("+");
 
+        // Folder selection is the bar's right-aligned title dropdown (the
+        // designer's pane-switcher idiom); Accounts is a plain button beside it.
         let menubar = MenuBar::new(0.0, 0.0, 800.0, MENUBAR_H)
             .with_recess(true)
+            .with_title("Inbox")
+            .with_right_aligned_title(true)
             .with_item("Mail", &["New Message", "Sync Now", "Quit"])
             .with_item("Message", &["Reply", "Delete", "Mark Read/Unread"])
-            .with_item("View", &["Inbox", "Sent", "Trash", "Accounts"]);
-        let paginator = Paginator::new(vec![
-            "Inbox".to_string(),
-            "Sent".to_string(),
-            "Trash".to_string(),
-            "Accounts".to_string(),
-        ]);
+            .with_context_options(vec!["Inbox".to_string(), "Sent".to_string(), "Trash".to_string()], 0);
+
+        let btn_accounts = Button::new(0.0, 5.0, 90.0, 26.0).with_label("Accounts");
 
         let mut search_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true);
         search_box.font_size = 11.0;
@@ -1604,7 +1589,7 @@ impl Application for ClearEmailApp {
             keys: EmailKeys::load(),
             btn_compose,
             menubar,
-            paginator,
+            btn_accounts,
             search_box,
             email_list,
             email_buttons: Vec::new(),
@@ -1953,10 +1938,6 @@ impl Application for ClearEmailApp {
                 self.needs_rebuild = true;
             }
         }
-        if self.paginator.tick(dt, &mut self.ui_context) {
-            *needs_rebuild = true;
-            self.needs_rebuild = true;
-        }
     }
 
     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
@@ -1985,13 +1966,10 @@ impl Application for ClearEmailApp {
             Folder::Accounts => "accounts",
         };
 
-        let sidebar_w = self.paginator.sidebar_w();
-        let list_x = sidebar_w + 10.0;
+        let list_x = 10.0;
         let detail_x = list_x + 325.0;
         let separator_x = list_x + 310.0;
         let detail_panel_x = separator_x + 1.0;
-        let tab_w = 46.0;
-        let margin_x = (sidebar_w - tab_w) / 2.0;
 
         if self.needs_rebuild || size_changed {
             // Sidebar buttons layout
@@ -2003,26 +1981,37 @@ impl Application for ClearEmailApp {
             if self.menubar.popover_rect().is_some() {
                 self.ui_context.register_popover(&mut self.menubar);
             }
-            self.btn_compose.set_rect((sidebar_w - 26.0) / 2.0, 15.0 + MENUBAR_H, 26.0, 26.0);
+            // Bar title = current folder (the context-dropdown trigger), with
+            // the inbox unread count folded in where the sidebar badge lived.
+            let inbox_unread = self.emails.iter().filter(|e| e.folder == "inbox" && !e.read).count();
+            self.menubar.title = match self.current_folder {
+                Folder::Inbox if inbox_unread > 0 => format!("Inbox ({})", inbox_unread),
+                Folder::Inbox => "Inbox".to_string(),
+                Folder::Sent => "Sent".to_string(),
+                Folder::Trash => "Trash".to_string(),
+                Folder::Accounts => "Accounts".to_string(),
+            };
+            if let Some(ci) = match self.current_folder {
+                Folder::Inbox => Some(0),
+                Folder::Sent => Some(1),
+                Folder::Trash => Some(2),
+                Folder::Accounts => None,
+            } {
+                self.menubar.set_context_selected(ci);
+            }
+            // Fixed offset from the right edge — anchoring to the title would
+            // make the button drift as the folder name changes length.
+            self.btn_accounts.set_rect(w_f32 - 250.0, 5.0, 90.0, 26.0);
 
-            // Set paginator layout — tabs start below the compose button (the two used
-            // to stack at y=0 and the "+" sat buried under the Inbox tab).
             cce_ui::scale::set_scale_factor(scale as f32);
-            self.paginator.set_rect(0.0, 60.0 + MENUBAR_H, sidebar_w, h_f32 - 60.0 - MENUBAR_H);
-            let folder_idx = match self.current_folder {
-                Folder::Inbox => 0,
-                Folder::Sent => 1,
-                Folder::Trash => 2,
-                Folder::Accounts => 3,
-            };
-            self.paginator.set_selected_page(folder_idx);
+            self.btn_compose.set_rect(list_x, 15.0 + MENUBAR_H, 26.0, 26.0);
 
             // Search box / Add Account and Scrolling list
             let list_count = if self.current_folder == Folder::Accounts {
-                self.btn_manage_accounts.set_rect(list_x, 15.0 + MENUBAR_H, 300.0, 26.0);
+                self.btn_manage_accounts.set_rect(list_x + 36.0, 15.0 + MENUBAR_H, 264.0, 26.0);
                 self.accounts.len()
             } else {
-                self.search_box.set_rect(list_x, 15.0 + MENUBAR_H, 300.0, 26.0);
+                self.search_box.set_rect(list_x + 36.0, 15.0 + MENUBAR_H, 264.0, 26.0);
                 
                 // Get filtered emails count for bounds setup
                 self.emails.iter()
@@ -2160,25 +2149,13 @@ impl Application for ClearEmailApp {
         // 1. General window background (deep slate blue)
         quads.push((0.0, 0.0, w_f32, h_f32, [0.05, 0.05, 0.07, 1.0]));
 
-        // 2. Sidebar background panel
-        quads.push((0.0, 0.0, sidebar_w, h_f32, [0.08, 0.08, 0.12, 1.0]));
-        quads.push((sidebar_w, 0.0, 1.0, h_f32, [0.18, 0.18, 0.22, 1.0])); // sidebar separator
-
         // Compose Button and Folders Graphics — full paint walk: chrome (rounded rects,
         // hover/selected states) AND text in one pass. The legacy extra_quads bridge only
         // forwarded plain Prim::Quads, so every widget's rounded chrome was dropped.
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.btn_compose, &mut *quads.pc);
-        // Menubar painted after the other roots below cannot work — its dropdown
-        // must overlay them, so it is painted at the END of display_list instead.
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.paginator, &mut *quads.pc);
-
-        // Draw badge pill for inbox unread (centered on vertical rotated tab)
-        let inbox_unread = self.emails.iter().filter(|e| e.folder == "inbox" && !e.read).count();
-        if inbox_unread > 0 {
-            let bx = margin_x;
-            let by = 92.0 + MENUBAR_H; // lower edge of the Inbox tab (tabs start 60 below the menubar)
-            quads.push((bx + (tab_w - 22.0) / 2.0, by + 20.0, 22.0, 16.0, [0.20, 0.45, 0.85, 0.8]));
-        }
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.btn_accounts, &mut *quads.pc);
+        // The menubar itself paints at the END of display_list — its dropdown
+        // must overlay every pane beneath.
 
         // 3. Email List Panel Separator
         quads.push((separator_x, MENUBAR_H, 1.0, h_f32 - MENUBAR_H, [0.18, 0.18, 0.22, 1.0]));
@@ -2227,15 +2204,15 @@ impl Application for ClearEmailApp {
         if self.current_folder == Folder::Accounts {
             if self.selected_account_idx < self.accounts.len() {
                 // Top action toolbar background
-                quads.push((detail_panel_x, 0.0, w_f32 - detail_panel_x, 42.0, [0.08, 0.08, 0.12, 1.0]));
-                quads.push((detail_panel_x, 42.0, w_f32 - detail_panel_x, 1.0, [0.18, 0.18, 0.22, 1.0]));
+                quads.push((detail_panel_x, MENUBAR_H, w_f32 - detail_panel_x, 42.0, [0.08, 0.08, 0.12, 1.0]));
+                quads.push((detail_panel_x, MENUBAR_H + 42.0, w_f32 - detail_panel_x, 1.0, [0.18, 0.18, 0.22, 1.0]));
 
             }
         } else if let Some(selected_id) = self.selected_email_id {
             if self.emails.iter().any(|e| e.id == selected_id) {
                 // Top action toolbar background
-                quads.push((detail_panel_x, 0.0, w_f32 - detail_panel_x, 42.0, [0.08, 0.08, 0.12, 1.0]));
-                quads.push((detail_panel_x, 42.0, w_f32 - detail_panel_x, 1.0, [0.18, 0.18, 0.22, 1.0]));
+                quads.push((detail_panel_x, MENUBAR_H, w_f32 - detail_panel_x, 42.0, [0.08, 0.08, 0.12, 1.0]));
+                quads.push((detail_panel_x, MENUBAR_H + 42.0, w_f32 - detail_panel_x, 1.0, [0.18, 0.18, 0.22, 1.0]));
 
                 cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.btn_reply, &mut *quads.pc);
                 cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.btn_delete, &mut *quads.pc);
@@ -2391,12 +2368,7 @@ impl Application for ClearEmailApp {
         } else {
             // Sidebar buttons
             if ctx.propagate_event(&mv, self.btn_compose.id()) { changed = true; }
-            let sidebar_w = self.paginator.sidebar_w();
-            if px < sidebar_w {
-                // Self-routing composite: handle_event, not propagate — the router's
-                // children-first descent would let the embedded strip consume this.
-                if self.paginator.handle_event(&mv, ctx) { changed = true; }
-            }
+            if ctx.propagate_event(&mv, self.btn_accounts.id()) { changed = true; }
 
             // Search / Add account and lists
             if self.current_folder == Folder::Accounts {
@@ -2406,7 +2378,7 @@ impl Application for ClearEmailApp {
             }
             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;
+            self.detail_hovered = px > 10.0 + 310.0;
 
             for btn in &mut self.email_buttons {
                 if btn.rect().0 > -9000.0 {
@@ -2461,10 +2433,30 @@ impl Application for ClearEmailApp {
 
         let ctx = &mut self.ui_context;
 
-        // Menubar first — an open dropdown overlays the panes, so a handled
+        // Accounts button first: it sits inside the bar band, and the menubar
+        // consumes any press within its rect — routed after, it would never
+        // see the click.
+        if ctx.propagate_event(&ev, self.btn_accounts.id()) {
+            if state == ElementState::Released && self.btn_accounts.take_click() {
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+                return Some(AppMessage::SwitchFolder(Folder::Accounts));
+            }
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+            return None;
+        }
+
+        // Menubar next — an open dropdown overlays the panes, so a handled
         // press/release must not fall through to the content beneath it.
         if ctx.propagate_event(&ev, self.menubar.id()) {
-            if let Some((menu_idx, item_idx)) = self.menubar.menu_click() {
+            if let Some(idx) = self.menubar.take_context_change() {
+                msg_out = Some(AppMessage::SwitchFolder(match idx {
+                    0 => Folder::Inbox,
+                    1 => Folder::Sent,
+                    _ => Folder::Trash,
+                }));
+            } else if let Some((menu_idx, item_idx)) = self.menubar.menu_click() {
                 msg_out = match (menu_idx, item_idx) {
                     (0, 0) => Some(AppMessage::ComposeNew),
                     (0, 1) => Some(AppMessage::SyncNow),
@@ -2472,10 +2464,6 @@ impl Application for ClearEmailApp {
                     (1, 0) => Some(AppMessage::Reply),
                     (1, 1) => Some(AppMessage::DeleteSelected),
                     (1, 2) => Some(AppMessage::ToggleUnread),
-                    (2, 0) => Some(AppMessage::SwitchFolder(Folder::Inbox)),
-                    (2, 1) => Some(AppMessage::SwitchFolder(Folder::Sent)),
-                    (2, 2) => Some(AppMessage::SwitchFolder(Folder::Trash)),
-                    (2, 3) => Some(AppMessage::SwitchFolder(Folder::Accounts)),
                     _ => None,
                 };
             }
@@ -2534,22 +2522,6 @@ impl Application for ClearEmailApp {
                     msg_out = Some(AppMessage::ComposeNew);
                 }
             }
-            let sidebar_w = self.paginator.sidebar_w();
-            if px < sidebar_w {
-                if self.paginator.handle_event(&ev, ctx) {
-                    changed = true;
-                    if let Some((page, _)) = self.paginator.menu_click() {
-                        let folder = match page {
-                            0 => Folder::Inbox,
-                            1 => Folder::Sent,
-                            2 => Folder::Trash,
-                            3 => Folder::Accounts,
-                            _ => Folder::Inbox,
-                        };
-                        msg_out = Some(AppMessage::SwitchFolder(folder));
-                    }
-                }
-            }
 
             if self.current_folder == Folder::Accounts {
                 if ctx.propagate_event(&ev, self.btn_manage_accounts.id()) {
@@ -2687,7 +2659,7 @@ impl Application for ClearEmailApp {
             && self.current_folder != Folder::Accounts
             && self.selected_email_id.is_some()
         {
-            let separator_x = self.paginator.sidebar_w() + 10.0 + 310.0;
+            let separator_x = 10.0 + 310.0;
             if px > separator_x {
                 let dy = match delta {
                     MouseScrollDelta::LineDelta(_, y) => -y * 24.0,