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 }