git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

commit967d3c53e9212e331e42cc504dd3d848faf8adf2
parent9aa6b235d4
authorLucas Galante <[email protected]>
date2026-09-19 00:18
Give the Apps launcher a System tab, stepped to with Tab

super+d opened on one flat list of .desktop entries, so every window and
session verb the DE has lived only on a keybinding you had to already know.
FuzzelWidget now splits its list into TabPages: an Apps page and a System
page of SYSTEM_COMMANDS — the window-manager verbs driven through ccectl
(close, minimize, fullscreen, center, the overlays, both tiling-mode
cycles, retile, overview, the zooms, screenshot), plus reload/restart/log
out and the power commands. Tab steps forward, Shift+Tab back, and a click
on the strip works too. Each page keeps its own query, so switching back
lands on the rows you left.

Those window verbs act on the window BEHIND the popup, which is the only
reason "Close Window" from a launcher means anything: the compositor's
focused_window skips overlay UI and names cce-cloud explicitly among it,
falling back to the most recent real window.

Tab only switches tabs where tabs exist. Below two it keeps its old job of
cycling the highlight, and that fallback is load-bearing — the Super-Tab
window switcher is Dmenu mode, and Tab is the key the whole feature is
named for. Apps is therefore the only tabbed mode.

Two places had to be taught which page they mean. The stdin/socket feed
addresses tab 0 through set_tab_items: check_stdin_updates skips the
ingest when the items match what it last pushed, and compared against the
ACTIVE tab that test would differ on every poll and clobber the page being
read. And a tab click is handled in the pointer branch ahead of the row
branch, next to the scrollbar press and for the same reason — any press
on_event resolves is treated there as a row selection and, in
Dmenu/switcher mode, committed.

The list geometry moves behind search_y()/list_y()/list_h() instead of the
`let pad = 15.0; let search_h = 35.0;` locals that were repeated in each of
the paint, scroll and hit-test paths: the strip shifts the whole list down
by its own height, and a path that missed the shift would put the rows, the
clip and the click out of step. The icon gutter becomes per-tab, so the
System page sits flush left while Apps keeps its column.

Verified in a headless shadow: the strip renders with the active segment
raised, Tab and a click both switch, per-page queries survive a round trip,
two Tabs in --dmenu still walk the highlight, and "Close Window" picked
from the System tab closed a foot window sitting behind the popup.

Note that the first half of this change is already in 9aa6b23, whose
message describes only the close-fade rework: that commit was made from
another session with this work uncommitted in the shared tree and swept it
up. It is already pushed, so it is left alone rather than rewritten.

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

 CLAUDE.md   |  51 +++++++++++++++
 src/main.rs | 213 ++++++++++++++++++++++++++++++++++++++++++++++++++----------
 2 files changed, 229 insertions(+), 35 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2e4a588..f9b9d09 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -87,6 +87,10 @@ needs-stdin decision, in `run_client()`).
   Icons are uploaded per popup on purpose (`cce_ui::icon::upload_themed` caches
   the decode, not the image id) because `Drop` destroys this app's `VkRenderer`
   between popups and an id cached across them would name freed GPU resources.
+  Apps is also the one **tabbed** mode: the list carries an *Apps* page and a
+  *System* page of DE verbs (`SYSTEM_COMMANDS` — window-manager actions through
+  `ccectl`, plus session/power commands), and **Tab / Shift+Tab step between
+  them**. See "Tabs" below.
 - `Json` — a `JsonLayoutConfig` read from stdin builds a widget panel; clicking a
   button prints one JSON object with the button id and every control's state
   (`{"button", "checkboxes", "spinboxes", "colors", "sliders"}`) and closes.
@@ -95,6 +99,53 @@ Key flags: `-p/--prompt`, `-s/--select <item>`, `-x/-y` (position → forces lay
 anchoring), `--align-right`, `--parent-app-id` (app_id becomes `cce-cloud:<parent>`),
 `--switcher`, and mode flags `--apps|--path|--dmenu|--json` (or `--mode <m>`).
 
