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

src/main.rs (235.8K)

   1 use cce_ui::widget::Handle;
   2 use cce_ui::widget::ScrollRegion;
   3 
   4 use std::sync::{Arc, Mutex};
   5 use std::io::{self, BufRead, IsTerminal};
   6 
   7 use cce_ui::widget::{WidgetHost, TextLabel};
   8 use crate::json_layout::{JsonLayoutWidget, JsonLayoutConfig};
   9 mod json_layout;
  10 #[cfg(test)]
  11 use crate::json_layout::JsonWidgetConfig;
  12 
  13 use smithay_client_toolkit::{
  14     compositor::{CompositorHandler, CompositorState},
  15     delegate_compositor, delegate_keyboard, delegate_pointer, delegate_registry,
  16     delegate_seat, delegate_shm, delegate_layer, delegate_output,
  17     delegate_xdg_shell, delegate_xdg_window,
  18     registry::{ProvidesRegistryState, RegistryState},
  19     output::{OutputHandler, OutputState},
  20     seat::{
  21         keyboard::KeyboardHandler,
  22         pointer::PointerHandler,
  23         Capability, SeatHandler, SeatState,
  24     },
  25     shell::{
  26         wlr_layer::{
  27             Anchor, KeyboardInteractivity, Layer, LayerShell, LayerShellHandler,
  28             LayerSurface, LayerSurfaceConfigure,
  29         },
  30         xdg::{
  31             window::{Window as XdgWindow, WindowConfigure, WindowHandler, WindowDecorations},
  32             XdgShell,
  33         },
  34         WaylandSurface,
  35     },
  36     shm::{Shm, ShmHandler},
  37 };
  38 use wayland_client::{
  39     globals::registry_queue_init,
  40     protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface},
  41     Connection, QueueHandle, Proxy,
  42 };
  43 use calloop_wayland_source::WaylandSource;
  44 
  45 use cce_ui::cosmic_text::{Attrs, Buffer, FontSystem, Metrics, SwashCache};
  46 
  47 use cce_ui::vk::{Batch2D, Frame2D, ImageQuad, TextSpan, VkRenderer};
  48 
  49 // Vertex is shared from the cce-ui engine.
  50 pub(crate) use cce_ui::engine::Vertex;
  51 
  52 /// Tessellate a display list into the flat vertex buffer plus the renderer
  53 /// batches, converting the tessellator's logical-px `DlBatch` clips to
  54 /// `Batch2D`'s physical px (the same mapping the engine runner applies). Going
  55 /// through the display list — instead of the old `extra_quads` flattening —
  56 /// is what lets bevel/recess prims survive to the tessellator.
  57 fn tessellate(
  58     dl: &cce_ui::scene::paint::DisplayList,
  59     sw: f32, sh: f32,
  60     scale: f32,
  61 ) -> (Vec<Vertex>, Vec<Batch2D>, Vec<ImageQuad>, Vec<[f32; 12]>) {
  62     let (verts, dl_batches, dl_images, features) =
  63         cce_ui::backend::window_runner::tessellate_display_list(dl, sw, sh, scale);
  64     // Images ride a separate pipeline from the vertex batches, so they have to
  65     // be carried across explicitly — this return value used to be dropped and
  66     // `Frame2D::images` hardcoded to &[], which made `PaintCtx::image` a silent
  67     // no-op in this app while working fine in every engine-runner client.
  68     let images = dl_images
  69         .iter()
  70         .map(|di| ImageQuad {
  71             image: di.image,
  72             rect: (
  73                 di.rect.x * scale,
  74                 di.rect.y * scale,
  75                 di.rect.width * scale,
  76                 di.rect.height * scale,
  77             ),
  78             alpha: di.alpha,
  79             z_before: di.at,
  80             clip: di.clip.map(|c| {
  81                 (
  82                     (c.x * scale).max(0.0) as u32,
  83                     (c.y * scale).max(0.0) as u32,
  84                     (c.width * scale) as u32,
  85                     (c.height * scale) as u32,
  86                 )
  87             }),
  88         })
  89         .collect();
  90     let batches = dl_batches
  91         .iter()
  92         .map(|b| Batch2D {
  93             scissor: b.scissor.map(|c| {
  94                 (
  95                     (c.x * scale).max(0.0) as u32,
  96                     (c.y * scale).max(0.0) as u32,
  97                     (c.width * scale) as u32,
  98                     (c.height * scale) as u32,
  99                 )
 100             }),
 101             clip_rrect: b.clip_rrect.map(|c| {
 102                 [c[0] * scale, c[1] * scale, c[2] * scale, c[3] * scale, c[4] * scale]
 103             }),
 104             start: b.start,
 105             end: b.end,
 106             plate: b.plate,
 107             blur_behind: b.blur_behind,
 108         })
 109         .collect();
 110     (verts, batches, images, features)
 111 }
 112 
 113 fn make_text_buffer(font_system: &mut FontSystem, text: &str, size: f32) -> Buffer {
 114     let metrics = Metrics::new(size, size * 1.4);
 115     let mut buffer = Buffer::new(font_system, metrics);
 116     let font_family = cce_ui::layout::control_label_font_parsed().0;
 117     let attrs = Attrs::new().family(cce_ui::cosmic_text::Family::Name(&font_family));
 118     buffer.set_text(font_system, text, attrs, cce_ui::cosmic_text::Shaping::Advanced);
 119     buffer.shape_until_scroll(font_system, true);
 120     buffer
 121 }
 122 
 123 /// A widget subtree's text via the paint walk (not the legacy text_labels getter),
 124 /// reduced to the plain labels this renderer shapes: the buffer font and window bounds
 125 /// stay exactly as before (make_text_buffer applies the control font to every label).
 126 /// Each label rides with its merged clip bounds (logical `[l, t, r, b]`, from
 127 /// the paint walk's clip ∩ the prim's own bounds — `append_widget_text` merges
 128 /// them): prepare_text turns them into the span's physical clip so text cut by
 129 /// a clip (a partially visible list row) is cut at the glyph pass too, not
 130 /// drawn whole.
 131 fn walk_text_labels(
 132     ui: &cce_ui::context::UiContext,
 133     w: &dyn WidgetHost,
 134 ) -> Vec<(TextLabel, Option<[f32; 4]>)> {
 135     let mut pc = cce_ui::scene::paint::PaintCtx::new();
 136     cce_ui::scene::painter::append_widget_text(ui, w, &mut pc);
 137     pc.finish()
 138         .items
 139         .into_iter()
 140         .filter_map(|item| match item.prim {
 141             cce_ui::scene::paint::Prim::Text { text, x, y, font_size, color, bounds, .. } => {
 142                 Some((TextLabel { text, x, y, font_size, color }, bounds))
 143             }
 144             _ => None,
 145         })
 146         .collect()
 147 }
 148 
 149 fn filter_and_sort_items(items: &[String], query: &str) -> Vec<String> {
 150     if query.is_empty() {
 151         return items.to_vec();
 152     }
 153     let query_lower = query.to_lowercase();
 154     
 155     let mut scored: Vec<(i32, usize, &String)> = items
 156         .iter()
 157         .enumerate()
 158         .filter_map(|(idx, item)| {
 159             let item_lower = item.to_lowercase();
 160             if item_lower == query_lower {
 161                 Some((100, idx, item))
 162             } else if item_lower.starts_with(&query_lower) {
 163                 Some((80, idx, item))
 164             } else if item_lower.contains(&query_lower) {
 165                 Some((50, idx, item))
 166             } else {
 167                 // Character sequence match
 168                 let mut query_chars = query_lower.chars().peekable();
 169                 for c in item_lower.chars() {
 170                     if let Some(&qc) = query_chars.peek() {
 171                         if c == qc {
 172                             query_chars.next();
 173                         }
 174                     }
 175                 }
 176                 if query_chars.peek().is_none() {
 177                     Some((10, idx, item))
 178                 } else {
 179                     None
 180                 }
 181             }
 182         })
 183         .collect();
 184         
 185     scored.sort_by(|a, b| {
 186         let score_cmp = b.0.cmp(&a.0);
 187         if score_cmp != std::cmp::Ordering::Equal {
 188             score_cmp
 189         } else {
 190             a.1.cmp(&b.1)
 191         }
 192     });
 193     scored.into_iter().map(|(_, _, item)| item.clone()).collect()
 194 }
 195 
 196 fn scan_path() -> Vec<String> {
 197     let mut executables = std::collections::BTreeSet::new();
 198     if let Ok(path_var) = std::env::var("PATH") {
 199         for dir in path_var.split(':') {
 200             if let Ok(entries) = std::fs::read_dir(dir) {
 201                 for entry in entries {
 202                     if let Ok(entry) = entry {
 203                         let path = entry.path();
 204                         if path.is_file() {
 205                             #[cfg(unix)]
 206                             {
 207                                 use std::os::unix::fs::PermissionsExt;
 208                                 if let Ok(metadata) = entry.metadata() {
 209                                     if metadata.permissions().mode() & 0o111 != 0 {
 210                                         if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
 211                                             executables.insert(name.to_string());
 212                                         }
 213                                     }
 214                                 }
 215                             }
 216                             #[cfg(not(unix))]
 217                             {
 218                                 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
 219                                     executables.insert(name.to_string());
 220                                 }
 221                             }
 222                         }
 223                     }
 224                 }
 225             }
 226         }
 227     }
 228     executables.into_iter().collect()
 229 }
 230 
 231 fn clean_exec_command(exec: &str) -> String {
 232     let mut words = Vec::new();
 233     for word in exec.split_whitespace() {
 234         match word {
 235             "%f" | "%F" | "%u" | "%U" | "%d" | "%D" | "%n" | "%N" | "%i" | "%c" | "%k" | "%v" => {
 236                 // Skip these field codes
 237             }
 238             _ => {
 239                 let cleaned = word
 240                     .replace("%f", "")
 241                     .replace("%F", "")
 242                     .replace("%u", "")
 243                     .replace("%U", "")
 244                     .replace("%d", "")
 245                     .replace("%D", "")
 246                     .replace("%n", "")
 247                     .replace("%N", "")
 248                     .replace("%i", "")
 249                     .replace("%c", "")
 250                     .replace("%k", "")
 251                     .replace("%v", "")
 252                     .replace("%%", "%");
 253                 if !cleaned.is_empty() {
 254                     words.push(cleaned);
 255                 }
 256             }
 257         }
 258     }
 259     words.join(" ")
 260 }
 261 
 262 fn parse_desktop_file(path: &std::path::Path) -> Option<AppInfo> {
 263     let file = std::fs::File::open(path).ok()?;
 264     let reader = std::io::BufReader::new(file);
 265     
 266     let mut in_desktop_entry = false;
 267     let mut name = None;
 268     let mut exec = None;
 269     let mut is_application = true;
 270     let mut no_display = false;
 271     let mut terminal = false;
 272     let mut icon = None;
 273 
 274     for line in reader.lines() {
 275         let line = line.ok()?;
 276         let trimmed = line.trim();
 277         if trimmed.starts_with('#') || trimmed.is_empty() {
 278             continue;
 279         }
 280         if trimmed.starts_with('[') && trimmed.ends_with(']') {
 281             if trimmed == "[Desktop Entry]" {
 282                 in_desktop_entry = true;
 283             } else {
 284                 in_desktop_entry = false;
 285             }
 286             continue;
 287         }
 288         if in_desktop_entry {
 289             if let Some(pos) = trimmed.find('=') {
 290                 let key = trimmed[..pos].trim();
 291                 let value = trimmed[pos + 1..].trim();
 292                 match key {
 293                     "Name" => {
 294                         if name.is_none() {
 295                             name = Some(value.to_string());
 296                         }
 297                     }
 298                     "Exec" => {
 299                         if exec.is_none() {
 300                             exec = Some(clean_exec_command(value));
 301                         }
 302                     }
 303                     "Icon" => {
 304                         if icon.is_none() && !value.is_empty() {
 305                             icon = Some(value.to_string());
 306                         }
 307                     }
 308                     "Type" => {
 309                         if value != "Application" {
 310                             is_application = false;
 311                         }
 312                     }
 313                     "NoDisplay" => {
 314                         if value == "true" {
 315                             no_display = true;
 316                         }
 317                     }
 318                     "Terminal" => {
 319                         if value == "true" {
 320                             terminal = true;
 321                         }
 322                     }
 323                     // Hidden means "treat as deleted" — same outcome as
 324                     // NoDisplay for a launcher: the entry never shows.
 325                     "Hidden" => {
 326                         if value == "true" {
 327                             no_display = true;
 328                         }
 329                     }
 330                     _ => {}
 331                 }
 332             }
 333         }
 334     }
 335 
 336     if is_application && !no_display {
 337         if let (Some(n), Some(e)) = (name, exec) {
 338             let icon = path.file_stem().and_then(|s| s.to_str()).and_then(icon_override).or(icon);
 339             return Some(AppInfo { name: n, exec: e, terminal, icon });
 340         }
 341     }
 342     None
 343 }
 344 
 345 /// The `applications/` dirs `.desktop` files are read from, in XDG precedence:
 346 /// $XDG_DATA_HOME first, then each $XDG_DATA_DIRS entry in order (defaults per
 347 /// the base-directory spec). Honoring XDG_DATA_DIRS is what makes Flatpak/Snap
 348 /// exports visible.
 349 fn application_dirs() -> Vec<std::path::PathBuf> {
 350     let mut dirs = Vec::new();
 351     if let Some(data_home) = data_home() {
 352         dirs.push(data_home.join("applications"));
 353     }
 354     let data_dirs = std::env::var("XDG_DATA_DIRS")
 355         .ok()
 356         .filter(|v| !v.is_empty())
 357         .unwrap_or_else(|| "/usr/local/share:/usr/share".to_string());
 358     for dir in std::env::split_paths(&data_dirs) {
 359         if !dir.as_os_str().is_empty() {
 360             dirs.push(dir.join("applications"));
 361         }
 362     }
 363     dirs
 364 }
 365 
 366 /// `$XDG_DATA_HOME`, defaulting per the base-directory spec.
 367 fn data_home() -> Option<std::path::PathBuf> {
 368     std::env::var("XDG_DATA_HOME")
 369         .ok()
 370         .filter(|v| !v.is_empty())
 371         .map(std::path::PathBuf::from)
 372         .or_else(|| {
 373             std::env::var("HOME")
 374                 .ok()
 375                 .map(|h| std::path::PathBuf::from(h).join(".local/share"))
 376         })
 377 }
 378 
 379 /// cce's own icon for the desktop entry `id` (its file stem), when cce-icons
 380 /// ships one: `hicolor/scalable/apps/<id>.svg` under `$XDG_DATA_HOME/icons`,
 381 /// where `ccebuild install` puts that tree. Returned as an absolute path, so
 382 /// it bypasses the theme search entirely.
 383 ///
 384 /// This is what lets cce draw its own artwork for apps it does not own. An
 385 /// override named after the entry's `Icon=` value already wins without help
 386 /// (the user's data dir is searched first), but that cannot reach an entry
 387 /// whose `Icon=` is an absolute path (Houdini's PNG), is missing (Raindrop),
 388 /// or is a generic name several apps share (`network-wired` for all three
 389 /// Avahi browsers) — those are overridden by desktop-file ID instead. One stat
 390 /// per entry: the theme walk `cce_ui::icon::lookup` does would cost ~200 per
 391 /// miss, for every app, on every launcher open.
 392 fn icon_override(id: &str) -> Option<String> {
 393     icon_override_in(&data_home()?.join("icons/hicolor/scalable/apps"), id)
 394 }
 395 
 396 fn icon_override_in(dir: &std::path::Path, id: &str) -> Option<String> {
 397     let path = dir.join(format!("{id}.svg"));
 398     path.is_file().then(|| path.to_string_lossy().into_owned())
 399 }
 400 
 401 /// `Name=` and `Icon=` of the desktop entry with ID `id` (its file stem),
 402 /// from the first applications dir that has it — NoDisplay entries
 403 /// included: a handler the portal offers is a valid choice whether or not
 404 /// the launcher lists it. The icon goes through [`icon_override`] like the
 405 /// launcher's. `None` when no dir has the entry.
 406 fn desktop_entry_label(id: &str, dirs: &[std::path::PathBuf]) -> Option<(String, Option<String>)> {
 407     let path = dirs.iter().map(|d| d.join(format!("{id}.desktop"))).find(|p| p.is_file())?;
 408     let text = std::fs::read_to_string(&path).ok()?;
 409     let (mut name, mut icon) = (None, None);
 410     let mut in_entry = false;
 411     for line in text.lines().map(str::trim) {
 412         if line.starts_with('[') {
 413             in_entry = line == "[Desktop Entry]";
 414             continue;
 415         }
 416         if !in_entry {
 417             continue;
 418         }
 419         if let Some(v) = line.strip_prefix("Name=") {
 420             name.get_or_insert_with(|| v.trim().to_string());
 421         } else if let Some(v) = line.strip_prefix("Icon=") {
 422             if !v.trim().is_empty() {
 423                 icon.get_or_insert_with(|| v.trim().to_string());
 424             }
 425         }
 426     }
 427     Some((name.unwrap_or_else(|| id.to_string()), icon_override(id).or(icon)))
 428 }
 429 
 430 /// `--choose`: the app chooser the desktop portal opens ("Open with…").
 431 /// Dmenu mode with a different feed and a different answer: stdin carries
 432 /// desktop-file IDs, the rows show each one's name and icon, and the choice
 433 /// is printed back as the ID. Rows are labels, so the label → ID map is
 434 /// what turns a pick back into the answer.
 435 #[derive(Default)]
 436 struct Chooser {
 437     /// The IDs last ingested, so an unchanged feed is not re-resolved on
 438     /// every poll (the rows hold labels, which never equal the IDs).
 439     fed: Vec<String>,
 440     by_label: std::collections::HashMap<String, String>,
 441 }
 442 
 443 impl Chooser {
 444     /// Labels for `ids`, in order. A name two entries share (two "Firefox"
 445     /// builds) is told apart by the ID, so every label maps back to one app.
 446     fn labels(ids: &[String], dirs: &[std::path::PathBuf]) -> Vec<(String, String, Option<String>)> {
 447         let entries: Vec<(String, String, Option<String>)> = ids
 448             .iter()
 449             .map(|id| {
 450                 let (name, icon) = desktop_entry_label(id, dirs).unwrap_or_else(|| (id.clone(), None));
 451                 (id.clone(), name, icon)
 452             })
 453             .collect();
 454         entries
 455             .iter()
 456             .map(|(id, name, icon)| {
 457                 let shared = entries.iter().filter(|(_, n, _)| n == name).count() > 1;
 458                 let label = if shared { format!("{name} ({id})") } else { name.clone() };
 459                 (id.clone(), label, icon.clone())
 460             })
 461             .collect()
 462     }
 463 
 464     /// What a picked row answers: its ID, or the row itself if unknown.
 465     fn answer(&self, row: &str) -> String {
 466         self.by_label.get(row).cloned().unwrap_or_else(|| row.to_string())
 467     }
 468 }
 469 
 470 fn scan_apps() -> Vec<AppInfo> {
 471     let mut apps = Vec::new();
 472     let dirs = application_dirs();
 473 
 474     // The first file claiming a desktop-file ID (the file stem) shadows that
 475     // ID in every later dir — even when the winning entry is itself
 476     // Hidden/NoDisplay, which is how a user entry deletes a system one.
 477     let mut seen_ids = std::collections::HashSet::new();
 478     for dir in dirs {
 479         if let Ok(entries) = std::fs::read_dir(dir) {
 480             for entry in entries.flatten() {
 481                 let path = entry.path();
 482                 if path.is_file() && path.extension().map_or(false, |ext| ext == "desktop") {
 483                     let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
 484                         continue;
 485                     };
 486                     if !seen_ids.insert(id.to_string()) {
 487                         continue;
 488                     }
 489                     if let Some(app) = parse_desktop_file(&path) {
 490                         apps.push(app);
 491                     }
 492                 }
 493             }
 494         }
 495     }
 496 
 497     // Terminal=true apps need an emulator to host them; with none installed,
 498     // spawning them bare would fail silently, so drop the entries instead.
 499     if terminal_emulator().is_none() {
 500         apps.retain(|app| !app.terminal);
 501     }
 502 
 503     apps.sort_by(|a, b| a.name.cmp(&b.name));
 504     apps.dedup_by(|a, b| a.name == b.name);
 505     apps
 506 }
 507 
 508 /// A Super-Tab switcher row split into `(title, app_id)`. The compositor
 509 /// (`launch_window_switcher` in cce-compositor's window_manager.rs) writes each
 510 /// window as `Title (app_id)`, or the bare app_id when the title is empty —
 511 /// which is then both halves. It maps the echoed row back to a window by its
 512 /// whole text, so the row itself is left as sent: the title is only what is
 513 /// drawn, and the id is only what the icon is looked up by. The LAST
 514 /// parenthesised group is the id: a title may carry parentheses of its own.
 515 fn split_switcher_row(item: &str) -> (&str, &str) {
 516     item.strip_suffix(')')
 517         .and_then(|rest| rest.rfind(" (").map(|i| (&rest[..i], &rest[i + 2..])))
 518         .unwrap_or((item, item))
 519 }
 520 
 521 /// app_id → `Icon=` value, from every `.desktop` file on the search path.
 522 ///
 523 /// A window's app_id is not an icon name, but by convention it names its
 524 /// desktop entry: the file stem (`org.gnome.Nautilus`), or `StartupWMClass`
 525 /// for the apps whose id doesn't match their file. Keys are lowercased — ids in
 526 /// the wild disagree with their entries on case (`firefox` / `Firefox`) — and
 527 /// the last reverse-DNS component is indexed too, so `org.gnome.Nautilus`
 528 /// matches a window that reports plain `nautilus`. Unlike [`scan_apps`] this
 529 /// keeps NoDisplay entries: a helper window is still a window with an icon.
 530 /// A cce icon override for the entry ([`icon_override`]) replaces its `Icon=`.
 531 fn desktop_icon_index(dirs: &[std::path::PathBuf]) -> std::collections::HashMap<String, String> {
 532     let mut index = std::collections::HashMap::new();
 533     for dir in dirs {
 534         let Ok(entries) = std::fs::read_dir(dir) else { continue };
 535         for entry in entries.flatten() {
 536             let path = entry.path();
 537             if path.extension().map_or(true, |ext| ext != "desktop") {
 538                 continue;
 539             }
 540             let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue };
 541             let Ok(text) = std::fs::read_to_string(&path) else { continue };
 542             let mut in_entry = false;
 543             let mut icon = None;
 544             let mut wm_class = None;
 545             for line in text.lines() {
 546                 let line = line.trim();
 547                 if line.starts_with('[') {
 548                     in_entry = line == "[Desktop Entry]";
 549                 } else if in_entry {
 550                     if let Some(v) = line.strip_prefix("Icon=") {
 551                         icon.get_or_insert(v.trim().to_string());
 552                     } else if let Some(v) = line.strip_prefix("StartupWMClass=") {
 553                         wm_class.get_or_insert(v.trim().to_lowercase());
 554                     }
 555                 }
 556             }
 557             // A cce override for the entry wins, as in [`parse_desktop_file`].
 558             let Some(icon) = icon_override(stem).or(icon.filter(|i| !i.is_empty())) else { continue };
 559             let stem = stem.to_lowercase();
 560             let short = stem.rsplit('.').next().map(str::to_string);
 561             // First claim wins, in XDG precedence — the same shadowing
 562             // [`scan_apps`] applies to desktop-file IDs.
 563             for key in [Some(stem), wm_class, short].into_iter().flatten() {
 564                 index.entry(key).or_insert_with(|| icon.clone());
 565             }
 566         }
 567     }
 568     index
 569 }
 570 
 571 /// The icon name to draw for a window with this app_id: its desktop entry's
 572 /// `Icon=`, else the app_id itself, which is what apps without an entry (and
 573 /// cce's own clients, whose icons are installed under their app_id) name their
 574 /// icon after.
 575 fn icon_name_for_app_id(index: &std::collections::HashMap<String, String>, app_id: &str) -> String {
 576     index
 577         .get(&app_id.to_lowercase())
 578         .cloned()
 579         .unwrap_or_else(|| app_id.to_string())
 580 }
 581 
 582 #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
 583 struct AppLaunchHistory {
 584     count: u32,
 585     last_launch: u64,
 586 }
 587 
 588 fn get_cache_path() -> Option<std::path::PathBuf> {
 589     let cache_dir = if let Ok(cache_home) = std::env::var("XDG_CACHE_HOME") {
 590         std::path::PathBuf::from(cache_home)
 591     } else if let Ok(home) = std::env::var("HOME") {
 592         std::path::PathBuf::from(home).join(".cache")
 593     } else {
 594         return None;
 595     };
 596     Some(cache_dir.join("cce-cloud-apps.json"))
 597 }
 598 
 599 fn load_history() -> std::collections::HashMap<String, AppLaunchHistory> {
 600     if let Some(path) = get_cache_path() {
 601         if let Ok(content) = std::fs::read_to_string(path) {
 602             if let Ok(history) = serde_json::from_str(&content) {
 603                 return history;
 604             }
 605         }
 606     }
 607     std::collections::HashMap::new()
 608 }
 609 
 610 fn save_history(history: &std::collections::HashMap<String, AppLaunchHistory>) {
 611     if let Some(path) = get_cache_path() {
 612         if let Some(parent) = path.parent() {
 613             let _ = std::fs::create_dir_all(parent);
 614         }
 615         if let Ok(serialized) = serde_json::to_string_pretty(history) {
 616             let _ = std::fs::write(path, serialized);
 617         }
 618     }
 619 }
 620 
 621 fn record_app_launch(app_name: &str) {
 622     let mut history = load_history();
 623     let entry = history.entry(app_name.to_string()).or_default();
 624     entry.count += 1;
 625     entry.last_launch = std::time::SystemTime::now()
 626         .duration_since(std::time::UNIX_EPOCH)
 627         .unwrap_or_default()
 628         .as_secs();
 629     save_history(&history);
 630 }
 631 
 632 fn sort_apps_by_history(apps: &mut Vec<AppInfo>) {
 633     let history = load_history();
 634     apps.sort_by(|a, b| {
 635         let hist_a = history.get(&a.name);
 636         let hist_b = history.get(&b.name);
 637         match (hist_a, hist_b) {
 638             (Some(a_val), Some(b_val)) => {
 639                 let count_cmp = b_val.count.cmp(&a_val.count);
 640                 if count_cmp != std::cmp::Ordering::Equal {
 641                     count_cmp
 642                 } else {
 643                     let time_cmp = b_val.last_launch.cmp(&a_val.last_launch);
 644                     if time_cmp != std::cmp::Ordering::Equal {
 645                         time_cmp
 646                     } else {
 647                         a.name.cmp(&b.name)
 648                     }
 649                 }
 650             }
 651             (Some(_), None) => std::cmp::Ordering::Less,
 652             (None, Some(_)) => std::cmp::Ordering::Greater,
 653             (None, None) => a.name.cmp(&b.name),
 654         }
 655     });
 656 }
 657 
 658 fn spawn_command(cmd: &str) {
 659     spawn_detached("sh", &["-c", cmd]);
 660 }
 661 
 662 /// Resolve a cce binary installed beside this one.
 663 ///
 664 /// The launcher runs as a systemd user service, whose PATH is
 665 /// `/usr/local/bin:/usr/bin` — `~/.local/bin`, where every cce binary lives,
 666 /// is not on it, so spawning one by bare name fails with ENOENT under systemd
 667 /// while working fine from a shell.
 668 fn de_bin(name: &str) -> std::path::PathBuf {
 669     if let Ok(exe) = std::env::current_exe() {
 670         if let Some(dir) = exe.parent() {
 671             let beside = dir.join(name);
 672             if beside.exists() {
 673                 return beside;
 674             }
 675         }
 676     }
 677     std::path::PathBuf::from(name)
 678 }
 679 
 680 /// Open the launched window at the grid square this launcher was invoked at,
 681 /// keeping its remembered size and growing away from its neighbours.
 682 ///
 683 /// Only when there IS an invocation point: the desktop menu passes one
 684 /// through, a launcher summoned by keyboard does not, and in that case the app
 685 /// keeps its remembered place — there is no "here" to mean.
 686 fn place_next_at(exec: &str, invoked_at: Option<(i32, i32)>) {
 687     let Some((x, y)) = invoked_at else { return };
 688     let first = exec.split_whitespace().next().unwrap_or("");
 689     let prog = first.rsplit('/').next().unwrap_or(first);
 690     if prog.is_empty() {
 691         return;
 692     }
 693     let _ = std::process::Command::new(de_bin("ccectl"))
 694         .args(["place-next-cell", prog, &x.to_string(), &y.to_string()])
 695         .stdout(std::process::Stdio::null())
 696         .stderr(std::process::Stdio::null())
 697         .status();
 698 }
 699 
 700 /// Launch an app entry; `Terminal=true` entries are hosted in a terminal
 701 /// emulator (entries are dropped at scan time when none is installed).
 702 fn spawn_app(app: &AppInfo) {
 703     if app.terminal {
 704         if let Some(term) = terminal_emulator() {
 705             spawn_detached(&term, &["sh", "-c", &format!("exec {}", app.exec)]);
 706             return;
 707         }
 708     }
 709     spawn_command(&app.exec);
 710 }
 711 
 712 /// Terminal used to host `Terminal=true` desktop entries: $TERMINAL if it
 713 /// resolves on PATH (user env override), else the DE's configured default
 714 /// (config.kdl `default_terminal`, written by the settings app's Default
 715 /// Apps page), else foot. Callers pass the command positionally
 716 /// (`term sh -c …`), not via `-e` — the convention every candidate must
 717 /// accept (foot does natively; cce-terminal grew it alongside its entry).
 718 fn terminal_emulator() -> Option<String> {
 719     std::env::var("TERMINAL")
 720         .ok()
 721         .filter(|t| !t.is_empty() && command_in_path(t))
 722         .or_else(|| {
 723             cce_ui::config::get_string("/default_terminal")
 724                 .filter(|t| !t.is_empty() && command_in_path(t))
 725         })
 726         .or_else(|| command_in_path("foot").then(|| "foot".to_string()))
 727 }
 728 
 729 fn command_in_path(cmd: &str) -> bool {
 730     if cmd.contains('/') {
 731         return std::path::Path::new(cmd).is_file();
 732     }
 733     std::env::var_os("PATH").is_some_and(|paths| {
 734         std::env::split_paths(&paths).any(|dir| dir.join(cmd).is_file())
 735     })
 736 }
 737 
 738 /// Whether holding this key should repeat it: text, deletion and cursor or
 739 /// list movement. Not Return, Escape or Tab — a held Return would launch the
 740 /// selection again and again, a held Escape has nothing left to close.
 741 fn key_repeats(keysym: xkeysym::Keysym, utf8: Option<&str>) -> bool {
 742     use xkeysym::Keysym as K;
 743     match keysym {
 744         K::BackSpace | K::Delete | K::KP_Delete | K::Left | K::Right | K::Up | K::Down
 745         | K::KP_Left | K::KP_Right | K::KP_Up | K::KP_Down | K::Page_Up | K::Page_Down => true,
 746         K::Return | K::KP_Enter | K::Escape | K::Tab | K::ISO_Left_Tab => false,
 747         _ => utf8.is_some_and(|t| !t.is_empty() && !t.chars().any(char::is_control)),
 748     }
 749 }
 750 
 751 /// The session target a launched app's scope is `PartOf`: startcce starts it
 752 /// once the compositor is up and stops it when the compositor exits (see
 753 /// cce-cloud.service).
 754 const SESSION_TARGET: &str = "cce-session.target";
 755 
 756 /// A systemd unit-name fragment for `program`: its file name, reduced to the
 757 /// characters a unit name may hold.
 758 fn scope_name_part(program: &str) -> String {
 759     let base = program.rsplit('/').next().unwrap_or(program);
 760     let part: String = base
 761         .chars()
 762         .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
 763         .take(40)
 764         .collect();
 765     if part.is_empty() { "app".to_string() } else { part }
 766 }
 767 
 768 /// The program a launch actually runs, for naming its scope. Every desktop
 769 /// entry goes through `sh -c <Exec>` (a terminal-hosted one through
 770 /// `<terminal> sh -c "exec <Exec>"`), so naming the scope after `program`
 771 /// called every one of them `sh`: the first word of the shell command is
 772 /// the app, past a leading `exec`, `env` and `VAR=value` assignments.
 773 fn launch_name(program: &str, args: &[&str]) -> String {
 774     let shell_cmd = args
 775         .windows(2)
 776         .position(|w| w[0] == "-c")
 777         .filter(|&i| i == 0 || matches!(scope_name_part(args[i - 1]).as_str(), "sh" | "bash"))
 778         .filter(|&i| i > 0 || matches!(scope_name_part(program).as_str(), "sh" | "bash"))
 779         .map(|i| args[i + 1]);
 780     let Some(cmd) = shell_cmd else {
 781         return program.to_string();
 782     };
 783     cmd.split_whitespace()
 784         .map(|w| w.trim_matches(|c| c == '\'' || c == '"'))
 785         .find(|w| !w.is_empty() && *w != "exec" && *w != "env" && !w.contains('='))
 786         .unwrap_or(program)
 787         .to_string()
 788 }
 789 
 790 /// `systemd-run` arguments that start `program args` in its own transient
 791 /// scope, `app-cce\x2dcloud-<app>-<n>.scope` in app.slice, `PartOf` the
 792 /// session target, named after the app it runs (`launch_name`). `n` only
 793 /// has to make the name unique.
 794 fn scope_argv(program: &str, args: &[&str], n: u128) -> Vec<String> {
 795     let mut argv = vec![
 796         "--user".to_string(),
 797         "--scope".to_string(),
 798         "--collect".to_string(),
 799         "--slice=app.slice".to_string(),
 800         format!("--unit=app-cce\\x2dcloud-{}-{n}", scope_name_part(&launch_name(program, args))),
 801         format!("--property=PartOf={SESSION_TARGET}"),
 802         "--".to_string(),
 803         program.to_string(),
 804     ];
 805     argv.extend(args.iter().map(|a| a.to_string()));
 806     argv
 807 }
 808 
 809 fn spawn_detached(program: &str, args: &[&str]) {
 810     // Launched apps must outlive this daemon but not the session. Each gets
 811     // its own transient scope (`scope_argv`): a service restart signals only
 812     // the daemon (cce-cloud.service's KillMode=process) and never reaches
 813     // another unit's cgroup, while the scope's PartOf= stops the app with
 814     // the session. Until 2026-09-26 apps stayed in this service's cgroup,
 815     // where KillMode=process left them running after logout: a Proton
 816     // launch queued behind a still-running game started in the NEXT
 817     // session before its Xwayland existed, ran with no display, and held
 818     // every later launch of it behind itself. process_group(0) still keeps
 819     // a terminal ^C (manual daemon run) away from them.
 820     use std::os::unix::process::CommandExt;
 821     let mut cmd = if command_in_path("systemd-run") {
 822         let n = std::time::SystemTime::now()
 823             .duration_since(std::time::UNIX_EPOCH)
 824             .map_or(0, |d| d.as_nanos());
 825         let mut c = std::process::Command::new("systemd-run");
 826         c.args(scope_argv(program, args, n));
 827         c
 828     } else {
 829         let mut c = std::process::Command::new(program);
 830         c.args(args);
 831         c
 832     };
 833     cmd.process_group(0);
 834     // Per-user runtime dir, not /tmp: this records every app the launcher
 835     // starts and captures their stdout/stderr, so a fixed /tmp path is both a
 836     // collision between users and a readable trace of one user's activity.
 837     let log_path = cce_ui::config::cce_runtime_dir().join("spawn.log");
 838     if let Ok(file) = std::fs::OpenOptions::new()
 839         .create(true)
 840         .append(true)
 841         .open(&log_path)
 842     {
 843         let mut f = file;
 844         use std::io::Write;
 845         let _ = writeln!(f, "[spawn] executing: {} {}", program, args.join(" "));
 846         cmd.stdout(f.try_clone().unwrap()).stderr(f);
 847     }
 848     // Through the reaping spawn below, NOT a bare `cmd.spawn()`: the daemon
 849     // lives for the whole session, and a dropped Child handle means every app
 850     // it ever launched sits in the process table as a zombie once it exits —
 851     // unreadable in /proc and reported "alive" by kill(pid, 0) probes.
 852     let _ = spawn_reaped(cmd);
 853 }
 854 
 855 /// Spawn `cmd` and reap it on a background thread. This was
 856 /// `cce_ui::process::spawn_detached` until the toolkit dropped that module
 857 /// (cce-ui 4e94236) as caller-less — `spawn_detached` above was a caller.
 858 fn spawn_reaped(mut cmd: std::process::Command) -> std::io::Result<()> {
 859     let mut child = cmd.spawn()?;
 860     std::thread::spawn(move || {
 861         let _ = child.wait();
 862     });
 863     Ok(())
 864 }
 865 
 866 
 867 
 868 
 869 /// One row of the System tab: the label the list shows and the command that
 870 /// label runs.
 871 struct SystemCommand {
 872     name: &'static str,
 873     program: &'static str,
 874     args: &'static [&'static str],
 875 }
 876 
 877 /// The System tab's rows — window-manager verbs driven through `ccectl` (the
 878 /// DE's control CLI already exposes every one of them, so there is nothing to
 879 /// reimplement here) plus the session and power commands that are not the
 880 /// compositor's to run.
 881 ///
 882 /// The window verbs act on the window BEHIND this popup, not on the popup:
 883 /// the compositor's `focused_window` skips overlay UI and names `cce-cloud`
 884 /// explicitly among it, falling back to the most recent real window. That is
 885 /// what makes "Close Window" from a launcher mean anything at all.
 886 ///
 887 /// Hardcoded rather than config-driven: these are the DE's own verbs, and a
 888 /// row naming a command `ccectl` does not have is a row that silently does
 889 /// nothing.
 890 const SYSTEM_COMMANDS: &[SystemCommand] = &[
 891     SystemCommand { name: "Close Window", program: "ccectl", args: &["close"] },
 892     SystemCommand { name: "Minimize Window", program: "ccectl", args: &["minimize"] },
 893     SystemCommand { name: "Toggle Fullscreen", program: "ccectl", args: &["fullscreen"] },
 894     SystemCommand { name: "Center Window", program: "ccectl", args: &["center-window"] },
 895     SystemCommand { name: "Overlay Window Left", program: "ccectl", args: &["overlay-left"] },
 896     SystemCommand { name: "Overlay Window Right", program: "ccectl", args: &["overlay-right"] },
 897     SystemCommand { name: "Next Tiling Mode", program: "ccectl", args: &["mode-next"] },
 898     SystemCommand { name: "Next Tiling Mode (Shared)", program: "ccectl", args: &["mode-next-shared"] },
 899     SystemCommand { name: "Retile Windows", program: "ccectl", args: &["retile"] },
 900     SystemCommand { name: "Toggle Overview", program: "ccectl", args: &["overview"] },
 901     SystemCommand { name: "Zoom In", program: "ccectl", args: &["zoom-in"] },
 902     SystemCommand { name: "Zoom Out", program: "ccectl", args: &["zoom-out"] },
 903     SystemCommand { name: "Reset Zoom", program: "ccectl", args: &["zoom-reset"] },
 904     SystemCommand { name: "Take Screenshot", program: "ccectl", args: &["screenshot"] },
 905     SystemCommand { name: "Reload Configuration", program: "ccectl", args: &["reload"] },
 906     SystemCommand { name: "Restart Compositor", program: "ccectl", args: &["restart-compositor"] },
 907     SystemCommand { name: "Log Out", program: "ccectl", args: &["exit"] },
 908     SystemCommand { name: "Turn Off Display", program: "ccectl", args: &["idle", "display", "off"] },
 909     SystemCommand { name: "Suspend", program: "systemctl", args: &["suspend"] },
 910     SystemCommand { name: "Reboot", program: "systemctl", args: &["reboot"] },
 911     SystemCommand { name: "Power Off", program: "systemctl", args: &["poweroff"] },
 912 ];
 913 
 914 /// The titles the tabbed launcher shows, in strip order. Tab 0 is the mode's
 915 /// own list; tab 1 is [`SYSTEM_COMMANDS`].
 916 const SYSTEM_TAB_TITLE: &str = "System";
 917 
 918 /// Run `item` if it is a System-tab row, and say whether it was. These rows
 919 /// are the DE's own verbs rather than apps: they go straight to `ccectl` (or
 920 /// systemd), with none of the desktop-entry or place-next handling an app
 921 /// launch gets.
 922 fn run_system_item(fuzzel: &FuzzelWidget, item: &str) -> bool {
 923     if fuzzel.active_tab == 0 {
 924         return false;
 925     }
 926     let Some(cmd) = SYSTEM_COMMANDS.iter().find(|c| c.name == item) else {
 927         return false;
 928     };
 929     spawn_detached(cmd.program, cmd.args);
 930     true
 931 }
 932 
 933 pub struct FuzzelWidget {
 934     x: f32,
 935     y: f32,
 936     w: f32,
 937     h: f32,
 938     prompt: String,
 939     query: String,
 940     all_items: Vec<String>,
 941     filtered_items: Vec<String>,
 942     selected: usize,
 943     /// Item text → `(image id, px w, px h)` for the rows that resolved an icon.
 944     /// Keyed by text rather than index because filtering rebuilds the index
 945     /// space on every keystroke while the text is what identifies a row.
 946     icons: std::collections::HashMap<String, (u32, u32, u32)>,
 947     /// Width reserved for the icon column, 0 when no row has an icon. Applied to
 948     /// every row, not just the ones that resolved, so a list with one missing
 949     /// icon keeps a straight text edge instead of ragging in and out.
 950     icon_gutter: f32,
 951     /// Rows are the window switcher's `Title (app_id)` lines: draw only the
 952     /// title (see [`split_switcher_row`]). The item text — what filtering
 953     /// matches and what a selection echoes back — keeps the suffix. Also
 954     /// makes pointer motion select (see [`Self::pointer_moved`]).
 955     pub switcher_rows: bool,
 956     /// Row under the pointer, by filtered index — the hover wash and the
 957     /// brighter label. Distinct from `selected`: hovering never moves the
 958     /// keyboard selection, only a click does — except in the switcher, where
 959     /// MOVING onto a row selects it ([`Self::pointer_moved`]).
 960     hovered: Option<usize>,
 961     /// The row the switcher's last pointer motion selected, so the pointer
 962     /// only takes the selection when it moves onto a NEW row: Tab can still
 963     /// move the chip away from a resting (or jiggling) pointer. Cleared when
 964     /// the pointer leaves, so the first motion after it enters selects.
 965     motion_row: Option<usize>,
 966     /// Last pointer position seen over the surface, so the hovered row can be
 967     /// re-derived when the rows move under a STATIONARY pointer — a wheel
 968     /// glide, a keystroke refiltering the list, a keyboard snap.
 969     cursor: Option<(f32, f32)>,
 970     pub scroll_box: ScrollRegion,
 971     /// The pages the list is split into. Fewer than two means no tab strip and
 972     /// no chrome height for one, which is what keeps Dmenu, Path and the
 973     /// Super-Tab window switcher laid out exactly as they were.
 974     ///
 975     /// The ACTIVE page's items and query live in `all_items` / `query`, not in
 976     /// its `TabPage` — every existing caller reads them there, and only
 977     /// [`Self::switch_tab`] moves them across. A page's own copies are
 978     /// therefore stale for as long as it is the active one.
 979     pub tabs: Vec<TabPage>,
 980     pub active_tab: usize,
 981     /// Tab under the pointer, mirroring `hovered` for the rows.
 982     tab_hovered: Option<usize>,
 983 }
 984 
 985 /// One page of the tabbed list. See [`FuzzelWidget::tabs`] for which copy of
 986 /// `items` / `query` is the authoritative one.
 987 pub struct TabPage {
 988     title: String,
 989     items: Vec<String>,
 990     query: String,
 991 }
 992 
 993 /// Icon edge length inside an [`ICON_ITEM_H`] row. The gap between it and the
 994 /// label is the toolkit's control text inset, the same standoff the label
 995 /// keeps from the selection chip's edge.
 996 const ICON_PX: f32 = 26.0;
 997 
 998 /// The pixel size row icons are rasterized at: twice [`ICON_PX`], for a
 999 /// scale-2 output.
