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

commitf73f3b09e18dd0400ba7d5638dbbc2449e91647a
parent4392b57449
authorLucas Galante <[email protected]>
date2026-08-21 12:06
refactor: the whole bar is Dropdowns — MenuBar retired

Mail and Message become menu-button Dropdowns (custom_display_text =
fixed trigger label; command rows re-fire on repeat, which the widget
already special-cases), the folder switcher becomes a selection
Dropdown whose options[0] carries the live inbox unread count, and the
account switcher already was one. All four now share one widget, one
trough chrome, one grown-trigger animation, and the engine's popover
sweep; the app-side seams (menu_click index mapping, context-change
polling, title formatting) collapse into four take_change handlers.

The bar's recessed chrome — previously MenuBar's recessed paint — is
carved directly in display_list (bottom-wall-only recess_edges, the
flush-plateau idiom from menu.rs).

Live-verified: Mail > Sync Now fires a real sync (toast + fetch),
folder Sent/Inbox round-trip switches the list and relabels the
trigger, menu rows show no selection marker, folder rows do.

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

 src/main.rs | 184 +++++++++++++++++++++++++++++++++++++++---------------------
 1 file changed, 120 insertions(+), 64 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 5373b10..7629b7d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5,7 +5,7 @@ use cce_ui::cosmic_text::FontSystem;
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::widget::{
     MouseButton, ElementState, MouseScrollDelta, KeyEvent, WidgetHost,
-    TextBox, Button, TextLabel, Key, MenuController, MenuBar
+    TextBox, Button, TextLabel, Key, Dropdown
 };
 use cce_ui::context::UiContext;
 use native_tls::TlsConnector;