+### Tabs
+
+`FuzzelWidget::tabs` splits the list into `TabPage`s. Fewer than two draws no
+strip and claims no height (`tab_strip_h()` is 0), which is what leaves Dmenu,
+Path and the Super-Tab window switcher laid out and keyed exactly as they were —
+**Tab only switches tabs where tabs exist**, and falls back to its old job of
+cycling the highlight everywhere else. That fallback is load-bearing: the
+switcher is Dmenu mode, and its Tab is the key the whole feature is named for.
+
+Three things to know before touching it:
+
+- **The active page's items and query live in `all_items` / `query`, not in its
+  `TabPage`.** Every pre-existing caller reads them there, and only
+  `switch_tab` moves them across — so a page's own copies are stale for as long
+  as it is the active one. Parking the query is what makes switching back land
+  on the same filtered rows.
+- **The stdin/socket feed addresses tab 0, through `set_tab_items`,** never
+  `set_items` directly. `check_stdin_updates` skips the ingest when the items
+  match what it last pushed; compared against the *active* tab that test would
+  differ on every poll and clobber the page the user is reading.
+- **A tab click is handled in the pointer branch, ahead of the row branch** —
+  next to the scrollbar press, and for the same reason. Any press
+  `FuzzelWidget::on_event` resolves is treated there as a row selection and (in
+  Dmenu/switcher mode) committed, so a tab click routed through it would choose
+  a row and close the popup.
+
+The list geometry is derived from `search_y()` / `list_y()` / `list_h()` rather
+than the `let pad = 15.0; let search_h = 35.0;` locals that used to be repeated
+in each of the paint, scroll and hit-test paths: the strip shifts the whole list
+down by its own height, and a path that missed the shift would put the rows, the
+clip and the click out of step. The icon gutter is per-tab
+(`recompute_icon_gutter`), so the System page's rows sit flush left while the
+Apps page keeps its column.
+
+The strip is painted by hand in the toolkit's recessed `ButtonStrip` idiom — one
+well carved into the window plate, segments on its floor, the active one a
+raised `control_plate` — because this widget paints straight onto the `PaintCtx`
+and has no child layout pass to host a real `ButtonStrip`.
+
+`SYSTEM_COMMANDS` is hardcoded, not config-driven: the rows are the DE's own
+verbs, and a row naming a command `ccectl` does not have is one that silently
+does nothing when picked (`every_system_row_runs_something` pins the lookup the
+commit path makes). The window verbs act on the window *behind* the popup — the
+compositor's `focused_window` skips overlay UI and names `cce-cloud` among it,
+falling back to the most recent real window — which is the only reason "Close
+Window" from a launcher means anything.
+
 ### Rendering: hand-rolled loop on `cce_ui::vk`, not the cce-ui engine runner
 
 Unlike most cce clients, this app does **not** implement the `Application` trait or use
diff --git a/src/main.rs b/src/main.rs
index 6b2e32d..c9df5aa 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1641,6 +1641,18 @@ impl State {
             );
             fuzzel.set_item_icons(icons);
 
