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

commit5553d00be8f8cc96c123c0204780a441faa41c98
parent15a59ba28e
authorLucas Galante <[email protected]>
date2026-08-22 14:24
feat: search is a band opened with "/"; the compose button goes

The + button duplicated Mail > New Message and the compose chord, and the
search box sat there permanently as chrome for something used
occasionally. Both leave: search is now revealed and focused by the
open_search chord, whose default changes from ctrl+f to "/", and it
carries placeholder text saying what it matches on.

Closed is the resting state, so the strip belongs to the list and
list_geom() is the single source for where the rows start — the band
pushes them down only while it is open. Escape closes and clears the
query (a hidden filter would read as missing mail); clicking away closes
an empty one but leaves a box holding a query, since that is the visible
reason the list is short.

The chord check is now guarded on !editing. That guard is load-bearing
with a bare-character chord: without it a typed "/" re-matches the
shortcut and never reaches the text.

Verified in the shadow session: button gone, band absent by default with
the list reclaiming its 40px, and — with the field temporarily defaulted
open — the placeholder, the full-width box, the pushed-down list, focus
on click, and the collapse when clicking away while empty. The "/" and
Escape keystrokes themselves could NOT be exercised there: keyboard
input never reaches clients under the headless backend (wlr_seat has no
keyboard device, so no keymap ever reaches the client), confirmed by the
pre-existing ctrl+n chord failing the same way as a control.

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

 src/main.rs | 109 +++++++++++++++++++++++++++++++++++++-----------------------
 1 file changed, 68 insertions(+), 41 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 88e51b1..55d1e27 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -148,7 +148,7 @@ impl EmailKeys {
     fn load() -> Self {
         Self {
             compose: cce_ui::input::app_chord("compose", "ctrl+n"),
-            open_search: cce_ui::input::app_chord("open_search", "ctrl+f"),
+            open_search: cce_ui::input::app_chord("open_search", "/"),
         }
     }
 }