@@ -130,14 +130,20 @@ impl EmailKeys {
 struct ClearEmailApp {
     keys: EmailKeys,
 
-    // Navigation / Sidebar
+    // Navigation / Sidebar — the whole bar is Dropdowns (MenuBar retired):
+    // two menu-button dropdowns (custom_display_text = fixed trigger label,
+    // rows are commands that re-fire on repeat) and two selection dropdowns.
     btn_compose: cce_ui::widget::Adapted<cce_ui::widget::Button>,
-    menubar: cce_ui::widget::Adapted<MenuBar>,
+    mail_menu: cce_ui::widget::Adapted<Dropdown>,
+    message_menu: cce_ui::widget::Adapted<Dropdown>,
+    /// Folder switcher: options[0] carries the live inbox unread count
+    /// ("Inbox (6)"), refreshed each rebuild, so rows and trigger agree.
+    folder_dropdown: cce_ui::widget::Adapted<Dropdown>,
     /// Account switcher beside the folder dropdown: options are the account
     /// emails plus a trailing "Manage Accounts…" pseudo-entry (management
     /// lives in cce-system-interface). Options refresh from accounts.json on
     /// every open — the job the retired Accounts page did on entry.
-    account_dropdown: cce_ui::widget::Adapted<cce_ui::widget::Dropdown>,
+    account_dropdown: cce_ui::widget::Adapted<Dropdown>,
 
     // Search and List View
     search_box: cce_ui::widget::Adapted<TextBox>,
@@ -1762,7 +1768,11 @@ impl ClearEmailApp {
         self.ui_context.register_widget(id, ptr);
         let (id, ptr) = (self.btn_compose.id(), self.btn_compose.as_ptr_mut());
         self.ui_context.register_widget(id, ptr);
-        let (id, ptr) = (self.menubar.id(), self.menubar.as_ptr_mut());
+        let (id, ptr) = (self.mail_menu.id(), self.mail_menu.as_ptr_mut());
+        self.ui_context.register_widget(id, ptr);
+        let (id, ptr) = (self.message_menu.id(), self.message_menu.as_ptr_mut());
+        self.ui_context.register_widget(id, ptr);
+        let (id, ptr) = (self.folder_dropdown.id(), self.folder_dropdown.as_ptr_mut());
         self.ui_context.register_widget(id, ptr);
         let (id, ptr) = (self.account_dropdown.id(), self.account_dropdown.as_ptr_mut());
         self.ui_context.register_widget(id, ptr);
@@ -2083,16 +2093,28 @@ 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); the account switcher is a real
-        // Dropdown 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_context_options(vec!["Inbox".to_string(), "Sent".to_string(), "Drafts".to_string(), "Trash".to_string()], 0);
+        // The bar: four Dropdowns. Mail/Message are menu-button dropdowns
+        // (fixed trigger label, command rows that re-fire on repeat — the
+        // custom_display_text mode); folder + account are selection dropdowns.
+        // The recessed bar chrome itself is carved in display_list.
+        let bar_font = cce_ui::layout::parse_font_string(&cce_ui::layout::menubar_font()).0;
+        let mail_menu = Dropdown::new(
+            vec!["New Message".to_string(), "Sync Now".to_string(), "Quit".to_string()],
+            0,
+        )
+        .with_custom_display_text("Mail")
+        .with_font_family(&bar_font);
+        let message_menu = Dropdown::new(
+            vec!["Reply".to_string(), "Delete".to_string(), "Mark Read/Unread".to_string()],
+            0,
+        )
+        .with_custom_display_text("Message")
+        .with_font_family(&bar_font);
+        let folder_dropdown = Dropdown::new(
+            vec!["Inbox".to_string(), "Sent".to_string(), "Drafts".to_string(), "Trash".to_string()],
+            0,
+        )
+        .with_font_family(&bar_font);
 
         let mut search_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true);
         search_box.font_size = 11.0;
@@ -2112,11 +2134,11 @@ impl Application for ClearEmailApp {
             .or_else(|| accounts.iter().position(|a| a.is_default))
             .unwrap_or(0);
 
-        let account_dropdown = cce_ui::widget::Dropdown::new(
+        let account_dropdown = Dropdown::new(
             account_dropdown_options(&accounts),
             selected_account_idx,
         )
-        .with_font_family(&cce_ui::layout::parse_font_string(&cce_ui::layout::menubar_font()).0);
+        .with_font_family(&bar_font);
 
         let mut detail_body = TextBox::new(String::new()).with_multiline(true).with_draw_bg_border(false);
         detail_body.font_size = 12.0;
@@ -2179,7 +2201,9 @@ impl Application for ClearEmailApp {
             last_sync_start,
             keys: EmailKeys::load(),
             btn_compose,
-            menubar,
+            mail_menu,
+            message_menu,
+            folder_dropdown,
             account_dropdown,
             search_box,
             email_list,
@@ -2669,36 +2693,42 @@ impl Application for ClearEmailApp {
         let detail_panel_x = separator_x + 1.0;
 
         if self.needs_rebuild || size_changed {
-            // Sidebar buttons layout
-            self.menubar.set_rect(0.0, 0.0, w_f32, MENUBAR_H);
-            // An open menubar dropdown is a popover: registration feeds the
-            // dl-text occlusion clamp, and the render loop at the end of this
-            // function draws it on top of everything.
+            // Bar layout — every open dropdown is a popover: registration
+            // feeds the dl-text occlusion clamp, and the popover pass at the
+            // end of this function draws it on top of everything.
             self.ui_context.clear_popovers();
-            if self.menubar.popover_rect().is_some() {
-                self.ui_context.register_popover(&mut self.menubar);
+            if self.mail_menu.popover_rect().is_some() {
+                self.ui_context.register_popover(&mut self.mail_menu);
+            }
+            if self.message_menu.popover_rect().is_some() {
+                self.ui_context.register_popover(&mut self.message_menu);
+            }
+            if self.folder_dropdown.popover_rect().is_some() {
+                self.ui_context.register_popover(&mut self.folder_dropdown);
             }
             if self.account_dropdown.popover_rect().is_some() {
                 self.ui_context.register_popover(&mut self.account_dropdown);
             }
-            // Bar title = current folder (the context-dropdown trigger), with
-            // the inbox unread count folded in where the sidebar badge lived.
+            // Folder rows and trigger both carry the live inbox unread count
+            // (options[0]), where the old bar title folded the badge in.
             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::Drafts => "Drafts".to_string(),
-                Folder::Trash => "Trash".to_string(),
+            self.folder_dropdown.options[0] = if inbox_unread > 0 {
+                format!("Inbox ({})", inbox_unread)
+            } else {
+                "Inbox".to_string()
             };
-            self.menubar.set_context_selected(match self.current_folder {
+            self.folder_dropdown.selected = match self.current_folder {
                 Folder::Inbox => 0,
                 Folder::Sent => 1,
                 Folder::Drafts => 2,
                 Folder::Trash => 3,
-            });
-            // Fixed offset from the right edge — anchoring to the folder title
-            // would make the switcher drift as the folder name changes length.
+            };
+            // Menus left; selectors right at fixed offsets from the edge —
+            // anchoring to the folder label would make the account switcher
+            // drift as the folder name changes length.
+            self.mail_menu.set_rect(8.0, 5.0, 80.0, 26.0);
+            self.message_menu.set_rect(96.0, 5.0, 112.0, 26.0);
+            self.folder_dropdown.set_rect(w_f32 - 142.0, 5.0, 134.0, 26.0);
             self.account_dropdown.set_rect(w_f32 - 360.0, 5.0, 210.0, 26.0);
 
             cce_ui::scale::set_scale_factor(scale as f32);
@@ -2841,16 +2871,30 @@ impl Application for ClearEmailApp {
         // 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);
+
+        // Bar chrome (was MenuBar's recessed paint): the bar is a plateau one
+        // step down from the plate, flush with its top-left, so its only wall
+        // is the bottom one facing the content — the other three sides are the
+        // plate's own rolled edge.
+        {
+            let depth = cce_ui::layout::bar_wall_width().min(MENUBAR_H * 0.6);
+            quads.pc.recess_edges(
+                cce_ui::scene::layout::Rect { x: 0.0, y: 0.0, width: w_f32, height: MENUBAR_H },
+                (0.0, 0.0, 0.0, 0.0),
+                depth,
+                (false, false, true, false),
+            );
+        }
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.mail_menu, &mut *quads.pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.message_menu, &mut *quads.pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.folder_dropdown, &mut *quads.pc);
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.account_dropdown, &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]));
 
         // Search box / Add Account and List
         self.search_box.prepare_text(&mut self.font_system);
-        cce_ui::widget::WidgetHost::prepare_text(&mut self.menubar, &mut self.font_system);
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.search_box, &mut *quads.pc);
         {
             let mut list_quads = Vec::new();
@@ -2998,9 +3042,6 @@ impl Application for ClearEmailApp {
 
 
 
-        // Menubar last: its open dropdown must overlay every pane beneath.
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.menubar, &mut __pc);
-
         // Popover pass (the data-editor pattern): geometry and labels on top of
         // everything, exactly where they hit-test; labels carry bounds equal to
         // the overlay rect (the is-overlay-text convention).
@@ -3051,9 +3092,9 @@ impl Application for ClearEmailApp {
         let mv = cce_ui::widget::Event::PointerMove { x: px, y: py, local_x: px, local_y: py };
         let ctx = &mut self.ui_context;
 
-        if ctx.propagate_event(&mv, self.menubar.id()) {
-            changed = true;
-        }
+        if ctx.propagate_event(&mv, self.mail_menu.id()) { changed = true; }
+        if ctx.propagate_event(&mv, self.message_menu.id()) { changed = true; }
+        if ctx.propagate_event(&mv, self.folder_dropdown.id()) { changed = true; }
 
         if self.compose_open {
             if ctx.propagate_event(&mv, self.compose_to.id()) { changed = true; }
@@ -3144,9 +3185,9 @@ impl Application for ClearEmailApp {
 
         let ctx = &mut self.ui_context;
 
-        // Account dropdown first: it sits inside the bar band, and the menubar
-        // consumes any press within its rect — routed after, it would never
-        // see the click.
+        // Account dropdown (the bar dropdowns route before the pane content —
+        // an open menu overlays the panes, so a handled press must not fall
+        // through to what's beneath it).
         {
             let was_open = self.account_dropdown.open;
             if ctx.propagate_event(&ev, self.account_dropdown.id()) {
@@ -3199,26 +3240,41 @@ impl Application for ClearEmailApp {
             }
         }
 
-        // 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(idx) = self.menubar.take_context_change() {
-                msg_out = Some(AppMessage::SwitchFolder(match idx {
+        // The menu-button dropdowns — command rows; take_change re-fires on
+        // the same row (custom_display_text mode), so Sync Now works twice.
+        if ctx.propagate_event(&ev, self.mail_menu.id()) {
+            if self.mail_menu.take_change() {
+                msg_out = match self.mail_menu.selected {
+                    0 => Some(AppMessage::ComposeNew),
+                    1 => Some(AppMessage::SyncNow),
+                    _ => Some(AppMessage::Quit),
+                };
+            }
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+            return msg_out;
+        }
+        if ctx.propagate_event(&ev, self.message_menu.id()) {
+            if self.message_menu.take_change() {
+                msg_out = match self.message_menu.selected {
+                    0 => Some(AppMessage::Reply),
+                    1 => Some(AppMessage::DeleteSelected),
+                    _ => Some(AppMessage::ToggleUnread),
+                };
+            }
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+            return msg_out;
+        }
+        // Folder switcher.
+        if ctx.propagate_event(&ev, self.folder_dropdown.id()) {
+            if self.folder_dropdown.take_change() {
+                msg_out = Some(AppMessage::SwitchFolder(match self.folder_dropdown.selected {
                     0 => Folder::Inbox,
                     1 => Folder::Sent,
                     2 => Folder::Drafts,
                     _ => 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),
-                    (0, 2) => Some(AppMessage::Quit),
-                    (1, 0) => Some(AppMessage::Reply),
-                    (1, 1) => Some(AppMessage::DeleteSelected),
-                    (1, 2) => Some(AppMessage::ToggleUnread),
-                    _ => None,
-                };
             }
             *needs_rebuild = true;
             self.needs_rebuild = true;