+            // Apps is the only tabbed mode. Dmenu carries arbitrary caller
+            // items (and the Super-Tab window switcher, whose Tab key must
+            // keep cycling the highlight), Path is a raw $PATH dump, and Json
+            // is not a list at all.
+            fuzzel.set_tabs(vec![
+                ("Apps".to_string(), Vec::new()),
+                (
+                    SYSTEM_TAB_TITLE.to_string(),
+                    SYSTEM_COMMANDS.iter().map(|c| c.name.to_string()).collect(),
+                ),
+            ]);
+
             if let Ok(mut lock_state) = stdin_state.lock() {
                 lock_state.items = app_names;
                 lock_state.new_data = true;
@@ -1750,8 +1762,11 @@ impl State {
 
                 let mut changed = false;
                 let items = lock.items.clone();
-                if self.fuzzel.all_items != items {
-                    self.fuzzel.set_items(items);
+                // Against tab 0's items, not the active tab's: the feed only
+                // ever fills the mode's own list, and comparing against
+                // whatever tab the user is reading would differ every time.
+                if self.fuzzel.tab_items(0) != items.as_slice() {
+                    self.fuzzel.set_tab_items(0, items);
                     changed = true;
                     if let Some(ref select_name) = self.select_item {
                         let select_lower = select_name.to_lowercase();
@@ -1809,7 +1824,7 @@ impl State {
         }
         let num_items = self.fuzzel.filtered_items.len();
         let item_count = if num_items == 0 { 1 } else { num_items };
-        let needed_height = 75.0 + (item_count as f32) * 25.0;
+        let needed_height = 75.0 + self.fuzzel.tab_strip_h() + (item_count as f32) * 25.0;
         let target_height = needed_height.min(self.max_height as f32);
 
         // Calculate max text width
@@ -1842,6 +1857,24 @@ impl State {
             }
         }
 
+        // The strip's segments are equal shares of the run, so the whole run
+        // has to hold its widest title n times over or the narrowest tab
+        // clips. (Today it fits inside the 300px floor below; it is measured
+        // rather than assumed so adding a third tab cannot quietly break it.)
+        let titles: Vec<String> = self.fuzzel.tabs.iter().map(|t| t.title.clone()).collect();
+        if titles.len() > 1 {
+            let mut widest = 0.0f32;
+            for title in &titles {
+                let buf = make_text_buffer(&mut self.font_system, title, TAB_FONT_PX);
+                let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0);
+                widest = widest.max(tw);
+            }
+            let strip_w = (widest + 20.0) * titles.len() as f32;
+            if strip_w > max_text_w {
+                max_text_w = strip_w;
+            }
+        }
+
         let scrollbar_w = if needed_height > self.max_height as f32 { 10.0 } else { 0.0 };
         let needed_width = max_text_w + 50.0 + scrollbar_w;
         let target_width = needed_width.clamp(300.0, self.max_width as f32);
@@ -2105,20 +2138,22 @@ impl AppState {
                 if let Some(item) = st.fuzzel.filtered_items.get(st.fuzzel.selected) {
                     self.selected_item = Some(item.clone());
                     println!("{}", item);
-                    match st.mode {
-                        LauncherMode::Apps => {
-                            if let Some(app) = st.apps.iter().find(|app| &app.name == item) {
-                                record_app_launch(&app.name);
-                                place_next_at(&app.exec, st.invoked_at);
-                                spawn_app(app);
+                    if !run_system_item(&st.fuzzel, item) {
+                        match st.mode {
+                            LauncherMode::Apps => {
+                                if let Some(app) = st.apps.iter().find(|app| &app.name == item) {
+                                    record_app_launch(&app.name);
+                                    place_next_at(&app.exec, st.invoked_at);
+                                    spawn_app(app);
+                                }
                             }
+                            LauncherMode::Path => {
+                                place_next_at(item, st.invoked_at);
+                                spawn_command(item);
+                            }
+                            LauncherMode::Dmenu => {}
+                            LauncherMode::Json => {}
                         }
-                        LauncherMode::Path => {
-                            place_next_at(item, st.invoked_at);
-                            spawn_command(item);
-                        }
-                        LauncherMode::Dmenu => {}
-                        LauncherMode::Json => {}
                     }
                     should_close = true;
                 }
@@ -2371,6 +2406,17 @@ impl PointerHandler for AppState {
                                     st.upload_vertices();
                                     self.redraw = true;
                                 }
+                            } else if let Some(idx) = st.fuzzel.tab_at(cx, cy) {
+                                // Ahead of the row branch for the same reason
+                                // the scrollbar is: ANY press `on_event`
+                                // resolves is treated there as a selection and
+                                // — in Dmenu/switcher mode — committed. A tab
+                                // click must switch tabs, not choose a row.
+                                if st.fuzzel.switch_tab(idx) {
+                                    st.update_desired_size();
+                                    st.upload_vertices();
+                                    self.redraw = true;
+                                }
                             } else if st.fuzzel.scroll_box.press(cx, cy) {
                                 // Thumb grab or track jump. This must be handled here rather
                                 // than inside `on_event`, because the row branch below treats
@@ -2411,18 +2457,20 @@ impl PointerHandler for AppState {
                                         if let Some(item) = st.fuzzel.filtered_items.get(st.fuzzel.selected) {
                                             println!("{}", item);
                                             self.selected_item = Some(item.clone());
-                                            match st.mode {
-                                                LauncherMode::Apps => {
-                                                    if let Some(app) = st.apps.iter().find(|app| &app.name == item) {
-                                                        record_app_launch(&app.name);
-                                                        spawn_app(app);
+                                            if !run_system_item(&st.fuzzel, item) {
+                                                match st.mode {
+                                                    LauncherMode::Apps => {
+                                                        if let Some(app) = st.apps.iter().find(|app| &app.name == item) {
+                                                            record_app_launch(&app.name);
+                                                            spawn_app(app);
+                                                        }
                                                     }
+                                                    LauncherMode::Path => {
+                                                        spawn_command(item);
+                                                    }
+                                                    LauncherMode::Dmenu => {}
+                                                    LauncherMode::Json => {}
                                                 }
-                                                LauncherMode::Path => {
-                                                    spawn_command(item);
-                                                }
-                                                LauncherMode::Dmenu => {}
-                                                LauncherMode::Json => {}
                                             }
                                             should_close = true;
                                         }
@@ -2812,17 +2860,27 @@ impl AppState {
             return;
         }
 
-        // Shift+Tab arrives as ISO_Left_Tab: cycle the highlight backwards
-        // with wrap, mirroring Tab's forward cycle below.
+        // Shift+Tab arrives as ISO_Left_Tab: step back through the tabs where
+        // the list has them, else cycle the highlight backwards with wrap —
+        // mirroring Tab's forward step below in both halves.
         if event.keysym == xkeysym::Keysym::ISO_Left_Tab {
             if let Some(st) = &mut self.state {
-                if st.mode != LauncherMode::Json && !st.fuzzel.filtered_items.is_empty() {
-                    let len = st.fuzzel.filtered_items.len();
-                    st.fuzzel.selected = (st.fuzzel.selected + len - 1) % len;
-                    st.fuzzel.update_scroll();
-                    st.fuzzel.snap_to_selected();
-                    st.upload_vertices();
-                    self.redraw = true;
+                if st.mode != LauncherMode::Json {
+                    let mut changed = false;
+                    if st.fuzzel.cycle_tab(false) {
+                        st.update_desired_size();
+                        changed = true;
+                    } else if !st.fuzzel.filtered_items.is_empty() {
+                        let len = st.fuzzel.filtered_items.len();
+                        st.fuzzel.selected = (st.fuzzel.selected + len - 1) % len;
+                        st.fuzzel.update_scroll();
+                        st.fuzzel.snap_to_selected();
+                        changed = true;
+                    }
+                    if changed {
+                        st.upload_vertices();
+                        self.redraw = true;
+                    }
                 }
             }
             return;
@@ -2894,7 +2952,16 @@ impl AppState {
                         self.trigger_select_and_close();
                     }
                     Key::Named(NamedKey::Tab) => {
-                        if !st.fuzzel.filtered_items.is_empty() {
+                        // Tab switches tabs where there are tabs — the Apps
+                        // mode's Apps/System split. It keeps its old job of
+                        // cycling the highlight with wrap in the single-tab
+                        // modes, which is what the Super-Tab window switcher
+                        // (Dmenu, one tab) rides on.
+                        if st.fuzzel.cycle_tab(true) {
+                            st.update_desired_size();
+                            st.upload_vertices();
+                            self.redraw = true;
+                        } else if !st.fuzzel.filtered_items.is_empty() {
                             st.fuzzel.selected = (st.fuzzel.selected + 1) % st.fuzzel.filtered_items.len();
                             st.fuzzel.update_scroll();
                             st.fuzzel.snap_to_selected();
@@ -4284,6 +4351,82 @@ mod tests {
         let _ = std::fs::remove_dir_all(&base);
     }
 
+    #[test]
+    fn a_tab_keeps_its_own_query_and_items() {
+        let mut f = FuzzelWidget::new("Search: ".to_string());
+        f.set_tabs(vec![
+            ("Apps".to_string(), vec!["Firefox".to_string(), "Files".to_string()]),
+            ("System".to_string(), vec!["Suspend".to_string(), "Reboot".to_string()]),
+        ]);
+        f.query.push_str("fi");
+        f.filter();
+        assert_eq!(f.filtered_items, vec!["Firefox".to_string(), "Files".to_string()]);
+
+        assert!(f.cycle_tab(true));
+        assert_eq!(f.active_tab, 1);
+        // The System tab opens on its own (empty) query, not the Apps one.
+        assert_eq!(f.query, "");
+        assert_eq!(f.filtered_items, vec!["Suspend".to_string(), "Reboot".to_string()]);
+
+        // ...and coming back lands on the query that was left behind.
+        assert!(f.cycle_tab(true), "two tabs wrap");
+        assert_eq!(f.active_tab, 0);
+        assert_eq!(f.query, "fi");
+        assert_eq!(f.filtered_items, vec!["Firefox".to_string(), "Files".to_string()]);
+    }
+
+    #[test]
+    fn the_feed_fills_tab_zero_from_any_tab() {
+        let mut f = FuzzelWidget::new("Search: ".to_string());
+        f.set_tabs(vec![
+            ("Apps".to_string(), Vec::new()),
+            ("System".to_string(), vec!["Suspend".to_string()]),
+        ]);
+        f.switch_tab(1);
+        // The stdin/socket ingest addresses tab 0 while the user reads tab 1:
+        // the rows on screen must not move, and the items must still land.
+        f.set_tab_items(0, vec!["Firefox".to_string()]);
+        assert_eq!(f.filtered_items, vec!["Suspend".to_string()]);
+        assert_eq!(f.tab_items(0), ["Firefox".to_string()]);
+        f.switch_tab(0);
+        assert_eq!(f.filtered_items, vec!["Firefox".to_string()]);
+    }
+
+    #[test]
+    fn an_untabbed_list_takes_no_chrome_and_does_not_cycle() {
+        let mut f = FuzzelWidget::new("Search: ".to_string());
+        f.set_items(vec!["a".to_string(), "b".to_string()]);
+        // What keeps Dmenu, Path and the Super-Tab window switcher laid out
+        // and keyed exactly as they were.
+        assert_eq!(f.tab_strip_h(), 0.0);
+        assert!(!f.cycle_tab(true));
+        assert!(f.tab_at(20.0, 20.0).is_none());
+    }
+
+    #[test]
+    fn every_system_row_runs_something() {
+        // A row whose label no longer matches its command silently does
+        // nothing when picked, so the lookup the commit path makes is the
+        // thing to pin down.
+        let mut f = FuzzelWidget::new("Search: ".to_string());
+        f.set_tabs(vec![
+            ("Apps".to_string(), Vec::new()),
+            (
+                SYSTEM_TAB_TITLE.to_string(),
+                SYSTEM_COMMANDS.iter().map(|c| c.name.to_string()).collect(),
+            ),
+        ]);
+        f.switch_tab(1);
+        assert_eq!(f.filtered_items.len(), SYSTEM_COMMANDS.len());
+        for item in &f.filtered_items {
+            assert!(
+                SYSTEM_COMMANDS.iter().any(|c| c.name == item),
+                "System row {:?} resolves to no command",
+                item
+            );
+        }
+    }
+
     #[test]
     fn test_app_history_sorting() {
         let temp_dir = std::path::PathBuf::from("/tmp/cce-cloud-test-cache-dir");