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

commit13644906a0711aebe0ce95f444ea24281a407841
parentce91b3a06c
authorLucas Galante <[email protected]>
date2026-07-20 22:45
feat: reverse switcher cycling (__cce_switcher_prev__, shift+tab)

- New __cce_switcher_prev__ magic stdin line moves the highlight backwards
  (with wrap); consumed as a net delta against __cce_switcher_next__.
- Shift+Tab (ISO_Left_Tab) cycles backwards in the list UI, mirroring Tab.
- --switcher in client mode no longer blocks reading stdin to EOF: the
  compositor now holds the pipe open to stream cycle lines, so items are
  forwarded to the daemon by the streaming thread instead.
- Fix daemon->popup transfer to drain cycle counters instead of copying
  them; the daemon-side counts were never reset, so every past cycle was
  re-applied on each transfer.

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

 src/main.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 50 insertions(+), 8 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 76df737..0329fb4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -344,13 +344,15 @@ fn parse_desktop_file(path: &std::path::Path) -> Option<AppInfo> {
 
 fn scan_apps() -> Vec<AppInfo> {
     let mut apps = Vec::new();
-    let mut dirs = vec![
-        std::path::PathBuf::from("/usr/share/applications"),
-        std::path::PathBuf::from("/usr/local/share/applications"),
-    ];
+    // XDG precedence: user entries override system ones. The stable sort +
+    // dedup below keeps the first-pushed entry per name, so scan
+    // highest-priority dirs first.
+    let mut dirs = Vec::new();
     if let Ok(home) = std::env::var("HOME") {
         dirs.push(std::path::PathBuf::from(home).join(".local/share/applications"));
     }
+    dirs.push(std::path::PathBuf::from("/usr/local/share/applications"));
+    dirs.push(std::path::PathBuf::from("/usr/share/applications"));
 
     for dir in dirs {
         if let Ok(entries) = std::fs::read_dir(dir) {
@@ -673,6 +675,7 @@ struct StdinState {
     items: Vec<String>,
     new_data: bool,
     cycle_next: usize,
+    cycle_prev: usize,
     select_and_close: bool,
     // daemon mode: the requesting client hung up (killed/crashed) — the
     // popup has no owner left and must close, or the serial accept loop
@@ -931,6 +934,7 @@ impl State {
             items: Vec::new(),
             new_data: false,
             cycle_next: 0,
+            cycle_prev: 0,
             select_and_close: false,
             client_gone: false,
         }));
@@ -946,6 +950,9 @@ impl State {
                             if line == "__cce_switcher_next__" {
                                 lock_state.cycle_next += 1;
                                 lock_state.new_data = true;
+                            } else if line == "__cce_switcher_prev__" {
+                                lock_state.cycle_prev += 1;
+                                lock_state.new_data = true;
                             } else if line == "__cce_switcher_select_and_close__" {
                                 lock_state.select_and_close = true;
                                 lock_state.new_data = true;
@@ -1046,6 +1053,8 @@ impl State {
 
                 let cycles = lock.cycle_next;
                 lock.cycle_next = 0;
+                let cycles_back = lock.cycle_prev;
+                lock.cycle_prev = 0;
 
                 let mut changed = false;
                 let items = lock.items.clone();
@@ -1067,8 +1076,11 @@ impl State {
                     }
                 }
 
-                if cycles > 0 && !self.fuzzel.filtered_items.is_empty() {
-                    self.fuzzel.selected = (self.fuzzel.selected + cycles) % self.fuzzel.filtered_items.len();
+                if (cycles > 0 || cycles_back > 0) && !self.fuzzel.filtered_items.is_empty() {
+                    let len = self.fuzzel.filtered_items.len() as isize;
+                    let net = cycles as isize - cycles_back as isize;
+                    self.fuzzel.selected =
+                        (self.fuzzel.selected as isize + net).rem_euclid(len) as usize;
                     self.fuzzel.update_scroll();
                     self.fuzzel.snap_to_selected();
                     changed = true;
@@ -1945,6 +1957,22 @@ impl AppState {
             return;
         }
 
+        // Shift+Tab arrives as ISO_Left_Tab: cycle the highlight backwards
+        // with wrap, mirroring Tab's forward cycle below.
+        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;
+                }
+            }
+            return;
+        }
+
         let (select_next, select_prev) = *nav_keys();
         let logical_key = match event.keysym {
             xkeysym::Keysym::Escape => Key::Named(NamedKey::Escape),
@@ -2383,7 +2411,11 @@ fn run_client(socket_path: &str, args: &[String]) -> Result<(), Box<dyn std::err
             mode_specified = true;
             i += 1;
         } else if arg == "--switcher" {
-            needs_stdin = true;
+            // The compositor holds the pipe open to stream __cce_switcher_next__
+            // cycle lines after the item list, so there is no EOF to wait for —
+            // skip the blocking initial read and let the forwarding thread
+            // below stream everything (items included) to the daemon.
+            needs_stdin = false;
             mode_specified = true;
             i += 1;
         } else {
@@ -2647,6 +2679,7 @@ fn run_daemon(socket_path: &str) {
             items: initial_items,
             new_data: !initial_stdin.is_empty(),
             cycle_next: 0,
+            cycle_prev: 0,
             select_and_close: false,
             client_gone: false,
         }));
@@ -2666,6 +2699,9 @@ fn run_daemon(socket_path: &str) {
                     if trimmed == "__cce_switcher_next__" {
                         lock_state.cycle_next += 1;
                         lock_state.new_data = true;
+                    } else if trimmed == "__cce_switcher_prev__" {
+                        lock_state.cycle_prev += 1;
+                        lock_state.new_data = true;
                     } else if trimmed == "__cce_switcher_select_and_close__" {
                         lock_state.select_and_close = true;
                         lock_state.new_data = true;
@@ -2704,7 +2740,13 @@ fn run_daemon(socket_path: &str) {
                     if let (Ok(mut lock_daemon), Ok(mut lock_state)) = (stdin_state_for_handler.lock(), st.stdin_state.lock()) {
                         if lock_daemon.new_data {
                             lock_state.items = lock_daemon.items.clone();
-                            lock_state.cycle_next = lock_daemon.cycle_next;
+                            // Drain (not copy) the cycle counters: the daemon-side
+                            // counts are never consumed elsewhere, so leaving them
+                            // would re-apply every past cycle on each transfer.
+                            lock_state.cycle_next += lock_daemon.cycle_next;
+                            lock_daemon.cycle_next = 0;
+                            lock_state.cycle_prev += lock_daemon.cycle_prev;
+                            lock_daemon.cycle_prev = 0;
                             lock_state.select_and_close = lock_daemon.select_and_close;
                             lock_state.new_data = true;
                             lock_daemon.new_data = false;