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

commit73abf7a7948a75417715c2cfbfa71c816bbdd34a
parent96f5e37c93
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 15:01
feat(choose): an app chooser mode for the AppChooser portal

`cce-cloud --choose [-p prompt] [-s last_id]` takes desktop-file IDs on
stdin and shows each entry's Name= and icon; the pick is printed back as
the ID, not the label. It is what cce-desktop-portal opens for "Open
with…", replacing xdg-desktop-portal-gtk's GTK dialog.

Dmenu mode with a different feed and answer (`Chooser`): NoDisplay
entries are listed, since the portal's handler list is authoritative;
entries sharing a name are labelled `Name (id)` so every row maps back to
one app; Escape answers an empty line, which the portal reads as
cancelled. State::new takes the flag because it ingests stdin once
itself, before any post-construction state could be set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md   |  15 +++++-
 src/main.rs | 162 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 2 files changed, 169 insertions(+), 8 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7733831..88a6206 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,7 +10,7 @@ JSON-defined popup panel — all in one binary. It is one crate of the cce multi
 workspace; workspace-wide conventions (multi-repo layout, no `[workspace.dependencies]`,
 shared `../target/`) live in **`../cce-compositor/WORKSPACE.md`** — read that too.
 
-Two source files, all logic in `src/main.rs` (~5.1k lines):
+Two source files, all logic in `src/main.rs` (~5.6k lines):
 
 - `src/main.rs` — CLI parsing, daemon/client/standalone entry points, the Wayland
   surface and event loop, `FuzzelWidget` (the list UI: keyboard selection chip,
@@ -95,6 +95,17 @@ needs-stdin decision, in `run_client()`).
 - `Dmenu` — items from stdin, selection echoed out. Magic stdin lines
   `__cce_switcher_next__` / `__cce_switcher_select_and_close__` drive the compositor's
   window switcher (`--switcher` starts in Dmenu with its hold modifier down, and commits when neither Super nor Alt is held, so it works bound to Super+Tab or Alt+Tab; moving the pointer onto a row selects it — `FuzzelWidget::pointer_moved`, motion only, so a popup mapping under a resting pointer does not steal the row Super+Tab advanced to).
+- `--choose` — the **app chooser** ("Open with…") behind the AppChooser portal
+  (cce-desktop-portal spawns `cce-cloud --choose [-p prompt] [-s last_id]`).
+  Dmenu mode with a different feed and answer: stdin carries desktop-file IDs,
+  each row shows that entry's `Name=` and icon (`desktop_entry_label`, NoDisplay
+  entries included — the portal's handler list is authoritative), and the pick
+  is printed back as the ID, not the label (`Chooser::answer`). `-s` takes an
+  ID too. Two entries sharing a name get `Name (id)` labels so every row maps
+  back to one app. Escape answers an empty line, which the portal reads as
+  cancelled. `State::new` takes `chooser_mode` because it ingests stdin
+  once itself: state set after construction misses that first feed and
+  shows the raw IDs.
 - `Path` — executables scanned from `$PATH`.
 **Key repeat** is the client's job on Wayland: the keyboard is bound with
 `get_keyboard_with_repeat` on the calloop loop, which re-fires a held key at
@@ -170,7 +181,7 @@ bound with plain `get_keyboard`, and a held Backspace deleted one character.
 
 Key flags: `-p/--prompt`, `-s/--select <item>`, `-x/-y` (position → forces layer-shell
 anchoring), `--align-right`, `--parent-app-id` (app_id becomes `cce-cloud:<parent>`),
-`--switcher`, and mode flags `--apps|--path|--dmenu|--json` (or `--mode <m>`).
+`--switcher`, `--choose`, and mode flags `--apps|--path|--dmenu|--json` (or `--mode <m>`).
 
 ### Tabs
 
diff --git a/src/main.rs b/src/main.rs
index f1ad798..ea51b22 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -397,6 +397,75 @@ fn icon_override_in(dir: &std::path::Path, id: &str) -> Option<String> {
     path.is_file().then(|| path.to_string_lossy().into_owned())
 }
 