@@ -159,7 +159,6 @@ struct ClearEmailApp {
     // 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>,
     mail_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.
@@ -215,6 +214,9 @@ struct ClearEmailApp {
     /// Message per row of the open card context menu, built beside its
     /// labels — index 0 is the inert subject header, hence Option.
     context_menu_actions: Vec<Option<AppMessage>>,
+    /// Whether the search band is revealed. Closed is the resting state:
+    /// the box is laid out, painted and routed only while this is set.
+    search_open: bool,
     compose_open: bool,
     compose_title: String,
     /// When the most recent IMAP sync was spawned — folder switches re-sync
@@ -472,6 +474,14 @@ const SPLIT_GRAB_SLOP: f32 = 4.0;
 /// (`ContextMenuState`), mirrored here because hit-testing is done app-side.
 const CONTEXT_ROW_H: f32 = 24.0;
 
+// The search band. It is not permanent chrome: the `open_search` chord
+// ("/" by default) reveals and focuses it, and closing hands its strip back
+// to the list, so `list_geom` is the single source for where the rows start.
+const SEARCH_ROW_Y: f32 = 15.0;
+const SEARCH_ROW_H: f32 = 26.0;
+const LIST_TOP_WITH_SEARCH: f32 = 55.0;
+const LIST_BOTTOM_PAD: f32 = 15.0;
+
 // Detail-pane vertical layout, every offset measured from MENUBAR_H. These
 // were literals scattered across paint, layout, the scrollbar geometry and
 // three input handlers; the 170/190 pair in particular had to move in
@@ -1873,8 +1883,6 @@ impl ClearEmailApp {
         self.ui_context.register_widget(id, ptr);
         let (id, ptr) = (self.compose_body.id(), self.compose_body.as_ptr_mut());
         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.mail_menu.id(), self.mail_menu.as_ptr_mut());
         self.ui_context.register_widget(id, ptr);
         let (id, ptr) = (self.folder_dropdown.id(), self.folder_dropdown.as_ptr_mut());
@@ -1926,6 +1934,14 @@ impl ClearEmailApp {
         (lw, sep, sep + LIST_DETAIL_GAP)
     }
 
+    /// The email list's band: (top y, height). The search row above it only
+    /// exists while the band is open, and the list takes that strip back when
+    /// it closes — so nothing hardcodes the top.
+    fn list_geom(&self) -> (f32, f32) {
+        let top = if self.search_open { LIST_TOP_WITH_SEARCH } else { SEARCH_ROW_Y } + MENUBAR_H;
+        (top, (self.height as f32 - top - LIST_BOTTOM_PAD).max(50.0))
+    }
+
     /// The rows the list is currently showing, in paint order — the folder
     /// filter plus the search box. A row index means nothing without this:
     /// it is what maps the card under the pointer to its message.
@@ -2330,7 +2346,6 @@ impl Application for ClearEmailApp {
 
     fn new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<Self::Message>) -> Self {
         cce_ui::scale::set_scale_factor(1.0);
-        let btn_compose = Button::new(10.0, 15.0, 26.0, 26.0).with_label("+");
 
         // The bar: three Dropdowns. Mail is a menu-button dropdown (fixed
         // trigger label, command rows that re-fire on repeat — the
@@ -2351,7 +2366,10 @@ impl Application for ClearEmailApp {
         )
         .with_font_family(&bar_font);
 
-        let mut search_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true);
+        let mut search_box = TextBox::new(String::new())
+            .with_multiline(false)
+            .with_draw_bg_border(true)
+            .with_placeholder("Search mail — sender, subject or body");
         search_box.font_size = 11.0;
 
         let email_list = ScrollRegion::new(54.0, 4.0);
@@ -2431,7 +2449,6 @@ impl Application for ClearEmailApp {
         Self {
             last_sync_start,
             keys: EmailKeys::load(),
-            btn_compose,
             mail_menu,
             folder_dropdown,
             account_dropdown,
@@ -2461,6 +2478,7 @@ impl Application for ClearEmailApp {
             list_w: load_list_w().unwrap_or(LIST_W_DEFAULT),
             split_dragging: false,
             context_menu_actions: Vec::new(),
+            search_open: false,
             compose_open,
             compose_title,
             status_message: None,
@@ -2962,10 +2980,15 @@ impl Application for ClearEmailApp {
             self.account_dropdown.set_rect(w_f32 - 360.0, 5.0, 210.0, 26.0);
 
             cce_ui::scale::set_scale_factor(scale as f32);
-            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, list_w - 36.0, 26.0);
+            // The search box spans the band when open, and parks off-screen
+            // when closed so a stale rect can't be hit by anything that still
+            // routes to it.
+            if self.search_open {
+                self.search_box.set_rect(list_x, SEARCH_ROW_Y + MENUBAR_H, list_w, SEARCH_ROW_H);
+            } else {
+                self.search_box.set_rect(-9999.0, -9999.0, 0.0, 0.0);
+            }
 
             // Get filtered emails count for bounds setup
             let list_count = self.emails.iter()
@@ -2983,8 +3006,9 @@ impl Application for ClearEmailApp {
                 })
                 .count();
 
-            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);
+            let (list_top, list_h) = self.list_geom();
+            self.email_list.set_rect(list_x, list_top, list_w, list_h);
+            self.email_list.update_bounds(list_count, list_top, list_h);
 
             if self.email_buttons.len() != list_count {
                 // Widget ids are globally monotonic and never reused, so the fresh buttons
@@ -3093,11 +3117,6 @@ 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]));
 
-        // 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);
-
         // 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
@@ -3118,9 +3137,11 @@ impl Application for ClearEmailApp {
         // 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::scene::painter::paint_root_into(&self.ui_context, &self.search_box, &mut *quads.pc);
+        // Search band (only while open) and the list
+        if self.search_open {
+            self.search_box.prepare_text(&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();
             self.email_list.push_quads(&mut list_quads);
@@ -3398,12 +3419,10 @@ impl Application for ClearEmailApp {
             if ctx.propagate_event(&mv, self.btn_compose_cancel.id()) { changed = true; }
             if ctx.propagate_event(&mv, self.btn_compose_attach.id()) { changed = true; }
         } else {
-            // Sidebar buttons
-            if ctx.propagate_event(&mv, self.btn_compose.id()) { changed = true; }
             if ctx.propagate_event(&mv, self.account_dropdown.id()) { changed = true; }
 
             // Search and lists
-            if ctx.propagate_event(&mv, self.search_box.id()) { changed = true; }
+            if self.search_open && 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 > separator_x;
@@ -3687,16 +3706,8 @@ impl Application for ClearEmailApp {
                 }
             }
         } else {
-            // Sidebar buttons
-            if ctx.propagate_event(&ev, self.btn_compose.id()) {
-                changed = true;
-                if state == ElementState::Released && self.btn_compose.take_click() {
-                    msg_out = Some(AppMessage::ComposeNew);
-                }
-            }
-
-            // Search input
-            if ctx.propagate_event(&ev, self.search_box.id()) {
+            // Search input — only reachable while the band is open.
+            if self.search_open && ctx.propagate_event(&ev, self.search_box.id()) {
                 changed = true;
                 if state == ElementState::Pressed { ctx.set_focused(&mut self.search_box); }
                 if self.search_box.take_change() {
@@ -3705,6 +3716,12 @@ impl Application for ClearEmailApp {
             } else if state == ElementState::Pressed && button == MouseButton::Left {
                 ctx.clear_focus();
                 self.search_box.unfocus();
+                // Clicking away from an EMPTY search collapses the band —
+                // an unfocused box with no query is dead chrome. One holding
+                // a query stays: it is the visible reason the list is short.
+                if self.search_open && self.search_box.text.is_empty() && self.search_box.edit_buffer.is_empty() {
+                    self.search_open = false;
+                }
                 changed = true;
             }
 
@@ -3853,12 +3870,16 @@ impl Application for ClearEmailApp {
                 if cce_ui::widget::match_key_shortcut(event, &self.keys.compose) {
                     msg_out = Some(AppMessage::ComposeNew);
                     handled = true;
-                } else if cce_ui::widget::match_key_shortcut(event, &self.keys.open_search) {
-                    {
-                        ctx.set_focused(&mut self.search_box);
-                        self.search_box.focus();
-                        handled = true;
-                    }
+                } else if !self.search_box.editing
+                    && cce_ui::widget::match_key_shortcut(event, &self.keys.open_search)
+                {
+                    // The !editing guard is load-bearing now the chord is a
+                    // bare "/": without it, typing a slash into the open box
+                    // would re-match here and never reach the text.
+                    self.search_open = true;
+                    ctx.set_focused(&mut self.search_box);
+                    self.search_box.focus();
+                    handled = true;
                 }
             }
 
@@ -3902,11 +3923,17 @@ impl Application for ClearEmailApp {
                 }
             }
 
-            // Escape unfocuses search
+            // Escape closes the search band and drops the query with it, so
+            // the list is unfiltered again — leaving a hidden filter behind
+            // would look like mail had gone missing.
             if !handled && event.state == ElementState::Pressed && event.logical_key == Key::Named(cce_ui::widget::NamedKey::Escape) {
-                if self.search_box.editing {
+                if self.search_open {
                     ctx.clear_focus();
                     self.search_box.unfocus();
+                    self.search_box.text.clear();
+                    self.search_box.edit_buffer.clear();
+                    self.search_open = false;
+                    msg_out = Some(AppMessage::SearchChanged);
                     handled = true;
                 }
             }