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