1000 const ICON_RASTER_PX: u32 = ICON_PX as u32 * 2;
1001 
1002 /// A row icon's image, uploaded once per renderer: `(id, width, height)`, or
1003 /// `None` when the theme has no such icon. The daemon keeps one renderer for
1004 /// its whole life, so the launcher's icons are uploaded on its first open (or
1005 /// by the startup warm-up, `run_daemon`) and every later open draws the same
1006 /// images. Uploading per popup cost a synchronous GPU copy per icon at the
1007 /// first frame, plus a device-idle wait per free at the next one: ~90 stalls
1008 /// for 46 icons, most of the launcher's time to first frame.
1009 ///
1010 /// The cache follows [`cce_ui::vk::renderer_epoch`]: a different renderer
1011 /// means none of the ids name anything, so they are dropped and uploaded
1012 /// again. Thread-safe, because the warm-up runs off the main thread.
1013 fn icon_image(name: &str) -> Option<(u32, u32, u32)> {
1014     use std::collections::HashMap;
1015     use std::sync::Mutex;
1016     type Cache = (u32, HashMap<String, Option<(u32, u32, u32)>>);
1017     static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
1018 
1019     let epoch = cce_ui::vk::renderer_epoch();
1020     let mut guard = CACHE.lock().unwrap();
1021     let (cached_epoch, ids) = guard.get_or_insert_with(|| (epoch, HashMap::new()));
1022     if *cached_epoch != epoch {
1023         *cached_epoch = epoch;
1024         ids.clear();
1025     }
1026     if let Some(hit) = ids.get(name) {
1027         return *hit;
1028     }
1029     let img = cce_ui::icon::upload_themed(name, ICON_RASTER_PX);
1030     ids.insert(name.to_string(), img);
1031     img
1032 }
1033 
1034 /// The list chrome's metrics. The spacing around them — the inset from the
1035 /// popup edge, the gap under the tab strip and under the search well, the
1036 /// text inset inside a well or a row — is the toolkit's ladder
1037 /// (`cce_ui::layout::root_plate_inset` / `root_plate_gap` /
1038 /// `CONTROL_TEXT_INSET`), read where it is used; these were repeated as bare
1039 /// `let pad = 15.0;` locals in every one of the paint, scroll and hit-test
1040 /// paths. The tab strip shifts the whole list down by its own height, so the
1041 /// offset has to be derived in one place or the rows, the clip and the click
1042 /// go out of step. The search well and the tab run are the toolkit's
1043 /// textbox and button heights (`cce_ui::layout::textbox_height` /
1044 /// `button_height`), read where they are used.
1045 const ITEM_H: f32 = 25.0;
1046 /// Row height once the list carries icons (Apps mode, the window switcher):
1047 /// the icon plus a 5px standoff above and below. Text-only lists — dmenu,
1048 /// Path, the System tab — keep the tighter [`ITEM_H`].
1049 const ICON_ITEM_H: f32 = 36.0;
1050 /// style: deliberate — the hairline the selection chip (and the hover wash on
1051 /// its footprint) stands in from the list viewport on each side, so the chip's
1052 /// roll clears the clip. A standoff, not a rung of the spacing ladder.
1053 const CHIP_STANDOFF: f32 = 2.0;
1054 /// Tab-title size — a step under the row labels, as a control label is.
1055 const TAB_FONT_PX: f32 = 12.0;
1056 
1057 impl FuzzelWidget {
1058     pub fn new(prompt: String) -> cce_ui::widget::Adapted<FuzzelWidget> {
1059         cce_ui::widget::Adapted::new(Self {
1060             x: 0.0,
1061             y: 0.0,
1062             w: 0.0,
1063             h: 0.0,
1064             prompt,
1065             query: String::new(),
1066             all_items: Vec::new(),
1067             filtered_items: Vec::new(),
1068             selected: 0,
1069             icons: std::collections::HashMap::new(),
1070             icon_gutter: 0.0,
1071             switcher_rows: false,
1072             hovered: None,
1073             motion_row: None,
1074             cursor: None,
1075             // Designer raise/sink treatment: the bar idles sunk under the
1076             // list's translucent bg (dimly visible through it) and raises over
1077             // the rows on scroll. The region already sits inset from the popup
1078             // edge, so the stock 4px edge inset reads right here.
1079             scroll_box: ScrollRegion::new(22.0, 0.0).with_sink_behind(true),
1080             tabs: Vec::new(),
1081             active_tab: 0,
1082             tab_hovered: None,
1083         })
1084     }
1085 
1086     pub fn set_items(&mut self, items: Vec<String>) {
1087         self.all_items = items;
1088         self.recompute_icon_gutter();
1089         self.filter();
1090     }
1091 
1092     /// Split the list into tabs. Tab 0 is the mode's own list — its items keep
1093     /// arriving through [`Self::set_items`] — and every later tab carries the
1094     /// items it is given here. A single tab (or none) draws no strip.
1095     pub fn set_tabs(&mut self, tabs: Vec<(String, Vec<String>)>) {
1096         self.tabs = tabs
1097             .into_iter()
1098             .map(|(title, items)| TabPage { title, items, query: String::new() })
1099             .collect();
1100         self.active_tab = 0;
1101         if let Some(first) = self.tabs.first_mut() {
1102             self.all_items = std::mem::take(&mut first.items);
1103         }
1104         self.recompute_icon_gutter();
1105         self.filter();
1106     }
1107 
1108     /// Replace tab `idx`'s items wherever they are parked. The stdin/socket
1109     /// feed always addresses tab 0 through this, never `set_items` directly:
1110     /// the ingest compares against the items it last pushed, and on any other
1111     /// tab that comparison would differ every time and clobber the list the
1112     /// user is reading.
1113     pub fn set_tab_items(&mut self, idx: usize, items: Vec<String>) {
1114         if idx == self.active_tab {
1115             self.set_items(items);
1116         } else if let Some(page) = self.tabs.get_mut(idx) {
1117             page.items = items;
1118         }
1119     }
1120 
1121     /// The items tab `idx` holds right now — from `all_items` when it is the
1122     /// active tab, from its parked page otherwise.
1123     pub fn tab_items(&self, idx: usize) -> &[String] {
1124         if idx == self.active_tab {
1125             &self.all_items
1126         } else {
1127             self.tabs.get(idx).map(|p| p.items.as_slice()).unwrap_or(&[])
1128         }
1129     }
1130 
1131     /// Move to tab `idx`, parking the current tab's items and query in its
1132     /// page and unpacking the target's — so switching back lands on the same
1133     /// query and the same rows. False when nothing moved.
1134     pub fn switch_tab(&mut self, idx: usize) -> bool {
1135         if idx >= self.tabs.len() || idx == self.active_tab {
1136             return false;
1137         }
1138         self.tabs[self.active_tab].items = std::mem::take(&mut self.all_items);
1139         self.tabs[self.active_tab].query = std::mem::take(&mut self.query);
1140         self.active_tab = idx;
1141         self.all_items = std::mem::take(&mut self.tabs[idx].items);
1142         self.query = std::mem::take(&mut self.tabs[idx].query);
1143         self.selected = 0;
1144         self.scroll_box.scroll_y = 0.0;
1145         self.recompute_icon_gutter();
1146         self.filter();
1147         true
1148     }
1149 
1150     /// Step one tab forward (or back) with wrap — what Tab and Shift+Tab do
1151     /// once the list has more than one. False when there is nothing to step
1152     /// through, which is the signal for those keys to fall back to their old
1153     /// job of cycling the highlight.
1154     pub fn cycle_tab(&mut self, forward: bool) -> bool {
1155         let n = self.tabs.len();
1156         if n < 2 {
1157             return false;
1158         }
1159         let idx = if forward { (self.active_tab + 1) % n } else { (self.active_tab + n - 1) % n };
1160         self.switch_tab(idx)
1161     }
1162 
1163     /// Height the tab strip takes off the top of the popup — the run (a
1164     /// button's height) and the gap between it and the search well; 0 below
1165     /// two tabs.
1166     pub fn tab_strip_h(&self) -> f32 {
1167         if self.tabs.len() > 1 { cce_ui::layout::button_height() + cce_ui::layout::root_plate_gap() } else { 0.0 }
1168     }
1169 
1170     /// The segmented run itself, inset from the popup edge like the search
1171     /// well under it. `None` when no strip is drawn.
1172     fn tab_strip_rect(&self) -> Option<cce_ui::scene::layout::Rect> {
1173         let inset = cce_ui::layout::root_plate_inset();
1174         (self.tabs.len() > 1).then(|| cce_ui::scene::layout::Rect {
1175             x: self.x + inset,
1176             y: self.y + inset,
1177             width: self.w - inset * 2.0,
1178             height: cce_ui::layout::button_height(),
1179         })
1180     }
1181 
1182     /// Segment `i` of the run — equal shares of its width.
1183     fn tab_rect(&self, i: usize) -> Option<cce_ui::scene::layout::Rect> {
1184         let strip = self.tab_strip_rect()?;
1185         let seg_w = strip.width / self.tabs.len() as f32;
1186         Some(cce_ui::scene::layout::Rect {
1187             x: strip.x + i as f32 * seg_w,
1188             y: strip.y,
1189             width: seg_w,
1190             height: strip.height,
1191         })
1192     }
1193 
1194     /// The tab under `(px, py)` — the one predicate the strip's hover wash and
1195     /// its click share, as `row_at` is for the rows.
1196     pub fn tab_at(&self, px: f32, py: f32) -> Option<usize> {
1197         let strip = self.tab_strip_rect()?;
1198         if px < strip.x || px >= strip.x + strip.width || py < strip.y || py >= strip.y + strip.height {
1199             return None;
1200         }
1201         let n = self.tabs.len();
1202         Some((((px - strip.x) / (strip.width / n as f32)).floor() as usize).min(n - 1))
1203     }
1204 
1205     /// Y of the search well's top edge: under the tab strip, where there is one.
1206     fn search_y(&self) -> f32 {
1207         self.y + cce_ui::layout::root_plate_inset() + self.tab_strip_h()
1208     }
1209 
1210     /// Y of the list viewport's top edge — the number the scroll math, the row
1211     /// hit-test, the clip and the empty-state label all have to agree on.
1212     fn list_y(&self) -> f32 {
1213         self.search_y() + cce_ui::layout::textbox_height() + cce_ui::layout::root_plate_gap()
1214     }
1215 
1216     /// Height of the list viewport: everything left between it and the bottom
1217     /// inset.
1218     fn list_h(&self) -> f32 {
1219         (self.y + self.h - cce_ui::layout::root_plate_inset()) - self.list_y()
1220     }
1221 
1222     /// X of the text in a row (and of the query line in the search well): the
1223     /// control text inset past the selection chip's edge, which itself stands
1224     /// a hairline in from the list. Icons start here too.
1225     fn text_x(&self) -> f32 {
1226         self.x + cce_ui::layout::root_plate_inset() + CHIP_STANDOFF + cce_ui::layout::CONTROL_TEXT_INSET
1227     }
1228 
1229     /// Vertical chrome around the list: the inset above and below, the search
1230     /// well and the gap under it, and the tab strip when there is one. What
1231     /// the popup's height is over its rows.
1232     pub fn chrome_h(&self) -> f32 {
1233         2.0 * cce_ui::layout::root_plate_inset() + self.tab_strip_h() + cce_ui::layout::textbox_height() + cce_ui::layout::root_plate_gap()
1234     }
1235 
1236     /// Horizontal chrome around a row's text: the text inset on both sides.
1237     /// What the popup's width is over its widest label.
1238     pub fn chrome_w(&self) -> f32 {
1239         2.0 * (self.text_x() - self.x)
1240     }
1241 
1242     /// Reserve the icon column only when some item on the ACTIVE tab resolved
1243     /// an icon, so the System tab's rows sit flush left while the Apps tab
1244     /// keeps its gutter. Within a tab the gutter still applies to every row
1245     /// (see [`Self::set_item_icons`]).
1246     fn recompute_icon_gutter(&mut self) {
1247         let any = self.all_items.iter().any(|t| self.icons.contains_key(t));
1248         let gutter = if any { ICON_PX + cce_ui::layout::CONTROL_TEXT_INSET } else { 0.0 };
1249         if gutter != self.icon_gutter {
1250             self.icon_gutter = gutter;
1251             // The row height follows the gutter (see `item_h`), so the
1252             // content height the scroll bounds hold just changed.
1253             self.update_scroll();
1254         }
1255     }
1256 
1257     /// Height of one row: taller when the list has an icon column. Every
1258     /// path that turns an index into a y — paint, hit-test, scroll bounds,
1259     /// keyboard snap, the popup's own height — reads it here.
1260     pub fn item_h(&self) -> f32 {
1261         if self.icon_gutter > 0.0 { ICON_ITEM_H } else { ITEM_H }
1262     }
1263 
1264     /// Give rows an icon column. Apps mode sets its whole map here; the
1265     /// window switcher adds to it through [`Self::extend_item_icons`]. Other
1266     /// Dmenu and Path items are arbitrary strings with nothing to look an icon
1267     /// up by, and they keep the flush-left layout they have always had because
1268     /// the gutter stays 0.
1269     pub fn set_item_icons(&mut self, icons: std::collections::HashMap<String, (u32, u32, u32)>) {
1270         self.icons = icons;
1271         self.recompute_icon_gutter();
1272     }
1273 
1274     /// Add icons for rows that arrived after the map was set — the window
1275     /// switcher's rows stream in over stdin.
1276     pub fn extend_item_icons(&mut self, icons: impl IntoIterator<Item = (String, (u32, u32, u32))>) {
1277         self.icons.extend(icons);
1278         self.recompute_icon_gutter();
1279     }
1280 
1281     pub fn has_item_icon(&self, item: &str) -> bool {
1282         self.icons.contains_key(item)
1283     }
1284 
1285     /// What a row draws for `item` — the item itself, except for the window
1286     /// switcher's rows (see `switcher_rows`).
1287     pub fn row_label<'a>(&self, item: &'a str) -> &'a str {
1288         if self.switcher_rows {
1289             split_switcher_row(item).0
1290         } else {
1291             item
1292         }
1293     }
1294 
1295     /// The square an icon is fitted into for the row drawn at `draw_y`.
1296     fn icon_rect(&self, draw_y: f32, item_h: f32, w: u32, h: u32) -> cce_ui::scene::layout::Rect {
1297         // Fit the longer side to ICON_PX so a non-square icon keeps its aspect
1298         // ratio and stays centered in the column.
1299         let (w, h) = (w.max(1) as f32, h.max(1) as f32);
1300         let s = ICON_PX / w.max(h);
1301         let (iw, ih) = (w * s, h * s);
1302         cce_ui::scene::layout::Rect {
1303             x: self.text_x() + (ICON_PX - iw) / 2.0,
1304             y: draw_y + (item_h - ih) / 2.0,
1305             width: iw,
1306             height: ih,
1307         }
1308     }
1309 
1310     pub fn filter(&mut self) {
1311         self.filtered_items = filter_and_sort_items(&self.all_items, &self.query);
1312         if self.selected >= self.filtered_items.len() {
1313             self.selected = self.filtered_items.len().saturating_sub(1);
1314         }
1315         self.update_scroll();
1316         self.snap_to_selected();
1317     }
1318 
1319     pub fn update_scroll(&mut self) {
1320         let content_h = self.filtered_items.len() as f32 * self.item_h();
1321         self.scroll_box.update_bounds_raw(content_h, self.list_y(), self.list_h());
1322         self.refresh_hover();
1323     }
1324 
1325     /// Filtered index of the row drawn at `(px, py)` — the ONE predicate the
1326     /// click and the hover share, so what lights up is what a press picks:
1327     /// inside the list, off the scrollbar strip (`hit()` spans it, and a
1328     /// press there once resolved to a row and committed it in dmenu mode),
1329     /// and a row `get_draw_y` places in the viewport — partially visible
1330     /// rows included, drawn cut by the clip, so an edge sliver counts.
1331     fn row_at(&self, px: f32, py: f32) -> Option<usize> {
1332         let item_h = self.item_h();
1333         if !self.scroll_box.hit(px, py) || self.scroll_box.hit_scrollbar(px, py) {
1334             return None;
1335         }
1336         let virtual_y = py - self.scroll_box.viewport_y + self.scroll_box.scroll_y;
1337         if virtual_y < 0.0 {
1338             return None;
1339         }
1340         let idx = (virtual_y / item_h).floor() as usize;
1341         (idx < self.filtered_items.len()
1342             && self.scroll_box.get_draw_y(idx as f32 * item_h, item_h).is_some())
1343             .then_some(idx)
1344     }
1345 
1346     /// The pointer moved to `(px, py)`; true when the hovered row changed.
1347     pub fn hover_at(&mut self, px: f32, py: f32) -> bool {
1348         self.cursor = Some((px, py));
1349         self.refresh_hover()
1350     }
1351 
1352     /// The pointer MOVED to `(px, py)` — a Motion, unlike the Enter that a
1353     /// popup mapping under a resting pointer also sends. In the switcher,
1354     /// moving onto a row selects it, so releasing the hold modifier switches
1355     /// to the window under the pointer. Only motion does this: rows shifting
1356     /// under a still pointer (a refilter, a scroll, the popup mapping where
1357     /// it rests) must not steal the selection Super+Tab just advanced. True
1358     /// when anything drawn changed.
1359     pub fn pointer_moved(&mut self, px: f32, py: f32) -> bool {
1360         let moved = self.cursor != Some((px, py));
1361         let mut changed = self.hover_at(px, py);
1362         if self.switcher_rows && moved && self.hovered != self.motion_row {
1363             self.motion_row = self.hovered;
1364             // No `snap_to_selected`: the row is already drawn (`row_at`), and
1365             // scrolling a cut edge row fully in would slide the next row under
1366             // the pointer and select that one too.
1367             if let Some(idx) = self.hovered.filter(|&i| i != self.selected) {
1368                 self.selected = idx;
1369                 changed = true;
1370             }
1371         }
1372         changed
1373     }
1374 
1375     /// The pointer left the surface; true when a row was lit.
1376     pub fn clear_hover(&mut self) -> bool {
1377         self.cursor = None;
1378         self.motion_row = None;
1379         self.refresh_hover()
1380     }
1381 
1382     /// Re-derive the hovered row from the last pointer position — the rows
1383     /// move under a stationary pointer on every scroll and refilter. True on
1384     /// change, so callers can skip the re-upload when nothing moved.
1385     pub fn refresh_hover(&mut self) -> bool {
1386         let now = self.cursor.and_then(|(px, py)| self.row_at(px, py));
1387         let tab_now = self.cursor.and_then(|(px, py)| self.tab_at(px, py));
1388         let changed = now != self.hovered || tab_now != self.tab_hovered;
1389         self.hovered = now;
1390         self.tab_hovered = tab_now;
1391         changed
1392     }
1393 
1394     pub fn snap_to_selected(&mut self) {
1395         let item_h = self.item_h();
1396         let viewport_h = self.list_h();
1397         let content_h = self.filtered_items.len() as f32 * item_h;
1398 
1399         if self.filtered_items.is_empty() {
1400             return;
1401         }
1402 
1403         let virtual_selected_y = self.selected as f32 * item_h;
1404         let old_scroll = self.scroll_box.scroll_y;
1405         if virtual_selected_y + item_h > self.scroll_box.scroll_y + viewport_h {
1406             self.scroll_box.scroll_y = virtual_selected_y + item_h - viewport_h;
1407         } else if virtual_selected_y < self.scroll_box.scroll_y {
1408             self.scroll_box.scroll_y = virtual_selected_y;
1409         }
1410 
1411         let max_scroll = (content_h - viewport_h).max(0.0);
1412         self.scroll_box.scroll_y = self.scroll_box.scroll_y.clamp(0.0, max_scroll);
1413         // Keyboard navigation scrolls the list without touching the wheel
1414         // path — raise the sink-behind bar for it too.
1415         if (self.scroll_box.scroll_y - old_scroll).abs() > 0.01 {
1416             self.scroll_box.notify_scrolled();
1417         }
1418         self.refresh_hover();
1419     }
1420 }
1421 
1422 impl cce_ui::widget::Layout for FuzzelWidget {
1423     // The legacy `set_rect` override's body: mirror the landed rect into the model (the
1424     // scroll/label math reads it between events) and place the scroll region.
1425     fn rect_assigned(&mut self, rect: cce_ui::scene::layout::Rect) {
1426         self.x = rect.x;
1427         self.y = rect.y;
1428         self.w = rect.width;
1429         self.h = rect.height;
1430 
1431         let inset = cce_ui::layout::root_plate_inset();
1432         self.scroll_box.set_rect(self.x + inset, self.list_y(), self.w - inset * 2.0, self.list_h());
1433         self.update_scroll();
1434     }
1435 }
1436 
1437 impl cce_ui::widget::Paint for FuzzelWidget {
1438     fn color(&self) -> [f32; 4] {
1439         [0.0, 0.0, 0.0, 0.0]
1440     }
1441 
1442     fn paint(&self, _rect: cce_ui::scene::layout::Rect, ctx: &mut cce_ui::scene::paint::PaintCtx) {
1443         use cce_ui::scene::layout::Rect;
1444         let pad = cce_ui::layout::root_plate_inset();
1445 
1446         // Tab strip — one well carved into the window plate with the segments
1447         // butting together on its floor and the active one raised back out of
1448         // it, which is the toolkit's recessed ButtonStrip treatment rendered
1449         // by hand (this widget paints straight onto the PaintCtx; nesting a
1450         // real ButtonStrip would need a child layout pass it does not have).
1451         if let Some(strip) = self.tab_strip_rect() {
1452             let radius = cce_ui::layout::button_corner_radius();
1453             let depth = cce_ui::layout::bevel_width().min(strip.height * 0.2);
1454             let (floor, radii) =
1455                 cce_ui::layout::carve_inside(strip, (radius, radius, radius, radius), depth);
1456             ctx.recess(floor, radii, depth);
1457             let inset = depth * 0.5;
1458             let seg_r = (radius - inset).max(0.0);
1459             for i in 0..self.tabs.len() {
1460                 let Some(r) = self.tab_rect(i) else { continue };
1461                 let seg = Rect {
1462                     x: r.x + inset,
1463                     y: r.y + inset,
1464                     width: (r.width - 2.0 * inset).max(0.0),
1465                     height: (r.height - 2.0 * inset).max(0.0),
1466                 };
1467                 if i == self.active_tab {
1468                     // Faceless on purpose: the floor shows through the raised
1469                     // plate, so the active tab reads as part of the strip
1470                     // rather than a chip dropped on it.
1471                     ctx.control_plate(
1472                         &cce_ui::widget::ControlPlate::control(
1473                             seg,
1474                             seg_r,
1475                             cce_ui::widget::PlateStance::Raised,
1476                             None,
1477                         )
1478                         .with_depth(depth),
1479                     );
1480                 } else if self.tab_hovered == Some(i) {
1481                     ctx.rounded_rect(seg, seg_r, (true, true, true, true), cce_ui::colors::PANEL_MENU_HOVER);
1482                 }
1483             }
1484         }
1485 
1486         // Search bar — a well recessed into the plate, its rim lit in the
1487         // highlight accent (the toolkit's focused-well treatment; the query
1488         // line always holds keyboard focus here). Replaces the flat fill +
1489         // 1px border quads.
1490         let search_h = cce_ui::layout::textbox_height();
1491         let well = Rect { x: self.x + pad, y: self.search_y(), width: self.w - pad * 2.0, height: search_h };
1492         ctx.quad(well, [0.10, 0.10, 0.14, 1.0]);
1493         let depth = cce_ui::layout::bevel_width().min(search_h * 0.2);
1494         let hc = cce_ui::color::highlight_primary_color();
1495         ctx.recess_tinted(well, (0.0, 0.0, 0.0, 0.0), depth, [hc[0], hc[1], hc[2]]);
1496 
1497         // The list's scrollbar idles UNDER its translucent bg fill, every
1498         // frame: down the list's centre line (the toolkit centres a
1499         // sink-behind bar), dimly seen through the fill, taking no press.
1500         // The fore copy fades in over the rows below while a scroll holds it.
1501         self.scroll_box.push_scrollbar_prims(ctx);
1502         let sb = &self.scroll_box;
1503         ctx.quad(Rect { x: sb.x, y: sb.y, width: sb.w, height: sb.h }, cce_ui::color::list_bg_color());
1504 
1505         // The list content — selection chip, icons, row labels — under the
1506         // list-viewport clip: `get_draw_y` returns PARTIALLY visible rows (the
1507         // toolkit ScrollRegion's intersection contract), so an edge row
1508         // renders cut by the clip instead of vanishing. Row text carries the
1509         // clip as bounds through walk_text_labels → prepare_text.
1510         let viewport = Rect {
1511             x: self.scroll_box.x,
1512             y: self.scroll_box.viewport_y,
1513             width: self.scroll_box.w,
1514             height: self.scroll_box.viewport_h,
1515         };
1516         ctx.clip(viewport, |ctx| {
1517             // Selected Item Highlight
1518             let item_h = self.item_h();
1519             if !self.filtered_items.is_empty() {
1520                 let virtual_selected_y = self.selected as f32 * item_h;
1521                 if let Some(draw_y) = self.scroll_box.get_draw_y(virtual_selected_y, item_h) {
1522                     // A raised beveled chip, not a flat tint: the selection reads
1523                     // as sitting proud of the list the way focused panes do. No
1524                     // width reserved for the scrollbar anymore — the sink-behind
1525                     // bar idles under the list bg and rides OVER the rows while
1526                     // raised, so the chip keeps its full width either way.
1527                     let sel = Rect {
1528                         x: self.x + pad + CHIP_STANDOFF,
1529                         y: draw_y,
1530                         width: self.w - pad * 2.0 - 2.0 * CHIP_STANDOFF,
1531                         height: item_h - CHIP_STANDOFF,
1532                     };
1533                     let depth = cce_ui::color::plate_bevel_width().min(sel.height * 0.2);
1534                     ctx.bevel(sel, (4.0, 4.0, 4.0, 4.0), &cce_ui::scene::Material::from_fill([0.20, 0.35, 0.65, 0.9]), depth);
1535                 }
1536             }
1537 
1538             // Hover wash — a flat, translucent pass of the selection colour
1539             // on the chip's footprint under the pointer. Flat on purpose: the
1540             // bevelled chip says "this is what Enter picks", the wash only
1541             // "this is what a click would pick". Never on the selected row,
1542             // which already wears the chip.
1543             if let Some(idx) = self.hovered.filter(|&i| i != self.selected) {
1544                 if let Some(draw_y) = self.scroll_box.get_draw_y(idx as f32 * item_h, item_h) {
1545                     let hov = Rect {
1546                         x: self.x + pad + CHIP_STANDOFF,
1547                         y: draw_y,
1548                         width: self.w - pad * 2.0 - 2.0 * CHIP_STANDOFF,
1549                         height: item_h - CHIP_STANDOFF,
1550                     };
1551                     ctx.quad(hov, [0.20, 0.35, 0.65, 0.35]);
1552                 }
1553             }
1554 
1555             // App icons, on the same virtualization predicate as the labels:
1556             // only rows `get_draw_y` places in the viewport are emitted, so a
1557             // 300-app list still costs one image quad per visible row.
1558             if self.icon_gutter > 0.0 {
1559                 let item_h = self.item_h();
1560                 for (idx, item_text) in self.filtered_items.iter().enumerate() {
1561                     let Some((image, iw, ih)) = self.icons.get(item_text).copied() else { continue };
1562                     if let Some(draw_y) = self.scroll_box.get_draw_y(idx as f32 * item_h, item_h) {
1563                         ctx.image(image, self.icon_rect(draw_y, item_h, iw, ih), 1.0);
1564                     }
1565                 }
1566             }
1567 
1568             // Visible item labels.
1569             for l in self.row_labels() {
1570                 ctx.text(l.text, l.x, l.y, l.font_size, l.color);
1571             }
1572         });
1573 
1574         // The fore copy rides over the rows at the fade, so the raise and the
1575         // sink are a fade rather than a flip.
1576         self.scroll_box.push_scrollbar_fore(ctx);
1577 
1578         // Prompt/query line and the empty-state notice — outside the list clip.
1579         for l in self.own_labels() {
1580             ctx.text(l.text, l.x, l.y, l.font_size, l.color);
1581         }
1582     }
1583 }
1584 
1585 impl cce_ui::widget::Input for FuzzelWidget {
1586     fn on_event(&mut self, event: &cce_ui::widget::Event, _ectx: &mut cce_ui::widget::EventCtx) -> bool {
1587         if let cce_ui::widget::Event::MouseButton {
1588             button: cce_ui::widget::MouseButton::Left,
1589             state: cce_ui::widget::ElementState::Pressed,
1590             x: px,
1591             y: py,
1592             ..
1593         } = event
1594         {
1595             // `row_at` is the same predicate the hover and the paint loop use
1596             // (visible ⇒ clickable, culled ⇒ not), so a press picks the row
1597             // that is lit under the pointer.
1598             if let Some(idx) = self.row_at(*px, *py) {
1599                 self.selected = idx;
1600                 return true;
1601             }
1602         }
1603         false
1604     }
1605 }
1606 
1607 
1608 
1609 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1610 enum LauncherMode {
1611     Dmenu,
1612     Path,
1613     Apps,
1614     Json,
1615 }
1616 
1617 #[derive(Debug, Clone)]
1618 struct AppInfo {
1619     name: String,
1620     exec: String,
1621     terminal: bool,
1622     /// The entry's `Icon=` key, resolved against the icon theme at display time.
1623     /// A theme name (`cce-files`), or an absolute path — both are legal per the
1624     /// desktop-entry spec, and `cce_ui::icon` handles the distinction.
1625     icon: Option<String>,
1626 }
1627 
1628 struct StdinState {
1629     items: Vec<String>,
1630     new_data: bool,
1631     cycle_next: usize,
1632     cycle_prev: usize,
1633     select_and_close: bool,
1634     // daemon mode: the requesting client hung up (killed/crashed) — the
1635     // popup has no owner left and must close, or the serial accept loop
1636     // in run_daemon stays wedged on it forever
1637     client_gone: bool,
1638 }
1639 
1640 
1641 
1642 #[derive(Clone)]
1643 #[allow(dead_code)]
1644 enum AppWindow {
1645     Layer(LayerSurface),
1646     Xdg(XdgWindow),
1647 }
1648 
1649 /// Logical-px breathing room kept between a positioned popup and the screen edge.
1650 /// style: deliberate — a placement clearance against the OUTPUT edge, not an
1651 /// inset on any plate; the spacing ladder has no rung for it.
1652 const EDGE_GAP: i32 = 8;
1653 
1654 /// Logical geometry `(x, y, w, h)` of the output containing the point `(x, y)`, or —
1655 /// when the point is off every output — the first one that advertises a geometry.
1656 /// `None` if no output does (nothing to clamp against; the request is used raw).
1657 fn output_bounds_at(output_state: &OutputState, x: i32, y: i32) -> Option<(i32, i32, i32, i32)> {
1658     let mut fallback = None;
1659     for output in output_state.outputs() {
1660         let Some(info) = output_state.info(&output) else { continue };
1661         let (Some((ox, oy)), Some((ow, oh))) = (info.logical_position, info.logical_size) else {
1662             continue;
1663         };
1664         if (ox..ox + ow).contains(&x) && (oy..oy + oh).contains(&y) {
1665             return Some((ox, oy, ow, oh));
1666         }
1667         fallback.get_or_insert((ox, oy, ow, oh));
1668     }
1669     fallback
1670 }
1671 
1672 /// Where a `-x/-y` popup wants to sit, and the output it must stay inside.
1673 ///
1674 /// The requested point is a cursor position (the compositor passes the pointer
1675 /// straight through for the desktop/window context menus), so the fit rule is the
1676 /// usual menu one: grow away from the anchor, **flip** to the other side of it when
1677 /// the window would overhang, and clamp only when it fits on neither side.
1678 ///
1679 /// The flip decision latches for the life of the popup. The window auto-sizes to its
1680 /// content continuously (`update_desired_size`), so re-deciding on every resize makes
1681 /// a filtering list snap back and forth across the cursor.
1682 ///
1683 /// `bounds` is the whole output, not the layer-shell *usable* area — which is why the
1684 /// surface asks for `exclusive_zone(-1)`. Without it a panel's exclusive zone would
1685 /// shrink the box the compositor places against while this math still used the full
1686 /// output, and the clamp would be wrong by exactly the panel's height.
1687 #[derive(Clone, Copy, Debug)]
1688 struct Placement {
1689     /// Requested anchor, in layout (logical) coordinates.
1690     x: i32,
1691     y: i32,
1692     /// `--align-right`: the anchor is `x` in from the right edge and the window
1693     /// grows leftward from it.
1694     align_right: bool,
1695     /// The output to stay inside, as `(x, y, w, h)` in logical coords. `None` when no
1696     /// output advertised a logical geometry — then the raw request is used unchanged.
1697     bounds: Option<(i32, i32, i32, i32)>,
1698     flip_x: Option<bool>,
1699     flip_y: Option<bool>,
1700 }
1701 
1702 impl Placement {
1703     fn new(x: i32, y: i32, align_right: bool, bounds: Option<(i32, i32, i32, i32)>) -> Self {
1704         Self { x, y, align_right, bounds, flip_x: None, flip_y: None }
1705     }
1706 
1707     /// Anchor + `(top, right, bottom, left)` margins for a `w`x`h` logical-px layer
1708     /// surface. Always top-left anchored when the output is known: the margins are
1709     /// recomputed on every resize anyway, so a left-growing popup is expressed by
1710     /// moving its left edge rather than by anchoring the right one.
1711     fn resolve(&mut self, w: i32, h: i32) -> (Anchor, (i32, i32, i32, i32)) {
1712         let Some((ox, oy, ow, oh)) = self.bounds else {
1713             return if self.align_right {
1714                 (Anchor::TOP | Anchor::RIGHT, (self.y, self.x, 0, 0))
1715             } else {
1716                 (Anchor::TOP | Anchor::LEFT, (self.y, 0, 0, self.x))
1717             };
1718         };
1719 
1720         // Output-local anchor. In align-right mode `x` is measured from the right
1721         // edge and the window hangs to the left of the point.
1722         let anchor_x = if self.align_right { ow - self.x } else { self.x - ox };
1723         let (nat_x, alt_x) = if self.align_right {
1724             (anchor_x - w, anchor_x)
1725         } else {
1726             (anchor_x, anchor_x - w)
1727         };
1728         let left = Self::fit(&mut self.flip_x, nat_x, alt_x, w, ow);
1729         let top = Self::fit(&mut self.flip_y, self.y - oy, self.y - oy - h, h, oh);
1730 
1731         (Anchor::TOP | Anchor::LEFT, (top, 0, 0, left))
1732     }
1733 
1734     /// Pick between the natural and flipped edge for one axis, then clamp into
1735     /// `[EDGE_GAP, extent - size - EDGE_GAP]`. Latches the choice in `flip`.
1736     fn fit(flip: &mut Option<bool>, natural: i32, flipped: i32, size: i32, extent: i32) -> i32 {
1737         let fits = |start: i32| start >= EDGE_GAP && start + size <= extent - EDGE_GAP;
1738         let flipped_is_better = *flip.get_or_insert(!fits(natural) && fits(flipped));
1739         let start = if flipped_is_better { flipped } else { natural };
1740         // A window taller/wider than the output has no in-range clamp; pin it to the
1741         // near edge rather than letting max() invert the range.
1742         start.clamp(EDGE_GAP, (extent - size - EDGE_GAP).max(EDGE_GAP))
1743     }
1744 }
1745 
1746 struct State {
1747     window: Option<AppWindow>,
1748     wl_surface: wl_surface::WlSurface,
1749     // Option: dropped explicitly in Drop, before the wl_surface is destroyed
1750     // (the swapchain must not outlive its Wayland surface).
1751     renderer: Option<VkRenderer>,
1752     vertex_data: Vec<Vertex>,
1753     frame_batches: Vec<Batch2D>,
1754     frame_images: Vec<ImageQuad>,
1755     plate_features: Vec<[f32; 12]>,
1756 
1757     /// Where this popup was invoked, in layout px, when it was given a
1758     /// position at all (the desktop menu passes its click through; a
1759     /// keyboard-summoned launcher has no "here" to mean). Used to place the
1760     /// launched window on that grid square.
1761     invoked_at: Option<(i32, i32)>,
1762 
1763     fuzzel: Handle<cce_ui::widget::Adapted<FuzzelWidget>>,
1764     json_layout: Option<Handle<cce_ui::widget::Adapted<JsonLayoutWidget>>>,
1765     font_system: FontSystem,
1766     swash_cache: SwashCache,
1767 
1768     cursor_x: f32,
1769     cursor_y: f32,
1770     width: f32,
1771     height: f32,
1772     physical_width: u32,
1773     physical_height: u32,
1774     scale: f64,
1775 
1776     stdin_state: Arc<Mutex<StdinState>>,
1777     mode: LauncherMode,
1778     apps: Vec<AppInfo>,
1779 
1780     max_width: u32,
1781     max_height: u32,
1782     select_item: Option<String>,
1783     switcher_mode: bool,
1784     /// `Some` in `--choose` mode ([`Chooser`]).
1785     chooser: Option<Chooser>,
1786     /// app_id → icon name for the switcher's rows ([`desktop_icon_index`]),
1787     /// built on the first row that needs it and kept for the popup's life.
1788     switcher_icon_index: Option<std::collections::HashMap<String, String>>,
1789     /// When `State::new` began, and whether the first frame that shows any
1790     /// rows has been logged since. A streamed list (Dmenu, the switcher)
1791     /// arrives after the popup opens, so the first frame alone can be an
1792     /// empty list; this is the one that shows the user something to pick.
1793     opened_at: std::time::Instant,
1794     rows_frame_logged: bool,
1795     /// Whether the compositor has configured the surface yet. Nothing may be
1796     /// drawn before: a buffer attached ahead of a layer surface's first
1797     /// configure is a protocol error, and the compositor disconnects the
1798     /// whole client, which for the daemon is every popup after it. Until
1799     /// 2026-10-05 the renderer took long enough to build that the configure
1800     /// always won; with the daemon's kept renderer a popup is ready in
1801     /// microseconds, and the switcher's streamed rows asked for a frame first.
1802     configured: bool,
1803     last_tick: std::time::Instant,
1804     ui_context: cce_ui::context::UiContext,
1805     /// Dissolved root plate container (Phase 6as): the plate was a pure value-holder for the
1806     /// window background — color (at root plate opacity), radius, rect. No border, no
1807     /// children, no events.
1808     window_rect: (f32, f32, f32, f32),
1809     window_bg: [f32; 4],
1810     select_and_close_requested: bool,
1811     /// `Some` for `-x/-y` popups (always layer-shell): re-applied on every resize so
1812     /// an auto-sizing window can't grow off the screen edge.
1813     placement: Option<Placement>,
1814 }
1815 
1816 /// Logical-px width one JSON-layout widget wants for the auto-sizing popup.
1817 ///
1818 /// Buttons are the subtlety: `cce_ui::widget::Button` draws its label with the control text
1819 /// inset on each side (see `Button::paint`) in the *button* font — not the menubar font
1820 /// `measure_text` assumes. So measure the label the way the button itself does
1821 /// (`measure_text_width` in the button font/size) and budget the button's two insets on top
1822 /// of the container's root-plate inset per side; otherwise a left-justified label starts
1823 /// an inset in and spills past the button's right edge (`JsonLayoutWidget::layout_children`
1824 /// sets `usable_w = width - 2 * root_plate_inset()`).
1825 fn json_widget_desired_width(widget_type: &str, text: &str) -> f32 {
1826     let margins = 2.0 * cce_ui::layout::root_plate_inset();
1827     match widget_type {
1828         "button" => {
1829             let (family, size) = cce_ui::layout::parse_font_string(&cce_ui::layout::button_font());
1830             cce_ui::widget::display::measure_text_width(text, &family, size.unwrap_or(12.0))
1831                 + margins
1832                 + 2.0 * cce_ui::layout::CONTROL_TEXT_INSET
1833         }
1834         "label" => cce_ui::widget::display::measure_text(text, 13.0) + margins,
1835         // The remainders are each control's own width beside its label (the
1836         // checkbox's toggle-height box and the 10px before its label, the
1837         // spinbox's field, the slider's track), not spacing.
1838         "checkbox" => cce_ui::widget::display::measure_text(text, 13.0) + margins + cce_ui::layout::toggle_height() + 10.0,
1839         "spinbox" | "color" => cce_ui::widget::display::measure_text(text, 13.0) + margins + 88.0,
1840         "slider" => cce_ui::widget::display::measure_text(text, 13.0) + margins + 128.0,
1841         _ => 150.0,
1842     }
1843 }
1844 
1845 /// [`json_widget_desired_width`] for a widget as configured on page
1846 /// `page_idx` of `page` (its widgets): a button's width also holds its
1847 /// glyphs (`json_layout::button_glyphs`), and its text is measured without
1848 /// the mark the glyph replaces.
1849 fn json_conf_desired_width(w_conf: &crate::json_layout::JsonWidgetConfig, page: &[crate::json_layout::JsonWidgetConfig], page_idx: usize) -> f32 {
1850     use crate::json_layout::{button_font_size, button_glyphs, glyph_room, page_has_lead};
1851     if w_conf.widget_type != "button" {
1852         return json_widget_desired_width(&w_conf.widget_type, &w_conf.text);
1853     }
1854     let (_, text, trail) = button_glyphs(&w_conf.text, page_idx, w_conf.target_page);
1855     json_widget_desired_width("button", text)
1856         + glyph_room(page_has_lead(page, page_idx), trail.is_some(), button_font_size())
1857 }
1858 
1859 impl State {
1860     fn new(
1861         conn: &Connection,
1862         qh: &QueueHandle<AppState>,
1863         compositor_state: &CompositorState,
1864         layer_shell_state: &LayerShell,
1865         xdg_shell_state: Option<&XdgShell>,
1866         cce_wm: Option<&cce_ui::protocol::cce_window_management_v1::zcce_window_manager_v1::ZcceWindowManagerV1>,
1867         use_xdg: bool,
1868         prompt: String,
1869         stdin_sender: calloop::channel::Sender<()>,
1870         mode: LauncherMode,
1871         x_pos: Option<i32>,
1872         y_pos: Option<i32>,
1873         align_right: bool,
1874         output_bounds: Option<(i32, i32, i32, i32)>,
1875         scale: f64,
1876         select_item: Option<String>,
1877         switcher_mode: bool,
1878         chooser_mode: bool,
1879         json_layout_config: Option<JsonLayoutConfig>,
1880         parent_app_id: Option<String>,
1881         fonts: Option<(FontSystem, SwashCache)>,
1882         renderer: Option<VkRenderer>,
1883     ) -> Result<
1884         (Self, Option<cce_ui::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1>),
1885         cce_ui::vk::SurfaceLost,
1886     > {
1887         let t_start = std::time::Instant::now();
1888         cce_ui::scale::set_scale_factor(scale as f32);
1889         let (width, height) = if mode == LauncherMode::Json {
1890             if let Some(ref config) = json_layout_config {
1891                 let w = config.width.unwrap_or_else(|| {
1892                     let mut max_widget_w = 120.0f32; // fallback minimum
1893                     if let Some(ref widgets) = config.widgets {
1894                         for w_conf in widgets {
1895                             let w_w = json_conf_desired_width(w_conf, widgets, 0);
1896                             if w_w > max_widget_w {
1897                                 max_widget_w = w_w;
1898                             }
1899                         }
1900                     } else if let Some(ref pages) = config.pages {
1901                         for (page_idx, page) in pages.iter().enumerate() {
1902                             for w_conf in &page.widgets {
1903                                 let w_w = json_conf_desired_width(w_conf, &page.widgets, page_idx);
1904                                 if w_w > max_widget_w {
1905                                     max_widget_w = w_w;
1906                                 }
1907                             }
1908                         }
1909                     }
1910                     max_widget_w.round() as u32
1911                 });
1912                 let h = config.height.unwrap_or_else(|| {
1913                     // The same walk as `JsonLayoutWidget::layout_children`:
1914                     // the root-plate inset above, a root-plate gap after each
1915                     // widget, and the trailing gap traded for the inset below.
1916                     let inset = cce_ui::layout::root_plate_inset();
1917                     let gap = cce_ui::layout::root_plate_gap();
1918                     let mut current_y = inset;
1919                     if let Some(ref widgets) = config.widgets {
1920                         for w_conf in widgets {
1921                             let h = match w_conf.widget_type.as_str() {
1922                                 "label" => 18.0,
1923                                 "checkbox" => cce_ui::layout::toggle_height(),
1924                                 "button" => cce_ui::widget::context_menu::ROW_H,
1925                                 "spinbox" => cce_ui::layout::spinbox_height(),
1926                                 "color" => cce_ui::layout::color_selector_height(),
1927                                 _ => 20.0,
1928                             };
1929                             current_y += h + gap;
1930                         }
1931                     } else if let Some(ref pages) = config.pages {
1932                         let mut max_page_y = inset;
1933                         for page in pages {
1934                             let mut page_y = inset;
1935                             for w_conf in &page.widgets {
1936                                 let h = match w_conf.widget_type.as_str() {
1937                                     "label" => 18.0,
1938                                     "checkbox" => cce_ui::layout::toggle_height(),
1939                                     "button" => cce_ui::widget::context_menu::ROW_H,
1940                                     "spinbox" => cce_ui::layout::spinbox_height(),
1941                                     "color" => cce_ui::layout::color_selector_height(),
1942                                     _ => 20.0,
1943                                 };
1944                                 page_y += h + gap;
1945                             }
1946                             if page_y > max_page_y {
1947                                 max_page_y = page_y;
1948                             }
1949                         }
1950                         current_y = max_page_y;
1951                     }
1952                     current_y = (current_y - gap).max(inset) + inset;
1953                     std::cmp::min(current_y.round() as u32, 600)
1954                 });
1955                 (w, h)
1956             } else {
1957                 (300, 400)
1958             }
1959         } else {
1960             (600, 800)
1961         };
1962         let pw = (width as f64 * scale) as u32;
1963         let ph = (height as f64 * scale) as u32;
1964         let lw = width as f32;
1965         let lh = height as f32;
1966         log::debug!("[timing] size estimation: {:?}", t_start.elapsed());
1967 
1968         let t = std::time::Instant::now();
1969         let wl_surface = compositor_state.create_surface(qh);
1970         wl_surface.set_buffer_scale(scale as i32);
1971         let app_id = if let Some(ref parent) = parent_app_id {
1972             format!("cce-cloud:{}", parent)
1973         } else {
1974             "cce-cloud".to_string()
1975         };
1976 
1977         let placement = (x_pos.is_some() || y_pos.is_some()).then(|| {
1978             Placement::new(x_pos.unwrap_or(0), y_pos.unwrap_or(0), align_right, output_bounds)
1979         });
1980 
1981         let mut cce_toplevel = None;
1982         let window = if use_xdg {
1983             let xdg_shell = xdg_shell_state.expect("XdgShell state is required for XDG mode");
1984             let xdg_window = xdg_shell.create_window(wl_surface.clone(), WindowDecorations::None, qh);
1985             xdg_window.set_title("cce-cloud");
1986             xdg_window.set_app_id(app_id);
1987             xdg_window.set_min_size(Some((width, height)));
1988             if let Some(wm) = cce_wm {
1989                 let toplevel = wm.get_cce_toplevel(&wl_surface, qh, ());
1990                 toplevel.set_popup();
1991                 cce_toplevel = Some(toplevel);
1992             }
1993             xdg_window.commit();
1994             AppWindow::Xdg(xdg_window)
1995         } else {
1996             let layer_window = layer_shell_state.create_layer_surface(
1997                 qh,
1998                 wl_surface.clone(),
1999                 Layer::Overlay,
2000                 Some(app_id),
2001                 None,
2002             );
2003             layer_window.set_size(width, height);
2004             layer_window.set_keyboard_interactivity(KeyboardInteractivity::Exclusive);
2005             if placement.is_none() {
2006                 layer_window.set_anchor(Anchor::empty());
2007             } else {
2008                 // Place against the full output, not the area left over by panels:
2009                 // `Placement` clamps against the wl_output geometry, and the two must
2010                 // agree on the box or the clamp is off by the panel's exclusive zone.
2011                 layer_window.set_exclusive_zone(-1);
2012                 // The anchor/margins are left to the first `apply_placement()`, once
2013                 // the content has actually been measured — the size passed above is a
2014                 // pre-layout estimate, and latching the flip decision on it would flip
2015                 // menus that fit and clip ones that don't.
2016             }
2017             wl_surface.commit();
2018             AppWindow::Layer(layer_window)
2019         };
2020 
2021         log::debug!("[timing] surface/window setup: {:?}", t.elapsed());
2022 
2023         // Raw-Vulkan renderer on the same display/surface pointers the wgpu
2024         // stack used. Corner radius 0: the window background tessellates its own
2025         // rounded corners (rounded_rect_vertices_corners).
2026         //
2027         // The daemon hands in the renderer it kept from the last popup, and
2028         // moving it onto this surface costs one swapchain. Building a new one
2029         // costs a device and every pipeline: 70-100 ms of a ~100 ms popup on
2030         // an idle machine, several hundred under load. Both fail only when
2031         // the connection is already dead under the surface.
2032         let t = std::time::Instant::now();
2033         let display_ptr = conn.backend().display_id().as_ptr() as *mut std::ffi::c_void;
2034         let surface_ptr = wl_surface.id().as_ptr() as *mut std::ffi::c_void;
2035         let renderer = match renderer {
2036             Some(mut kept) => {
2037                 unsafe { kept.attach_surface(display_ptr, surface_ptr, pw, ph) }?;
2038                 log::debug!("[timing] VkRenderer::attach_surface: {:?}", t.elapsed());
2039                 kept
2040             }
2041             None => {
2042                 let made = unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) }?;
2043                 log::debug!("[timing] VkRenderer::try_new: {:?}", t.elapsed());
2044                 made
2045             }
2046         };
2047 
2048         // Reuse the daemon's font system across popups (a rebuild re-scans the
2049         // fonts dir and loses the shaping caches).
2050         let t = std::time::Instant::now();
2051         let (font_system, swash_cache) =
2052             fonts.unwrap_or_else(|| (cce_ui::create_font_system(), SwashCache::new()));
2053         log::debug!("[timing] font system: {:?}", t.elapsed());
2054 
2055         let mut fuzzel = FuzzelWidget::new(prompt);
2056         fuzzel.set_rect(0.0, 0.0, lw, lh);
2057         fuzzel.switcher_rows = switcher_mode;
2058 
2059         let stdin_state = Arc::new(Mutex::new(StdinState {
2060             items: Vec::new(),
2061             new_data: false,
2062             cycle_next: 0,
2063             cycle_prev: 0,
2064             select_and_close: false,
2065             client_gone: false,
2066         }));
2067 
2068         let mut apps = Vec::new();
2069         if mode == LauncherMode::Dmenu {
2070             let stdin_state_clone = stdin_state.clone();
2071             std::thread::spawn(move || {
2072                 let stdin = io::stdin();
2073                 for line in stdin.lock().lines() {
2074                     if let Ok(line) = line {
2075                         if let Ok(mut lock_state) = stdin_state_clone.lock() {
2076                             if line == "__cce_switcher_next__" {
2077                                 lock_state.cycle_next += 1;
2078                                 lock_state.new_data = true;
2079                             } else if line == "__cce_switcher_prev__" {
2080                                 lock_state.cycle_prev += 1;
2081                                 lock_state.new_data = true;
2082                             } else if line == "__cce_switcher_select_and_close__" {
2083                                 lock_state.select_and_close = true;
2084                                 lock_state.new_data = true;
2085                             } else {
2086                                 lock_state.items.push(line);
2087                                 lock_state.new_data = true;
2088                             }
2089                         }
2090                         let _ = stdin_sender.send(());
2091                     }
2092                 }
2093             });
2094         } else if mode == LauncherMode::Apps {
2095             apps = scan_apps();
2096             sort_apps_by_history(&mut apps);
2097             let app_names: Vec<String> = apps.iter().map(|app| app.name.clone()).collect();
2098 
2099             // Resolve every entry's Icon= against the icon theme. Each icon is
2100             // uploaded once for the daemon's life (see `icon_image`), so after
2101             // the first open this is a map lookup per entry.
2102             let t_icons = std::time::Instant::now();
2103             let icons: std::collections::HashMap<String, (u32, u32, u32)> = apps
2104                 .iter()
2105                 .filter_map(|app| {
2106                     let img = icon_image(app.icon.as_deref()?)?;
2107                     Some((app.name.clone(), img))
2108                 })
2109                 .collect();
2110             log::debug!(
2111                 "[timing] app icons: {} of {} resolved in {:?}",
2112                 icons.len(),
2113                 apps.len(),
2114                 t_icons.elapsed()
2115             );
2116             fuzzel.set_item_icons(icons);
2117 
2118             // Apps is the only tabbed mode. Dmenu carries arbitrary caller
2119             // items (and the Super-Tab window switcher, whose Tab key must
2120             // keep cycling the highlight), Path is a raw $PATH dump, and Json
2121             // is not a list at all.
2122             fuzzel.set_tabs(vec![
2123                 ("Apps".to_string(), Vec::new()),
2124                 (
2125                     SYSTEM_TAB_TITLE.to_string(),
2126                     SYSTEM_COMMANDS.iter().map(|c| c.name.to_string()).collect(),
2127                 ),
2128             ]);
2129 
2130             if let Ok(mut lock_state) = stdin_state.lock() {
2131                 lock_state.items = app_names;
2132                 lock_state.new_data = true;
2133             }
2134         } else if mode == LauncherMode::Path {
2135             let path_items = scan_path();
2136             if let Ok(mut lock_state) = stdin_state.lock() {
2137                 lock_state.items = path_items;
2138                 lock_state.new_data = true;
2139             }
2140         }
2141 
2142         let json_layout = if mode == LauncherMode::Json {
2143             if let Some(ref config) = json_layout_config {
2144                 let mut jl = JsonLayoutWidget::new(config);
2145                 jl.set_rect(0.0, 0.0, lw, lh);
2146                 Some(jl)
2147             } else {
2148                 None
2149             }
2150         } else {
2151             None
2152         };
2153 
2154         cce_ui::scale::set_app_id("cce-cloud".to_string());
2155         let bg_color = cce_ui::color::page_low_color();
2156         let window_rect = (0.0, 0.0, lw, lh);
2157 
2158         let invoked_at = match (x_pos, y_pos) {
2159             (Some(x), Some(y)) => Some((x, y)),
2160             _ => None,
2161         };
2162         // The context owns the widgets; the app keeps their handles.
2163         let mut ui_context = cce_ui::context::UiContext::new();
2164         let mut state = Self {
2165             invoked_at,
2166             window: Some(window),
2167             wl_surface,
2168             renderer: Some(renderer),
2169             vertex_data: Vec::new(),
2170             frame_batches: Vec::new(),
2171             frame_images: Vec::new(),
2172             plate_features: Vec::new(),
2173             fuzzel: ui_context.insert(fuzzel),
2174             json_layout: json_layout.map(|w| ui_context.insert(w)),
2175             font_system,
2176             swash_cache,
2177             cursor_x: 0.0,
2178             cursor_y: 0.0,
2179             width: lw,
2180             height: lh,
2181             physical_width: pw,
2182             physical_height: ph,
2183             scale,
2184             stdin_state,
2185             mode,
2186             apps,
2187 
2188             max_width: width,
2189             // For json mode the initial `height` is a crude pre-layout estimate
2190             // (it ignores per-widget label offsets), so it must not double as the
2191             // growth cap — the accurately measured page height would be clipped
2192             // against it. Cap at the caller's explicit height when given, else a
2193             // sane maximum; update_desired_size resizes to the measured content
2194             // within that.
2195             max_height: if mode == LauncherMode::Json {
2196                 json_layout_config.as_ref().and_then(|c| c.height).unwrap_or(600)
2197             } else {
2198                 height
2199             },
2200             select_item,
2201             switcher_mode,
2202             chooser: chooser_mode.then(Chooser::default),
2203             switcher_icon_index: None,
2204             opened_at: t_start,
2205             rows_frame_logged: false,
2206             configured: false,
2207             last_tick: std::time::Instant::now(),
2208             ui_context,
2209             window_rect,
2210             window_bg: bg_color,
2211             select_and_close_requested: false,
2212             placement,
2213         };
2214 
2215         let t = std::time::Instant::now();
2216         state.check_stdin_updates();
2217         state.update_desired_size();
2218         // update_desired_size only re-places when the size actually moved off the
2219         // estimate; a popup that happened to be estimated exactly still needs its
2220         // first anchor.
2221         state.apply_placement();
2222         state.apply_layout();
2223         state.upload_vertices();
2224         log::debug!("[timing] initial layout/upload: {:?}", t.elapsed());
2225         log::debug!("[timing] State::new total: {:?}", t_start.elapsed());
2226         Ok((state, cce_toplevel))
2227     }
2228 
2229     fn check_stdin_updates(&mut self) -> bool {
2230         if let Ok(mut lock) = self.stdin_state.lock() {
2231             if lock.new_data {
2232                 lock.new_data = false;
2233 
2234                 if lock.select_and_close {
2235                     lock.select_and_close = false;
2236                     self.select_and_close_requested = true;
2237                 }
2238 
2239                 let cycles = lock.cycle_next;
2240                 lock.cycle_next = 0;
2241                 let cycles_back = lock.cycle_prev;
2242                 lock.cycle_prev = 0;
2243 
2244                 let mut changed = false;
2245                 let mut items = lock.items.clone();
2246                 // The chooser's feed is IDs and its rows are labels: resolve a
2247                 // new feed once, and leave the rows alone on an unchanged one.
2248                 if let Some(chooser) = self.chooser.as_mut() {
2249                     if chooser.fed == items {
2250                         items = self.ui_context[self.fuzzel].tab_items(0).to_vec();
2251                     } else {
2252                         let entries = Chooser::labels(&items, &application_dirs());
2253                         chooser.fed = items;
2254                         chooser.by_label = entries.iter().map(|(id, label, _)| (label.clone(), id.clone())).collect();
2255                         // `-s` names the portal's last choice by ID; the row
2256                         // it selects is that ID's label.
2257                         if let Some(sel) = self.select_item.as_ref() {
2258                             if let Some((_, label, _)) = entries.iter().find(|(id, _, _)| id == sel) {
2259                                 self.select_item = Some(label.clone());
2260                             }
2261                         }
2262                         self.ui_context[self.fuzzel].set_item_icons(
2263                             entries
2264                                 .iter()
2265                                 .filter_map(|(_, label, icon)| Some((label.clone(), icon_image(icon.as_deref()?)?)))
2266                                 .collect(),
2267                         );
2268                         items = entries.into_iter().map(|(_, label, _)| label).collect();
2269                     }
2270                 }
2271                 // Against tab 0's items, not the active tab's: the feed only
2272                 // ever fills the mode's own list, and comparing against
2273                 // whatever tab the user is reading would differ every time.
2274                 if self.ui_context[self.fuzzel].tab_items(0) != items.as_slice() {
2275                     if self.switcher_mode {
2276                         // Fields, not `self`: the stdin lock guard borrows it.
2277                         Self::resolve_switcher_icons(&mut self.ui_context[self.fuzzel], &mut self.switcher_icon_index, &items);
2278                     }
2279                     self.ui_context[self.fuzzel].set_tab_items(0, items);
2280                     changed = true;
2281                     if let Some(ref select_name) = self.select_item {
2282                         let select_lower = select_name.to_lowercase();
2283                         if let Some(idx) = self.ui_context[self.fuzzel].filtered_items.iter().position(|item| item.to_lowercase() == select_lower) {
2284                             self.ui_context[self.fuzzel].selected = idx;
2285                             self.ui_context[self.fuzzel].update_scroll();
2286                             self.ui_context[self.fuzzel].snap_to_selected();
2287                             self.select_item = None;
2288                         }
2289                     } else if self.switcher_mode && self.ui_context[self.fuzzel].filtered_items.len() > 1 {
2290                         self.ui_context[self.fuzzel].selected = 1;
2291                         self.ui_context[self.fuzzel].update_scroll();
2292                         self.ui_context[self.fuzzel].snap_to_selected();
2293                     }
2294                 }
2295 
2296                 if (cycles > 0 || cycles_back > 0) && !self.ui_context[self.fuzzel].filtered_items.is_empty() {
2297                     let len = self.ui_context[self.fuzzel].filtered_items.len() as isize;
2298                     let net = cycles as isize - cycles_back as isize;
2299                     self.ui_context[self.fuzzel].selected =
2300                         (self.ui_context[self.fuzzel].selected as isize + net).rem_euclid(len) as usize;
2301                     self.ui_context[self.fuzzel].update_scroll();
2302                     self.ui_context[self.fuzzel].snap_to_selected();
2303                     changed = true;
2304                 }
2305 
2306                 return changed;
2307             }
2308         }
2309         false
2310     }
2311 
2312     /// Give the switcher's window rows their app's icon. Rows stream in over
2313     /// stdin, so this runs per ingest and only resolves the rows that don't
2314     /// have an icon yet. Each icon is uploaded once and shared with the
2315     /// launcher (`icon_image`). `index` is `State::switcher_icon_index`.
2316     fn resolve_switcher_icons(
2317         fuzzel: &mut FuzzelWidget,
2318         index: &mut Option<std::collections::HashMap<String, String>>,
2319         items: &[String],
2320     ) {
2321         let new: Vec<&String> = items.iter().filter(|i| !fuzzel.has_item_icon(i)).collect();
2322         if new.is_empty() {
2323             return;
2324         }
2325         let t = std::time::Instant::now();
2326         let index = index.get_or_insert_with(|| desktop_icon_index(&application_dirs()));
2327         log::debug!("[timing] switcher icon index: {:?}", t.elapsed());
2328         let icons: Vec<(String, (u32, u32, u32))> = new
2329             .into_iter()
2330             .filter_map(|item| {
2331                 let name = icon_name_for_app_id(index, split_switcher_row(item).1);
2332                 let img = icon_image(&name)?;
2333                 Some((item.clone(), img))
2334             })
2335             .collect();
2336         fuzzel.extend_item_icons(icons);
2337     }
2338 
2339     fn update_desired_size(&mut self) {
2340         if self.mode == LauncherMode::Json {
2341             if let Some(jl) = self.json_layout.and_then(|h| self.ui_context.get(h)) {
2342                 let mut max_widget_w = 120.0f32; // fallback minimum
2343                 let active_page = jl.active_page;
2344                 
2345                 for w in &jl.widgets {
2346                     if w.page_idx != active_page {
2347                         continue;
2348                     }
2349                     // `w.text` is a button's label without its mark; the
2350                     // glyphs take their own room beside it.
2351                     let mut w_w = json_widget_desired_width(&w.widget_type, &w.text);
2352                     if w.widget_type == "button" {
2353                         w_w += crate::json_layout::glyph_room(
2354                             w.lead_column,
2355                             w.trail.is_some(),
2356                             crate::json_layout::button_font_size(),
2357                         );
2358                     }
2359                     if w_w > max_widget_w {
2360                         max_widget_w = w_w;
2361                     }
2362                 }
2363                 
2364                 let target_height = jl.page_total_heights[active_page].min(self.max_height as f32);
2365                 let target_width = max_widget_w.clamp(120.0, self.max_width as f32);
2366                 
2367                 self.resize_window(target_width.round() as u32, target_height.round() as u32);
2368             }
2369             return;
2370         }
2371         let num_items = self.ui_context[self.fuzzel].filtered_items.len();
2372         let item_count = if num_items == 0 { 1 } else { num_items };
2373         let needed_height = self.ui_context[self.fuzzel].chrome_h() + (item_count as f32) * self.ui_context[self.fuzzel].item_h();
2374         let target_height = needed_height.min(self.max_height as f32);
2375 
2376         // Calculate max text width
2377         let mut max_text_w: f32 = 0.0;
2378         
2379         let query_text = if self.ui_context[self.fuzzel].query.is_empty() {
2380             format!("{}{}", self.ui_context[self.fuzzel].prompt, "Type to search...")
2381         } else {
2382             format!("{}{}", self.ui_context[self.fuzzel].prompt, self.ui_context[self.fuzzel].query)
2383         };
2384         let buf = make_text_buffer(&mut self.font_system, &query_text, 14.0);
2385         let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0);
2386         if tw > max_text_w {
2387             max_text_w = tw;
2388         }
2389 
2390         if self.ui_context[self.fuzzel].filtered_items.is_empty() {
2391             let buf = make_text_buffer(&mut self.font_system, "No matches found", 13.0);
2392             let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0);
2393             if tw > max_text_w {
2394                 max_text_w = tw;
2395             }
2396         } else {
2397             for item in &self.ui_context[self.fuzzel].filtered_items {
2398                 let label = self.ui_context[self.fuzzel].row_label(item);
2399                 let buf = make_text_buffer(&mut self.font_system, label, 13.0);
2400                 let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0);
2401                 if tw > max_text_w {
2402                     max_text_w = tw;
2403                 }
2404             }
2405         }
2406 
2407         // The strip's segments are equal shares of the run, so the whole run
2408         // has to hold its widest title n times over or the narrowest tab
2409         // clips. (Today it fits inside the 300px floor below; it is measured
2410         // rather than assumed so adding a third tab cannot quietly break it.)
2411         let titles: Vec<String> = self.ui_context[self.fuzzel].tabs.iter().map(|t| t.title.clone()).collect();
2412         if titles.len() > 1 {
2413             let mut widest = 0.0f32;
2414             for title in &titles {
2415                 let buf = make_text_buffer(&mut self.font_system, title, TAB_FONT_PX);
2416                 let tw = buf.layout_runs().next().map(|r| r.line_w).unwrap_or(0.0);
2417                 widest = widest.max(tw);
2418             }
2419             // Each title gets the control text inset on both sides, as a
2420             // button label does.
2421             let strip_w = (widest + 2.0 * cce_ui::layout::CONTROL_TEXT_INSET) * titles.len() as f32;
2422             if strip_w > max_text_w {
2423                 max_text_w = strip_w;
2424             }
2425         }
2426 
2427         let scrollbar_w = if needed_height > self.max_height as f32 { 10.0 } else { 0.0 };
2428         let needed_width = max_text_w + self.ui_context[self.fuzzel].chrome_w() + self.ui_context[self.fuzzel].icon_gutter + scrollbar_w;
2429         let target_width = needed_width.clamp(300.0, self.max_width as f32);
2430 
2431         self.resize_window(target_width.round() as u32, target_height.round() as u32);
2432     }
2433 
2434     /// Grow/shrink the window to `w`x`h` logical px, and re-place it so the new size
2435     /// still fits on screen. No-op when the size is unchanged.
2436     fn resize_window(&mut self, w: u32, h: u32) {
2437         if self.width as u32 == w && self.height as u32 == h {
2438             return;
2439         }
2440         if let Some(AppWindow::Layer(ref layer)) = self.window {
2441             layer.set_size(w, h);
2442         }
2443         let pw = (w as f64 * self.scale) as u32;
2444         let ph = (h as f64 * self.scale) as u32;
2445         self.resize(pw, ph);
2446         // After `resize`, so the placement sees the size it is fitting.
2447         self.apply_placement();
2448         self.wl_surface.commit();
2449     }
2450 
2451     /// Re-anchor a `-x/-y` popup for its current size. Popups without an explicit
2452     /// position are centered by the compositor and xdg toplevels are placed by it, so
2453     /// both are left alone.
2454     fn apply_placement(&mut self) {
2455         let Some(ref mut placement) = self.placement else { return };
2456         let Some(AppWindow::Layer(ref layer)) = self.window else { return };
2457         let (anchor, (top, right, bottom, left)) =
2458             placement.resolve(self.width.round() as i32, self.height.round() as i32);
2459         layer.set_anchor(anchor);
2460         layer.set_margin(top, right, bottom, left);
2461     }
2462 
2463     fn apply_layout(&mut self) {
2464         let (w, h) = (self.width, self.height);
2465         self.window_rect = (0.0, 0.0, w, h);
2466         if self.mode == LauncherMode::Json {
2467             if let Some(jh) = self.json_layout {
2468                 cce_ui::scale::set_scale_factor(self.scale as f32);
2469                 self.ui_context[jh].set_rect(0.0, 0.0, w, h);
2470             }
2471         } else {
2472             self.ui_context[self.fuzzel].set_rect(0.0, 0.0, w, h);
2473         }
2474     }
2475 
2476     fn collect_display_list(&self) -> cce_ui::scene::paint::DisplayList {
2477         use cce_ui::scene::layout::Rect;
2478         let mut pc = cce_ui::scene::paint::PaintCtx::new();
2479 
2480         // 1. Window background — the dissolved root plate container's emission (base color at
2481         // the configured root plate opacity), now as a beveled plate: the rolled
2482         // rim makes the popup read as a raised surface instead of a flat sheet.
2483         let mut bg_color = self.window_bg;
2484         if bg_color[3] > 0.001 {
2485             bg_color[3] = cce_ui::color::root_plate_opacity();
2486         }
2487         if bg_color[3] > 0.0 {
2488             // PlateSpec (cce-ui RFC 7b, closing 7b-2's cce-cloud question): the
2489             // overlay SHARES the decorated-window silhouette. The compositor
2490             // never clips layer surfaces (layer_shell.rs passes blur radius 0;
2491             // corner rounding is the app's), so what this draws IS the
2492             // silhouette — and a launcher-sized panel wearing the nominal
2493             // widget-scale radius reads nearly square next to the windows
2494             // around it. All four corners are window corners; the spec snaps
2495             // them to the shared curve. Depth stays this app's shallower
2496             // plate_bevel_width, not the window default.
2497             let (wx, wy, ww, wh) = self.window_rect;
2498             pc.plate_spec(
2499                 &cce_ui::scene::paint::PlateSpec::root_at(Rect { x: wx, y: wy, width: ww, height: wh })
2500                     .with_material(cce_ui::scene::Material::opaque(bg_color))
2501                     .with_depth(cce_ui::color::plate_bevel_width()),
2502             );
2503         }
2504 
2505         // 2. Child widgets, through the paint walk: bevel/recess prims reach the
2506         // tessellator instead of being flattened away by the legacy quad bridges.
2507         if self.mode == LauncherMode::Json {
2508             if let Some(jl) = self.json_layout.and_then(|h| self.ui_context.get(h)) {
2509                 jl.paint_self(&self.ui_context, &mut pc);
2510             }
2511         } else {
2512             self.ui_context[self.fuzzel].paint_self(&self.ui_context, &mut pc);
2513         }
2514 
2515         pc.finish()
2516     }
2517 
2518     fn upload_vertices(&mut self) {
2519         let dl = self.collect_display_list();
2520         let (verts, batches, images, features) =
2521             tessellate(&dl, self.width, self.height, self.scale as f32);
2522         // No close fade is applied here any more, and deliberately so. This
2523         // used to multiply every vertex and image alpha by a fade factor and
2524         // then DROP the SDF-plate batches outright — which took the window's
2525         // whole background plate with them, since a plate batch IS its cover
2526         // quad, leaving the rows and text dissolving over nothing. The fade is
2527         // the compositor's now (`cce_ui::ipc::request_close_fade`): it ramps
2528         // this surface's scene-node opacity, which fades the backdrop blur
2529         // behind the popup along with it.
2530         // The GPU upload happens in VkRenderer::draw_frame_2d, which consumes
2531         // vertex_data every frame.
2532         self.vertex_data = verts;
2533         self.frame_batches = batches;
2534         self.frame_images = images;
2535         self.plate_features = features;
2536     }
2537 
2538     fn prepare_text(&mut self) {
2539         let scale_f32 = self.scale as f32;
2540 
2541         let mut widget_labels: Vec<(TextLabel, Option<[f32; 4]>)> = Vec::new();
2542         if self.mode == LauncherMode::Json {
2543             if let Some(jl) = self.json_layout.and_then(|h| self.ui_context.get(h)) {
2544                 widget_labels.extend(walk_text_labels(&self.ui_context, jl));
2545             }
2546         } else {
2547             widget_labels.extend(walk_text_labels(&self.ui_context, &self.ui_context[self.fuzzel]));
2548         }
2549 
2550         let mut buffers: Vec<Buffer> = Vec::with_capacity(widget_labels.len());
2551         for (label, _) in &widget_labels {
2552             buffers.push(make_text_buffer(&mut self.font_system, &label.text, label.font_size));
2553         }
2554 
2555         let spans: Vec<TextSpan> = buffers
2556             .iter()
2557             .zip(widget_labels.iter())
2558             .map(|(buf, (label, bounds))| TextSpan {
2559                 buffer: buf,
2560                 left: (label.x * scale_f32).round(),
2561                 top: (label.y * scale_f32).round(),
2562                 // Buffers are shaped at logical size; the span scales to physical.
2563                 scale: scale_f32,
2564                 // Logical merged clip (walk clip ∩ prim bounds) → physical px,
2565                 // so a partially visible row's text is cut at the viewport.
2566                 bounds: bounds.map(|b| {
2567                     [
2568                         (b[0] * scale_f32).floor() as i32,
2569                         (b[1] * scale_f32).floor() as i32,
2570                         (b[2] * scale_f32).ceil() as i32,
2571                         (b[3] * scale_f32).ceil() as i32,
2572                     ]
2573                 }),
2574                 default_color: [
2575                     label.color[0] as f32 / 255.0,
2576                     label.color[1] as f32 / 255.0,
2577                     label.color[2] as f32 / 255.0,
2578                     // TextLabel carries RGB only; labels are opaque.
2579                     1.0,
2580                 ],
2581                 rotation: None,
2582                 clip_circle: [0.0; 3],
2583                 clip_extents: [0.0; 2],
2584             })
2585             .collect();
2586 
2587         self.renderer.as_mut().unwrap().prepare_text(
2588             &mut self.font_system,
2589             &mut self.swash_cache,
2590             &spans,
2591         );
2592     }
2593 
2594     fn resize(&mut self, width: u32, height: u32) {
2595         if width > 0 && height > 0 {
2596             self.physical_width = width;
2597             self.physical_height = height;
2598             self.width = width as f32 / self.scale as f32;
2599             self.height = height as f32 / self.scale as f32;
2600             self.renderer.as_mut().unwrap().resize(width, height);
2601             self.apply_layout();
2602             self.upload_vertices();
2603         }
2604     }
2605 
2606     /// Draw a frame, or nothing before the surface's first configure (see
2607     /// `State::configured`): true if a frame was drawn. The configure
2608     /// handlers ask for a redraw, so a skipped frame is drawn right after it.
2609     fn render(&mut self) -> bool {
2610         if !self.configured {
2611             return false;
2612         }
2613         let now = std::time::Instant::now();
2614         self.last_tick = now;
2615 
2616         self.upload_vertices();
2617         self.prepare_text();
2618         self.renderer.as_mut().unwrap().draw_frame_2d(Frame2D {
2619             verts: &self.vertex_data,
2620             batches: &self.frame_batches,
2621             overlay_verts: &[],
2622             images: &self.frame_images,
2623             plate_features: &self.plate_features,
2624             clear_color: [0.0; 4],
2625             // Always a full frame: the popup is small and repaints whole.
2626             damage: None,
2627         });
2628         if !self.rows_frame_logged && !self.ui_context[self.fuzzel].filtered_items.is_empty() {
2629             self.rows_frame_logged = true;
2630             log::info!("[timing] open -> first frame with rows: {:?}", self.opened_at.elapsed());
2631         }
2632         true
2633     }
2634 }
2635 
2636 impl Drop for State {
2637     fn drop(&mut self) {
2638         // Swapchain/device teardown must precede the wl_surface's destruction
2639         // (daemon mode churns States, one per popup).
2640         self.renderer.take();
2641         self.window.take();
2642         self.wl_surface.destroy();
2643     }
2644 }
2645 
2646 #[allow(dead_code)]
2647 struct AppState {
2648     registry_state: RegistryState,
2649     compositor_state: CompositorState,
2650     layer_shell_state: LayerShell,
2651     shm_state: Shm,
2652     seat_state: SeatState,
2653     output_state: OutputState,
2654 
2655     seats: Vec<wl_seat::WlSeat>,
2656     pointer: Option<wl_pointer::WlPointer>,
2657     keyboard: Option<wl_keyboard::WlKeyboard>,
2658 
2659     window: Option<AppWindow>,
2660     surface: Option<wl_surface::WlSurface>,
2661 
2662     state: Option<State>,
2663     exit: bool,
2664     redraw: bool,
2665     ctrl_pressed: bool,
2666     /// The switcher's hold modifier — Super or Alt, whichever chord opened
2667     /// it — is still down. Starts true in switcher mode (the chord that
2668     /// opened it is held); its release commits the highlighted row.
2669     switch_held: bool,
2670     switcher_mode: bool,
2671     /// When the compositor's close dissolve ends and this popup may go, set
2672     /// by `trigger_close`. `None` while the popup is live. The surface has to
2673     /// stay mapped until then — the fade is the compositor ramping this
2674     /// surface's scene-node opacity, and a destroyed surface cuts it off.
2675     fade_until: Option<std::time::Instant>,
2676     cce_toplevel: Option<cce_ui::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1>,
2677     selected_item: Option<String>,
2678     /// The event loop, for the keyboard's repeat timer. Set before the first
2679     /// roundtrip, since that is when the seat announces its keyboard.
2680     loop_handle: calloop::LoopHandle<'static, AppState>,
2681 }
2682 
2683 impl AppState {
2684     fn trigger_close(&mut self) {
2685         if self.fade_until.is_some() {
2686             return;
2687         }
2688         // The compositor owns the dissolve and its duration; all this side
2689         // does is hold the surface open for as long as it asks. A zero
2690         // answer — fading configured off, or no compositor — means go now.
2691         let fade = cce_ui::ipc::request_close_fade();
2692         if fade.is_zero() {
2693             self.exit = true;
2694         } else {
2695             self.fade_until = Some(std::time::Instant::now() + fade);
2696         }
2697     }
2698 
2699     fn trigger_select_and_close(&mut self) {
2700         let mut should_close = false;
2701         if let Some(st) = &mut self.state {
2702             if !st.ui_context[st.fuzzel].filtered_items.is_empty() {
2703                 if let Some(item) = st.ui_context[st.fuzzel].filtered_items.get(st.ui_context[st.fuzzel].selected) {
2704                     let answer = st.chooser.as_ref().map_or_else(|| item.clone(), |c| c.answer(item));
2705                     println!("{}", answer);
2706                     self.selected_item = Some(answer);
2707                     if !run_system_item(&st.ui_context[st.fuzzel], item) {
2708                         match st.mode {
2709                             LauncherMode::Apps => {
2710                                 if let Some(app) = st.apps.iter().find(|app| &app.name == item) {
2711                                     record_app_launch(&app.name);
2712                                     place_next_at(&app.exec, st.invoked_at);
2713                                     spawn_app(app);
2714                                 }
2715                             }
2716                             LauncherMode::Path => {
2717                                 place_next_at(item, st.invoked_at);
2718                                 spawn_command(item);
2719                             }
2720                             LauncherMode::Dmenu => {}
2721                             LauncherMode::Json => {}
2722                         }
2723                     }
2724                     should_close = true;
2725                 }
2726             }
2727         }
2728         if should_close {
2729             self.trigger_close();
2730         }
2731     }
2732 }
2733 
2734 impl CompositorHandler for AppState {
2735     fn scale_factor_changed(
2736         &mut self,
2737         _conn: &Connection,
2738         _qh: &QueueHandle<Self>,
2739         _surface: &wl_surface::WlSurface,
2740         scale_factor: i32,
2741     ) {
2742         if let Some(state) = &mut self.state {
2743             state.scale = scale_factor as f64;
2744             state.wl_surface.set_buffer_scale(scale_factor);
2745             let pw = (state.width as f64 * state.scale) as u32;
2746             let ph = (state.height as f64 * state.scale) as u32;
2747             state.resize(pw, ph);
2748             self.redraw = true;
2749         }
2750     }
2751 
2752     fn transform_changed(
2753         &mut self,
2754         _conn: &Connection,
2755         _qh: &QueueHandle<Self>,
2756         _surface: &wl_surface::WlSurface,
2757         _new_transform: wl_output::Transform,
2758     ) {}
2759 
2760     fn frame(
2761         &mut self,
2762         _conn: &Connection,
2763         _qh: &QueueHandle<Self>,
2764         _surface: &wl_surface::WlSurface,
2765         _time: u32,
2766     ) {}
2767 
2768     fn surface_enter(
2769         &mut self,
2770         _conn: &Connection,
2771         _qh: &QueueHandle<Self>,
2772         _surface: &wl_surface::WlSurface,
2773         _output: &wl_output::WlOutput,
2774     ) {}
2775 
2776     fn surface_leave(
2777         &mut self,
2778         _conn: &Connection,
2779         _qh: &QueueHandle<Self>,
2780         _surface: &wl_surface::WlSurface,
2781         _output: &wl_output::WlOutput,
2782     ) {}
2783 }
2784 
2785 impl OutputHandler for AppState {
2786     fn output_state(&mut self) -> &mut OutputState {
2787         &mut self.output_state
2788     }
2789 
2790     fn new_output(
2791         &mut self,
2792         _conn: &Connection,
2793         _qh: &QueueHandle<Self>,
2794         _output: wl_output::WlOutput,
2795     ) {}
2796 
2797     fn update_output(
2798         &mut self,
2799         _conn: &Connection,
2800         _qh: &QueueHandle<Self>,
2801         _output: wl_output::WlOutput,
2802     ) {}
2803 
2804     fn output_destroyed(
2805         &mut self,
2806         _conn: &Connection,
2807         _qh: &QueueHandle<Self>,
2808         _output: wl_output::WlOutput,
2809     ) {}
2810 }
2811 
2812 impl SeatHandler for AppState {
2813     fn seat_state(&mut self) -> &mut SeatState {
2814         &mut self.seat_state
2815     }
2816 
2817     fn new_seat(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
2818         self.seats.push(seat);
2819     }
2820 
2821     fn new_capability(
2822         &mut self,
2823         _conn: &Connection,
2824         qh: &QueueHandle<Self>,
2825         seat: wl_seat::WlSeat,
2826         capability: Capability,
2827     ) {
2828         if capability == Capability::Pointer && self.pointer.is_none() {
2829             let pointer = self.seat_state.get_pointer(qh, &seat).unwrap();
2830             self.pointer = Some(pointer);
2831         }
2832         if capability == Capability::Keyboard && self.keyboard.is_none() {
2833             // With repeat: held keys re-fire at the compositor's
2834             // repeat_info rate (see `repeat_key`). Plain `get_keyboard` has
2835             // no repeat at all, which is how holding Backspace in the search
2836             // field deleted one character (until 2026-09-26).
2837             let keyboard = self
2838                 .seat_state
2839                 .get_keyboard_with_repeat(
2840                     qh,
2841                     &seat,
2842                     None,
2843                     self.loop_handle.clone(),
2844                     Box::new(|state: &mut AppState, _keyboard, event| state.repeat_key(event)),
2845                 )
2846                 .unwrap();
2847             self.keyboard = Some(keyboard);
2848         }
2849     }
2850 
2851     fn remove_capability(
2852         &mut self,
2853         _conn: &Connection,
2854         _qh: &QueueHandle<Self>,
2855         _seat: wl_seat::WlSeat,
2856         capability: Capability,
2857     ) {
2858         if capability == Capability::Pointer {
2859             self.pointer = None;
2860         }
2861         if capability == Capability::Keyboard {
2862             self.keyboard = None;
2863         }
2864     }
2865 
2866     fn remove_seat(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
2867         self.seats.retain(|s| s != &seat);
2868     }
2869 }
2870 
2871 impl ShmHandler for AppState {
2872     fn shm_state(&mut self) -> &mut Shm {
2873         &mut self.shm_state
2874     }
2875 }
2876 
2877 impl PointerHandler for AppState {
2878     fn pointer_frame(
2879         &mut self,
2880         _conn: &Connection,
2881         _qh: &QueueHandle<Self>,
2882         _pointer: &wl_pointer::WlPointer,
2883         events: &[smithay_client_toolkit::seat::pointer::PointerEvent],
2884     ) {
2885         use smithay_client_toolkit::seat::pointer::PointerEventKind;
2886         let mut should_close = false;
2887         for event in events {
2888             if let Some(ref active_surface) = self.surface {
2889                 if active_surface != &event.surface {
2890                     continue;
2891                 }
2892             }
2893             if let Some(st) = &mut self.state {
2894                 log::debug!("Event: position={:?}, scale={}, kind={:?}", event.position, st.scale, event.kind);
2895                 // Surface-local LOGICAL coords. This app's widget geometry is logical (the
2896                 // window tracks `width / scale`), and every other consumer — the Json
2897                 // dispatch and `FuzzelWidget::on_event` below — already uses the raw
2898                 // `event.position`. `scale_pointer_pos` multiplies by the scale, so feeding
2899                 // its result to the scroll region compared PHYSICAL cursor coords against a
2900                 // LOGICAL rect: on a scale-2 output the wheel silently stopped working past
2901                 // the list's midpoint (cursor at logical x=250 arrived as 500 against a rect
2902                 // ending at 285, so `hit()` was false and nothing scrolled).
2903                 let (cx, cy) = (event.position.0 as f32, event.position.1 as f32);
2904                 match &event.kind {
2905                     PointerEventKind::Motion { .. } => {
2906                         st.cursor_x = cx;
2907                         st.cursor_y = cy;
2908                         if st.mode == LauncherMode::Json {
2909                             let mut changed = false;
2910                             if let Some(jh) = st.json_layout {
2911                                 // Routed dispatch (6bd shrink): one Event through the router.
2912                                 let mv = cce_ui::widget::Event::PointerMove {
2913                                     x: event.position.0 as f32,
2914                                     y: event.position.1 as f32,
2915                                     local_x: event.position.0 as f32,
2916                                     local_y: event.position.1 as f32,
2917                                 };
2918                                 let root = jh.id();
2919                                 if st.ui_context.propagate_event(&mv, root) {
2920                                     changed = true;
2921                                 }
2922                             }
2923                             if changed {
2924                                 st.upload_vertices();
2925                                 self.redraw = true;
2926                             }
2927                         } else {
2928                             // Returns true only while a thumb drag is live; it also keeps
2929                             // the region's `hovered` current for the wheel/keyboard scope
2930                             // either way. A drag moves the rows under the pointer, so the
2931                             // hover row is re-derived after it (update_scroll does that).
2932                             let dragged = st.ui_context[st.fuzzel].scroll_box.cursor_moved(cx, cy);
2933                             if dragged {
2934                                 st.ui_context[st.fuzzel].update_scroll();
2935                             }
2936                             if st.ui_context[st.fuzzel].pointer_moved(cx, cy) || dragged {
2937                                 st.upload_vertices();
2938                                 self.redraw = true;
2939                             }
2940                         }
2941                     }
2942                     // The entry into the surface arrives as Enter with the
2943                     // position, not as a Motion — a pointer that crosses onto
2944                     // the list from outside lands ON a row and must light it.
2945                     PointerEventKind::Enter { .. } => {
2946                         st.cursor_x = cx;
2947                         st.cursor_y = cy;
2948                         if st.mode != LauncherMode::Json && st.ui_context[st.fuzzel].hover_at(cx, cy) {
2949                             st.upload_vertices();
2950                             self.redraw = true;
2951                         }
2952                     }
2953                     PointerEventKind::Leave { .. } => {
2954                         if st.mode != LauncherMode::Json && st.ui_context[st.fuzzel].clear_hover() {
2955                             st.upload_vertices();
2956                             self.redraw = true;
2957                         }
2958                     }
2959                     PointerEventKind::Press { button, .. } => {
2960                         if *button == 272 {
2961                             st.cursor_x = cx;
2962                             st.cursor_y = cy;
2963                             if st.mode == LauncherMode::Json {
2964                                 let mut changed = false;
2965                                 if let Some(jh) = st.json_layout {
2966                                     let ev = cce_ui::widget::Event::MouseButton {
2967                                         button: cce_ui::widget::MouseButton::Left,
2968                                         state: cce_ui::widget::ElementState::Pressed,
2969                                         x: event.position.0 as f32,
2970                                         y: event.position.1 as f32,
2971                                         local_x: event.position.0 as f32,
2972                                         local_y: event.position.1 as f32,
2973                                     };
2974                                     let root = jh.id();
2975                                     if st.ui_context.propagate_event(&ev, root) {
2976                                         changed = true;
2977                                     }
2978                                 }
2979                                 if changed {
2980                                     st.upload_vertices();
2981                                     self.redraw = true;
2982                                 }
2983                             } else if let Some(idx) = st.ui_context[st.fuzzel].tab_at(cx, cy) {
2984                                 // Ahead of the row branch for the same reason
2985                                 // the scrollbar is: ANY press `on_event`
2986                                 // resolves is treated there as a selection and
2987                                 // — in Dmenu/switcher mode — committed. A tab
2988                                 // click must switch tabs, not choose a row.
2989                                 if st.ui_context[st.fuzzel].switch_tab(idx) {
2990                                     st.update_desired_size();
2991                                     st.upload_vertices();
2992                                     self.redraw = true;
2993                                 }
2994                             } else if st.ui_context[st.fuzzel].scroll_box.press(cx, cy) {
2995                                 // Thumb grab or track jump. This must be handled here rather
2996                                 // than inside `on_event`, because the row branch below treats
2997                                 // ANY handled press as a selection and — in Dmenu/switcher
2998                                 // mode — commits it and closes the popup. A scrollbar press
2999                                 // must scroll, not choose.
3000                                 st.ui_context[st.fuzzel].update_scroll();
3001                                 st.upload_vertices();
3002                                 self.redraw = true;
3003                             } else {
3004                                 let prev_selected = st.ui_context[st.fuzzel].selected;
3005                                 let changed = {
3006                                     let ev = cce_ui::widget::Event::MouseButton {
3007                                         button: cce_ui::widget::MouseButton::Left,
3008                                         state: cce_ui::widget::ElementState::Pressed,
3009                                         x: event.position.0 as f32,
3010                                         y: event.position.1 as f32,
3011                                         local_x: event.position.0 as f32,
3012                                         local_y: event.position.1 as f32,
3013                                     };
3014                                     let root = st.fuzzel.id();
3015                                     st.ui_context.propagate_event(&ev, root)
3016                                 };
3017                                 if changed {
3018                                     // A single click launches — the fuzzel on_event only
3019                                     // reports presses it resolved to a really-drawn row
3020                                     // (scrollbar and clipped-sliver presses never get
3021                                     // here), so the click IS the choice, exactly as Enter.
3022                                     // (The old gate gated Apps/Path on `selected ==
3023                                     // prev_selected`, which read as the click doing
3024                                     // nothing.) The SWITCHER commits only a click on the
3025                                     // row already selected — which, since moving onto a
3026                                     // row selects it (`pointer_moved`), is any row the
3027                                     // pointer reached by motion. A click on a row it only
3028                                     // rests on (the popup mapped under it) selects first.
3029                                     let commit = !st.switcher_mode || st.ui_context[st.fuzzel].selected == prev_selected;
3030                                     if commit {
3031                                         if let Some(item) = st.ui_context[st.fuzzel].filtered_items.get(st.ui_context[st.fuzzel].selected) {
3032                                             let answer = st.chooser.as_ref().map_or_else(|| item.clone(), |c| c.answer(item));
3033                                             println!("{}", answer);
3034                                             self.selected_item = Some(answer);
3035                                             if !run_system_item(&st.ui_context[st.fuzzel], item) {
3036                                                 match st.mode {
3037                                                     LauncherMode::Apps => {
3038                                                         if let Some(app) = st.apps.iter().find(|app| &app.name == item) {
3039                                                             record_app_launch(&app.name);
3040                                                             spawn_app(app);
3041                                                         }
3042                                                     }
3043                                                     LauncherMode::Path => {
3044                                                         spawn_command(item);
3045                                                     }
3046                                                     LauncherMode::Dmenu => {}
3047                                                     LauncherMode::Json => {}
3048                                                 }
3049                                             }
3050                                             should_close = true;
3051                                         }
3052                                     }
3053                                     st.upload_vertices();
3054                                     self.redraw = true;
3055                                 }
3056                             }
3057                         }
3058                     }
3059                     PointerEventKind::Release { button, .. } => {
3060                         if *button == 272 {
3061                             if st.mode == LauncherMode::Json {
3062                                 let mut changed = false;
3063                                 let mut clicked_btn_id = None;
3064                                 // A `target_page` button switches pages inside
3065                                 // propagate_event (JsonLayoutWidget takes its
3066                                 // click there, so it never reaches the
3067                                 // clicked_btn_id scan below). The popup is
3068                                 // sized per page — a submenu page with more
3069                                 // rows than the first was clipped to the first
3070                                 // page's height until this resize.
3071                                 let mut page_switched = false;
3072                                 if let Some(jh) = st.json_layout {
3073                                     let page_before = st.ui_context[jh].active_page;
3074                                     let ev = cce_ui::widget::Event::MouseButton {
3075                                         button: cce_ui::widget::MouseButton::Left,
3076                                         state: cce_ui::widget::ElementState::Released,
3077                                         x: event.position.0 as f32,
3078                                         y: event.position.1 as f32,
3079                                         local_x: event.position.0 as f32,
3080                                         local_y: event.position.1 as f32,
3081                                     };
3082                                     let root = jh.id();
3083                                     if st.ui_context.propagate_event(&ev, root) {
3084                                         changed = true;
3085                                     }
3086                                     page_switched = st.ui_context[jh].active_page != page_before;
3087                                     for w in &mut st.ui_context[jh].widgets {
3088                                         // take_click is an WidgetHost method; Phase 5 Buttons are
3089                                         // Adapted, so ask the box directly.
3090                                         if w.widget_type == "button" && w.widget.take_click() {
3091                                             clicked_btn_id = Some(w.id.clone());
3092                                             break;
3093                                         }
3094                                     }
3095                                 }
3096                                 if page_switched {
3097                                     st.update_desired_size();
3098                                     changed = true;
3099                                 }
3100                                 if changed {
3101                                     st.upload_vertices();
3102                                     self.redraw = true;
3103                                 }
3104                                 if let Some(btn_id) = clicked_btn_id {
3105                                     let mut checkboxes = std::collections::HashMap::new();
3106                                     let mut spinboxes = std::collections::HashMap::new();
3107                                     let mut colors = std::collections::HashMap::new();
3108                                     let mut sliders = std::collections::HashMap::new();
3109                                     if let Some(jl) = st.json_layout.and_then(|h| st.ui_context.get(h)) {
3110                                         for w in &jl.widgets {
3111                                             if let Some(cb) = w.widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Checkbox>() {
3112                                                 checkboxes.insert(w.id.clone(), cb.checked());
3113                                             } else if let Some(sb) = w.widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Spinbox>() {
3114                                                 spinboxes.insert(w.id.clone(), sb.value);
3115                                             } else if let Some(cs) = w.widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::ColorSelector>() {
3116                                                 colors.insert(w.id.clone(), cs.color);
3117                                             } else if let Some(sl) = w.widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Slider>() {
3118                                                 sliders.insert(w.id.clone(), sl.get_scaled_value());
3119                                             }
3120                                         }
3121                                     }
3122                                     let out_val = serde_json::json!({
3123                                         "button": btn_id,
3124                                         "checkboxes": checkboxes,
3125                                         "spinboxes": spinboxes,
3126                                         "colors": colors,
3127                                         "sliders": sliders
3128                                     });
3129                                     let out_str = out_val.to_string();
3130                                     println!("{}", out_str);
3131                                     self.selected_item = Some(out_str);
3132                                     should_close = true;
3133                                 }
3134                             } else if st.ui_context[st.fuzzel].scroll_box.release() {
3135                                 // Ends a thumb drag. Returns true only if one was live, so a
3136                                 // plain click on a row is unaffected.
3137                                 st.upload_vertices();
3138                                 self.redraw = true;
3139                             }
3140                         }
3141                     }
3142                     PointerEventKind::Axis { horizontal, vertical, source, .. } => {
3143                         // Synthesized the way cce-ui's runner does it
3144                         // (window_runner.rs, `axis_stop`): discrete steps are
3145                         // wheel notches (LineDelta), anything else is pixels
3146                         // 1:1 (PixelDelta), and a bare stop is the finger
3147                         // lift. The phase is published before the dispatch so
3148                         // the ScrollMotion under each scroll host knows whether
3149                         // to glide a notch, track a finger, or fling.
3150                         use cce_ui::widget::scroll_motion::{set_scroll_phase, ScrollPhase};
3151                         let factors = cce_ui::input::scroll_factors();
3152                         let discrete = horizontal.discrete != 0 || vertical.discrete != 0;
3153                         let no_delta = !discrete && horizontal.absolute == 0.0 && vertical.absolute == 0.0;
3154                         let stop = horizontal.stop || vertical.stop;
3155                         let phase = if stop && no_delta {
3156                             ScrollPhase::FingerEnd
3157                         } else if !discrete
3158                             && matches!(
3159                                 source,
3160                                 None | Some(wl_pointer::AxisSource::Finger) | Some(wl_pointer::AxisSource::Continuous)
3161                             )
3162                         {
3163                             ScrollPhase::Finger
3164                         } else {
3165                             ScrollPhase::Wheel
3166                         };
3167                         set_scroll_phase(phase);
3168                         let delta = if discrete {
3169                             let h = if horizontal.discrete != 0 { horizontal.discrete as f32 } else { horizontal.absolute as f32 / 10.0 };
3170                             let v = if vertical.discrete != 0 { vertical.discrete as f32 } else { vertical.absolute as f32 / 10.0 };
3171                             cce_ui::widget::MouseScrollDelta::LineDelta(-h * factors.mouse as f32, -v * factors.mouse as f32)
3172                         } else {
3173                             cce_ui::widget::MouseScrollDelta::PixelDelta(cce_ui::widget::Position {
3174                                 x: -horizontal.absolute * factors.trackpad,
3175                                 y: -vertical.absolute * factors.trackpad,
3176                             })
3177                         };
3178                         if st.mode == LauncherMode::Json {
3179                             // The JSON layout's page scroll never received the
3180                             // wheel (only the fuzzel list did, and it is not the
3181                             // surface shown in this mode): route it the way the
3182                             // PointerMove above is routed.
3183                             let mut changed = false;
3184                             if let Some(jh) = st.json_layout {
3185                                 let ev = cce_ui::widget::Event::MouseWheel { delta, x: cx, y: cy, local_x: cx, local_y: cy };
3186                                 let root = jh.id();
3187                                 if st.ui_context.propagate_event(&ev, root) {
3188                                     changed = true;
3189                                 }
3190                             }
3191                             if changed {
3192                                 st.upload_vertices();
3193                                 self.redraw = true;
3194                             }
3195                         } else if st.ui_context[st.fuzzel].scroll_box.wheel(&delta, st.cursor_x, st.cursor_y) {
3196                             st.ui_context[st.fuzzel].update_scroll();
3197                             st.upload_vertices();
3198                             self.redraw = true;
3199                         }
3200                     }
3201                     _ => {}
3202                 }
3203             }
3204         }
3205         if should_close {
3206             self.trigger_close();
3207         }
3208     }
3209 }
3210 
3211 impl KeyboardHandler for AppState {
3212     fn enter(
3213         &mut self,
3214         _conn: &Connection,
3215         _qh: &QueueHandle<Self>,
3216         _keyboard: &wl_keyboard::WlKeyboard,
3217         _surface: &wl_surface::WlSurface,
3218         _serial: u32,
3219         _raw_modifiers: &[u32],
3220         _keysyms: &[xkeysym::Keysym],
3221     ) {
3222         log::debug!("KeyboardHandler::enter called!");
3223     }
3224 
3225     fn leave(
3226         &mut self,
3227         _conn: &Connection,
3228         _qh: &QueueHandle<Self>,
3229         _keyboard: &wl_keyboard::WlKeyboard,
3230         surface: &wl_surface::WlSurface,
3231         _serial: u32,
3232     ) {
3233         log::debug!("KeyboardHandler::leave called!");
3234         if let Some(ref active_surface) = self.surface {
3235             if active_surface == surface {
3236                 self.trigger_close();
3237             }
3238         }
3239     }
3240 
3241     fn press_key(
3242         &mut self,
3243         _conn: &Connection,
3244         _qh: &QueueHandle<Self>,
3245         _keyboard: &wl_keyboard::WlKeyboard,
3246         _serial: u32,
3247         event: smithay_client_toolkit::seat::keyboard::KeyEvent,
3248     ) {
3249         log::debug!("press_key keysym={:?}, utf8={:?}", event.keysym, event.utf8);
3250         self.handle_key(event, cce_ui::widget::ElementState::Pressed);
3251     }
3252 
3253     fn release_key(
3254         &mut self,
3255         _conn: &Connection,
3256         _qh: &QueueHandle<Self>,
3257         _keyboard: &wl_keyboard::WlKeyboard,
3258         _serial: u32,
3259         event: smithay_client_toolkit::seat::keyboard::KeyEvent,
3260     ) {
3261         log::debug!("release_key keysym={:?}, utf8={:?}", event.keysym, event.utf8);
3262         self.handle_key(event, cce_ui::widget::ElementState::Released);
3263     }
3264 
3265     fn update_modifiers(
3266         &mut self,
3267         _conn: &Connection,
3268         _qh: &QueueHandle<Self>,
3269         _keyboard: &wl_keyboard::WlKeyboard,
3270         _serial: u32,
3271         modifiers: smithay_client_toolkit::seat::keyboard::Modifiers,
3272         _layout: u32,
3273     ) {
3274         let prev_held = self.switch_held;
3275         self.ctrl_pressed = modifiers.ctrl;
3276         // Super+Tab or Alt+Tab: either can be bound to the switcher, and it
3277         // cannot tell which opened it, so it commits once neither is held.
3278         self.switch_held = modifiers.logo || modifiers.alt;
3279         log::debug!("update_modifiers: logo={}, alt={}, prev_held={}", modifiers.logo, modifiers.alt, prev_held);
3280 
3281         if self.switcher_mode && prev_held && !self.switch_held {
3282             log::info!("Switcher modifier (Super/Alt) released, selecting currently highlighted item");
3283             self.trigger_select_and_close();
3284         }
3285     }
3286 }
3287 
3288 impl LayerShellHandler for AppState {
3289     fn closed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _layer: &LayerSurface) {
3290         self.exit = true;
3291     }
3292 
3293     fn configure(
3294         &mut self,
3295         _conn: &Connection,
3296         _qh: &QueueHandle<Self>,
3297         _layer: &LayerSurface,
3298         configure: LayerSurfaceConfigure,
3299         _serial: u32,
3300     ) {
3301         let (width, height) = configure.new_size;
3302         if let Some(state) = &mut self.state {
3303             let pw = (width as f64 * state.scale) as u32;
3304             let ph = (height as f64 * state.scale) as u32;
3305             state.resize(pw, ph);
3306             state.configured = true;
3307         }
3308         self.redraw = true;
3309     }
3310 }
3311 
3312 impl WindowHandler for AppState {
3313     fn configure(
3314         &mut self,
3315         _conn: &Connection,
3316         _qh: &QueueHandle<Self>,
3317         _window: &XdgWindow,
3318         configure: WindowConfigure,
3319         _serial: u32,
3320     ) {
3321         if let Some(state) = &mut self.state {
3322             state.configured = true;
3323         }
3324         let (w, h) = configure.new_size;
3325         if let (Some(w), Some(h)) = (w, h) {
3326             let width = w.get();
3327             let height = h.get();
3328             if let Some(state) = &mut self.state {
3329                 let pw = (width as f64 * state.scale) as u32;
3330                 let ph = (height as f64 * state.scale) as u32;
3331                 state.resize(pw, ph);
3332                 // Re-assert the content-derived size. The compositor's overlay
3333                 // fresh-slot configure arrives full-height (cce-cloud app_ids are
3334                 // mode-forced to Overlay); obeying it verbatim left --json popups
3335                 // as a monitor-tall strip. Dmenu mode always recovered because
3336                 // every stdin batch re-runs this — json got sized exactly once,
3337                 // before the configure. The commit below updates box_geom, which
3338                 // the compositor's stored-geometry path then respects.
3339                 state.update_desired_size();
3340             }
3341         }
3342         self.redraw = true;
3343     }
3344 
3345     fn request_close(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _window: &XdgWindow) {
3346         self.exit = true;
3347     }
3348 }
3349 
3350 impl ProvidesRegistryState for AppState {
3351     fn registry(&mut self) -> &mut RegistryState {
3352         &mut self.registry_state
3353     }
3354     
3355     fn runtime_add_global(
3356         &mut self,
3357         _conn: &Connection,
3358         _qh: &QueueHandle<Self>,
3359         _name: u32,
3360         _interface: &str,
3361         _version: u32,
3362     ) {}
3363     
3364     fn runtime_remove_global(
3365         &mut self,
3366         _conn: &Connection,
3367         _qh: &QueueHandle<Self>,
3368         _name: u32,
3369         _interface: &str,
3370     ) {}
3371 }
3372 
3373 delegate_compositor!(AppState);
3374 delegate_layer!(AppState);
3375 delegate_shm!(AppState);
3376 delegate_seat!(AppState);
3377 delegate_pointer!(AppState);
3378 delegate_keyboard!(AppState);
3379 delegate_registry!(AppState);
3380 delegate_output!(AppState);
3381 delegate_xdg_shell!(AppState);
3382 delegate_xdg_window!(AppState);
3383 
3384 impl wayland_client::Dispatch<cce_ui::protocol::cce_window_management_v1::zcce_window_manager_v1::ZcceWindowManagerV1, ()> for AppState {
3385     fn event(
3386         _state: &mut Self,
3387         _proxy: &cce_ui::protocol::cce_window_management_v1::zcce_window_manager_v1::ZcceWindowManagerV1,
3388         _event: cce_ui::protocol::cce_window_management_v1::zcce_window_manager_v1::Event,
3389         _data: &(),
3390         _conn: &Connection,
3391         _qh: &QueueHandle<Self>,
3392     ) {}
3393 
3394     wayland_client::event_created_child!(
3395         AppState,
3396         cce_ui::protocol::cce_window_management_v1::zcce_window_manager_v1::ZcceWindowManagerV1,
3397         [
3398             6 => (cce_ui::protocol::cce_window_management_v1::zcce_window_v1::ZcceWindowV1, ()),
3399             7 => (cce_ui::protocol::cce_window_management_v1::zcce_output_v1::ZcceOutputV1, ()),
3400             8 => (cce_ui::protocol::cce_window_management_v1::zcce_seat_v1::ZcceSeatV1, ()),
3401         ]
3402     );
3403 }
3404 
3405 impl wayland_client::Dispatch<cce_ui::protocol::cce_window_management_v1::zcce_window_v1::ZcceWindowV1, ()> for AppState {
3406     fn event(
3407         _state: &mut Self,
3408         _proxy: &cce_ui::protocol::cce_window_management_v1::zcce_window_v1::ZcceWindowV1,
3409         _event: cce_ui::protocol::cce_window_management_v1::zcce_window_v1::Event,
3410         _data: &(),
3411         _conn: &Connection,
3412         _qh: &QueueHandle<Self>,
3413     ) {}
3414 }
3415 
3416 impl wayland_client::Dispatch<cce_ui::protocol::cce_window_management_v1::zcce_output_v1::ZcceOutputV1, ()> for AppState {
3417     fn event(
3418         _state: &mut Self,
3419         _proxy: &cce_ui::protocol::cce_window_management_v1::zcce_output_v1::ZcceOutputV1,
3420         _event: cce_ui::protocol::cce_window_management_v1::zcce_output_v1::Event,
3421         _data: &(),
3422         _conn: &Connection,
3423         _qh: &QueueHandle<Self>,
3424     ) {}
3425 }
3426 
3427 impl wayland_client::Dispatch<cce_ui::protocol::cce_window_management_v1::zcce_seat_v1::ZcceSeatV1, ()> for AppState {
3428     fn event(
3429         _state: &mut Self,
3430         _proxy: &cce_ui::protocol::cce_window_management_v1::zcce_seat_v1::ZcceSeatV1,
3431         _event: cce_ui::protocol::cce_window_management_v1::zcce_seat_v1::Event,
3432         _data: &(),
3433         _conn: &Connection,
3434         _qh: &QueueHandle<Self>,
3435     ) {}
3436 }
3437 
3438 impl wayland_client::Dispatch<cce_ui::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1, ()> for AppState {
3439     fn event(
3440         _state: &mut Self,
3441         _proxy: &cce_ui::protocol::cce_window_management_v1::zcce_toplevel_v1::ZcceToplevelV1,
3442         _event: cce_ui::protocol::cce_window_management_v1::zcce_toplevel_v1::Event,
3443         _data: &(),
3444         _conn: &Connection,
3445         _qh: &QueueHandle<Self>,
3446     ) {}
3447 }
3448 
3449 impl AppState {
3450     /// A held key's repeat: fed back in as another press, but only for keys
3451     /// where a repeat means more of the same (`key_repeats`).
3452     fn repeat_key(&mut self, event: smithay_client_toolkit::seat::keyboard::KeyEvent) {
3453         if key_repeats(event.keysym, event.utf8.as_deref()) {
3454             self.handle_key(event, cce_ui::widget::ElementState::Pressed);
3455         }
3456     }
3457 
3458     fn handle_key(&mut self, event: smithay_client_toolkit::seat::keyboard::KeyEvent, state: cce_ui::widget::ElementState) {
3459         use cce_ui::widget::{Key, NamedKey};
3460         if state != cce_ui::widget::ElementState::Pressed {
3461             return;
3462         }
3463 
3464         // Shift+Tab arrives as ISO_Left_Tab: step back through the tabs where
3465         // the list has them, else cycle the highlight backwards with wrap —
3466         // mirroring Tab's forward step below in both halves.
3467         if event.keysym == xkeysym::Keysym::ISO_Left_Tab {
3468             if let Some(st) = &mut self.state {
3469                 if st.mode != LauncherMode::Json {
3470                     let mut changed = false;
3471                     if st.ui_context[st.fuzzel].cycle_tab(false) {
3472                         st.update_desired_size();
3473                         changed = true;
3474                     } else if !st.ui_context[st.fuzzel].filtered_items.is_empty() {
3475                         let len = st.ui_context[st.fuzzel].filtered_items.len();
3476                         st.ui_context[st.fuzzel].selected = (st.ui_context[st.fuzzel].selected + len - 1) % len;
3477                         st.ui_context[st.fuzzel].update_scroll();
3478                         st.ui_context[st.fuzzel].snap_to_selected();
3479                         changed = true;
3480                     }
3481                     if changed {
3482                         st.upload_vertices();
3483                         self.redraw = true;
3484                     }
3485                 }
3486             }
3487             return;
3488         }
3489 
3490         let (select_next, select_prev) = *nav_keys();
3491         let logical_key = match event.keysym {
3492             xkeysym::Keysym::Escape => Key::Named(NamedKey::Escape),
3493             xkeysym::Keysym::Return => Key::Named(NamedKey::Enter),
3494             xkeysym::Keysym::BackSpace => Key::Named(NamedKey::Backspace),
3495             xkeysym::Keysym::Down => Key::Named(NamedKey::ArrowDown),
3496             xkeysym::Keysym::Up => Key::Named(NamedKey::ArrowUp),
3497             xkeysym::Keysym::Left => Key::Named(NamedKey::ArrowLeft),
3498             xkeysym::Keysym::Right => Key::Named(NamedKey::ArrowRight),
3499             xkeysym::Keysym::Tab => Key::Named(NamedKey::Tab),
3500             xkeysym::Keysym::Delete => Key::Named(NamedKey::Delete),
3501             xkeysym::Keysym::space => Key::Named(NamedKey::Space),
3502             sym if nav_matches(select_next, self.ctrl_pressed, sym) => Key::Named(NamedKey::ArrowDown),
3503             sym if nav_matches(select_prev, self.ctrl_pressed, sym) => Key::Named(NamedKey::ArrowUp),
3504             _ => {
3505                 if let Some(ref text) = event.utf8 {
3506                     Key::Character(text.clone())
3507                 } else {
3508                     return;
3509                 }
3510             }
3511         };
3512 
3513         let mut should_close = false;
3514         if let Some(st) = &mut self.state {
3515             let mut handled = true;
3516             if st.mode == LauncherMode::Json {
3517                 let mut widget_handled = false;
3518                 let key_event = cce_ui::widget::KeyEvent {
3519                     state,
3520                     logical_key: logical_key.clone(),
3521                     text: event.utf8.clone(),
3522                     repeat: false,
3523                     ctrl: self.ctrl_pressed,
3524                     shift: false,
3525                     alt: false,
3526                 };
3527                 if let Some(jh) = st.json_layout {
3528                     let kev = cce_ui::widget::Event::KeyInput(key_event.clone());
3529                     let root = jh.id();
3530                     if st.ui_context.propagate_event(&kev, root) {
3531                         widget_handled = true;
3532                         st.upload_vertices();
3533                         self.redraw = true;
3534                     }
3535                 }
3536                 if !widget_handled {
3537                     match &logical_key {
3538                         Key::Named(NamedKey::Escape) => {
3539                             should_close = true;
3540                         }
3541                         _ => {
3542                             handled = false;
3543                         }
3544                     }
3545                 }
3546             } else {
3547                 match &logical_key {
3548                     Key::Named(NamedKey::Escape) => {
3549                         should_close = true;
3550                     }
3551                     Key::Named(NamedKey::Enter) => {
3552                         self.trigger_select_and_close();
3553                     }
3554                     Key::Named(NamedKey::Tab) => {
3555                         // Tab switches tabs where there are tabs — the Apps
3556                         // mode's Apps/System split. It keeps its old job of
3557                         // cycling the highlight with wrap in the single-tab
3558                         // modes, which is what the Super-Tab window switcher
3559                         // (Dmenu, one tab) rides on.
3560                         if st.ui_context[st.fuzzel].cycle_tab(true) {
3561                             st.update_desired_size();
3562                             st.upload_vertices();
3563                             self.redraw = true;
3564                         } else if !st.ui_context[st.fuzzel].filtered_items.is_empty() {
3565                             st.ui_context[st.fuzzel].selected = (st.ui_context[st.fuzzel].selected + 1) % st.ui_context[st.fuzzel].filtered_items.len();
3566                             st.ui_context[st.fuzzel].update_scroll();
3567                             st.ui_context[st.fuzzel].snap_to_selected();
3568                             st.upload_vertices();
3569                             self.redraw = true;
3570                         }
3571                     }
3572                     Key::Named(NamedKey::ArrowDown) => {
3573                         if !st.ui_context[st.fuzzel].filtered_items.is_empty() {
3574                             st.ui_context[st.fuzzel].selected = (st.ui_context[st.fuzzel].selected + 1).min(st.ui_context[st.fuzzel].filtered_items.len() - 1);
3575                             st.ui_context[st.fuzzel].update_scroll();
3576                             st.ui_context[st.fuzzel].snap_to_selected();
3577                             st.upload_vertices();
3578                             self.redraw = true;
3579                         }
3580                     }
3581                     Key::Named(NamedKey::ArrowUp) => {
3582                         if st.ui_context[st.fuzzel].selected > 0 {
3583                             st.ui_context[st.fuzzel].selected -= 1;
3584                             st.ui_context[st.fuzzel].update_scroll();
3585                             st.ui_context[st.fuzzel].snap_to_selected();
3586                             st.upload_vertices();
3587                             self.redraw = true;
3588                         }
3589                     }
3590                     Key::Named(NamedKey::Backspace) => {
3591                         st.ui_context[st.fuzzel].query.pop();
3592                         st.ui_context[st.fuzzel].filter();
3593                         st.update_desired_size();
3594                         st.upload_vertices();
3595                         self.redraw = true;
3596                     }
3597                     _ => {
3598                         if let Some(text) = &event.utf8 {
3599                             for ch in text.chars().filter(|c| !c.is_control()) {
3600                                 st.ui_context[st.fuzzel].query.push(ch);
3601                             }
3602                             st.ui_context[st.fuzzel].filter();
3603                             st.update_desired_size();
3604                             st.upload_vertices();
3605                             self.redraw = true;
3606                         } else {
3607                             handled = false;
3608                         }
3609                     }
3610                 }
3611             }
3612             if handled {
3613                 self.redraw = true;
3614             }
3615         }
3616         if should_close {
3617             self.trigger_close();
3618         }
3619     }
3620 }
3621 
3622 fn run_standalone() {
3623     let mut prompt = "Search: ".to_string();
3624     let mut mode = if !io::stdin().is_terminal() {
3625         LauncherMode::Dmenu
3626     } else {
3627         LauncherMode::Path
3628     };
3629     let mut x_pos: Option<i32> = None;
3630     let mut y_pos: Option<i32> = None;
3631     let mut select_item: Option<String> = None;
3632     let mut align_right = false;
3633     let mut switcher_mode = false;
3634     let mut chooser_mode = false;
3635     let mut parent_app_id: Option<String> = None;
3636 
3637     let args = std::env::args().skip(1).collect::<Vec<String>>();
3638     let mut i = 0;
3639     while i < args.len() {
3640         let arg = &args[i];
3641         if arg == "-p" || arg == "--prompt" {
3642             if i + 1 < args.len() {
3643                 prompt = args[i + 1].clone();
3644                 i += 2;
3645             } else {
3646                 i += 1;
3647             }
3648         } else if arg == "-s" || arg == "--select" {
3649             if i + 1 < args.len() {
3650                 select_item = Some(args[i + 1].clone());
3651                 i += 2;
3652             } else {
3653                 i += 1;
3654             }
3655         } else if arg == "-x" || arg == "--x-pos" {
3656             if i + 1 < args.len() {
3657                 if let Ok(val) = args[i + 1].parse::<i32>() {
3658                     x_pos = Some(val);
3659                 }
3660                 i += 2;
3661             } else {
3662                 i += 1;
3663             }
3664         } else if arg == "-y" || arg == "--y-pos" {
3665             if i + 1 < args.len() {
3666                 if let Ok(val) = args[i + 1].parse::<i32>() {
3667                     y_pos = Some(val);
3668                 }
3669                 i += 2;
3670             } else {
3671                 i += 1;
3672             }
3673         } else if arg == "--parent-app-id" {
3674             if i + 1 < args.len() {
3675                 parent_app_id = Some(args[i + 1].clone());
3676                 i += 2;
3677             } else {
3678                 i += 1;
3679             }
3680         } else if arg == "--mode" {
3681             if i + 1 < args.len() {
3682                 let m = &args[i + 1];
3683                 match m.as_str() {
3684                     "apps" | "app" => mode = LauncherMode::Apps,
3685                     "path" => mode = LauncherMode::Path,
3686                     "dmenu" => mode = LauncherMode::Dmenu,
3687                     _ => eprintln!("Unknown mode: {}", m),
3688                 }
3689                 i += 2;
3690             } else {
3691                 i += 1;
3692             }
3693         } else if arg == "--apps" || arg == "--app" {
3694             mode = LauncherMode::Apps;
3695             i += 1;
3696         } else if arg == "--path" {
3697             mode = LauncherMode::Path;
3698             i += 1;
3699         } else if arg == "--dmenu" {
3700             mode = LauncherMode::Dmenu;
3701             i += 1;
3702         } else if arg == "--json" || arg == "--layout" {
3703             mode = LauncherMode::Json;
3704             i += 1;
3705         } else if arg == "--align-right" {
3706             align_right = true;
3707             i += 1;
3708         } else if arg == "--switcher" {
3709             switcher_mode = true;
3710             mode = LauncherMode::Dmenu;
3711             i += 1;
3712         } else if arg == "--choose" {
3713             chooser_mode = true;
3714             mode = LauncherMode::Dmenu;
3715             i += 1;
3716         } else {
3717             i += 1;
3718         }
3719     }
3720 
3721     let mut json_layout_config: Option<JsonLayoutConfig> = None;
3722     if mode == LauncherMode::Json {
3723         use std::io::Read;
3724         let mut json_str = String::new();
3725         let mut stdin = std::io::stdin();
3726         match stdin.read_to_string(&mut json_str) {
3727             Ok(_) => {
3728                 match serde_json::from_str::<JsonLayoutConfig>(&json_str) {
3729                     Ok(cfg) => {
3730                         json_layout_config = Some(cfg);
3731                     }
3732                     Err(e) => {
3733                         eprintln!("Failed to parse JSON layout: {}", e);
3734                         std::process::exit(1);
3735                     }
3736                 }
3737             }
3738             Err(e) => {
3739                 eprintln!("Failed to read JSON layout from stdin: {}", e);
3740                 std::process::exit(1);
3741             }
3742         }
3743     }
3744 
3745     let conn = Connection::connect_to_env().unwrap();
3746     let conn_clone = conn.clone();
3747     let (globals, mut event_queue) = registry_queue_init(&conn).unwrap();
3748     let qh = event_queue.handle();
3749 
3750     let compositor_state = CompositorState::bind(&globals, &qh).unwrap();
3751     let layer_shell_state = LayerShell::bind(&globals, &qh).unwrap();
3752     let shm_state = Shm::bind(&globals, &qh).unwrap();
3753     let seat_state = SeatState::new(&globals, &qh);
3754     let output_state = OutputState::new(&globals, &qh);
3755     let cce_wm = globals.bind::<cce_ui::protocol::cce_window_management_v1::zcce_window_manager_v1::ZcceWindowManagerV1, _, _>(&qh, 2..=4, ()).ok();
3756 
3757     let (stdin_sender, stdin_channel) = calloop::channel::channel::<()>();
3758 
3759     // Before the app state: the roundtrip below delivers the seat's keyboard,
3760     // and binding it with key repeat needs the loop.
3761     let mut event_loop = calloop::EventLoop::try_new().unwrap();
3762 
3763     let mut app = AppState {
3764         registry_state: RegistryState::new(&globals),
3765         compositor_state,
3766         layer_shell_state,
3767         shm_state,
3768         seat_state,
3769         output_state,
3770         seats: Vec::new(),
3771         pointer: None,
3772         keyboard: None,
3773         window: None,
3774         surface: None,
3775         state: None,
3776         exit: false,
3777         redraw: false,
3778         ctrl_pressed: false,
3779         switch_held: switcher_mode,
3780         switcher_mode,
3781         fade_until: None,
3782         cce_toplevel: None,
3783         selected_item: None,
3784         loop_handle: event_loop.handle(),
3785     };
3786 
3787     // Perform a roundtrip to populate output_state with active output scales
3788     event_queue.roundtrip(&mut app).unwrap();
3789 
3790     let scale = cce_ui::wayland::detect_scale_factor(&app.output_state);
3791 
3792     let xdg_shell_state = smithay_client_toolkit::shell::xdg::XdgShell::bind(&globals, &qh).ok();
3793     let use_xdg = cce_wm.is_some() && xdg_shell_state.is_some() && x_pos.is_none() && y_pos.is_none();
3794     log::info!("Starting launcher window: x_pos={:?}, y_pos={:?}, align_right={}, scale={}, use_xdg={}", x_pos, y_pos, align_right, scale, use_xdg);
3795 
3796     let (state, cce_toplevel) = State::new(
3797         &conn,
3798         &qh,
3799         &app.compositor_state,
3800         &app.layer_shell_state,
3801         xdg_shell_state.as_ref(),
3802         cce_wm.as_ref(),
3803         use_xdg,
3804         prompt,
3805         stdin_sender,
3806         mode,
3807         x_pos,
3808         y_pos,
3809         align_right,
3810         output_bounds_at(&app.output_state, x_pos.unwrap_or(0), y_pos.unwrap_or(0)),
3811         scale,
3812         select_item,
3813         switcher_mode,
3814         chooser_mode,
3815         json_layout_config,
3816         parent_app_id,
3817         None,
3818         None,
3819     )
3820     .unwrap_or_else(|lost| {
3821         log::error!("cannot open the window: {lost}");
3822         std::process::exit(1);
3823     });
3824 
3825     app.window = state.window.clone();
3826     app.surface = Some(state.wl_surface.clone());
3827     app.cce_toplevel = cce_toplevel;
3828     app.state = Some(state);
3829 
3830     let loop_handle = event_loop.handle();
3831 
3832     WaylandSource::new(conn, event_queue).insert(loop_handle.clone()).unwrap();
3833 
3834     loop_handle.insert_source(stdin_channel, |event, _metadata, app_state: &mut AppState| {
3835         if let calloop::channel::Event::Msg(()) = event {
3836             let mut select_and_close = false;
3837             if let Some(st) = &mut app_state.state {
3838                 if st.check_stdin_updates() {
3839                     st.update_desired_size();
3840                     st.apply_layout();
3841                     st.upload_vertices();
3842                     app_state.redraw = true;
3843                 }
3844                 if st.select_and_close_requested {
3845                     st.select_and_close_requested = false;
3846                     select_and_close = true;
3847                 }
3848             }
3849             if select_and_close {
3850                 app_state.trigger_select_and_close();
3851             }
3852         }
3853     }).unwrap();
3854 
3855     let mut last_tick = std::time::Instant::now();
3856     // A popup opens animating (its expand); the first ticks say when it stops.
3857     let mut animating = true;
3858     loop {
3859         // The compositor is dissolving the popup out; hold the surface open
3860         // until its deadline, then go. Nothing to redraw in the meantime —
3861         // the pixels stay put and the scene node's opacity does the work.
3862         if let Some(until) = app.fade_until {
3863             if std::time::Instant::now() >= until {
3864                 app.exit = true;
3865             }
3866         }
3867 
3868         // Frame rate only while something moved last turn (a layout
3869         // animation, the scrollbar's hold, a wheel glide); otherwise sleep
3870         // until an event — input, the client's lines, a new request on the
3871         // daemon socket all wake the loop — or the fade's deadline. Until
3872         // 2026-10-06 an open popup woke every 16 ms with nothing moving.
3873         let timeout = if app.redraw {
3874             Some(std::time::Duration::from_millis(0))
3875         } else if animating {
3876             Some(std::time::Duration::from_millis(16))
3877         } else {
3878             app.fade_until.map(|t| t.saturating_duration_since(std::time::Instant::now()))
3879         };
3880         if let Err(e) = event_loop.dispatch(timeout, &mut app) {
3881             log::error!("compositor connection lost: {e}");
3882             std::process::exit(1);
3883         }
3884 
3885         if app.exit {
3886             break;
3887         }
3888 
3889         let now = std::time::Instant::now();
3890         let mut dt = now.duration_since(last_tick).as_secs_f32();
3891         last_tick = now;
3892         if dt > 0.1 {
3893             dt = 0.1;
3894         }
3895         animating = false;
3896         if let Some(state) = &mut app.state {
3897             if let Some(jh) = state.json_layout {
3898                 if state.ui_context.lend_h(jh, |jl, ctx| jl.tick(dt, ctx)).unwrap_or(false) {
3899                     app.redraw = true;
3900                     animating = true;
3901                 }
3902             }
3903             // Raise/sink upkeep for the list scrollbar (true while the
3904             // post-scroll hold runs or on the depth flip).
3905             if state.ui_context[state.fuzzel].scroll_box.tick(dt) {
3906                 app.redraw = true;
3907                 animating = true;
3908             }
3909             // A wheel glide moves the rows under a stationary pointer.
3910             if state.ui_context[state.fuzzel].refresh_hover() {
3911                 state.upload_vertices();
3912                 app.redraw = true;
3913                 animating = true;
3914             }
3915         }
3916 
3917         if app.redraw {
3918             app.redraw = false;
3919             if let Some(state) = &mut app.state {
3920                 let _ = state.render();
3921             }
3922         }
3923     }
3924 
3925     if let Some(state) = &mut app.state {
3926         if let Some(ref window) = state.window {
3927             match window {
3928                 AppWindow::Layer(layer) => layer.set_keyboard_interactivity(KeyboardInteractivity::None),
3929                 AppWindow::Xdg(_) => {}
3930             }
3931         }
3932         state.wl_surface.commit();
3933     }
3934     drop(app);
3935     let _ = conn_clone.roundtrip();
3936 }
3937 
3938 fn run_client(socket_path: &str, args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
3939     use std::io::{Read, Write};
3940     let mut stream = std::os::unix::net::UnixStream::connect(socket_path)?;
3941 
3942     let mut needs_stdin = false;
3943     let mut mode_specified = false;
3944     let mut i = 1;
3945     while i < args.len() {
3946         let arg = &args[i];
3947         if arg == "--mode" {
3948             if i + 1 < args.len() {
3949                 let m = &args[i + 1];
3950                 match m.as_str() {
3951                     "apps" | "app" | "path" => {
3952                         needs_stdin = false;
3953                         mode_specified = true;
3954                     }
3955                     "dmenu" | "json" => {
3956                         needs_stdin = true;
3957                         mode_specified = true;
3958                     }
3959                     _ => {}
3960                 }
3961                 i += 2;
3962             } else {
3963                 i += 1;
3964             }
3965         } else if arg == "--apps" || arg == "--app" || arg == "--path" {
3966             needs_stdin = false;
3967             mode_specified = true;
3968             i += 1;
3969         } else if arg == "--dmenu" || arg == "--json" || arg == "--layout" || arg == "--choose" {
3970             needs_stdin = true;
3971             mode_specified = true;
3972             i += 1;
3973         } else if arg == "--switcher" {
3974             // The compositor holds the pipe open to stream __cce_switcher_next__
3975             // cycle lines after the item list, so there is no EOF to wait for —
3976             // skip the blocking initial read and let the forwarding thread
3977             // below stream everything (items included) to the daemon.
3978             needs_stdin = false;
3979             mode_specified = true;
3980             i += 1;
3981         } else {
3982             i += 1;
3983         }
3984     }
3985 
3986     if !mode_specified {
3987         needs_stdin = !std::io::stdin().is_terminal();
3988     }
3989 
3990     let mut stdin_str = String::new();
3991     if needs_stdin {
3992         std::io::stdin().read_to_string(&mut stdin_str)?;
3993     }
3994 
3995 
3996     let payload = serde_json::json!({
3997         "args": args,
3998         "initial_stdin": stdin_str,
3999     });
4000 
4001     let payload_str = payload.to_string();
4002     stream.write_all(payload_str.as_bytes())?;
4003     stream.write_all(b"\n")?;
4004 
4005     let mut stream_clone = stream.try_clone()?;
4006     std::thread::spawn(move || {
4007         use std::io::BufRead;
4008         let stdin = std::io::stdin();
4009         for line in stdin.lock().lines() {
4010             if let Ok(line) = line {
4011                 let _ = stream_clone.write_all(line.as_bytes());
4012                 let _ = stream_clone.write_all(b"\n");
4013             }
4014         }
4015     });
4016 
4017     let mut response = String::new();
4018     stream.read_to_string(&mut response)?;
4019     print!("{}", response);
4020     Ok(())
4021 }
4022 
4023 /// The compositor this daemon serves has gone: exit, as a Wayland client whose
4024 /// display went away does.
4025 ///
4026 /// The daemon holds ONE Wayland connection for its whole life, so it has to
4027 /// notice when that connection dies. Until 2026-09-25 it did not: between
4028 /// popups it sat in a blocking `accept()`, never reading the Wayland socket,
4029 /// and a daemon that outlived a logout kept a connection to a compositor that
4030 /// no longer existed. The next session's first popup was then built on that
4031 /// dead connection and the renderer panicked (`No surface formats:
4032 /// ERROR_SURFACE_LOST_KHR`). The idle wait now dispatches the Wayland source
4033 /// alongside the listener, so a dead connection surfaces as a dispatch error
4034 /// the moment the compositor goes; a surface that is lost anyway (the death
4035 /// raced a request) lands here too.
4036 ///
4037 /// Status 0, so `Restart=on-failure` does not relaunch it into a session with
4038 /// no compositor: the unit is bound to `cce-session.target`, which startcce
4039 /// starts with each compositor, and that start brings up a fresh daemon.
4040 fn compositor_gone(why: impl std::fmt::Display) -> ! {
4041     log::warn!("compositor is gone ({why}); exiting");
4042     std::process::exit(0);
4043 }
4044 
4045 fn run_daemon(socket_path: &str) {
4046     use std::io::{Write, BufRead};
4047     let _ = std::fs::remove_file(socket_path);
4048     let listener = match std::os::unix::net::UnixListener::bind(socket_path) {
4049         Ok(l) => l,
4050         Err(e) => {
4051             eprintln!("Failed to bind to socket {}: {}", socket_path, e);
4052             std::process::exit(1);
4053         }
4054     };
4055 
4056     log::info!("cce-cloud daemon started, listening on {}", socket_path);
4057 
4058     // Pay the window-independent startup costs now (login time), not on the
4059     // first popup: Vulkan driver + shader compiles, and the fonts-dir scan.
4060     // The font system is then reused across popups (each State hands it back).
4061     let t_prewarm = std::time::Instant::now();
4062     cce_ui::vk::prewarm();
4063     let mut fonts_slot: Option<(FontSystem, SwashCache)> =
4064         Some((cce_ui::create_font_system(), SwashCache::new()));
4065     let _ = cce_ui::widget::get_font_db(); // measure_text's resvg fontdb (system-font scan)
4066     log::info!("[timing] daemon prewarm: {:?}", t_prewarm.elapsed());
4067 
4068     let conn = match Connection::connect_to_env() {
4069         Ok(conn) => conn,
4070         Err(e) => {
4071             // A failure status, so systemd's Restart=on-failure tries again
4072             // if the session is still starting up.
4073             log::error!("cannot connect to the compositor: {e}");
4074             std::process::exit(1);
4075         }
4076     };
4077     let conn_clone = conn.clone();
4078     let (globals, mut event_queue) = registry_queue_init(&conn).unwrap();
4079     let qh = event_queue.handle();
4080 
4081     let compositor_state = CompositorState::bind(&globals, &qh).unwrap();
4082     let layer_shell_state = LayerShell::bind(&globals, &qh).unwrap();
4083     let shm_state = Shm::bind(&globals, &qh).unwrap();
4084     let seat_state = SeatState::new(&globals, &qh);
4085     let output_state = OutputState::new(&globals, &qh);
4086     let cce_wm = globals.bind::<cce_ui::protocol::cce_window_management_v1::zcce_window_manager_v1::ZcceWindowManagerV1, _, _>(&qh, 2..=4, ()).ok();
4087 
4088     // Before the app state: the roundtrip below delivers the seat's keyboard,
4089     // and binding it with key repeat needs the loop.
4090     let mut event_loop = calloop::EventLoop::try_new().unwrap();
4091 
4092     let mut app = AppState {
4093         registry_state: RegistryState::new(&globals),
4094         compositor_state,
4095         layer_shell_state,
4096         shm_state,
4097         seat_state,
4098         output_state,
4099         seats: Vec::new(),
4100         pointer: None,
4101         keyboard: None,
4102         window: None,
4103         surface: None,
4104         state: None,
4105         exit: false,
4106         redraw: false,
4107         ctrl_pressed: false,
4108         switch_held: false,
4109         switcher_mode: false,
4110         fade_until: None,
4111         cce_toplevel: None,
4112         selected_item: None,
4113         loop_handle: event_loop.handle(),
4114     };
4115 
4116     event_queue.roundtrip(&mut app).unwrap();
4117 
4118     // The renderer every popup draws with, built now on a surface that is
4119     // never mapped and then detached from it, so the first popup only
4120     // attaches it as every later one does. Each popup hands it back when it
4121     // closes (see the end of the loop). If this fails the first popup builds
4122     // its own, as all of them used to.
4123     let t_renderer = std::time::Instant::now();
4124     let mut renderer_slot: Option<VkRenderer> = {
4125         let scratch = app.compositor_state.create_surface(&qh);
4126         let made = unsafe {
4127             VkRenderer::try_new(
4128                 conn_clone.backend().display_id().as_ptr() as *mut std::ffi::c_void,
4129                 scratch.id().as_ptr() as *mut std::ffi::c_void,
4130                 1,
4131                 1,
4132                 0.0,
4133             )
4134         };
4135         let kept = match made {
4136             Ok(mut r) => {
4137                 r.detach_surface();
4138                 Some(r)
4139             }
4140             Err(e) => {
4141                 log::warn!("could not prewarm the popup renderer: {e}");
4142                 None
4143             }
4144         };
4145         scratch.destroy();
4146         let _ = conn_clone.flush();
4147         kept
4148     };
4149     log::info!("[timing] daemon renderer prewarm: {:?}", t_renderer.elapsed());
4150 
4151     // Decode and queue the launcher's icons now, off the main thread, so its
4152     // first open does not read and rasterize every one (~350 ms). The uploads
4153     // drain into the renderer at the first popup's first frame. An open that
4154     // starts before this finishes simply shares the cache with it.
4155     if renderer_slot.is_some() {
4156         std::thread::spawn(|| {
4157             let t = std::time::Instant::now();
4158             let apps = scan_apps();
4159             let resolved = apps
4160                 .iter()
4161                 .filter(|app| app.icon.as_deref().and_then(icon_image).is_some())
4162                 .count();
4163             log::info!(
4164                 "[timing] launcher icon prewarm: {resolved} of {} in {:?}",
4165                 apps.len(),
4166                 t.elapsed()
4167             );
4168         });
4169     }
4170 
4171     let scale = cce_ui::wayland::detect_scale_factor(&app.output_state);
4172     let xdg_shell_state = smithay_client_toolkit::shell::xdg::XdgShell::bind(&globals, &qh).ok();
4173 
4174     let loop_handle = event_loop.handle();
4175     WaylandSource::new(conn, event_queue).insert(loop_handle.clone()).unwrap();
4176     // The listener wakes the loop too, so waiting for the next request also
4177     // reads the Wayland connection — see `compositor_gone`. The callback does
4178     // nothing: the accept after each dispatch takes the connection.
4179     let listener_wake = listener.try_clone().expect("dup the daemon socket");
4180     loop_handle
4181         .insert_source(
4182             calloop::generic::Generic::new(
4183                 listener_wake,
4184                 calloop::Interest::READ,
4185                 calloop::Mode::Level,
4186             ),
4187             |_, _, _| Ok(calloop::PostAction::Continue),
4188         )
4189         .unwrap();
4190     let _ = listener.set_nonblocking(true);
4191 
4192     let mut pending: Option<std::os::unix::net::UnixStream> = None;
4193     loop {
4194         let mut stream = match pending.take() {
4195             Some(s) => s,
4196             None => match listener.accept() {
4197                 Ok((s, _)) => s,
4198                 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
4199                     if let Err(e) = event_loop.dispatch(None, &mut app) {
4200                         compositor_gone(e);
4201                     }
4202                     continue;
4203                 }
4204                 Err(_) => continue,
4205             },
4206         };
4207 
4208         let (stdin_sender, stdin_channel) = calloop::channel::channel::<()>();
4209 
4210         // Bounded: this runs on the launcher's main loop, so a client that
4211         // connected and said nothing used to freeze the launcher outright.
4212         // The line carries a dmenu list inline, hence the generous size.
4213         let Some(initial_line) = cce_ui::ipc::read_request_line(&stream, 16 * 1024 * 1024, std::time::Duration::from_secs(3)) else {
4214             continue;
4215         };
4216         let t_request = std::time::Instant::now();
4217 
4218         let payload: serde_json::Value = match serde_json::from_str(&initial_line) {
4219             Ok(p) => p,
4220             Err(_) => continue,
4221         };
4222 
4223         let client_args: Vec<String> = payload["args"].as_array()
4224             .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
4225             .unwrap_or_default();
4226         let initial_stdin = payload["initial_stdin"].as_str().unwrap_or("").to_string();
4227 
4228         let mut prompt = "Search: ".to_string();
4229         let mut mode = if !initial_stdin.is_empty() {
4230             LauncherMode::Dmenu
4231         } else {
4232             LauncherMode::Path
4233         };
4234         let mut x_pos: Option<i32> = None;
4235         let mut y_pos: Option<i32> = None;
4236         let mut select_item: Option<String> = None;
4237         let mut align_right = false;
4238         let mut switcher_mode = false;
4239         let mut chooser_mode = false;
4240         let mut parent_app_id: Option<String> = None;
4241 
4242         let mut i = 1;
4243         while i < client_args.len() {
4244             let arg = &client_args[i];
4245             if arg == "-p" || arg == "--prompt" {
4246                 if i + 1 < client_args.len() {
4247                     prompt = client_args[i + 1].clone();
4248                     i += 2;
4249                 } else {
4250                     i += 1;
4251                 }
4252             } else if arg == "-s" || arg == "--select" {
4253                 if i + 1 < client_args.len() {
4254                     select_item = Some(client_args[i + 1].clone());
4255                     i += 2;
4256                 } else {
4257                     i += 1;
4258                 }
4259             } else if arg == "-x" || arg == "--x-pos" {
4260                 if i + 1 < client_args.len() {
4261                     if let Ok(val) = client_args[i + 1].parse::<i32>() {
4262                         x_pos = Some(val);
4263                     }
4264                     i += 2;
4265                 } else {
4266                     i += 1;
4267                 }
4268             } else if arg == "-y" || arg == "--y-pos" {
4269                 if i + 1 < client_args.len() {
4270                     if let Ok(val) = client_args[i + 1].parse::<i32>() {
4271                         y_pos = Some(val);
4272                     }
4273                     i += 2;
4274                 } else {
4275                     i += 1;
4276                 }
4277             } else if arg == "--parent-app-id" {
4278                 if i + 1 < client_args.len() {
4279                     parent_app_id = Some(client_args[i + 1].clone());
4280                     i += 2;
4281                 } else {
4282                     i += 1;
4283                 }
4284             } else if arg == "--mode" {
4285                 if i + 1 < client_args.len() {
4286                     let m = &client_args[i + 1];
4287                     match m.as_str() {
4288                         "apps" | "app" => mode = LauncherMode::Apps,
4289                         "path" => mode = LauncherMode::Path,
4290                         "dmenu" => mode = LauncherMode::Dmenu,
4291                         _ => eprintln!("Unknown mode: {}", m),
4292                     }
4293                     i += 2;
4294                 } else {
4295                     i += 1;
4296                 }
4297             } else if arg == "--apps" || arg == "--app" {
4298                 mode = LauncherMode::Apps;
4299                 i += 1;
4300             } else if arg == "--path" {
4301                 mode = LauncherMode::Path;
4302                 i += 1;
4303             } else if arg == "--dmenu" {
4304                 mode = LauncherMode::Dmenu;
4305                 i += 1;
4306             } else if arg == "--json" || arg == "--layout" {
4307                 mode = LauncherMode::Json;
4308                 i += 1;
4309             } else if arg == "--align-right" {
4310                 align_right = true;
4311                 i += 1;
4312             } else if arg == "--switcher" {
4313                 switcher_mode = true;
4314                 mode = LauncherMode::Dmenu;
4315                 i += 1;
4316             } else if arg == "--choose" {
4317                 chooser_mode = true;
4318                 mode = LauncherMode::Dmenu;
4319                 i += 1;
4320             } else {
4321                 i += 1;
4322             }
4323         }
4324 
4325         let mut json_layout_config: Option<JsonLayoutConfig> = None;
4326         if mode == LauncherMode::Json {
4327             match serde_json::from_str::<JsonLayoutConfig>(&initial_stdin) {
4328                 Ok(cfg) => {
4329                     json_layout_config = Some(cfg);
4330                 }
4331                 Err(e) => {
4332                     let _ = stream.write_all(format!("Failed to parse JSON layout: {}\n", e).as_bytes());
4333                     continue;
4334                 }
4335             }
4336         }
4337 
4338         let use_xdg = cce_wm.is_some() && xdg_shell_state.is_some() && x_pos.is_none() && y_pos.is_none();
4339 
4340         let mut initial_items = Vec::new();
4341         if mode == LauncherMode::Dmenu {
4342             for line in initial_stdin.lines() {
4343                 initial_items.push(line.to_string());
4344             }
4345         }
4346 
4347         let stdin_state = Arc::new(std::sync::Mutex::new(StdinState {
4348             items: initial_items,
4349             new_data: !initial_stdin.is_empty(),
4350             cycle_next: 0,
4351             cycle_prev: 0,
4352             select_and_close: false,
4353             client_gone: false,
4354         }));
4355 
4356         let stdin_state_clone = stdin_state.clone();
4357         let stdin_sender_clone = stdin_sender.clone();
4358         let mut reader_clone = stream.try_clone().unwrap();
4359         let thread_handle = std::thread::spawn(move || {
4360             let mut line = String::new();
4361             let mut buf_reader = std::io::BufReader::new(&mut reader_clone);
4362             while let Ok(n) = buf_reader.read_line(&mut line) {
4363                 if n == 0 {
4364                     break;
4365                 }
4366                 let trimmed = line.trim_end_matches('\n');
4367                 if let Ok(mut lock_state) = stdin_state_clone.lock() {
4368                     if trimmed == "__cce_switcher_next__" {
4369                         lock_state.cycle_next += 1;
4370                         lock_state.new_data = true;
4371                     } else if trimmed == "__cce_switcher_prev__" {
4372                         lock_state.cycle_prev += 1;
4373                         lock_state.new_data = true;
4374                     } else if trimmed == "__cce_switcher_select_and_close__" {
4375                         lock_state.select_and_close = true;
4376                         lock_state.new_data = true;
4377                     } else {
4378                         lock_state.items.push(trimmed.to_string());
4379                         lock_state.new_data = true;
4380                     }
4381                 }
4382                 let _ = stdin_sender_clone.send(());
4383                 line.clear();
4384             }
4385             // EOF/error: the client hung up. Tell the event loop so the
4386             // popup closes instead of wedging the accept loop. (The normal
4387             // service-end path also lands here via shutdown(Read); by then
4388             // the channel source is already removed, so the send is inert.)
4389             if let Ok(mut lock_state) = stdin_state_clone.lock() {
4390                 lock_state.client_gone = true;
4391             }
4392             let _ = stdin_sender_clone.send(());
4393         });
4394 
4395         let stdin_state_for_handler = stdin_state.clone();
4396         let registration_token = loop_handle.insert_source(stdin_channel, move |event, _metadata, app_state: &mut AppState| {
4397             if let calloop::channel::Event::Msg(()) = event {
4398                 if stdin_state_for_handler
4399                     .lock()
4400                     .map(|s| s.client_gone)
4401                     .unwrap_or(false)
4402                 {
4403                     log::info!("client disconnected, closing popup");
4404                     app_state.exit = true;
4405                     return;
4406                 }
4407                 let mut select_and_close = false;
4408                 if let Some(st) = &mut app_state.state {
4409                     if let (Ok(mut lock_daemon), Ok(mut lock_state)) = (stdin_state_for_handler.lock(), st.stdin_state.lock()) {
4410                         if lock_daemon.new_data {
4411                             lock_state.items = lock_daemon.items.clone();
4412                             // Drain (not copy) the cycle counters: the daemon-side
4413                             // counts are never consumed elsewhere, so leaving them
4414                             // would re-apply every past cycle on each transfer.
4415                             lock_state.cycle_next += lock_daemon.cycle_next;
4416                             lock_daemon.cycle_next = 0;
4417                             lock_state.cycle_prev += lock_daemon.cycle_prev;
4418                             lock_daemon.cycle_prev = 0;
4419                             lock_state.select_and_close = lock_daemon.select_and_close;
4420                             lock_state.new_data = true;
4421                             lock_daemon.new_data = false;
4422                         }
4423                     }
4424                     if st.check_stdin_updates() {
4425                         st.update_desired_size();
4426                         st.apply_layout();
4427                         st.upload_vertices();
4428                         app_state.redraw = true;
4429                     }
4430                     if st.select_and_close_requested {
4431                         st.select_and_close_requested = false;
4432                         select_and_close = true;
4433                     }
4434                 }
4435                 if select_and_close {
4436                     app_state.trigger_select_and_close();
4437                 }
4438             }
4439         }).unwrap();
4440 
4441         app.switch_held = switcher_mode;
4442         app.switcher_mode = switcher_mode;
4443         app.selected_item = None;
4444 
4445         let (state, cce_toplevel) = match State::new(
4446             &conn_clone,
4447             &qh,
4448             &app.compositor_state,
4449             &app.layer_shell_state,
4450             xdg_shell_state.as_ref(),
4451             cce_wm.as_ref(),
4452             use_xdg,
4453             prompt,
4454             stdin_sender.clone(),
4455             mode,
4456             x_pos,
4457             y_pos,
4458             align_right,
4459             output_bounds_at(&app.output_state, x_pos.unwrap_or(0), y_pos.unwrap_or(0)),
4460             scale,
4461             select_item,
4462             switcher_mode,
4463             chooser_mode,
4464             json_layout_config,
4465             parent_app_id,
4466             fonts_slot.take(),
4467             renderer_slot.take(),
4468         ) {
4469             Ok(made) => made,
4470             Err(lost) => {
4471                 let _ = stream.write_all(b"\n");
4472                 compositor_gone(lost);
4473             }
4474         };
4475 
4476         app.window = state.window.clone();
4477         app.surface = Some(state.wl_surface.clone());
4478         app.cce_toplevel = cce_toplevel;
4479         app.state = Some(state);
4480 
4481         app.exit = false;
4482         app.fade_until = None;
4483 
4484         // The reader thread only signals for lines that arrive after this
4485         // point; the initial_stdin items are already sitting in stdin_state,
4486         // so fire one signal to make the channel handler ingest them.
4487         let _ = stdin_sender.send(());
4488 
4489         log::debug!("[timing] request -> popup ready: {:?}", t_request.elapsed());
4490         let mut first_frame_logged = false;
4491         let mut last_tick = std::time::Instant::now();
4492         let mut animating = true;
4493         while !app.exit {
4494             // The compositor is dissolving the popup out; hold the surface open
4495             // until its deadline, then go. Nothing to redraw in the meantime —
4496             // the pixels stay put and the scene node's opacity does the work.
4497             if let Some(until) = app.fade_until {
4498                 if std::time::Instant::now() >= until {
4499                     app.exit = true;
4500                 }
4501             }
4502 
4503             // Frame rate only while something moved last turn (a layout
4504             // animation, the scrollbar's hold, a wheel glide); otherwise sleep
4505             // until an event — input, the client's lines, a new request on the
4506             // daemon socket all wake the loop — or the fade's deadline. Until
4507             // 2026-10-06 an open popup woke every 16 ms with nothing moving.
4508             let timeout = if app.redraw {
4509                 Some(std::time::Duration::from_millis(0))
4510             } else if animating {
4511                 Some(std::time::Duration::from_millis(16))
4512             } else {
4513                 app.fade_until.map(|t| t.saturating_duration_since(std::time::Instant::now()))
4514             };
4515             if let Err(e) = event_loop.dispatch(timeout, &mut app) {
4516                 let _ = stream.write_all(b"\n");
4517                 compositor_gone(e);
4518             }
4519 
4520             if app.exit {
4521                 break;
4522             }
4523 
4524             // A new client preempts the current popup: global single-popup
4525             // semantics, even when the two popups are owned by different
4526             // bar module processes that can't see each other's state.
4527             if let Ok((s, _)) = listener.accept() {
4528                 pending = Some(s);
4529                 break;
4530             }
4531 
4532             let now = std::time::Instant::now();
4533             let mut dt = now.duration_since(last_tick).as_secs_f32();
4534             last_tick = now;
4535             if dt > 0.1 {
4536                 dt = 0.1;
4537             }
4538             animating = false;
4539             if let Some(st) = &mut app.state {
4540                 if let Some(jh) = st.json_layout {
4541                     if st.ui_context.lend_h(jh, |jl, ctx| jl.tick(dt, ctx)).unwrap_or(false) {
4542                         app.redraw = true;
4543                         animating = true;
4544                     }
4545                 }
4546                 // Raise/sink upkeep for the list scrollbar (true while the
4547                 // post-scroll hold runs or on the depth flip).
4548                 if st.ui_context[st.fuzzel].scroll_box.tick(dt) {
4549                     app.redraw = true;
4550                     animating = true;
4551                 }
4552                 // A wheel glide moves the rows under a stationary pointer.
4553                 if st.ui_context[st.fuzzel].refresh_hover() {
4554                     st.upload_vertices();
4555                     app.redraw = true;
4556                     animating = true;
4557                 }
4558             }
4559 
4560             if app.redraw {
4561                 app.redraw = false;
4562                 if let Some(st) = &mut app.state {
4563                     if st.render() && !first_frame_logged {
4564                         first_frame_logged = true;
4565                         log::info!("[timing] request -> first frame: {:?}", t_request.elapsed());
4566                     }
4567                 }
4568             }
4569         }
4570 
4571         loop_handle.remove(registration_token);
4572         let _ = stream.shutdown(std::net::Shutdown::Read);
4573         let _ = thread_handle.join();
4574 
4575         let response = app.selected_item.take().unwrap_or_default();
4576         let _ = stream.write_all(response.as_bytes());
4577         let _ = stream.write_all(b"\n");
4578         let _ = stream.flush();
4579 
4580         if let Some(st) = &mut app.state {
4581             if let Some(ref window) = st.window {
4582                 match window {
4583                     AppWindow::Layer(layer) => layer.set_keyboard_interactivity(KeyboardInteractivity::None),
4584                     AppWindow::Xdg(_) => {}
4585                 }
4586             }
4587             st.wl_surface.commit();
4588             // Take the font system back for the next popup (State has a Drop
4589             // impl, so swap rather than move; the placeholder is never used).
4590             let fs = std::mem::replace(
4591                 &mut st.font_system,
4592                 FontSystem::new_with_locale_and_db(
4593                     "en-US".to_string(),
4594                     cce_ui::cosmic_text::fontdb::Database::new(),
4595                 ),
4596             );
4597             let sc = std::mem::replace(&mut st.swash_cache, SwashCache::new());
4598             fonts_slot = Some((fs, sc));
4599             // Keep the renderer for the next popup, detached now: the State's
4600             // drop below destroys this wl_surface, and the swapchain must not
4601             // outlive it. The row icons stay uploaded with it (`icon_image`).
4602             if let Some(mut r) = st.renderer.take() {
4603                 r.detach_surface();
4604                 renderer_slot = Some(r);
4605             }
4606         }
4607         app.window = None;
4608         app.surface = None;
4609         app.state = None;
4610         app.cce_toplevel = None;
4611 
4612         // Dropping the State only queues wl_surface.destroy() on the
4613         // connection; the blocking accept() below would leave it unsent and
4614         // the compositor would keep showing the dead popup until the next
4615         // client connects.
4616         let _ = conn_clone.flush();
4617     }
4618 }
4619 
4620 /// A launcher list-nav chord parsed to (needs_ctrl, key char). This app
4621 /// handles keys at the raw keysym layer, so only single-character keys
4622 /// (optionally with ctrl) are supported here.
4623 fn chord_ctrl_char(chord: &str) -> Option<(bool, char)> {
4624     let mut ctrl = false;
4625     let mut segs = chord.split('+').map(str::trim);
4626     let key = segs.next_back()?;
4627     for seg in segs {
4628         match seg.to_lowercase().as_str() {
4629             "ctrl" | "control" => ctrl = true,
4630             _ => return None,
4631         }
4632     }
4633     let mut chars = key.chars();
4634     let c = chars.next()?;
4635     if chars.next().is_some() {
4636         return None;
4637     }
4638     Some((ctrl, c.to_ascii_lowercase()))
4639 }
4640 
4641 /// input.kdl `cce-cloud` domain: select_next / select_prev (emacs-style
4642 /// ctrl+n / ctrl+p defaults), resolved once per process.
4643 fn nav_keys() -> &'static (Option<(bool, char)>, Option<(bool, char)>) {
4644     static KEYS: std::sync::OnceLock<(Option<(bool, char)>, Option<(bool, char)>)> = std::sync::OnceLock::new();
4645     KEYS.get_or_init(|| {
4646         (
4647             chord_ctrl_char(&cce_ui::input::app_chord("select_next", "ctrl+n")),
4648             chord_ctrl_char(&cce_ui::input::app_chord("select_prev", "ctrl+p")),
4649         )
4650     })
4651 }
4652 
4653 fn nav_matches(spec: Option<(bool, char)>, ctrl_pressed: bool, sym: xkeysym::Keysym) -> bool {
4654     match spec {
4655         Some((need_ctrl, c)) => {
4656             ctrl_pressed == need_ctrl && sym.key_char().map(|k| k.to_ascii_lowercase()) == Some(c)
4657         }
4658         None => false,
4659     }
4660 }
4661 
4662 fn main() {
4663     env_logger::Builder::from_default_env()
4664         .filter_level(log::LevelFilter::Info)
4665         .init();
4666 
4667     let args = std::env::args().collect::<Vec<String>>();
4668     let is_daemon = args.iter().any(|arg| arg == "--daemon");
4669 
4670     let uid = unsafe { libc::getuid() };
4671     let socket_dir = format!("/run/user/{}", uid);
4672     // Key the socket by display so a nested/second compositor session gets its
4673     // own daemon instead of hijacking (or being hijacked by) another session's.
4674     let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
4675     let socket_path = if std::path::Path::new(&socket_dir).exists() {
4676         format!("{}/cce-cloud-{}.socket", socket_dir, display)
4677     } else {
4678         format!("/tmp/cce-cloud-{}-{}.socket", uid, display)
4679     };
4680 
4681     if is_daemon {
4682         run_daemon(&socket_path);
4683     } else {
4684         match run_client(&socket_path, &args) {
4685             Ok(_) => {}
4686             Err(e) => {
4687                 log::warn!("Could not connect to cce-cloud daemon: {}. Running in standalone mode.", e);
4688                 run_standalone();
4689             }
4690         }
4691     }
4692 }
4693 
4694 #[cfg(test)]
4695 mod placement_tests {
4696     use super::*;
4697 
4698     /// A 1920x1200 logical output at the layout origin.
4699     const SCREEN: Option<(i32, i32, i32, i32)> = Some((0, 0, 1920, 1200));
4700 
4701     /// `(left, top)` of a `w`x`h` popup anchored at `(x, y)`.
4702     fn place(x: i32, y: i32, w: i32, h: i32) -> (i32, i32) {
4703         let (_, (top, _, _, left)) = Placement::new(x, y, false, SCREEN).resolve(w, h);
4704         (left, top)
4705     }
4706 
4707     #[test]
4708     fn interior_anchor_is_used_verbatim() {
4709         assert_eq!(place(400, 300, 200, 250), (400, 300));
4710     }
4711 
4712     #[test]
4713     fn overhanging_popup_flips_to_the_other_side_of_the_cursor() {
4714         // The desktop context menu at (1800, 1050) with a 200x250 body: it ran off
4715         // both edges before, and now hangs up-and-left of the cursor instead.
4716         assert_eq!(place(1800, 1050, 200, 250), (1600, 800));
4717         // One axis at a time.
4718         assert_eq!(place(1850, 300, 200, 250), (1650, 300));
4719         assert_eq!(place(400, 1150, 200, 250), (400, 900));
4720     }
4721 
4722     #[test]
4723     fn a_popup_that_fits_on_neither_side_clamps_to_the_edge_gap() {
4724         // Anchored in the far corner, so flipping alone still leaves it off-screen.
4725         assert_eq!(place(1919, 1199, 200, 250), (1920 - 200 - EDGE_GAP, 1200 - 250 - EDGE_GAP));
4726         // Bigger than the output on both axes: pin to the near edge rather than
4727         // letting the clamp range invert.
4728         assert_eq!(place(500, 500, 3000, 3000), (EDGE_GAP, EDGE_GAP));
4729     }
4730 
4731     #[test]
4732     fn align_right_measures_the_anchor_from_the_right_edge() {
4733         // `x` in from the right, growing leftward: right edge at 1920-100, so left
4734         // edge at 1620.
4735         let mut p = Placement::new(100, 300, true, SCREEN);
4736         let (_, (top, _, _, left)) = p.resolve(200, 250);
4737         assert_eq!((left, top), (1620, 300));
4738         // Wider than the room to its left, so it flips and grows rightward instead.
4739         let mut p = Placement::new(1850, 300, true, SCREEN);
4740         let (_, (_, _, _, left)) = p.resolve(200, 250);
4741         assert_eq!(left, 1920 - 1850);
4742     }
4743 
4744     #[test]
4745     fn the_flip_decision_latches_across_resizes() {
4746         // The popup auto-sizes as its list filters. Once flipped it stays flipped:
4747         // unlatching would snap the window back across the cursor mid-typing.
4748         let mut p = Placement::new(400, 1150, false, SCREEN);
4749         assert_eq!(p.resolve(200, 250).1 .0, 900); // flips up
4750         assert_eq!(p.resolve(200, 100).1 .0, 1050); // shrinks upward, still flipped
4751     }
4752 
4753     #[test]
4754     fn the_anchor_is_output_local_on_a_secondary_output() {
4755         // Layer-shell margins are relative to the output, but `-x/-y` are layout
4756         // coordinates — the output origin has to come back off.
4757         let mut p = Placement::new(2320, 300, false, Some((1920, 0, 1920, 1200)));
4758         assert_eq!(p.resolve(200, 250).1 .3, 400);
4759     }
4760 
4761     #[test]
4762     fn the_window_switcher_geometry_is_untouched() {
4763         // window_manager.rs centers the 600-wide switcher horizontally and drops it
4764         // 80px down. It already fits, so placement must be a no-op for it.
4765         assert_eq!(place((1920 - 600) / 2, 80, 600, 800), (660, 80));
4766     }
4767 
4768     #[test]
4769     fn an_unknown_output_falls_back_to_the_raw_request() {
4770         let (anchor, margins) = Placement::new(1800, 1050, false, None).resolve(200, 250);
4771         assert_eq!(anchor, Anchor::TOP | Anchor::LEFT);
4772         assert_eq!(margins, (1050, 0, 0, 1800));
4773 
4774         let (anchor, margins) = Placement::new(1800, 1050, true, None).resolve(200, 250);
4775         assert_eq!(anchor, Anchor::TOP | Anchor::RIGHT);
4776         assert_eq!(margins, (1050, 1800, 0, 0));
4777     }
4778 }
4779 
4780 #[cfg(test)]
4781 mod tests {
4782     use super::*;
4783 
4784     #[test]
4785     fn button_width_covers_label_inset() {
4786         // Regression: a left-justified Button draws its label the control text inset in
4787         // from its own left edge (Button::paint) in the button font. layout_children gives
4788         // the button `usable_w = popup_width - 2 * root_plate_inset()`. If the popup width
4789         // doesn't budget the button's inset per side, the label spills past the button's
4790         // right edge — the desktop context-menu bug. Every desktop-menu label must fit
4791         // within usable_w with the inset.
4792         let (family, size) = cce_ui::layout::parse_font_string(&cce_ui::layout::button_font());
4793         let size = size.unwrap_or(12.0);
4794         let text_inset = cce_ui::layout::CONTROL_TEXT_INSET;
4795         for label in [
4796             "Terminal", "Files", "Data Editor", "Applications",
4797             "System Settings", "Expose Windows", "Reload Config", "Logout",
4798         ] {
4799             let popup_w = json_widget_desired_width("button", label);
4800             // container margins, per layout_children
4801             let usable_w = popup_w - 2.0 * cce_ui::layout::root_plate_inset();
4802             let label_w = cce_ui::widget::display::measure_text_width(label, &family, size);
4803             // left inset + label + right breathing room must fit the button.
4804             assert!(
4805                 usable_w >= label_w + 2.0 * text_inset,
4806                 "button '{label}': usable_w {usable_w} < label {label_w} + {} inset",
4807                 2.0 * text_inset,
4808             );
4809         }
4810     }
4811 
4812     /// A JSON menu's marks and page turns are cce-icons glyphs, by the
4813     /// toolkit's context-menu conventions: the label's leading mark is
4814     /// drawn as its glyph and not as text, a button turning to a later page
4815     /// ends in chevron-right, one turning back starts with chevron-left, and
4816     /// the labels of a page with a left glyph share one edge.
4817     #[test]
4818     fn json_menu_marks_and_page_turns_are_glyphs() {
4819         use crate::json_layout::button_glyphs;
4820         assert_eq!(button_glyphs("✓ Wrap", 0, None), (Some("check"), "Wrap", None));
4821         assert_eq!(button_glyphs("● Tiled", 1, None), (Some("circle"), "Tiled", None));
4822         assert_eq!(button_glyphs("○ Floating", 1, None), (Some("circle-outline"), "Floating", None));
4823         assert_eq!(button_glyphs("Window Mode", 0, Some(1)), (None, "Window Mode", Some("chevron-right")));
4824         assert_eq!(button_glyphs("Back", 1, Some(0)), (Some("chevron-left"), "Back", None));
4825         assert_eq!(button_glyphs("Terminal", 0, None), (None, "Terminal", None));
4826 
4827         let json = r#"{"pages": [
4828             {"title": "menu", "justify": "left", "widgets": [
4829                 {"type": "button", "text": "Window Mode", "id": "mode_page", "target_page": 1},
4830                 {"type": "button", "text": "Close Window", "id": "close"}
4831             ]},
4832             {"title": "Window Mode", "justify": "left", "widgets": [
4833                 {"type": "button", "text": "○ Floating", "id": "floating"},
4834                 {"type": "button", "text": "● Tiled", "id": "tiled"},
4835                 {"type": "button", "text": "Back", "id": "back", "target_page": 0}
4836             ]}
4837         ]}"#;
4838         let config: JsonLayoutConfig = serde_json::from_str(json).unwrap();
4839         let mut layout = JsonLayoutWidget::new(&config);
4840         layout.set_rect(0.0, 0.0, 240.0, 300.0);
4841         let ctx = cce_ui::context::UiContext::new();
4842 
4843         let (labels, glyphs) = layout.labels_and_glyphs(&ctx);
4844         let texts: Vec<&str> = labels.iter().map(|(l, _)| l.text.as_str()).collect();
4845         assert_eq!(texts, ["Window Mode", "Close Window"]);
4846         let names: Vec<&str> = glyphs.iter().map(|g| g.0).collect();
4847         assert_eq!(names, ["chevron-right"]);
4848         // The chevron stands at the row's right end.
4849         let row = &layout.widgets[0];
4850         assert!(glyphs[0].1.x > row.x + row.w / 2.0);
4851 
4852         layout.active_page = 1;
4853         layout.layout_children();
4854         let (labels, glyphs) = layout.labels_and_glyphs(&ctx);
4855         let texts: Vec<&str> = labels.iter().map(|(l, _)| l.text.as_str()).collect();
4856         assert_eq!(texts, ["Floating", "Tiled", "Back"]);
4857         let names: Vec<&str> = glyphs.iter().map(|g| g.0).collect();
4858         assert_eq!(names, ["circle-outline", "circle", "chevron-left"]);
4859         // Every label starts after its glyph, on one edge.
4860         let edge = labels[0].0.x;
4861         for ((l, _), g) in labels.iter().zip(&glyphs) {
4862             assert!((l.x - edge).abs() < 0.01, "{} is not on the page's text edge", l.text);
4863             assert!(g.1.x + g.1.width <= l.x, "the {} glyph overlaps its label", g.0);
4864         }
4865     }
4866 
4867     #[test]
4868     fn test_json_layout_parsing() {
4869         let json_str = r#"{
4870             "width": 320,
4871             "height": 240,
4872             "widgets": [
4873                 { "type": "label", "text": "Select Option:" },
4874                 { "id": "feat_a", "type": "checkbox", "text": "Enable Feature A", "checked": true },
4875                 { "id": "btn_ok", "type": "button", "text": "OK" }
4876             ]
4877         }"#;
4878 
4879         let config: JsonLayoutConfig = serde_json::from_str(json_str).expect("Failed to parse JSON");
4880         assert_eq!(config.width, Some(320));
4881         assert_eq!(config.height, Some(240));
4882         let widgets = config.widgets.as_ref().expect("widgets option should be Some");
4883         assert_eq!(widgets.len(), 3);
4884 
4885         assert_eq!(widgets[0].widget_type, "label");
4886         assert_eq!(widgets[0].text, "Select Option:");
4887 
4888         assert_eq!(widgets[1].widget_type, "checkbox");
4889         assert_eq!(widgets[1].id.as_deref(), Some("feat_a"));
4890         assert_eq!(widgets[1].checked, Some(true));
4891     }
4892 
4893     #[test]
4894     fn test_json_layout_widget_flow() {
4895         use cce_ui::widget::WidgetHost;
4896 
4897         let widgets_conf = vec![
4898             JsonWidgetConfig {
4899                 widget_type: "label".to_string(),
4900                 text: "Label 1".to_string(),
4901                 id: None,
4902                 checked: None,
4903                 value: None,
4904                 min: None,
4905                 max: None,
4906                 step: None,
4907                 decimals: None,
4908                 color: None,
4909                 value_f32: None,
4910                 min_f32: None,
4911                 max_f32: None,
4912                 target_page: None,
4913             },
4914             JsonWidgetConfig {
4915                 widget_type: "checkbox".to_string(),
4916                 text: "Check 1".to_string(),
4917                 id: Some("chk".to_string()),
4918                 checked: Some(false),
4919                 value: None,
4920                 min: None,
4921                 max: None,
4922                 step: None,
4923                 decimals: None,
4924                 color: None,
4925                 value_f32: None,
4926                 min_f32: None,
4927                 max_f32: None,
4928                 target_page: None,
4929             },
4930             JsonWidgetConfig {
4931                 widget_type: "button".to_string(),
4932                 text: "Click 1".to_string(),
4933                 id: Some("btn".to_string()),
4934                 checked: None,
4935                 value: None,
4936                 min: None,
4937                 max: None,
4938                 step: None,
4939                 decimals: None,
4940                 color: None,
4941                 value_f32: None,
4942                 min_f32: None,
4943                 max_f32: None,
4944                 target_page: None,
4945             },
4946         ];
4947 
4948         let config = JsonLayoutConfig {
4949             width: Some(300),
4950             height: Some(400),
4951             widgets: Some(widgets_conf),
4952             pages: None,
4953             justify: None,
4954         };
4955 
4956         let mut layout = JsonLayoutWidget::new(&config);
4957         layout.set_rect(0.0, 0.0, 300.0, 400.0);
4958         let mut ctx = cce_ui::context::UiContext::new();
4959 
4960         // Verify sub-widgets are populated and positioned correctly
4961         assert_eq!(layout.widgets.len(), 3);
4962         
4963         let w_label_y = layout.widgets[0].y;
4964         let w_label_h = layout.widgets[0].h;
4965         let w_label_x = layout.widgets[0].x;
4966         let w_label_w = layout.widgets[0].w;
4967 
4968         let w_check_y = layout.widgets[1].y;
4969         let w_check_h = layout.widgets[1].h;
4970         let w_check_x = layout.widgets[1].x;
4971         let w_check_w = layout.widgets[1].w;
4972 
4973         let w_btn_y = layout.widgets[2].y;
4974         let w_btn_h = layout.widgets[2].h;
4975         let w_btn_x = layout.widgets[2].x;
4976         let w_btn_w = layout.widgets[2].w;
4977 
4978         assert!(layout.widgets[0].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Label>().is_some());
4979         assert!(layout.widgets[1].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Checkbox>().is_some());
4980         assert!(layout.widgets[2].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Button>().is_some());
4981 
4982         // Check vertical sequence positions: the root-plate inset above, a
4983         // root-plate gap after each widget.
4984         let inset = cce_ui::layout::root_plate_inset();
4985         let gap = cce_ui::layout::root_plate_gap();
4986         assert_eq!(w_label_y, inset);
4987         assert_eq!(w_label_h, 18.0);
4988 
4989         assert_eq!(w_check_y, inset + 18.0 + gap); // y_prev + h_prev + spacing
4990         assert_eq!(w_check_h, cce_ui::layout::toggle_height());
4991 
4992         assert_eq!(w_btn_y, w_check_y + w_check_h + gap);
4993         assert_eq!(w_btn_h, cce_ui::widget::context_menu::ROW_H);
4994 
4995         // Check horizontal positioning (should match usable width: 300 - 2 * inset)
4996         let usable_w = 300.0 - 2.0 * inset;
4997         assert_eq!(w_label_x, inset);
4998         assert_eq!(w_label_w, usable_w);
4999         assert_eq!(w_check_x, inset);
5000         assert_eq!(w_check_w, usable_w);
5001         assert_eq!(w_btn_x, inset);
5002         assert_eq!(w_btn_w, usable_w);
5003 
5004         // Verify Checkbox initial state
5005         assert_eq!(layout.widgets[1].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Checkbox>().unwrap().checked(), false);
5006 
5007         // Simulate click on Checkbox row
5008         let changed = layout.mouse_input(
5009             cce_ui::widget::MouseButton::Left,
5010             cce_ui::widget::ElementState::Released,
5011             w_check_x + 5.0,
5012             w_check_y + 5.0,
5013             &mut ctx,
5014         );
5015         assert!(changed);
5016         assert_eq!(layout.widgets[1].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Checkbox>().unwrap().checked(), true);
5017 
5018         // Simulate hover on button
5019         let changed_hover = layout.on_cursor_moved(w_btn_x + 10.0, w_btn_y + 10.0, &mut ctx);
5020         assert!(changed_hover);
5021         // Phase 5: hover state lives on the Button model (it drives the color matrix), not the base.
5022         assert!(layout.widgets[2].widget.as_dyn().as_any().downcast_ref::<cce_ui::widget::Button>().unwrap().hovered());
5023     }
5024 
5025     /// Hover must be exclusive: a pointer over one button leaves the others
5026     /// unhovered (the desktop context menu regression — every button lit up
5027     /// once the paint walk started rendering the Button model's hover state).
5028     #[test]
5029     fn test_json_button_hover_is_exclusive() {
5030         let mk_btn = |id: &str, text: &str| JsonWidgetConfig {
5031             widget_type: "button".to_string(),
5032             text: text.to_string(),
5033             id: Some(id.to_string()),
5034             checked: None,
5035             value: None,
5036             min: None,
5037             max: None,
5038             step: None,
5039             decimals: None,
5040             color: None,
5041             value_f32: None,
5042             min_f32: None,
5043             max_f32: None,
5044             target_page: None,
5045         };
5046         let config = JsonLayoutConfig {
5047             width: Some(300),
5048             height: Some(400),
5049             widgets: Some(vec![mk_btn("a", "Alpha"), mk_btn("b", "Beta"), mk_btn("c", "Gamma")]),
5050             pages: None,
5051             justify: None,
5052         };
5053         let mut layout = JsonLayoutWidget::new(&config);
5054         layout.set_rect(0.0, 0.0, 300.0, 400.0);
5055         let mut ctx = cce_ui::context::UiContext::new();
5056 
5057         let hovered = |layout: &cce_ui::widget::Adapted<JsonLayoutWidget>, i: usize| {
5058             layout.widgets[i]
5059                 .widget
5060                 .as_dyn()
5061                 .as_any()
5062                 .downcast_ref::<cce_ui::widget::Button>()
5063                 .unwrap()
5064                 .hovered()
5065         };
5066 
5067         // Pointer over the first button only.
5068         let (x0, y0) = (layout.widgets[0].x, layout.widgets[0].y);
5069         layout.on_cursor_moved(x0 + 10.0, y0 + 5.0, &mut ctx);
5070         assert!(hovered(&layout, 0), "hovered button must be hovered");
5071         assert!(!hovered(&layout, 1), "second button must not be hovered");
5072         assert!(!hovered(&layout, 2), "third button must not be hovered");
5073 
5074         // Move to the third button: hover follows, first clears.
5075         let (x2, y2) = (layout.widgets[2].x, layout.widgets[2].y);
5076         layout.on_cursor_moved(x2 + 10.0, y2 + 5.0, &mut ctx);
5077         assert!(!hovered(&layout, 0), "old hover must clear");
5078         assert!(!hovered(&layout, 1));
5079         assert!(hovered(&layout, 2), "new hover must set");
5080 
5081         // Pointer inside the panel but on no button: everything clears.
5082         layout.on_cursor_moved(150.0, 395.0, &mut ctx);
5083         assert!(!hovered(&layout, 0));
5084         assert!(!hovered(&layout, 1));
5085         assert!(!hovered(&layout, 2));
5086 
5087         // The live path: routed dispatch through the UiContext, and crucially a
5088         // SECOND move that changes no child hover. The first (consumed) move
5089         // skips the adapter's base-hover bookkeeping; the unconsumed second one
5090         // runs it, synthesizing a MouseEnter for the panel — which route_event
5091         // must NOT broadcast to the children (the desktop-menu regression: every
5092         // button lit up on the first stationary wiggle).
5093         let (x1, y1) = (layout.widgets[1].x, layout.widgets[1].y);
5094         let root = layout.id();
5095         let layout = ctx.insert(layout);
5096         let mv = |x: f32, y: f32| cce_ui::widget::Event::PointerMove { x, y, local_x: x, local_y: y };
5097         ctx.propagate_event(&mv(x1 + 10.0, y1 + 5.0), root);
5098         ctx.propagate_event(&mv(x1 + 12.0, y1 + 5.0), root);
5099         assert!(!hovered(&ctx[layout], 0), "unconsumed move must not hover-broadcast");
5100         assert!(hovered(&ctx[layout], 1));
5101         assert!(!hovered(&ctx[layout], 2), "unconsumed move must not hover-broadcast");
5102     }
5103 
5104     #[test]
5105     fn parse_desktop_terminal_flag() {
5106         let dir = std::path::PathBuf::from("/tmp/cce-cloud-test-desktop-dir");
5107         let _ = std::fs::create_dir_all(&dir);
5108 
5109         let path = dir.join("htop.desktop");
5110         std::fs::write(
5111             &path,
5112             "[Desktop Entry]\nType=Application\nName=htop\nExec=htop\nTerminal=true\n",
5113         )
5114         .unwrap();
5115         let app = parse_desktop_file(&path).unwrap();
5116         assert!(app.terminal);
5117 
5118         let path = dir.join("gui.desktop");
5119         std::fs::write(
5120             &path,
5121             "[Desktop Entry]\nType=Application\nName=Gui\nExec=gui %U\n",
5122         )
5123         .unwrap();
5124         let app = parse_desktop_file(&path).unwrap();
5125         assert!(!app.terminal);
5126 
5127         let _ = std::fs::remove_dir_all(&dir);
5128     }
5129 
5130     #[test]
5131     fn switcher_rows_split_into_title_and_app_id() {
5132         assert_eq!(split_switcher_row("~/src - Terminal (cce-terminal)"), ("~/src - Terminal", "cce-terminal"));
5133         // The last group is the id; the title's own parentheses are not.
5134         assert_eq!(
5135             split_switcher_row("Inbox (3) - Mail (org.gnome.Evolution)"),
5136             ("Inbox (3) - Mail", "org.gnome.Evolution")
5137         );
5138         // An untitled window is sent as the bare app_id, which is both halves.
5139         assert_eq!(split_switcher_row("firefox"), ("firefox", "firefox"));
5140     }
5141 
5142     #[test]
5143     fn app_ids_resolve_through_their_desktop_entry() {
5144         let base = std::path::PathBuf::from("/tmp/cce-cloud-test-icon-index");
5145         let _ = std::fs::remove_dir_all(&base);
5146         let user = base.join("user");
5147         let sys = base.join("sys");
5148         std::fs::create_dir_all(&user).unwrap();
5149         std::fs::create_dir_all(&sys).unwrap();
5150         std::fs::write(
5151             sys.join("org.gnome.Nautilus.desktop"),
5152             "[Desktop Entry]\nName=Files\nIcon=org.gnome.Nautilus\n",
5153         )
5154         .unwrap();
5155         std::fs::write(
5156             sys.join("code-oss.desktop"),
5157             "[Desktop Entry]\nName=Code\nIcon=com.visualstudio.code.oss\nStartupWMClass=Code - OSS\n",
5158         )
5159         .unwrap();
5160         // NoDisplay helpers still name an icon for their windows.
5161         std::fs::write(
5162             sys.join("helper.desktop"),
5163             "[Desktop Entry]\nName=Helper\nIcon=helper-icon\nNoDisplay=true\n\n[Desktop Action x]\nIcon=wrong\n",
5164         )
5165         .unwrap();
5166         // The user dir shadows the system entry's icon.
5167         std::fs::write(sys.join("editor.desktop"), "[Desktop Entry]\nIcon=editor-sys\n").unwrap();
5168         std::fs::write(user.join("editor.desktop"), "[Desktop Entry]\nIcon=editor-user\n").unwrap();
5169 
5170         let index = desktop_icon_index(&[user, sys]);
5171         let icon = |id| icon_name_for_app_id(&index, id);
5172         assert_eq!(icon("org.gnome.Nautilus"), "org.gnome.Nautilus");
5173         assert_eq!(icon("nautilus"), "org.gnome.Nautilus");
5174         assert_eq!(icon("Code - OSS"), "com.visualstudio.code.oss");
5175         assert_eq!(icon("helper"), "helper-icon");
5176         assert_eq!(icon("editor"), "editor-user");
5177         // No entry: the app_id is the icon name, as it is for cce's own apps.
5178         assert_eq!(icon("cce-terminal"), "cce-terminal");
5179         let _ = std::fs::remove_dir_all(&base);
5180     }
5181 
5182     #[test]
5183     fn icon_override_is_keyed_by_desktop_id() {
5184         let dir = std::path::PathBuf::from("/tmp/cce-cloud-test-icon-override");
5185         let _ = std::fs::remove_dir_all(&dir);
5186         std::fs::create_dir_all(&dir).unwrap();
5187         std::fs::write(dir.join("houdini.svg"), "<svg/>").unwrap();
5188 
5189         // The ID's own file, as an absolute path - an entry whose Icon= is
5190         // an absolute path or missing still gets cce's artwork.
5191         assert_eq!(
5192             icon_override_in(&dir, "houdini").as_deref(),
5193             Some(dir.join("houdini.svg").to_str().unwrap())
5194         );
5195         // No override: the caller falls back to the entry's own Icon=.
5196         assert_eq!(icon_override_in(&dir, "raindropio"), None);
5197         let _ = std::fs::remove_dir_all(&dir);
5198     }
5199 
5200     #[test]
5201     fn chooser_labels_resolve_ids_and_tell_twins_apart() {
5202         let base = std::env::temp_dir().join(format!("cce-cloud-test-choose-{}", std::process::id()));
5203         let _ = std::fs::remove_dir_all(&base);
5204         let (user, sys) = (base.join("user/applications"), base.join("sys/applications"));
5205         std::fs::create_dir_all(&user).unwrap();
5206         std::fs::create_dir_all(&sys).unwrap();
5207         let entry = |name: &str, extra: &str| format!("[Desktop Entry]\nType=Application\nName={name}\nExec=x\n{extra}");
5208         std::fs::write(sys.join("org.gnome.Evince.desktop"), entry("Document Viewer", "Icon=evince\n")).unwrap();
5209         // NoDisplay: the launcher hides it, the chooser must not.
5210         std::fs::write(sys.join("helper.desktop"), entry("Helper", "NoDisplay=true\n")).unwrap();
5211         std::fs::write(sys.join("firefox.desktop"), entry("Firefox", "")).unwrap();
5212         std::fs::write(sys.join("firefox-nightly.desktop"), entry("Firefox", "")).unwrap();
5213         // The user dir shadows the system entry of the same ID.
5214         std::fs::write(user.join("helper.desktop"), entry("My Helper", "")).unwrap();
5215         // A localized name after the plain one does not replace it.
5216         std::fs::write(sys.join("loc.desktop"), entry("Plain", "Name[de]=Lokal\n")).unwrap();
5217 
5218         let dirs = vec![user.clone(), sys.clone()];
5219         let ids: Vec<String> = ["org.gnome.Evince", "helper", "firefox", "firefox-nightly", "missing", "loc"]
5220             .iter()
5221             .map(|s| s.to_string())
5222             .collect();
5223         let got = Chooser::labels(&ids, &dirs);
5224         let labels: Vec<&str> = got.iter().map(|(_, l, _)| l.as_str()).collect();
5225         assert_eq!(
5226             labels,
5227             ["Document Viewer", "My Helper", "Firefox (firefox)", "Firefox (firefox-nightly)", "missing", "Plain"]
5228         );
5229         assert_eq!(got[0].2.as_deref(), Some("evince"));
5230 
5231         let chooser = Chooser { fed: ids.clone(), by_label: got.iter().map(|(id, l, _)| (l.clone(), id.clone())).collect() };
5232         assert_eq!(chooser.answer("Firefox (firefox-nightly)"), "firefox-nightly");
5233         assert_eq!(chooser.answer("Document Viewer"), "org.gnome.Evince");
5234         assert_eq!(chooser.answer("typed text"), "typed text", "an unknown row answers itself");
5235         let _ = std::fs::remove_dir_all(&base);
5236     }
5237 
5238     #[test]
5239     fn scan_apps_xdg_precedence() {
5240         let base = std::path::PathBuf::from("/tmp/cce-cloud-test-xdg");
5241         let _ = std::fs::remove_dir_all(&base);
5242         let user = base.join("home/applications");
5243         let sys = base.join("sys/applications");
5244         std::fs::create_dir_all(&user).unwrap();
5245         std::fs::create_dir_all(&sys).unwrap();
5246 
5247         std::fs::write(
5248             sys.join("editor.desktop"),
5249             "[Desktop Entry]\nType=Application\nName=Editor\nExec=editor-sys\n",
5250         )
5251         .unwrap();
5252         std::fs::write(
5253             sys.join("player.desktop"),
5254             "[Desktop Entry]\nType=Application\nName=Player\nExec=player\n",
5255         )
5256         .unwrap();
5257         // User dir: renames editor (same ID must still shadow the system
5258         // entry) and deletes player via Hidden.
5259         std::fs::write(
5260             user.join("editor.desktop"),
5261             "[Desktop Entry]\nType=Application\nName=My Editor\nExec=editor-user\n",
5262         )
5263         .unwrap();
5264         std::fs::write(
5265             user.join("player.desktop"),
5266             "[Desktop Entry]\nType=Application\nName=Player\nExec=player\nHidden=true\n",
5267         )
5268         .unwrap();
5269 
5270         let orig_home = std::env::var("XDG_DATA_HOME").ok();
5271         let orig_dirs = std::env::var("XDG_DATA_DIRS").ok();
5272         std::env::set_var("XDG_DATA_HOME", base.join("home"));
5273         std::env::set_var("XDG_DATA_DIRS", base.join("sys"));
5274 
5275         let apps = scan_apps();
5276 
5277         match orig_home {
5278             Some(v) => std::env::set_var("XDG_DATA_HOME", v),
5279             None => std::env::remove_var("XDG_DATA_HOME"),
5280         }
5281         match orig_dirs {
5282             Some(v) => std::env::set_var("XDG_DATA_DIRS", v),
5283             None => std::env::remove_var("XDG_DATA_DIRS"),
5284         }
5285 
5286         assert_eq!(apps.len(), 1);
5287         assert_eq!(apps[0].name, "My Editor");
5288         assert_eq!(apps[0].exec, "editor-user");
5289 
5290         let _ = std::fs::remove_dir_all(&base);
5291     }
5292 
5293     #[test]
5294     fn a_tab_keeps_its_own_query_and_items() {
5295         let mut f = FuzzelWidget::new("Search: ".to_string());
5296         f.set_tabs(vec![
5297             ("Apps".to_string(), vec!["Firefox".to_string(), "Files".to_string()]),
5298             ("System".to_string(), vec!["Suspend".to_string(), "Reboot".to_string()]),
5299         ]);
5300         f.query.push_str("fi");
5301         f.filter();
5302         assert_eq!(f.filtered_items, vec!["Firefox".to_string(), "Files".to_string()]);
5303 
5304         assert!(f.cycle_tab(true));
5305         assert_eq!(f.active_tab, 1);
5306         // The System tab opens on its own (empty) query, not the Apps one.
5307         assert_eq!(f.query, "");
5308         assert_eq!(f.filtered_items, vec!["Suspend".to_string(), "Reboot".to_string()]);
5309 
5310         // ...and coming back lands on the query that was left behind.
5311         assert!(f.cycle_tab(true), "two tabs wrap");
5312         assert_eq!(f.active_tab, 0);
5313         assert_eq!(f.query, "fi");
5314         assert_eq!(f.filtered_items, vec!["Firefox".to_string(), "Files".to_string()]);
5315     }
5316 
5317     #[test]
5318     fn the_feed_fills_tab_zero_from_any_tab() {
5319         let mut f = FuzzelWidget::new("Search: ".to_string());
5320         f.set_tabs(vec![
5321             ("Apps".to_string(), Vec::new()),
5322             ("System".to_string(), vec!["Suspend".to_string()]),
5323         ]);
5324         f.switch_tab(1);
5325         // The stdin/socket ingest addresses tab 0 while the user reads tab 1:
5326         // the rows on screen must not move, and the items must still land.
5327         f.set_tab_items(0, vec!["Firefox".to_string()]);
5328         assert_eq!(f.filtered_items, vec!["Suspend".to_string()]);
5329         assert_eq!(f.tab_items(0), ["Firefox".to_string()]);
5330         f.switch_tab(0);
5331         assert_eq!(f.filtered_items, vec!["Firefox".to_string()]);
5332     }
5333 
5334     #[test]
5335     fn an_untabbed_list_takes_no_chrome_and_does_not_cycle() {
5336         let mut f = FuzzelWidget::new("Search: ".to_string());
5337         f.set_items(vec!["a".to_string(), "b".to_string()]);
5338         // What keeps Dmenu, Path and the Super-Tab window switcher laid out
5339         // and keyed exactly as they were.
5340         assert_eq!(f.tab_strip_h(), 0.0);
5341         assert!(!f.cycle_tab(true));
5342         assert!(f.tab_at(20.0, 20.0).is_none());
5343     }
5344 
5345     #[test]
5346     fn switcher_motion_onto_a_row_selects_it() {
5347         let mut f = FuzzelWidget::new("Search: ".to_string());
5348         f.set_rect(0.0, 0.0, 600.0, 400.0);
5349         f.switcher_rows = true;
5350         f.set_items((0..4).map(|i| format!("Window {i} (app{i})")).collect());
5351         f.selected = 1; // Super+Tab advanced past the focused window
5352         let x = 100.0;
5353         let (list_y, item_h) = (f.list_y(), f.item_h());
5354         let row_y = |i: usize| list_y + item_h * (i as f32 + 0.5);
5355 
5356         // The popup mapping under a resting pointer is an Enter, not motion:
5357         // it lights the row but leaves the selection where Super+Tab put it.
5358         f.hover_at(x, row_y(3));
5359         assert_eq!(f.selected, 1);
5360 
5361         // Any motion over a row selects it...
5362         assert!(f.pointer_moved(x, row_y(3) + 1.0));
5363         assert_eq!(f.selected, 3);
5364         assert!(f.pointer_moved(x, row_y(2)));
5365         assert_eq!(f.selected, 2);
5366 
5367         // ...but Tab can still move the chip off a pointer jiggling in place.
5368         f.selected = 0;
5369         f.pointer_moved(x, row_y(2) + 1.0);
5370         assert_eq!(f.selected, 0);
5371 
5372         // Outside the switcher, hovering never moves the selection.
5373         let mut g = FuzzelWidget::new("Search: ".to_string());
5374         g.set_rect(0.0, 0.0, 600.0, 400.0);
5375         g.set_items(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
5376         let (list_y, item_h) = (g.list_y(), g.item_h());
5377         g.pointer_moved(x, list_y + item_h * 2.5);
5378         assert_eq!(g.selected, 0);
5379     }
5380 
5381     #[test]
5382     fn every_system_row_runs_something() {
5383         // A row whose label no longer matches its command silently does
5384         // nothing when picked, so the lookup the commit path makes is the
5385         // thing to pin down.
5386         let mut f = FuzzelWidget::new("Search: ".to_string());
5387         f.set_tabs(vec![
5388             ("Apps".to_string(), Vec::new()),
5389             (
5390                 SYSTEM_TAB_TITLE.to_string(),
5391                 SYSTEM_COMMANDS.iter().map(|c| c.name.to_string()).collect(),
5392             ),
5393         ]);
5394         f.switch_tab(1);
5395         assert_eq!(f.filtered_items.len(), SYSTEM_COMMANDS.len());
5396         for item in &f.filtered_items {
5397             assert!(
5398                 SYSTEM_COMMANDS.iter().any(|c| c.name == item),
5399                 "System row {:?} resolves to no command",
5400                 item
5401             );
5402         }
5403     }
5404 
5405     #[test]
5406     fn test_app_history_sorting() {
5407         let temp_dir = std::path::PathBuf::from("/tmp/cce-cloud-test-cache-dir");
5408         let _ = std::fs::remove_dir_all(&temp_dir);
5409         let _ = std::fs::create_dir_all(&temp_dir);
5410 
5411         let orig_xdg = std::env::var("XDG_CACHE_HOME").ok();
5412         std::env::set_var("XDG_CACHE_HOME", &temp_dir);
5413 
5414         let mut apps = vec![
5415             AppInfo { name: "App A".to_string(), exec: "exec_a".to_string(), terminal: false, icon: None },
5416             AppInfo { name: "App B".to_string(), exec: "exec_b".to_string(), terminal: false, icon: None },
5417             AppInfo { name: "App C".to_string(), exec: "exec_c".to_string(), terminal: false, icon: None },
5418         ];
5419 
5420         // Initially no history, sorted alphabetically.
5421         sort_apps_by_history(&mut apps);
5422         assert_eq!(apps[0].name, "App A");
5423         assert_eq!(apps[1].name, "App B");
5424         assert_eq!(apps[2].name, "App C");
5425 
5426         // Record launch for App B once, and App C twice.
5427         record_app_launch("App B");
5428         std::thread::sleep(std::time::Duration::from_millis(10));
5429         record_app_launch("App C");
5430         std::thread::sleep(std::time::Duration::from_millis(10));
5431         record_app_launch("App C");
5432 
5433         sort_apps_by_history(&mut apps);
5434         // App C (count 2) -> App B (count 1) -> App A (count 0)
5435         assert_eq!(apps[0].name, "App C");
5436         assert_eq!(apps[1].name, "App B");
5437         assert_eq!(apps[2].name, "App A");
5438 
5439         // Record App A launches 3 times to move it to the top.
5440         record_app_launch("App A");
5441         record_app_launch("App A");
5442         record_app_launch("App A");
5443 
5444         sort_apps_by_history(&mut apps);
5445         // App A (count 3) -> App C (count 2) -> App B (count 1)
5446         assert_eq!(apps[0].name, "App A");
5447         assert_eq!(apps[1].name, "App C");
5448         assert_eq!(apps[2].name, "App B");
5449 
5450         // Record App B launch once, now both App B and App C have count 2.
5451         // App B was launched most recently, so it should rank higher than App C.
5452         record_app_launch("App B");
5453         sort_apps_by_history(&mut apps);
5454         // App A (count 3) -> App B (count 2, recent) -> App C (count 2, older)
5455         assert_eq!(apps[0].name, "App A");
5456         assert_eq!(apps[1].name, "App B");
5457         assert_eq!(apps[2].name, "App C");
5458 
5459         // Cleanup
5460         let _ = std::fs::remove_dir_all(&temp_dir);
5461         if let Some(val) = orig_xdg {
5462             std::env::set_var("XDG_CACHE_HOME", val);
5463         } else {
5464             std::env::remove_var("XDG_CACHE_HOME");
5465         }
5466     }
5467 
5468     #[test]
5469     fn test_filter_and_sort_preserving_history() {
5470         let items = vec![
5471             "Firefox".to_string(),
5472             "File Manager".to_string(),
5473             "foo".to_string(),
5474         ];
5475 
5476         let filtered = filter_and_sort_items(&items, "f");
5477         assert_eq!(filtered.len(), 3);
5478         assert_eq!(filtered[0], "Firefox");
5479         assert_eq!(filtered[1], "File Manager");
5480         assert_eq!(filtered[2], "foo");
5481 
5482         let filtered_fi = filter_and_sort_items(&items, "fi");
5483         assert_eq!(filtered_fi.len(), 2);
5484         assert_eq!(filtered_fi[0], "Firefox");
5485         assert_eq!(filtered_fi[1], "File Manager");
5486     }
5487 }
5488 
5489 impl FuzzelWidget {
5490     fn own_labels(&self) -> Vec<TextLabel> {
5491         let mut labels = Vec::new();
5492 
5493         // Tab titles, centred on their segments. Outside the list clip, like
5494         // the rest of the chrome.
5495         for i in 0..self.tabs.len() {
5496             let Some(r) = self.tab_rect(i) else { continue };
5497             let title = &self.tabs[i].title;
5498             let tw = cce_ui::widget::display::measure_text(title, TAB_FONT_PX);
5499             labels.push(TextLabel {
5500                 text: title.clone(),
5501                 x: r.x + (r.width - tw) / 2.0,
5502                 y: r.y + (r.height - TAB_FONT_PX) / 2.0 - 1.0,
5503                 font_size: TAB_FONT_PX,
5504                 color: if i == self.active_tab {
5505                     [0xff, 0xff, 0xff]
5506                 } else if self.tab_hovered == Some(i) {
5507                     [0xe6, 0xe6, 0xee]
5508                 } else {
5509                     [0x99, 0x99, 0xa6]
5510                 },
5511             });
5512         }
5513 
5514         let query_text = if self.query.is_empty() {
5515             format!("{}{}", self.prompt, "Type to search...")
5516         } else {
5517             format!("{}{}", self.prompt, self.query)
5518         };
5519         let query_color = if self.query.is_empty() {
5520             [0x66, 0x66, 0x77]
5521         } else {
5522             [0xcc, 0xff, 0xcc]
5523         };
5524 
5525         labels.push(TextLabel {
5526             text: query_text,
5527             x: self.text_x(),
5528             // The 14px line centred in the well.
5529             y: self.search_y() + (cce_ui::layout::textbox_height() - 17.0) / 2.0,
5530             font_size: 14.0,
5531             color: query_color,
5532         });
5533 
5534         if self.filtered_items.is_empty() {
5535             labels.push(TextLabel {
5536                 text: "No matches found".to_string(),
5537                 x: self.text_x(),
5538                 y: self.list_y() + 4.0,
5539                 font_size: 13.0,
5540                 color: [0x88, 0x88, 0x99],
5541             });
5542         }
5543 
5544         labels
5545     }
5546 
5547     /// Visible item labels — one per row `get_draw_y` places in (or partially
5548     /// in) the viewport. Emitted under the paint walk's list clip, separately
5549     /// from [`Self::own_labels`], which draws chrome outside it.
5550     fn row_labels(&self) -> Vec<TextLabel> {
5551         let item_h = self.item_h();
5552         let mut labels = Vec::new();
5553         for (idx, item_text) in self.filtered_items.iter().enumerate() {
5554             let virtual_y = idx as f32 * item_h;
5555             if let Some(draw_y) = self.scroll_box.get_draw_y(virtual_y, item_h) {
5556                 let color = if idx == self.selected {
5557                     [0xff, 0xff, 0xff]
5558                 } else if self.hovered == Some(idx) {
5559                     // A step toward the selected white, over the hover wash.
5560                     [0xe6, 0xe6, 0xee]
5561                 } else {
5562                     [0xbb, 0xbb, 0xc5]
5563                 };
5564 
5565                 labels.push(TextLabel {
5566                     text: self.row_label(item_text).to_string(),
5567                     // Indented past the icon column whether or not THIS row
5568                     // resolved an icon — see `icon_gutter`.
5569                     x: self.text_x() + self.icon_gutter,
5570                     // 4px down in a text-only row; a taller icon row
5571                     // centres the same line box.
5572                     y: draw_y + (item_h - ITEM_H) / 2.0 + 4.0,
5573                     font_size: 13.0,
5574                     color,
5575                 });
5576             }
5577         }
5578         labels
5579     }
5580 }
5581 
5582 #[cfg(test)]
5583 mod scope_tests {
5584     use super::*;
5585 
5586     #[test]
5587     fn launches_run_in_a_session_bound_scope() {
5588         let argv = scope_argv("/usr/bin/sh", &["-c", "exec cce-files"], 7);
5589         assert_eq!(
5590             argv,
5591             [
5592                 "--user",
5593                 "--scope",
5594                 "--collect",
5595                 "--slice=app.slice",
5596                 "--unit=app-cce\\x2dcloud-cce-files-7",
5597                 "--property=PartOf=cce-session.target",
5598                 "--",
5599                 "/usr/bin/sh",
5600                 "-c",
5601                 "exec cce-files",
5602             ]
5603         );
5604     }
5605 
5606     #[test]
5607     fn a_scope_is_named_after_the_app_not_the_shell() {
5608         assert_eq!(launch_name("sh", &["-c", "cce-mail"]), "cce-mail");
5609         assert_eq!(launch_name("sh", &["-c", "/opt/google/chrome/chrome %U"]), "/opt/google/chrome/chrome");
5610         assert_eq!(launch_name("sh", &["-c", "env GDK_SCALE=1 inkscape"]), "inkscape");
5611         assert_eq!(launch_name("sh", &["-c", "FOO=1 exec 'cce-files'"]), "cce-files");
5612         // A terminal-hosted entry: the app inside the terminal.
5613         assert_eq!(launch_name("foot", &["sh", "-c", "exec htop"]), "htop");
5614         // Not a shell command: the program itself.
5615         assert_eq!(launch_name("ccectl", &["close"]), "ccectl");
5616         assert_eq!(scope_name_part(&launch_name("sh", &["-c", "/opt/1Password/1password"])), "1password");
5617     }
5618 
5619     #[test]
5620     fn unit_names_keep_only_legal_characters() {
5621         assert_eq!(scope_name_part("google-chrome-stable"), "google-chrome-stable");
5622         assert_eq!(scope_name_part("/opt/1Password/1password"), "1password");
5623         assert_eq!(scope_name_part("my app.bin"), "my_app_bin");
5624         assert_eq!(scope_name_part(""), "app");
5625     }
5626 }
5627 
5628 #[cfg(test)]
5629 mod repeat_tests {
5630     use super::key_repeats;
5631     use xkeysym::Keysym as K;
5632 
5633     #[test]
5634     fn text_deletion_and_movement_repeat_but_commits_do_not() {
5635         assert!(key_repeats(K::a, Some("a")));
5636         assert!(key_repeats(K::BackSpace, Some("\u{8}")));
5637         assert!(key_repeats(K::Down, None));
5638         assert!(!key_repeats(K::Return, Some("\r")));
5639         assert!(!key_repeats(K::Escape, Some("\u{1b}")));
5640         assert!(!key_repeats(K::Tab, Some("\t")));
5641         // A modifier or dead key carries no text: nothing to repeat.
5642         assert!(!key_repeats(K::Shift_L, None));
5643     }
5644 }