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

src/main.rs (187.9K)

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