+/// `Name=` and `Icon=` of the desktop entry with ID `id` (its file stem),
+/// from the first applications dir that has it — NoDisplay entries
+/// included: a handler the portal offers is a valid choice whether or not
+/// the launcher lists it. The icon goes through [`icon_override`] like the
+/// launcher's. `None` when no dir has the entry.
+fn desktop_entry_label(id: &str, dirs: &[std::path::PathBuf]) -> Option<(String, Option<String>)> {
+    let path = dirs.iter().map(|d| d.join(format!("{id}.desktop"))).find(|p| p.is_file())?;
+    let text = std::fs::read_to_string(&path).ok()?;
+    let (mut name, mut icon) = (None, None);
+    let mut in_entry = false;
+    for line in text.lines().map(str::trim) {
+        if line.starts_with('[') {
+            in_entry = line == "[Desktop Entry]";
+            continue;
+        }
+        if !in_entry {
+            continue;
+        }
+        if let Some(v) = line.strip_prefix("Name=") {
+            name.get_or_insert_with(|| v.trim().to_string());
+        } else if let Some(v) = line.strip_prefix("Icon=") {
+            if !v.trim().is_empty() {
+                icon.get_or_insert_with(|| v.trim().to_string());
+            }
+        }
+    }
+    Some((name.unwrap_or_else(|| id.to_string()), icon_override(id).or(icon)))
+}
+
+/// `--choose`: the app chooser the desktop portal opens ("Open with…").
+/// Dmenu mode with a different feed and a different answer: stdin carries
+/// desktop-file IDs, the rows show each one's name and icon, and the choice
+/// is printed back as the ID. Rows are labels, so the label → ID map is
+/// what turns a pick back into the answer.
+#[derive(Default)]
+struct Chooser {
+    /// The IDs last ingested, so an unchanged feed is not re-resolved on
+    /// every poll (the rows hold labels, which never equal the IDs).
+    fed: Vec<String>,
+    by_label: std::collections::HashMap<String, String>,
+}
+
+impl Chooser {
+    /// Labels for `ids`, in order. A name two entries share (two "Firefox"
+    /// builds) is told apart by the ID, so every label maps back to one app.
+    fn labels(ids: &[String], dirs: &[std::path::PathBuf]) -> Vec<(String, String, Option<String>)> {
+        let entries: Vec<(String, String, Option<String>)> = ids
+            .iter()
+            .map(|id| {
+                let (name, icon) = desktop_entry_label(id, dirs).unwrap_or_else(|| (id.clone(), None));
+                (id.clone(), name, icon)
+            })
+            .collect();
+        entries
+            .iter()
+            .map(|(id, name, icon)| {
+                let shared = entries.iter().filter(|(_, n, _)| n == name).count() > 1;
+                let label = if shared { format!("{name} ({id})") } else { name.clone() };
+                (id.clone(), label, icon.clone())
+            })
+            .collect()
+    }
+
+    /// What a picked row answers: its ID, or the row itself if unknown.
+    fn answer(&self, row: &str) -> String {
+        self.by_label.get(row).cloned().unwrap_or_else(|| row.to_string())
+    }
+}
+
 fn scan_apps() -> Vec<AppInfo> {
     let mut apps = Vec::new();
     let dirs = application_dirs();
@@ -1711,6 +1780,8 @@ struct State {
     max_height: u32,
     select_item: Option<String>,
     switcher_mode: bool,
+    /// `Some` in `--choose` mode ([`Chooser`]).
+    chooser: Option<Chooser>,
     /// app_id → icon name for the switcher's rows ([`desktop_icon_index`]),
     /// built on the first row that needs it and kept for the popup's life.
     switcher_icon_index: Option<std::collections::HashMap<String, String>>,
@@ -1803,6 +1874,7 @@ impl State {
         scale: f64,
         select_item: Option<String>,
         switcher_mode: bool,
+        chooser_mode: bool,
         json_layout_config: Option<JsonLayoutConfig>,
         parent_app_id: Option<String>,
         fonts: Option<(FontSystem, SwashCache)>,
@@ -2124,6 +2196,7 @@ impl State {
             },
             select_item,
             switcher_mode,
+            chooser: chooser_mode.then(Chooser::default),
             switcher_icon_index: None,
             opened_at: t_start,
             rows_frame_logged: false,
@@ -2166,7 +2239,32 @@ impl State {
                 lock.cycle_prev = 0;
 
                 let mut changed = false;
-                let items = lock.items.clone();
+                let mut items = lock.items.clone();
+                // The chooser's feed is IDs and its rows are labels: resolve a
+                // new feed once, and leave the rows alone on an unchanged one.
+                if let Some(chooser) = self.chooser.as_mut() {
+                    if chooser.fed == items {
+                        items = self.fuzzel.tab_items(0).to_vec();
+                    } else {
+                        let entries = Chooser::labels(&items, &application_dirs());
+                        chooser.fed = items;
+                        chooser.by_label = entries.iter().map(|(id, label, _)| (label.clone(), id.clone())).collect();
+                        // `-s` names the portal's last choice by ID; the row
+                        // it selects is that ID's label.
+                        if let Some(sel) = self.select_item.as_ref() {
+                            if let Some((_, label, _)) = entries.iter().find(|(id, _, _)| id == sel) {
+                                self.select_item = Some(label.clone());
+                            }
+                        }
+                        self.fuzzel.set_item_icons(
+                            entries
+                                .iter()
+                                .filter_map(|(_, label, icon)| Some((label.clone(), icon_image(icon.as_deref()?)?)))
+                                .collect(),
+                        );
+                        items = entries.into_iter().map(|(_, label, _)| label).collect();
+                    }
+                }
                 // 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.
@@ -2600,8 +2698,9 @@ impl AppState {
         if let Some(st) = &mut self.state {
             if !st.fuzzel.filtered_items.is_empty() {
                 if let Some(item) = st.fuzzel.filtered_items.get(st.fuzzel.selected) {
-                    self.selected_item = Some(item.clone());
-                    println!("{}", item);
+                    let answer = st.chooser.as_ref().map_or_else(|| item.clone(), |c| c.answer(item));
+                    println!("{}", answer);
+                    self.selected_item = Some(answer);
                     if !run_system_item(&st.fuzzel, item) {
                         match st.mode {
                             LauncherMode::Apps => {
@@ -2930,8 +3029,9 @@ impl PointerHandler for AppState {
                                     let commit = !st.switcher_mode || st.fuzzel.selected == prev_selected;
                                     if commit {
                                         if let Some(item) = st.fuzzel.filtered_items.get(st.fuzzel.selected) {
-                                            println!("{}", item);
-                                            self.selected_item = Some(item.clone());
+                                            let answer = st.chooser.as_ref().map_or_else(|| item.clone(), |c| c.answer(item));
+                                            println!("{}", answer);
+                                            self.selected_item = Some(answer);
                                             if !run_system_item(&st.fuzzel, item) {
                                                 match st.mode {
                                                     LauncherMode::Apps => {
@@ -3534,6 +3634,7 @@ fn run_standalone() {
     let mut select_item: Option<String> = None;
     let mut align_right = false;
     let mut switcher_mode = false;
+    let mut chooser_mode = false;
     let mut parent_app_id: Option<String> = None;
 
     let args = std::env::args().skip(1).collect::<Vec<String>>();
@@ -3611,6 +3712,10 @@ fn run_standalone() {
             switcher_mode = true;
             mode = LauncherMode::Dmenu;
             i += 1;
+        } else if arg == "--choose" {
+            chooser_mode = true;
+            mode = LauncherMode::Dmenu;
+            i += 1;
         } else {
             i += 1;
         }
@@ -3709,6 +3814,7 @@ fn run_standalone() {
         scale,
         select_item,
         switcher_mode,
+        chooser_mode,
         json_layout_config,
         parent_app_id,
         None,
@@ -3863,7 +3969,7 @@ fn run_client(socket_path: &str, args: &[String]) -> Result<(), Box<dyn std::err
             needs_stdin = false;
             mode_specified = true;
             i += 1;
-        } else if arg == "--dmenu" || arg == "--json" || arg == "--layout" {
+        } else if arg == "--dmenu" || arg == "--json" || arg == "--layout" || arg == "--choose" {
             needs_stdin = true;
             mode_specified = true;
             i += 1;
@@ -4133,6 +4239,7 @@ fn run_daemon(socket_path: &str) {
         let mut select_item: Option<String> = None;
         let mut align_right = false;
         let mut switcher_mode = false;
+        let mut chooser_mode = false;
         let mut parent_app_id: Option<String> = None;
 
         let mut i = 1;
@@ -4209,6 +4316,10 @@ fn run_daemon(socket_path: &str) {
                 switcher_mode = true;
                 mode = LauncherMode::Dmenu;
                 i += 1;
+            } else if arg == "--choose" {
+                chooser_mode = true;
+                mode = LauncherMode::Dmenu;
+                i += 1;
             } else {
                 i += 1;
             }
@@ -4352,6 +4463,7 @@ fn run_daemon(socket_path: &str) {
             scale,
             select_item,
             switcher_mode,
+            chooser_mode,
             json_layout_config,
             parent_app_id,
             fonts_slot.take(),
@@ -5088,6 +5200,44 @@ mod tests {
         let _ = std::fs::remove_dir_all(&dir);
     }
 
+    #[test]
+    fn chooser_labels_resolve_ids_and_tell_twins_apart() {
+        let base = std::env::temp_dir().join(format!("cce-cloud-test-choose-{}", std::process::id()));
+        let _ = std::fs::remove_dir_all(&base);
+        let (user, sys) = (base.join("user/applications"), base.join("sys/applications"));
+        std::fs::create_dir_all(&user).unwrap();
+        std::fs::create_dir_all(&sys).unwrap();
+        let entry = |name: &str, extra: &str| format!("[Desktop Entry]\nType=Application\nName={name}\nExec=x\n{extra}");
+        std::fs::write(sys.join("org.gnome.Evince.desktop"), entry("Document Viewer", "Icon=evince\n")).unwrap();
+        // NoDisplay: the launcher hides it, the chooser must not.
+        std::fs::write(sys.join("helper.desktop"), entry("Helper", "NoDisplay=true\n")).unwrap();
+        std::fs::write(sys.join("firefox.desktop"), entry("Firefox", "")).unwrap();
+        std::fs::write(sys.join("firefox-nightly.desktop"), entry("Firefox", "")).unwrap();
+        // The user dir shadows the system entry of the same ID.
+        std::fs::write(user.join("helper.desktop"), entry("My Helper", "")).unwrap();
+        // A localized name after the plain one does not replace it.
+        std::fs::write(sys.join("loc.desktop"), entry("Plain", "Name[de]=Lokal\n")).unwrap();
+
+        let dirs = vec![user.clone(), sys.clone()];
+        let ids: Vec<String> = ["org.gnome.Evince", "helper", "firefox", "firefox-nightly", "missing", "loc"]
+            .iter()
+            .map(|s| s.to_string())
+            .collect();
+        let got = Chooser::labels(&ids, &dirs);
+        let labels: Vec<&str> = got.iter().map(|(_, l, _)| l.as_str()).collect();
+        assert_eq!(
+            labels,
+            ["Document Viewer", "My Helper", "Firefox (firefox)", "Firefox (firefox-nightly)", "missing", "Plain"]
+        );
+        assert_eq!(got[0].2.as_deref(), Some("evince"));
+
+        let chooser = Chooser { fed: ids.clone(), by_label: got.iter().map(|(id, l, _)| (l.clone(), id.clone())).collect() };
+        assert_eq!(chooser.answer("Firefox (firefox-nightly)"), "firefox-nightly");
+        assert_eq!(chooser.answer("Document Viewer"), "org.gnome.Evince");
+        assert_eq!(chooser.answer("typed text"), "typed text", "an unknown row answers itself");
+        let _ = std::fs::remove_dir_all(&base);
+    }
+
     #[test]
     fn scan_apps_xdg_precedence() {
         let base = std::path::PathBuf::from("/tmp/cce-cloud-test-xdg");