file manager
git clone https://git.lucas.co/cce-files.git
src/main.rs (147.2K)
1 use cce_ui::widget::Handle;
2 use cce_ui::cosmic_text::FontSystem;
3
4 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
5 use cce_ui::widget::{MouseButton, ElementState, MouseScrollDelta, KeyEvent, WidgetHost, PageSelector, Paginator, MenuController};
6 use cce_ui::widget::{GraphController, PathController};
7
8 use notify::{Watcher, RecommendedWatcher, RecursiveMode, Config};
9
10 use cce_files::{Message, pages, services};
11 use cce_files::pages::Page;
12 use cce_files::pages::browse::is_project_dir;
13
14 // ── Layout constants ────────────────────────────────────────────────
15
16 const ROW_H: f32 = 24.0; // context-menu row height
17 const DIALOG_W: f32 = 400.0;
18 const DIALOG_H: f32 = 160.0;
19 const MENU_MIN_W: f32 = 120.0;
20
21 /// Context-menu width/height for a given set of options.
22 ///
23 /// The width comes from the SHAPED widest label in the menu font — the same
24 /// face [`cce_ui::widget::context_menu`] uses for the toolkit's own menus.
25 /// The old `bytes * 7.5` estimate belonged to neither face, so the plate was
26 /// sized for a font nothing here draws in.
27 fn context_menu_size(options: &[(String, Option<Message>)]) -> (f32, f32) {
28 let (family, size) = cce_ui::widget::context_menu::label_font();
29 let widest = options
30 .iter()
31 .map(|(s, _)| cce_ui::widget::display::measure_text_width(s, &family, size))
32 .fold(0.0f32, f32::max);
33 // TODO(style): 24 is the menu's own label side-padding (12 a side), a
34 // control-level number the toolkit's context_menu should supply.
35 let w = (widest + 24.0).max(MENU_MIN_W);
36 let h = options.len() as f32 * ROW_H;
37 (w, h)
38 }
39
40 /// Computed rects for the prompt modal ("Open with…" / "Rename"), so layout
41 /// and hit-testing agree.
42 struct PromptRects {
43 x: f32,
44 y: f32,
45 w: f32,
46 h: f32,
47 tb: (f32, f32, f32, f32),
48 cancel: (f32, f32, f32, f32),
49 ok: (f32, f32, f32, f32),
50 }
51
52 /// What a [`PromptDialog`] submits to.
53 #[derive(Clone, Copy, PartialEq, Eq)]
54 enum PromptKind {
55 /// Run the typed command on `path`.
56 OpenWith,
57 /// Rename `path` to the typed name, in place.
58 Rename,
59 }
60
61 impl PromptKind {
62 fn title(self) -> &'static str {
63 match self {
64 PromptKind::OpenWith => "Open with...",
65 PromptKind::Rename => "Rename",
66 }
67 }
68 fn prompt(self) -> &'static str {
69 match self {
70 PromptKind::OpenWith => "Enter command:",
71 PromptKind::Rename => "New name:",
72 }
73 }
74 fn ok_label(self) -> &'static str {
75 match self {
76 PromptKind::OpenWith => "Open",
77 PromptKind::Rename => "Rename",
78 }
79 }
80 }
81
82 /// The single-line modal prompt: one text well, Cancel + an OK button.
83 struct PromptDialog {
84 kind: PromptKind,
85 path: std::path::PathBuf,
86 textbox: Handle<cce_ui::widget::Adapted<cce_ui::widget::TextBox>>,
87 }
88
89 fn prompt_rects(win_w: f32, win_h: f32) -> PromptRects {
90 let x = (win_w - DIALOG_W) / 2.0;
91 let y = (win_h - DIALOG_H) / 2.0;
92 let tb_h = cce_ui::layout::textbox_height();
93 let btn_h = cce_ui::layout::button_height();
94 // The dialog is a pane plate standing on the root: its content insets
95 // from the rim by the pane rung, and its two buttons sit one pane gap
96 // apart, right-aligned to that inset.
97 let pad = cce_ui::layout::plate_padding();
98 let gap = cce_ui::layout::plate_gap();
99 let (cancel_w, open_w) = (70.0, 80.0);
100 let btn_y = y + DIALOG_H - btn_h - pad;
101 let open_x = x + DIALOG_W - pad - open_w;
102 PromptRects {
103 x,
104 y,
105 w: DIALOG_W,
106 h: DIALOG_H,
107 // TODO(style): the 60px drop to the well (title, then the prompt at
108 // 42) is the dialog's own text rhythm, not a rung.
109 tb: (x + pad, y + 60.0, DIALOG_W - 2.0 * pad, tb_h),
110 cancel: (open_x - gap - cancel_w, btn_y, cancel_w, btn_h),
111 ok: (open_x, btn_y, open_w, btn_h),
112 }
113 }
114
115 /// The overlap of two rects (zero-sized where they do not meet).
116 fn intersect_rect(a: cce_ui::scene::layout::Rect, b: cce_ui::scene::layout::Rect) -> cce_ui::scene::layout::Rect {
117 let x0 = a.x.max(b.x);
118 let y0 = a.y.max(b.y);
119 let x1 = (a.x + a.width).min(b.x + b.width);
120 let y1 = (a.y + a.height).min(b.y + b.height);
121 cce_ui::scene::layout::Rect { x: x0, y: y0, width: (x1 - x0).max(0.0), height: (y1 - y0).max(0.0) }
122 }
123
124 /// Clip a vertical span `[y, y+h)` to the viewport `[top, bottom)`.
125 /// Returns the clipped `(y, h)`, or `None` if fully outside.
126 fn clip_to_viewport(y: f32, h: f32, top: f32, bottom: f32) -> Option<(f32, f32)> {
127 if y >= bottom || y + h <= top {
128 return None;
129 }
130 let mut ny = y;
131 let mut nh = h;
132 if ny < top {
133 let diff = top - ny;
134 ny = top;
135 nh = (nh - diff).max(0.0);
136 }
137 if ny + nh > bottom {
138 nh = (bottom - ny).max(0.0);
139 }
140 Some((ny, nh))
141 }
142
143 /// Which side of an occluder a box's surviving span lies on, and where the cut
144 /// falls — see [`wider_side`].
145 enum Side {
146 /// Keep what lies before the occluder: clamp the box's far edge to this.
147 Before(f32),
148 /// Keep what lies after it: clamp the box's near edge to this.
149 After(f32),
150 }
151
152 /// On one axis, which side of the occluder leaves more of the box — the piece
153 /// worth keeping when only one can be. `None` when the occluder covers the span
154 /// outright and neither side survives.
155 fn wider_side(t_min: f32, t_max: f32, o_min: f32, o_max: f32) -> Option<Side> {
156 let before = o_min - t_min;
157 let after = t_max - o_max;
158 if before <= 0.0 && after <= 0.0 {
159 return None;
160 }
161 if before >= after {
162 Some(Side::Before(o_min))
163 } else {
164 Some(Side::After(o_max))
165 }
166 }
167
168 /// Clip a text/label box against overlay rects so it does not bleed through
169 /// popovers/menus/dialogs. Returns the adjusted clip bounds, or `None` if the
170 /// box is fully covered (should be discarded).
171 fn occlude_against(
172 mut bounds: [f32; 4],
173 t_min_x: f32,
174 t_max_x: f32,
175 t_min_y: f32,
176 t_max_y: f32,
177 overlays: &[&pages::PageContent],
178 ) -> Option<[f32; 4]> {
179 for overlay_pc in overlays {
180 // Plates occlude exactly as rects do — an overlay's opaque face is its
181 // plate now, not a flat fill, and a face missing from this sweep lets the
182 // page's text bleed through the surface covering it.
183 let faces = overlay_pc
184 .rects
185 .iter()
186 .map(|(_, x, y, w, h, _, _)| (*x, *y, *w, *h))
187 .chain(overlay_pc.plates.iter().map(|(_, x, y, w, h, _, _)| (*x, *y, *w, *h)));
188 for (ox, oy, ow, oh) in faces {
189 let o_min_x = ox;
190 let o_max_x = ox + ow;
191 let o_min_y = oy;
192 let o_max_y = oy + oh;
193
194 if t_max_x > o_min_x && t_min_x < o_max_x && t_max_y > o_min_y && t_min_y < o_max_y {
195 if t_min_x >= o_min_x && t_max_x <= o_max_x && t_min_y >= o_min_y && t_max_y <= o_max_y {
196 return None;
197 }
198 // Cut on ONE axis, on ONE side. The result of subtracting a rect
199 // from a rect is not a rect, so this picks the largest piece that
200 // is: trim x, unless the occluder already spans the box
201 // horizontally and only a y cut can uncover anything.
202 //
203 // Clamping every side independently is what this used to do, and
204 // two clamps on one axis cross over into an inverted, empty band —
205 // so a box the occluder merely dipped into vanished whole, the
206 // part nothing covered along with the rest. That is how hovering a
207 // context-menu row erased the file-list row beside it: the hover
208 // fill's top edge landed inside that row's text band, and the y
209 // clamp it triggered threw away the name sitting well clear of the
210 // menu.
211 if o_min_x <= t_min_x && o_max_x >= t_max_x {
212 match wider_side(t_min_y, t_max_y, o_min_y, o_max_y) {
213 Some(Side::Before(cut)) => bounds[3] = bounds[3].min(cut),
214 Some(Side::After(cut)) => bounds[1] = bounds[1].max(cut),
215 None => return None,
216 }
217 } else {
218 match wider_side(t_min_x, t_max_x, o_min_x, o_max_x) {
219 Some(Side::Before(cut)) => bounds[2] = bounds[2].min(cut),
220 Some(Side::After(cut)) => bounds[0] = bounds[0].max(cut),
221 None => return None,
222 }
223 }
224 }
225 }
226 }
227 Some(bounds)
228 }
229
230 /// Height of the chooser-mode bottom action bar (the band carved into the plate).
231 const SELECT_BAR_H: f32 = 48.0;
232
233 // ── State ───────────────────────────────────────────────────────────
234
235 /// How a flat quad renders under the SDF-lit plate system. `Flat` is the plain
236 /// fill; the relief variants carry the roll/carve depth in px.
237 #[derive(Clone, PartialEq)]
238 enum WidgetFx {
239 Flat,
240 /// Edges-only raised plateau over whatever is painted below.
241 Boss(f32),
242 /// A lit plate: a rounded face in the quad's own color plus the rolled, lit
243 /// perimeter, at full size. What the pane plates and the preview stub wear.
244 /// Distinct from [`WidgetFx::Boss`], which is a rim with no face of its own.
245 Plate(f32),
246 /// Edges-only carve into whatever is painted below (recessed wells).
247 Recess(f32),
248 /// [`WidgetFx::Recess`] with pointer focus: the tinted carve — the wrapped
249 /// accent glint REPLACING the relief lighting (the DE's one focus
250 /// language; the shader drops the diffuse/curvature terms for tinted
251 /// carves, so the ring is all that shows).
252 RecessFocus(f32),
253 /// A flush inset control (buttons): groove ring carved down around the
254 /// rect, beveled lip back up inside, face level with the surface.
255 Inset(f32),
256 /// [`WidgetFx::Inset`] with keyboard focus: the rim lit in the highlight —
257 /// the ring a focused control plate wears.
258 InsetFocus(f32),
259 /// A GPU-textured quad; the id comes from `cce_ui::vk::upload_rgba`
260 /// (the preview pane's image). `color` is unused.
261 Image { id: u32, alpha: f32 },
262 /// A cce-icons glyph tinted with the widget's `color` (`PaintCtx::icon`),
263 /// cut to `clip` when it has one.
264 Icon { name: String, clip: Option<cce_ui::scene::layout::Rect> },
265 /// A widget's stroke, arc or disc (a graph's wire or port) in the
266 /// widget's `color`, cut to `clip` when it has one; the rect is its
267 /// bounding box, for culling.
268 Stroke { shape: crate::pages::Stroke, clip: Option<cce_ui::scene::layout::Rect> },
269 /// A line engraved from (ax, ay) to (bx, by) into the widget's rect, which
270 /// is the HOST surface here rather than the mark's own bounds — the shading
271 /// fades out across the host's rolled edge. The breadcrumb's slanted seams;
272 /// the only quad in this list that is not axis-aligned.
273 Groove { ax: f32, ay: f32, bx: f32, by: f32, width: f32, depth: f32 },
274 }
275
276 struct AppWidget {
277 x: f32,
278 y: f32,
279 w: f32,
280 h: f32,
281 color: [f32; 4],
282 radius: f32,
283 corners: (bool, bool, bool, bool),
284 fx: WidgetFx,
285 }
286
287 #[derive(Clone)]
288 struct ContextMenu {
289 visible: bool,
290 x: f32,
291 y: f32,
292 w: f32,
293 h: f32,
294 options: Vec<(String, Option<Message>)>,
295 hovered: Option<usize>,
296 }
297
298 /// A row of the breadcrumb's context menu that the app runs itself.
299 #[derive(Clone, Debug, PartialEq)]
300 enum BreadcrumbRow {
301 CopyPath(String),
302 View(Page),
303 }
304
305 /// The breadcrumb's context menu while it is open: which show of the shared
306 /// menu it is (`context_menu::generation`), so a stale one never runs, and
307 /// what each row does (`None` for the header and the separator).
308 struct BreadcrumbMenu {
309 generation: u64,
310 rows: Vec<Option<BreadcrumbRow>>,
311 }
312
313 /// The breadcrumb menu below its header: Copy Path, then the view switch —
314 /// a dropdown beside the breadcrumb until 2026-10-06 — offering the views
315 /// other than the one showing. Not all three with the current one marked:
316 /// the menu face has no radio or check glyph (a "●" drew as a blank), and
317 /// the page itself says which view it is.
318 fn breadcrumb_menu_rows(path: String, current: Page) -> (Vec<String>, Vec<Option<BreadcrumbRow>>) {
319 let mut labels = vec!["Copy Path".to_string(), "-".to_string()];
320 let mut rows = vec![Some(BreadcrumbRow::CopyPath(path)), None];
321 for page in Page::ALL.into_iter().filter(|&p| p != current) {
322 labels.push(page.view_label().to_string());
323 rows.push(Some(BreadcrumbRow::View(page)));
324 }
325 (labels, rows)
326 }
327
328 /// The context menu for one path — a Space tile or a graph node — header
329 /// first. A directory opens by navigating to it, as a double-click does;
330 /// `openable` false leaves Open out (the directory already showing).
331 fn path_menu(name: &str, path: std::path::PathBuf, is_dir: bool, openable: bool) -> Vec<(String, Option<Message>)> {
332 let kind = if is_dir { "Directory" } else { "File" };
333 let mut options = vec![(format!("[{kind}] {name}"), None)];
334 let copy = Some(Message::CopyPath(path.to_string_lossy().into_owned()));
335 if is_dir {
336 if openable {
337 options.push(("Open".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(path)))));
338 }
339 } else {
340 options.push(("Open".to_string(), Some(Message::OpenFile(path.clone()))));
341 options.push(("Open with...".to_string(), Some(Message::PromptOpenWith(path))));
342 }
343 options.push(("Copy Path".to_string(), copy));
344 options
345 }
346
347 /// The Space map's context menu for `tile`. The root tile is the map's own
348 /// directory, so it offers nothing to open. A "smaller items" block carries
349 /// its directory's path, so its rows name the folder they act on.
350 fn space_tile_menu(tile: &pages::space::Tile) -> Vec<(String, Option<Message>)> {
351 if tile.rest > 0 {
352 let path = tile.path.clone();
353 return vec![
354 (tile.name.clone(), None),
355 ("Open Folder".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(path.clone())))),
356 ("Copy Folder Path".to_string(), Some(Message::CopyPath(path.to_string_lossy().into_owned()))),
357 ];
358 }
359 path_menu(&tile.name, tile.path.clone(), tile.is_dir, tile.depth > 0)
360 }
361
362 /// The graph's context menu for node `idx`: the parent (when there is one),
363 /// then the directory showing, then `entries` in order — the order
364 /// `NetworkState::populate_graph` lays them in.
365 fn graph_node_menu(idx: usize, current_dir: &std::path::Path, entries: &[pages::browse::DirEntry]) -> Option<Vec<(String, Option<Message>)>> {
366 let dir_name = |p: &std::path::Path| p.file_name().map_or_else(|| "/".to_string(), |n| n.to_string_lossy().into_owned());
367 let parent = current_dir.parent();
368 let offset = if parent.is_some() { 2 } else { 1 };
369 match (parent, idx) {
370 (Some(parent), 0) => Some(path_menu(&dir_name(parent), parent.to_path_buf(), true, true)),
371 _ if idx + 1 == offset => Some(path_menu(&dir_name(current_dir), current_dir.to_path_buf(), true, false)),
372 _ => {
373 let entry = entries.get(idx.checked_sub(offset)?)?;
374 Some(path_menu(&entry.name, entry.path.clone(), entry.is_dir, true))
375 }
376 }
377 }
378
379 /// App-owned two-pane horizontal split, replacing the dissolved `SplitBox` +
380 /// `BrowseContainer`/`NetworkContainer` shims (Phase 6y). Those existed to (a) position
381 /// pane content — but the pages already lay out and render everything from the pane rect,
382 /// the container copies just coincided (the Phase 0 double-paint) — and (b) own the
383 /// divider: its quad, hover tint, and proportion drag. This is (b), app-side, with the
384 /// `SplitBox` two-child horizontal math verbatim.
385 /// A file as `stat` sees it: what decides whether a preview already read
386 /// is still the file's. The link itself and, through it, its target — a
387 /// symlink to a file being rewritten keeps its own stamp.
388 #[derive(Debug, Clone, PartialEq, Eq)]
389 struct PreviewStamp {
390 link: Option<(u64, u64, std::time::SystemTime)>,
391 target: Option<(u64, u64, std::time::SystemTime)>,
392 }
393
394 impl PreviewStamp {
395 fn of(path: &std::path::Path) -> Self {
396 use std::os::unix::fs::MetadataExt;
397 let key = |m: std::fs::Metadata| (m.ino(), m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH));
398 Self {
399 link: std::fs::symlink_metadata(path).ok().map(key),
400 target: std::fs::metadata(path).ok().map(key),
401 }
402 }
403 }
404
405 /// Width of the preview pane's column while collapsed to its title stub:
406 /// room for the label and the corner control that restores it.
407 const PREVIEW_STUB_W: f32 = 170.0;
408
409 struct SplitPane {
410 x: f32,
411 y: f32,
412 w: f32,
413 h: f32,
414 /// Left pane's share of the space (proportions summed to 1.0 in the legacy SplitBox).
415 frac: f32,
416 min_left: f32,
417 min_right: f32,
418 gap: f32,
419 dragging: bool,
420 hovered: bool,
421 }
422
423 impl SplitPane {
424 fn new(frac: f32, min_left: f32, min_right: f32, gap: f32) -> Self {
425 Self { x: 0.0, y: 0.0, w: 0.0, h: 0.0, frac, min_left, min_right, gap, dragging: false, hovered: false }
426 }
427
428 fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
429 self.x = x;
430 self.y = y;
431 self.w = w;
432 self.h = h;
433 }
434
435 fn left_w(&self) -> f32 {
436 self.frac * (self.w - self.gap).max(0.0)
437 }
438
439 fn left_rect(&self) -> (f32, f32, f32, f32) {
440 (self.x, self.y, self.left_w(), self.h)
441 }
442
443 fn right_rect(&self) -> (f32, f32, f32, f32) {
444 let lx = self.x + self.left_w() + self.gap;
445 (lx, self.y, (self.x + self.w - lx).max(0.0), self.h)
446 }
447
448 fn divider_rect(&self) -> (f32, f32, f32, f32) {
449 (self.x + self.left_w(), self.y, self.gap, self.h)
450 }
451
452 fn hit_divider(&self, px: f32, py: f32) -> bool {
453 let (sx, sy, sw, sh) = self.divider_rect();
454 px >= sx && px <= sx + sw && py >= sy && py <= sy + sh
455 }
456
457 /// Divider drag + hover (`SplitBox::on_cursor_moved`, two-child horizontal case).
458 fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
459 let mut handled = false;
460 if self.dragging {
461 let combined = (self.w - self.gap).max(0.0);
462 if combined > 0.1 {
463 let new_left = (px - self.x - self.gap / 2.0)
464 .clamp(self.min_left, (combined - self.min_right).max(self.min_left));
465 let new_frac = new_left / combined;
466 if (new_frac - self.frac).abs() > 0.0001 {
467 self.frac = new_frac;
468 handled = true;
469 }
470 }
471 }
472 let new_hovered = !self.dragging && self.hit_divider(px, py);
473 if new_hovered != self.hovered {
474 self.hovered = new_hovered;
475 handled = true;
476 }
477 handled
478 }
479
480 /// Left press on the divider grabs it (`SplitBox::mouse_input`).
481 fn press(&mut self, px: f32, py: f32) -> bool {
482 if self.hit_divider(px, py) {
483 self.dragging = true;
484 return true;
485 }
486 false
487 }
488
489 /// Returns whether a drag was in progress (the legacy release consumed the event).
490 fn release(&mut self) -> bool {
491 std::mem::take(&mut self.dragging)
492 }
493
494 /// The divider's mark (`SplitBox::extra_quads`): accent while dragging, tint
495 /// on hover, and NOTHING at rest.
496 ///
497 /// At rest the gap is left as bare window plate, which is what the panes
498 /// either side already establish — a strip of surface between two plates,
499 /// with no line drawn down it. The 1px hairline that used to sit here was
500 /// the last painted separator in a UI that is otherwise all lit geometry,
501 /// and against 4 logical px of clearance it read as a mark on a plain
502 /// rather than as a seam.
503 ///
504 /// Note what this deliberately does NOT do: it does not make the divider
505 /// look like the preview|details seam. That seam's floor measures ~79
506 /// against this plate's 96 — it is genuinely RECESSED — and removing marks
507 /// cannot recess a flush gap. Matching it would take a carve. (For the
508 /// opposite mistake, see the reverted `Prim::Ridge` bead in this file's
509 /// history: a crest where that seam has a trough.)
510 fn divider_quad(&self) -> Option<(f32, f32, f32, f32, [f32; 4])> {
511 let (sx, sy, sw, sh) = self.divider_rect();
512 let color = if self.dragging {
513 [0.36, 0.56, 0.38, 0.8]
514 } else if self.hovered {
515 [0.25, 0.25, 0.32, 0.6]
516 } else {
517 return None;
518 };
519 Some((sx + sw / 2.0 - 1.0, sy, 2.0, sh, color))
520 }
521 }
522
523
524 /// Browse-page shortcuts, resolved once at startup from input.kdl
525 /// (`cce-files` domain → `cce-ui` domain), defaulting to vim-ish j/k for
526 /// selection and h/l (parent/enter) for directories. Arrow keys,
527 /// Enter-in-save-mode, and Escape stay fixed.
528 struct BrowseKeys {
529 open_file: String,
530 enter_dir: String,
531 parent_dir: String,
532 select_next: String,
533 select_prev: String,
534 delete_entry: String,
535 toggle_hidden: String,
536 }
537
538 impl BrowseKeys {
539 fn load() -> Self {
540 let get = cce_ui::input::app_chord;
541 Self {
542 open_file: get("open_file", "enter"),
543 enter_dir: get("enter_dir", "l"),
544 parent_dir: get("parent_dir", "h"),
545 select_next: get("select_next", "j"),
546 select_prev: get("select_prev", "k"),
547 delete_entry: get("delete_entry", "delete"),
548 toggle_hidden: get("toggle_hidden", "."),
549 }
550 }
551 }
552
553 /// Which recessed well holds pointer focus — the one wearing the accent ring
554 /// (its relief lighting swapped for the tinted carve's wrapped glint, the
555 /// same focus language as plates). Follows the last left press that reaches
556 /// page content; the content well (list / treemap) starts focused.
557 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
558 enum FocusedWell {
559 Content,
560 PreviewTop,
561 PreviewBottom,
562 }
563
564 struct FilesystemApp {
565 current_page: Page,
566 browse: pages::browse::BrowseState,
567 network: pages::network::NetworkState,
568 space: pages::space::SpaceState,
569 preview: cce_files::preview_pane::PreviewPane,
570 /// What the preview was last asked for: the path, and the file as it
571 /// stood then. Every browse message — a watcher refresh, a search
572 /// keystroke — used to ask again, and each ask decodes and resizes an
573 /// image and spawns `xdg-mime` twice; with a download growing beside a
574 /// selected photo that was a full decode per refresh. Now a request goes
575 /// out only when this changes.
576 preview_requested: Option<(std::path::PathBuf, PreviewStamp)>,
577 /// Generation of the newest preview request; a `PreviewLoaded` from any
578 /// other is a selection the user has already moved past.
579 preview_generation: u64,
580 /// See [`FocusedWell`]; drives the per-well `focused` flags each rebuild.
581 focused_well: FocusedWell,
582
583 // Command-line chooser options
584 select_mode: bool,
585 select_directory: bool,
586 save_mode: bool,
587
588 // Rendering resources
589 widgets: Vec<AppWidget>,
590 // (content, font_size, x, y, color, font, bounds) — occlusion-adjusted text tuples,
591 // emitted as display-list Text prims.
592 texts: Vec<(String, f32, f32, f32, [f32; 4], Option<String>, Option<[f32; 4]>)>,
593 font_system: FontSystem,
594 needs_rebuild: bool,
595 /// What the last rebuild's widgets claimed for text input (an editing
596 /// TextBox, `cce_ui::text_input::capture`). Claimed again every frame:
597 /// a frame that only replays the cached layout paints no widget, and
598 /// would otherwise read as the field closing.
599 text_claim: Option<[f32; 4]>,
600 width: u32,
601 height: u32,
602 scale_factor: f64,
603 page_buttons: Vec<(cce_ui::widget::Adapted<cce_ui::widget::Button>, Message)>,
604 hovered_button: Option<usize>,
605 cursor_x: f32,
606 cursor_y: f32,
607 paginator: Handle<cce_ui::widget::Adapted<Paginator>>,
608 just_initialized: bool,
609 ui_context: cce_ui::context::UiContext,
610 watcher: Option<notify::RecommendedWatcher>,
611 fs_service: services::fs::FsService,
612 /// Whether a renderer has been handed over yet — the first one is the
613 /// process's own, any later one is a replacement after a reconnect. See
614 /// `renderer_init`.
615 seen_renderer: bool,
616 context_menu: ContextMenu,
617 prompt_dialog: Option<PromptDialog>,
618 browse_split: SplitPane,
619 network_split: SplitPane,
620 space_split: SplitPane,
621 /// The preview pane's plate-dock state (cce-ui RFC 7c-2): collapsing
622 /// narrows the pane column to a title stub and the list keeps the space.
623 preview_dock: cce_ui::widget::plate_dock::PlateDockState,
624 /// The three splits' fracs as they stood before a collapse, restored on
625 /// expand.
626 preview_prior_fracs: Option<(f32, f32, f32)>,
627 /// Rows of the OPEN plate-dock corner menu (empty = not ours); routed
628 /// before the generic context-menu dispatch.
629 plate_menu_actions: Vec<cce_ui::widget::plate_dock::PlateDockAction>,
630 /// The breadcrumb's context menu when it is the one open; routed, like
631 /// the plate-dock rows, before the generic context-menu dispatch.
632 breadcrumb_menu: Option<BreadcrumbMenu>,
633 // Space's double-click is tracked by path, not row index: its tiles are
634 // renumbered by every relayout, so an index would not survive a resize.
635 last_space_click_time: std::time::Instant,
636 last_space_path: Option<std::path::PathBuf>,
637 last_click_time: std::time::Instant,
638 last_clicked_idx: Option<usize>,
639 keys: BrowseKeys,
640 }
641
642
643 // ── Layout Rebuild ──────────────────────────────────────────────────
644
645 impl FilesystemApp {
646 /// Show `dialog` in place of the prompt that is up, if any, whose text box
647 /// leaves the context with it.
648 fn set_prompt(&mut self, dialog: Option<PromptDialog>) {
649 if let Some(old) = std::mem::replace(&mut self.prompt_dialog, dialog) {
650 self.ui_context.remove(old.textbox);
651 }
652 }
653
654 /// Kick off a subtree scan if the Space page is showing a directory it has
655 /// not scanned. Cheap to call — it no-ops off the Space page, and while a
656 /// scan for the same directory is already running.
657 fn ensure_space_scan(&mut self) {
658 if self.current_page != Page::Space {
659 return;
660 }
661 let dir = self.browse.current_dir.clone();
662 if !self.space.needs_scan(&dir) {
663 return;
664 }
665 let cancel = self.space.begin_scan(&dir);
666 self.fs_service.send(services::fs::FsRequest::ScanTree(dir, cancel));
667 }
668
669 /// Show the app's context menu at (x, y). Row 0 of `options` is the
670 /// non-interactive header.
671 fn open_context_menu(&mut self, x: f32, y: f32, options: Vec<(String, Option<Message>)>) {
672 let (w, h) = context_menu_size(&options);
673 self.context_menu = ContextMenu { visible: true, x, y, w, h, options, hovered: None };
674 }
675
676 /// Ask for `path`'s preview unless that is what was last asked for and
677 /// the file has not changed since. `force` asks regardless (the texture
678 /// was lost with its renderer).
679 fn request_preview(&mut self, path: std::path::PathBuf, force: bool) {
680 let stamp = PreviewStamp::of(&path);
681 if !force
682 && self
683 .preview_requested
684 .as_ref()
685 .is_some_and(|(p, st)| *p == path && *st == stamp)
686 {
687 return;
688 }
689 self.preview_requested = Some((path.clone(), stamp));
690 let generation = self.next_preview_generation();
691 self.fs_service.send(services::fs::FsRequest::ReadPreview(path, generation));
692 }
693
694 /// Empty the preview pane. Also retires whatever request is in flight,
695 /// so a slow read cannot fill the pane back in after it was cleared.
696 fn clear_preview(&mut self) {
697 self.preview_requested = None;
698 self.next_preview_generation();
699 pages::preview::update(&mut self.preview, pages::preview::PreviewMessage::Clear);
700 }
701
702 fn next_preview_generation(&mut self) -> u64 {
703 self.preview_generation += 1;
704 self.fs_service
705 .preview_latest
706 .store(self.preview_generation, std::sync::atomic::Ordering::Relaxed);
707 self.preview_generation
708 }
709
710 fn start_watching(&mut self, path: std::path::PathBuf) {
711 use tokio::sync::mpsc;
712 use std::time::Duration;
713
714 let (tx, mut rx) = mpsc::channel::<()>(100);
715 let fs_service = self.fs_service.sender.clone();
716 let path_clone = path.clone();
717
718 tokio::spawn(async move {
719 while rx.recv().await.is_some() {
720 tokio::time::sleep(Duration::from_millis(150)).await;
721 while rx.try_recv().is_ok() {}
722
723 let _ = fs_service.send(services::fs::FsRequest::RefreshDirectory(path_clone.clone())).await;
724 }
725 });
726
727 let mut watcher = match RecommendedWatcher::new(
728 move |res: Result<notify::Event, notify::Error>| {
729 if let Ok(event) = res {
730 match event.kind {
731 notify::EventKind::Create(_) | notify::EventKind::Modify(_) | notify::EventKind::Remove(_) => {
732 let _ = tx.try_send(());
733 }
734 _ => {}
735 }
736 }
737 },
738 Config::default(),
739 ) {
740 Ok(w) => w,
741 Err(e) => {
742 log::error!("Failed to create watcher: {:?}", e);
743 return;
744 }
745 };
746
747 if let Err(e) = watcher.watch(&path, RecursiveMode::NonRecursive) {
748 log::error!("Failed to watch path {}: {:?}", path.display(), e);
749 return;
750 }
751
752 self.watcher = Some(watcher);
753 }
754
755 /// The rect the preview pane's corner control anchors to: the full pane,
756 /// or its title-stub band while stubbed (cce-ui RFC 7c-2).
757 fn preview_dock_band(&self) -> (f32, f32, f32, f32) {
758 let split = match self.current_page {
759 Page::Browse => &self.browse_split,
760 Page::Network => &self.network_split,
761 Page::Space => &self.space_split,
762 };
763 let (rx, ry, rw, rh) = split.right_rect();
764 if self.preview_dock.stubbed() {
765 (rx, ry, rw, cce_ui::widget::plate_dock::STUB_H)
766 } else {
767 (rx, ry, rw, rh)
768 }
769 }
770
771 fn dispatch_preview_dock(&mut self, action: cce_ui::widget::plate_dock::PlateDockAction) {
772 use cce_ui::widget::plate_dock::PlateDockAction;
773 match action {
774 PlateDockAction::Collapse => {
775 self.preview_prior_fracs =
776 Some((self.browse_split.frac, self.network_split.frac, self.space_split.frac));
777 self.preview_dock.collapsed = true;
778 }
779 PlateDockAction::Expand => {
780 if let Some((b, n, sp)) = self.preview_prior_fracs.take() {
781 self.browse_split.frac = b;
782 self.network_split.frac = n;
783 self.space_split.frac = sp;
784 }
785 self.preview_dock.collapsed = false;
786 }
787 // No detach model here (yet): standard_menu is called with
788 // can_detach = false, so these rows never appear.
789 PlateDockAction::Detach | PlateDockAction::Reattach => {}
790 }
791 self.needs_rebuild = true;
792 }
793
794 /// Lay the window out and flatten it into the cached frame, keeping what
795 /// its widgets claimed for text input (`text_claim`).
796 fn rebuild_layout(&mut self) {
797 let ((), claim) = cce_ui::text_input::capture(|| self.rebuild_layout_inner());
798 self.text_claim = claim;
799 }
800
801 fn rebuild_layout_inner(&mut self) {
802
803 // Well focus → the emitters (each picks the tinted carve when set).
804 self.browse.list.focused = self.focused_well == FocusedWell::Content;
805 self.space.focused = self.focused_well == FocusedWell::Content;
806 self.preview.focused_well = match self.focused_well {
807 FocusedWell::PreviewTop => Some(cce_files::preview_pane::PreviewWell::Top),
808 FocusedWell::PreviewBottom => Some(cce_files::preview_pane::PreviewWell::Bottom),
809 FocusedWell::Content => None,
810 };
811
812 self.ui_context.clear_hierarchy();
813 self.ui_context[self.browse.save_name_box].prepare_text(&mut self.font_system);
814 self.ui_context[self.browse.search_box].prepare_text(&mut self.font_system);
815
816 let mut widgets = Vec::new();
817 let mut texts = Vec::new();
818
819 cce_ui::widget::hover_animation::reset_frame_registration();
820 cce_ui::widget::hover_animation::set_cursor_pos(self.cursor_x, self.cursor_y);
821
822 // root plate container DISSOLVED: top-level widgets register parentless below; the
823 // window plate tuple is emitted in the legacy aggregate order (after the plain
824 // child quads).
825
826 // Clear all widgets' hierarchy links
827 self.ui_context.lend_h(self.paginator, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
828
829
830 self.ui_context.lend_h(self.browse.save_name_box, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
831 self.ui_context.lend_h(self.browse.breadcrumb, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
832 self.ui_context.lend_h(self.network.breadcrumb, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
833 self.ui_context.lend_h(self.network.graph, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
834 self.ui_context.lend_h(self.space.breadcrumb, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
835 if let Some(tb) = self.prompt_dialog.as_ref().map(|d| d.textbox) {
836 self.ui_context.lend_h(tb, |w, ctx| { w.clear_children(ctx); w.set_parent(None, ctx); });
837 }
838
839 let has_sidebar = false;
840 let sidebar_w = if has_sidebar { self.ui_context[self.paginator].sidebar_w() } else { 0.0 };
841 // Inset from the WINDOW edge: the root plate's roll plus its padding
842 // (the padding alone left most of the run on the roll, so the edge
843 // read narrower than the gap between the panes).
844 let pad = cce_ui::layout::root_plate_inset();
845 let browse_x = if has_sidebar { sidebar_w + pad + 1.0 } else { pad };
846 let usable_w = self.width as f32 - sidebar_w - (if has_sidebar { 1.0 } else { 0.0 }) - 2.0 * pad;
847 let content_y = pad;
848
849 let select_bar_h = SELECT_BAR_H;
850 let content_h = if self.select_mode {
851 self.height as f32 - 2.0 * pad - select_bar_h
852 } else {
853 self.height as f32 - 2.0 * pad
854 };
855
856 if has_sidebar {
857 self.ui_context.lend_h(self.paginator, |w, ctx| w.set_parent(None, ctx));
858 }
859
860 // SplitBox + pane containers DISSOLVED (Phase 6y): the split is app state; the
861 // pages lay out and render the left pane's content from the pane rect (they
862 // always did — the container copies just coincided), the preview renders into
863 // the right pane below.
864 match self.current_page {
865 Page::Browse => self.browse_split.set_rect(browse_x, content_y, usable_w, content_h),
866 Page::Network => self.network_split.set_rect(browse_x, content_y, usable_w, content_h),
867 Page::Space => self.space_split.set_rect(browse_x, content_y, usable_w, content_h),
868 }
869 // Collapsed preview (plate-dock, cce-ui RFC 7c-2): every page's pane
870 // column narrows to the stub's width — re-forced each layout so a
871 // window resize keeps the stub fixed while the list takes the rest.
872 if self.preview_dock.collapsed {
873 for split in [&mut self.browse_split, &mut self.network_split, &mut self.space_split] {
874 let combined = (split.w - split.gap).max(1.0);
875 split.frac = (1.0 - PREVIEW_STUB_W / combined).clamp(0.0, 1.0);
876 }
877 }
878
879 if let Some(tb) = self.prompt_dialog.as_ref().map(|d| d.textbox) {
880 self.ui_context.lend_h(tb, |w, ctx| w.set_parent(None, ctx));
881 }
882
883 // Layout widgets recursively inside the parent space
884 let mut dummy_pc = pages::PageContent::new();
885 if has_sidebar {
886 let page_idx = Page::ALL.iter().position(|&p| p == self.current_page).unwrap_or(0);
887 self.ui_context[self.paginator].set_selected_page(page_idx);
888 cce_ui::layout::render_widget_h(&mut dummy_pc, self.paginator, 0.0, 0.0, sidebar_w, self.height as f32, &mut self.ui_context);
889 }
890
891 // Each top-level widget rendered through the same immediate-mode path the root
892 // recursion used, replicating the legacy TUPLE ORDER: plain child quads first,
893 // then the dissolved root plate container's plate, then the rounded children (the
894 // aggregate emitted all plain quads before the rounded root bg).
895 let mut window_pc = pages::PageContent::new();
896 {
897 let self_ptr = self as *mut Self;
898 unsafe {
899 let mut plain_pc = pages::PageContent::new();
900 // The dissolved splitter's paint: its divider quad, then the preview
901 // pane (the only pane content the pages don't render themselves). The
902 // left pane's container copy is gone — the legacy aggregate painted it
903 // UNDER the page's own copy, double-compositing every translucent quad.
904 {
905 let split = match self.current_page {
906 Page::Browse => &self.browse_split,
907 Page::Network => &self.network_split,
908 Page::Space => &self.space_split,
909 };
910 // A collapsed preview draws neither divider nor content —
911 // the stub band and its corner control paint in
912 // display_list, over everything (cce-ui RFC 7c-2).
913 if !self.preview_dock.collapsed {
914 if let Some((dx, dy, dw, dh, dc)) = split.divider_quad() {
915 plain_pc.rects.push((dc, dx, dy, dw, dh, 0.0, (true, true, true, true)));
916 }
917 let (px_r, py_r, pw_r, ph_r) = split.right_rect();
918 // The pane clamps its own text bounds to its rect inside
919 // push_prims (the old SplitBox clamp, absorbed).
920 (*self_ptr).preview.set_rect(px_r, py_r, pw_r, ph_r);
921 (*self_ptr).preview.push_prims(&mut plain_pc);
922 }
923 }
924 if let Some(tb) = self.prompt_dialog.as_ref().map(|d| d.textbox) {
925 let (x, y, w, h) = self.ui_context[tb].rect();
926 cce_ui::layout::render_widget_h(&mut plain_pc, tb, x, y, w, h, &mut self.ui_context);
927 }
928
929 // Legacy aggregate order: plain child quads, then rounded child quads.
930 // The dissolved root plate that used to sit between them is now a
931 // Prim::Plate emitted FIRST in display_list — the lit window slab the
932 // rest of the frame sits on (and the surface the band carves CSG into).
933 let mut plain_pc = plain_pc;
934 let (plain, rounded): (Vec<_>, Vec<_>) =
935 std::mem::take(&mut plain_pc.rects).into_iter().partition(|r| r.5 <= 0.1);
936 window_pc.rects.extend(plain);
937 window_pc.rects.extend(rounded);
938 window_pc.absorb(plain_pc);
939 }
940 }
941
942 // 3. Draw Page custom/static content (drawn to pc)
943 let mut pc = pages::PageContent::new();
944 match self.current_page {
945 Page::Browse => {
946 let (bx, by, bw, bh) = self.browse_split.left_rect();
947 let browse_pc = pages::browse::view(&mut self.browse, bx, by, bw, bh, self.select_mode, &mut self.ui_context);
948
949 pc.absorb(browse_pc);
950 }
951 Page::Network => {
952 let (nx, ny, nw, nh) = self.network_split.left_rect();
953 let network_pc = pages::network::view(&mut self.network, &self.browse, nx, ny, nw, nh, &mut self.ui_context);
954
955 pc.absorb(network_pc);
956 }
957 Page::Space => {
958 let (sx, sy, sw, sh) = self.space_split.left_rect();
959 let space_pc = pages::space::view(&mut self.space, &self.browse, sx, sy, sw, sh, &mut self.ui_context);
960
961 pc.absorb(space_pc);
962 }
963 }
964
965 // Draw bottom selection bar if select_mode is enabled. It lives below the
966 // content region, so it goes into window_pc: page content (pc) is clipped
967 // to the viewport and would swallow the bar entirely.
968 if self.select_mode {
969 let bar_y = self.height as f32 - select_bar_h - cce_ui::layout::root_plate_inset();
970 // Divider line — under control_relief the bar is a band carved into the
971 // plate (see display_list), so the flat line is the fallback only.
972 if !cce_ui::layout::control_relief() {
973 window_pc.rect([0.15, 0.20, 0.16, 1.0], browse_x, bar_y, usable_w, 1.0);
974 }
975
976 let btn_h = cce_ui::layout::button_height();
977 let btn_y = bar_y + (select_bar_h - btn_h) / 2.0;
978
979 // Cancel and Save/Select: equal widths, one gap, right-aligned to
980 // the content region with the bar's own padding — and the
981 // toolkit's plain button face, not hand-picked red/green tints.
982 let btn_w = 84.0;
983 let gap = cce_ui::layout::root_plate_gap();
984 // Flush with the panels' right edge — an extra inset here left the
985 // buttons hanging short of the column above them.
986 let confirm_x = browse_x + usable_w - btn_w;
987 let cancel_x = confirm_x - gap - btn_w;
988 window_pc.button_plain("Cancel", cancel_x, btn_y, btn_w, btn_h, Message::SelectCancel);
989 let button_label = if self.save_mode { "Save" } else { "Select" };
990 window_pc.button_plain(button_label, confirm_x, btn_y, btn_w, btn_h, Message::SelectOpen);
991 }
992
993 // Gather all popovers
994 let mut popover_pc = pages::PageContent::new();
995 cce_ui::layout::render_popovers(&mut popover_pc, &mut self.ui_context);
996
997 // Gather context menu overlay if visible
998 let mut context_menu_pc = pages::PageContent::new();
999 if self.context_menu.visible {
1000 let cx = self.context_menu.x;
1001 let cy = self.context_menu.y;
1002 let cw = self.context_menu.w;
1003 let ch = self.context_menu.h;
1004
1005 // The menu is a lit plate of the same material as the surface it
1006 // opens on: it takes the HOST plate's face rather than a popover
1007 // color of its own, and floats as frosted glass — the configured
1008 // translucency plus the blur-behind sentinel (negative alpha), which
1009 // is the breadcrumb's raised-run treatment. The frost is not
1010 // decoration at this alpha: it is what keeps the labels readable
1011 // over live content instead of over a legible-by-luck backdrop.
1012 //
1013 // A fully transparent host fill degrades to the edges-only boss,
1014 // exactly as the raised run does — with no face to tint, a plate
1015 // would paint a hole.
1016 let menu_r = cce_ui::layout::plate_corner_radius();
1017 let face = cce_ui::color::page_low_color();
1018 if face[3] > 0.001 {
1019 let mut frosted = face;
1020 frosted[3] = -frosted[3];
1021 context_menu_pc.plate(frosted, cx, cy, cw, ch, menu_r);
1022 } else {
1023 context_menu_pc.relief_raised(cx, cy, cw, ch, menu_r);
1024 }
1025 // Hover highlight, inset off the roll so it sits on the face rather
1026 // than climbing the lit edge. The last row is the only one that meets
1027 // a rounded corner (row 0 is the header and never highlights), so it
1028 // carries the plate's radius on the bottom two.
1029 if let Some(h_idx) = self.context_menu.hovered {
1030 let iy = cy + h_idx as f32 * ROW_H;
1031 let inset = (cce_ui::layout::bevel_width().min(ch * 0.2) * 0.5).max(2.0);
1032 let last = h_idx + 1 == self.context_menu.options.len();
1033 context_menu_pc.rect_rounded(
1034 [0.20, 0.40, 0.65, 0.6],
1035 cx + inset,
1036 iy + 2.0,
1037 cw - 2.0 * inset,
1038 ROW_H - 4.0,
1039 (menu_r - inset).max(0.0),
1040 (false, false, last, last),
1041 );
1042 }
1043 // Text options, in the DE's menu font — family and size. Drawn
1044 // with a hardcoded 12.0 and no family, the row menu read in the
1045 // default sans while the list it opened over wore the configured
1046 // face.
1047 let (menu_family, menu_size) = cce_ui::widget::context_menu::label_font();
1048 for (idx, (opt, _)) in self.context_menu.options.iter().enumerate() {
1049 let iy = cy + idx as f32 * ROW_H + (ROW_H - menu_size) / 2.0;
1050 // The toolkit's semantic text colors, not the hand-mixed greys
1051 // that came with the near-black popover face: the header's old
1052 // 0.44 grey was a step above black and all but vanishes on the
1053 // plate's own mid-slate.
1054 let text_color = if idx == 0 {
1055 cce_ui::color::TEXT_DIM
1056 } else if self.context_menu.hovered == Some(idx) {
1057 cce_ui::color::TEXT_HEADER
1058 } else {
1059 cce_ui::color::TEXT_FG
1060 };
1061 // TODO(style): the row label's 8px lead-in is a control-level inset.
1062 context_menu_pc.text_with_font(opt, cx + 8.0, iy, menu_size, text_color, &menu_family);
1063 }
1064 }
1065
1066 // Gather the prompt dialog backdrop & panel if active (its textbox renders with the window widgets; this is everything else)
1067 let mut dialog_pc = pages::PageContent::new();
1068 if let Some(PromptDialog { kind, textbox, .. }) = &mut self.prompt_dialog {
1069 let kind = *kind;
1070 let r = prompt_rects(self.width as f32, self.height as f32);
1071 let (dialog_x, dialog_y, dialog_w, dialog_h) = (r.x, r.y, r.w, r.h);
1072 let (tb_x, tb_y, tb_w, tb_h) = r.tb;
1073 let (btn_cancel_x, btn_cancel_y, btn_cancel_w, btn_cancel_h) = r.cancel;
1074 let (btn_open_x, btn_open_y, btn_open_w, btn_open_h) = r.ok;
1075
1076 // Semi-transparent backdrop overlay
1077 dialog_pc.rect([0.02, 0.02, 0.03, 0.6], 0.0, 0.0, self.width as f32, self.height as f32);
1078 // Dialog panel border
1079 dialog_pc.rect([0.22, 0.22, 0.28, 1.0], dialog_x, dialog_y, dialog_w, dialog_h);
1080 // Dialog panel background
1081 dialog_pc.rect([0.08, 0.08, 0.12, 1.0], dialog_x + 1.0, dialog_y + 1.0, dialog_w - 2.0, dialog_h - 2.0);
1082
1083 // Title and prompt, inset from the plate rim by the pane rung
1084 // (the same inset the well and buttons take in prompt_rects).
1085 let dialog_pad = cce_ui::layout::plate_padding();
1086 dialog_pc.text(kind.title(), dialog_x + dialog_pad, dialog_y + dialog_pad, 14.0, [1.0, 1.0, 1.0, 1.0]);
1087 // TODO(style): the prompt's 42px drop is the dialog's text rhythm.
1088 dialog_pc.text(kind.prompt(), dialog_x + dialog_pad, dialog_y + 42.0, 11.0, [0.54, 0.54, 0.58, 1.0]);
1089
1090 // Set textbox position dynamically using configured textbox height
1091 self.ui_context[*textbox].set_rect(tb_x, tb_y, tb_w, tb_h);
1092 // Raised dialog plate + recessed command well (control_relief styling).
1093 dialog_pc.relief_raised(dialog_x, dialog_y, dialog_w, dialog_h, 0.0);
1094 dialog_pc.relief_recessed(tb_x, tb_y, tb_w, tb_h, cce_ui::layout::textbox_corner_radius());
1095
1096 // Plain toolkit faces, like the chooser footer: the themed Button
1097 // owns its own hover state, so the hand-rolled cursor tracking and
1098 // the red/green tints go together.
1099 dialog_pc.button_plain("Cancel", btn_cancel_x, btn_cancel_y, btn_cancel_w, btn_cancel_h, Message::PromptCancel);
1100 dialog_pc.button_plain(kind.ok_label(), btn_open_x, btn_open_y, btn_open_w, btn_open_h, Message::PromptSubmit);
1101 }
1102
1103 // Translate everything into widgets and text_items!
1104 // We collect from: window_pc, pc, popover_pc, context_menu_pc, dialog_pc
1105 let mut page_buttons = Vec::new();
1106
1107 for (part_idx, pc_part) in [&window_pc, &pc, &popover_pc, &context_menu_pc, &dialog_pc].into_iter().enumerate() {
1108 let is_page_content = part_idx == 1;
1109
1110 // Plates first: a plate owns a FACE, so it is the floor of its part —
1111 // everything else the part emits (a hover fill, an engraved seam, the
1112 // labels) is meant to land on top of it. Emitted after the rects it
1113 // would paint straight over them, which is where the context menu's
1114 // hover row went the first time this ran.
1115 for (c, px, py, pw, ph, pr, pd) in &pc_part.plates {
1116 let (mut wy, mut wh) = (*py, *ph);
1117 if is_page_content {
1118 match clip_to_viewport(wy, wh, content_y, content_y + content_h) {
1119 Some((cy, ch)) => { wy = cy; wh = ch; }
1120 None => continue,
1121 }
1122 }
1123 widgets.push(AppWidget {
1124 x: *px,
1125 y: wy,
1126 w: *pw,
1127 h: wh,
1128 color: *c,
1129 radius: *pr,
1130 corners: (true, true, true, true),
1131 fx: WidgetFx::Plate(*pd),
1132 });
1133 }
1134
1135 for (c, x, y, w, h, r, corners) in &pc_part.rects {
1136 let wx = *x;
1137 let mut wy = *y;
1138 let ww = *w;
1139 let mut wh = *h;
1140
1141 if is_page_content {
1142 match clip_to_viewport(wy, wh, content_y, content_y + content_h) {
1143 Some((cy, ch)) => { wy = cy; wh = ch; }
1144 None => continue,
1145 }
1146 }
1147
1148 widgets.push(AppWidget {
1149 x: wx,
1150 y: wy,
1151 w: ww,
1152 h: wh,
1153 color: *c,
1154 radius: *r,
1155 corners: *corners,
1156 fx: WidgetFx::Flat,
1157 });
1158 }
1159 for (id, ix, iy, iw, ih, alpha) in &pc_part.images {
1160 let (mut wy, mut wh) = (*iy, *ih);
1161 if is_page_content {
1162 match clip_to_viewport(wy, wh, content_y, content_y + content_h) {
1163 Some((cy, ch)) => { wy = cy; wh = ch; }
1164 None => continue,
1165 }
1166 }
1167 widgets.push(AppWidget {
1168 x: *ix,
1169 y: wy,
1170 w: *iw,
1171 h: wh,
1172 color: [0.0; 4],
1173 radius: 0.0,
1174 corners: (true, true, true, true),
1175 fx: WidgetFx::Image { id: *id, alpha: *alpha },
1176 });
1177 }
1178 for (name, ix, iy, iw, ih, color, bounds) in &pc_part.icons {
1179 // Cut, never squashed: a glyph is a picture, so a row scrolled
1180 // half under the list's edge loses its half rather than being
1181 // pressed flat the way the fills are clipped.
1182 let mut clip = bounds.map(|[l, t, r, b]| cce_ui::scene::layout::Rect { x: l, y: t, width: r - l, height: b - t });
1183 if is_page_content {
1184 let view = cce_ui::scene::layout::Rect { x: *ix, y: content_y, width: *iw, height: content_h };
1185 clip = Some(match clip {
1186 Some(c) => intersect_rect(c, view),
1187 None => view,
1188 });
1189 }
1190 if let Some(c) = clip {
1191 if c.width <= 0.0 || c.height <= 0.0
1192 || c.x >= ix + iw || c.x + c.width <= *ix
1193 || c.y >= iy + ih || c.y + c.height <= *iy
1194 {
1195 continue;
1196 }
1197 }
1198 widgets.push(AppWidget {
1199 x: *ix,
1200 y: *iy,
1201 w: *iw,
1202 h: *ih,
1203 color: *color,
1204 radius: 0.0,
1205 corners: (true, true, true, true),
1206 fx: WidgetFx::Icon { name: name.clone(), clip },
1207 });
1208 }
1209 for (shape, color, bounds) in &pc_part.strokes {
1210 // Cut by a clip, as the glyphs are: a wire is a line, not a
1211 // fill to press flat.
1212 let (sx, sy, sw, sh) = shape.bounds();
1213 let mut clip = bounds.map(|[l, t, r, b]| cce_ui::scene::layout::Rect { x: l, y: t, width: r - l, height: b - t });
1214 if is_page_content {
1215 // The page's view cuts a stroke top and bottom only. Its
1216 // own box is no clip across: a vertical wire is one pixel
1217 // wide on a half-pixel, and a scissor that tight rounds
1218 // it away (the graph page drew only its horizontal runs).
1219 let view = cce_ui::scene::layout::Rect { x: sx - 4.0, y: content_y, width: sw + 8.0, height: content_h };
1220 clip = Some(match clip {
1221 Some(c) => intersect_rect(c, view),
1222 None => view,
1223 });
1224 }
1225 if let Some(c) = clip {
1226 if c.width <= 0.0 || c.height <= 0.0
1227 || c.x >= sx + sw || c.x + c.width <= sx
1228 || c.y >= sy + sh || c.y + c.height <= sy
1229 {
1230 continue;
1231 }
1232 }
1233 widgets.push(AppWidget {
1234 x: sx,
1235 y: sy,
1236 w: sw,
1237 h: sh,
1238 color: *color,
1239 radius: 0.0,
1240 corners: (true, true, true, true),
1241 fx: WidgetFx::Stroke { shape: *shape, clip },
1242 });
1243 }
1244 for (rx, ry, rw, rh, rr, rd, kind) in &pc_part.reliefs {
1245 let (mut wy, mut wh) = (*ry, *rh);
1246 if is_page_content {
1247 match clip_to_viewport(wy, wh, content_y, content_y + content_h) {
1248 Some((cy, ch)) => { wy = cy; wh = ch; }
1249 None => continue,
1250 }
1251 }
1252 widgets.push(AppWidget {
1253 x: *rx,
1254 y: wy,
1255 w: *rw,
1256 h: wh,
1257 color: [0.0; 4],
1258 radius: *rr,
1259 corners: (true, true, true, true),
1260 fx: match *kind {
1261 pages::RELIEF_RAISED => WidgetFx::Boss(*rd),
1262 pages::RELIEF_INSET => WidgetFx::Inset(*rd),
1263 pages::RELIEF_INSET_FOCUS => WidgetFx::InsetFocus(*rd),
1264 pages::RELIEF_RECESSED_FOCUS => WidgetFx::RecessFocus(*rd),
1265 _ => WidgetFx::Recess(*rd),
1266 },
1267 });
1268 }
1269 for (ax, ay, bx, by, gw, gd, hx, hy, hw, hh) in &pc_part.grooves {
1270 // Clipped by the HOST rect, not the seam's own span: a groove
1271 // whose host is scrolled out has nothing left to engrave.
1272 let (mut wy, mut wh) = (*hy, *hh);
1273 if is_page_content {
1274 match clip_to_viewport(wy, wh, content_y, content_y + content_h) {
1275 Some((cy, ch)) => { wy = cy; wh = ch; }
1276 None => continue,
1277 }
1278 }
1279 widgets.push(AppWidget {
1280 x: *hx,
1281 y: wy,
1282 w: *hw,
1283 h: wh,
1284 color: [0.0; 4],
1285 radius: 0.0,
1286 corners: (true, true, true, true),
1287 fx: WidgetFx::Groove { ax: *ax, ay: *ay, bx: *bx, by: *by, width: *gw, depth: *gd },
1288 });
1289 }
1290 for (btn, action) in &pc_part.buttons {
1291 let base = btn.base();
1292 let bg = btn.bg.unwrap_or([0.16, 0.16, 0.24, 1.0]);
1293 let hover_bg = btn.hover_bg.unwrap_or([0.25, 0.30, 0.26, 1.0]);
1294 let label = base.label.as_deref().unwrap_or("");
1295 // The label's font is the configured button font, resolved the
1296 // same way `Button::paint` resolves it — family AND size. A
1297 // hardcoded 12.0 here rendered every plain button's label a
1298 // size or two off whatever the rest of the DE's buttons wear.
1299 let (label_family, label_size) = {
1300 let (family, size) = cce_ui::layout::parse_font_string(&cce_ui::layout::button_font());
1301 (family, size.unwrap_or(12.0))
1302 };
1303 let label_color = btn.label_color.unwrap_or([0.83, 0.83, 0.83, 1.0]);
1304
1305 let wx = base.x;
1306 let mut wy = base.y;
1307 let ww = base.w;
1308 let mut wh = base.h;
1309
1310 if is_page_content {
1311 match clip_to_viewport(wy, wh, content_y, content_y + content_h) {
1312 Some((cy, ch)) => { wy = cy; wh = ch; }
1313 None => continue,
1314 }
1315 }
1316
1317 let hovering = self.cursor_x >= wx && self.cursor_x <= wx + ww
1318 && self.cursor_y >= wy && self.cursor_y <= wy + wh;
1319 let col = if hovering { hover_bg } else { bg };
1320
1321 // Flush inset button (control_relief): groove ring down,
1322 // beveled lip back up, face level with the surface —
1323 // mirroring Button::paint's relief branch.
1324 let fx = if cce_ui::layout::control_relief() {
1325 WidgetFx::Inset(cce_ui::layout::bevel_width().min(wh * 0.2))
1326 } else {
1327 WidgetFx::Flat
1328 };
1329 widgets.push(AppWidget {
1330 x: wx,
1331 y: wy,
1332 w: ww,
1333 h: wh,
1334 color: col,
1335 radius: 4.0, // standard button radius
1336 corners: (true, true, true, true),
1337 fx,
1338 });
1339
1340 // Centring needs the SHAPED width, not `chars * size * 0.65`:
1341 // that estimate runs wide on a proportional face (and narrow on
1342 // a large mono one), so a centred label sat visibly off to one
1343 // side of its own plate — the chooser's Cancel/Select were ~6px
1344 // left of centre. Measure the run, as `Button::label_width`
1345 // does, and let `align_text_y` place it vertically, the
1346 // toolkit's convention for every other text-bearing control.
1347 let text_w = cce_ui::widget::display::measure_text_width(label, &label_family, label_size);
1348 let text_x = if btn.justify == cce_ui::widget::Justification::Left {
1349 // TODO(style): a left-justified button label's lead-in is a
1350 // control-level inset (Button's own, not a rung).
1351 base.x + 8.0
1352 } else {
1353 base.x + (base.w - text_w) / 2.0
1354 };
1355 let text_y = cce_ui::layout::align_text_y(base.y, base.h, label_size, 0.0);
1356
1357 let start_bounds = if is_page_content {
1358 [0.0, content_y, self.width as f32, content_y + content_h]
1359 } else {
1360 [0.0, 0.0, self.width as f32, self.height as f32]
1361 };
1362 let text_h = label_size * 1.4;
1363 let occluded = if part_idx < 2 {
1364 occlude_against(
1365 start_bounds,
1366 text_x,
1367 text_x + text_w,
1368 text_y,
1369 text_y + text_h,
1370 &[&popover_pc, &context_menu_pc, &dialog_pc],
1371 )
1372 } else {
1373 Some(start_bounds)
1374 };
1375 let final_button_bounds = match occluded {
1376 Some(b) => Some(b),
1377 None => {
1378 page_buttons.push((btn.clone(), action.clone()));
1379 continue;
1380 }
1381 };
1382
1383 // The family goes with it: measuring in one face and rendering
1384 // in another is how the centring drifted in the first place.
1385 texts.push((label.to_string(), label_size, text_x, text_y, label_color, Some(label_family), final_button_bounds));
1386
1387 page_buttons.push((btn.clone(), action.clone()));
1388 }
1389 for (text, size, x, y, col, font, bounds) in &pc_part.texts {
1390 let clamped_bounds = if is_page_content {
1391 let viewport_top = content_y;
1392 let viewport_bottom = content_y + content_h;
1393 match bounds {
1394 Some(b) => Some([
1395 b[0],
1396 b[1].max(viewport_top),
1397 b[2],
1398 b[3].min(viewport_bottom),
1399 ]),
1400 None => Some([
1401 0.0,
1402 viewport_top,
1403 self.width as f32,
1404 viewport_bottom,
1405 ]),
1406 }
1407 } else {
1408 *bounds
1409 };
1410
1411 let start_bounds = clamped_bounds.unwrap_or([0.0, 0.0, self.width as f32, self.height as f32]);
1412 let text_w = text.chars().count() as f32 * size * 0.65;
1413 let text_h = *size * 1.4;
1414 let occluded = if part_idx < 2 {
1415 occlude_against(
1416 start_bounds,
1417 *x,
1418 *x + text_w,
1419 *y,
1420 *y + text_h,
1421 &[&popover_pc, &context_menu_pc, &dialog_pc],
1422 )
1423 } else {
1424 Some(start_bounds)
1425 };
1426 let final_bounds = match occluded {
1427 Some(b) => Some(b),
1428 None => continue,
1429 };
1430
1431 texts.push((text.clone(), *size, *x, *y, *col, font.clone(), final_bounds));
1432 }
1433 }
1434
1435 self.widgets = widgets;
1436 self.texts = texts;
1437 self.page_buttons = page_buttons;
1438 self.ui_context.clear_dirty();
1439 self.needs_rebuild = false;
1440 }
1441 }
1442
1443 // ── Application Trait Implementation ────────────────────────────────
1444
1445 impl Application for FilesystemApp {
1446 type Message = Message;
1447
1448 fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
1449 Some(&self.ui_context)
1450 }
1451
1452 // The engine ticks the exposed context each loop — this is what drives the
1453 // registered widgets' animation frames.
1454 fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
1455 Some(&mut self.ui_context)
1456 }
1457
1458 fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
1459 // 1. If dialog is open, do not drag
1460 if self.prompt_dialog.is_some() {
1461 return false;
1462 }
1463 // 2. If context menu is visible, do not drag
1464 if self.context_menu.visible {
1465 return false;
1466 }
1467 // 3. If in the sidebar area (when sidebar is active), do not drag
1468 if false {
1469 let sidebar_w = self.ui_context[self.paginator].sidebar_w();
1470 if px <= sidebar_w {
1471 return false;
1472 }
1473 }
1474 // 4. If over any page button, do not drag
1475 for (btn, _) in &self.page_buttons {
1476 let base = btn.base();
1477 if px >= base.x && px <= base.x + base.w && py >= base.y && py <= base.y + base.h {
1478 return false;
1479 }
1480 }
1481 if self.current_page == Page::Browse {
1482 if self.browse_split.dragging || self.browse_split.hovered {
1483 return false;
1484 }
1485 // The dissolved List blocked window drags via its registered ScrollBox
1486 // (blocks_root_plate_drag); veto app-side now or every row press starts a
1487 // compositor window move and the app never sees it.
1488 let l = &self.browse.list;
1489 if px >= l.x && px <= l.x + l.w && py >= l.y && py <= l.y + l.h {
1490 return false;
1491 }
1492 } else if self.current_page == Page::Network {
1493 if self.network_split.dragging || self.network_split.hovered {
1494 return false;
1495 }
1496 } else if self.current_page == Page::Space {
1497 if self.space_split.dragging || self.space_split.hovered {
1498 return false;
1499 }
1500 // Same reasoning as the List above: without this veto every press
1501 // on a tile starts a compositor window move and the app never sees
1502 // the click.
1503 let (mx, my, mw, mh) = self.space.map_rect;
1504 if px >= mx && px <= mx + mw && py >= my && py <= my + mh {
1505 return false;
1506 }
1507 }
1508 // 5. root plate container dissolved: the surface itself is the movable plate; drag
1509 // anywhere a drag-blocking widget isn't.
1510 self.ui_context.drag_allowed_at(px, py)
1511 }
1512
1513 fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
1514 // The app keeps calloop's sender; `AppSender` converts into it.
1515 let sender: calloop::channel::Sender<Self::Message> = sender.into();
1516 // Parse command line arguments
1517 let args: Vec<String> = std::env::args().collect();
1518 let select_directory = args.iter().any(|arg| arg == "--select-dir");
1519 let save_mode = args.iter().any(|arg| arg == "--save");
1520 let select_mode = save_mode || args.iter().any(|arg| arg == "--select" || arg == "--select-dir");
1521
1522 // A positional path opens there instead of the remembered directory.
1523 // This is what `Exec=cce-files %f` passes as the inode/directory
1524 // handler; without it the desktop entry could claim the type but
1525 // always land on the last-visited dir, ignoring the folder clicked.
1526 // Flags are skipped rather than just args[1], so `--select /tmp`
1527 // works. A path that does not resolve falls through to the last dir,
1528 // and a file opens its containing directory.
1529 let start_dir = args
1530 .iter()
1531 .skip(1)
1532 .find(|a| !a.starts_with("--"))
1533 .and_then(|a| std::fs::canonicalize(a).ok())
1534 .and_then(|p| if p.is_dir() { Some(p) } else { p.parent().map(|q| q.to_path_buf()) });
1535
1536 cce_ui::scale::set_scale_factor(1.0);
1537
1538 // The context owns the widgets; the app keeps their handles.
1539 let mut ui_context = cce_ui::context::UiContext::new();
1540 let browse = pages::browse::BrowseState::new(&mut ui_context);
1541 let _current_dir = browse.current_dir.clone();
1542
1543 let pages_names = Page::ALL.iter().map(|p| p.label().to_string()).collect::<Vec<_>>();
1544 let paginator = cce_ui::widget::Paginator::new(pages_names);
1545 let fs_service = services::fs::FsService::new(sender.clone());
1546 let initial_w = if select_mode { 900 } else { 1200 };
1547 let initial_h = if select_mode { 500 } else { 720 };
1548 let app = Self {
1549 current_page: Page::Browse,
1550 browse,
1551 network: pages::network::NetworkState::new(&mut ui_context),
1552 space: pages::space::SpaceState::new(&mut ui_context),
1553 preview: Default::default(),
1554 preview_requested: None,
1555 preview_generation: 0,
1556 focused_well: FocusedWell::Content,
1557 preview_dock: Default::default(),
1558 preview_prior_fracs: None,
1559 plate_menu_actions: Vec::new(),
1560 breadcrumb_menu: None,
1561 select_mode,
1562 select_directory,
1563 save_mode,
1564 widgets: Vec::new(),
1565 texts: Vec::new(),
1566 font_system: cce_ui::create_font_system(),
1567 needs_rebuild: true,
1568 text_claim: None,
1569 width: initial_w,
1570 height: initial_h,
1571 scale_factor: 1.0,
1572 page_buttons: Vec::new(),
1573 hovered_button: None,
1574 cursor_x: 0.0,
1575 cursor_y: 0.0,
1576 paginator: ui_context.insert(paginator),
1577 just_initialized: true,
1578 ui_context,
1579 watcher: None,
1580 fs_service,
1581 seen_renderer: false,
1582 context_menu: ContextMenu {
1583 visible: false,
1584 x: 0.0,
1585 y: 0.0,
1586 w: 120.0,
1587 h: 0.0,
1588 options: Vec::new(),
1589 hovered: None,
1590 },
1591 prompt_dialog: None,
1592 browse_split: SplitPane::new(0.49, 100.0, 100.0, cce_ui::layout::root_plate_gap()),
1593 network_split: SplitPane::new(0.49, 100.0, 100.0, cce_ui::layout::root_plate_gap()),
1594 space_split: SplitPane::new(0.49, 100.0, 100.0, cce_ui::layout::root_plate_gap()),
1595 last_space_click_time: std::time::Instant::now(),
1596 last_space_path: None,
1597 last_click_time: std::time::Instant::now(),
1598 last_clicked_idx: None,
1599 keys: BrowseKeys::load(),
1600 };
1601
1602
1603 // Start initial directory loading via FsService. ReadDirectory lands in
1604 // the same DirectoryLoaded handler ReadLastDir eventually reaches, so an
1605 // argv path just skips the restore step; current_dir and the breadcrumb
1606 // are set when the load completes either way.
1607 match start_dir {
1608 Some(dir) => app.fs_service.send(services::fs::FsRequest::ReadDirectory(dir)),
1609 None => app.fs_service.send(services::fs::FsRequest::ReadLastDir),
1610 }
1611
1612 // NOTE: do not call rebuild_layout() here. This value is moved out of new()
1613 // into the engine, which changes its address; the container/splitter widgets
1614 // capture raw self-pointers during rebuild, so the first rebuild must happen
1615 // after the move (the engine triggers it on the first frame via needs_rebuild).
1616 app
1617 }
1618
1619 fn settings(&self) -> WindowSettings {
1620 if self.select_mode {
1621 let title = if self.save_mode {
1622 "Save File"
1623 } else if self.select_directory {
1624 "Select Directory"
1625 } else {
1626 "Select File"
1627 };
1628 WindowSettings {
1629 title: title.to_string(),
1630 // The cce- prefix matters: the compositor's is_cce_app gate keys
1631 // blur and the root plate corner radius off it.
1632 app_id: "cce-filesystem-chooser".to_string(),
1633 width: 900,
1634 height: 500,
1635 fullscreen: false,
1636 min_size: Some((800, 400)),
1637 }
1638 } else {
1639 WindowSettings {
1640 title: "Files".to_string(),
1641 app_id: "cce-files".to_string(),
1642 width: 1200,
1643 height: 720,
1644 fullscreen: false,
1645 min_size: Some((1020, 600)),
1646 }
1647 }
1648 }
1649
1650 fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
1651 match msg {
1652 Message::SwitchPage(page) => {
1653 self.current_page = page;
1654 let page_idx = Page::ALL.iter().position(|&p| p == page).unwrap_or(0);
1655 self.ui_context[self.paginator].set_selected_page(page_idx);
1656 // Switching to Space is what triggers the first scan — it is
1657 // far too expensive to run for a page nobody is looking at.
1658 self.ensure_space_scan();
1659 *needs_rebuild = true;
1660 self.needs_rebuild = true;
1661 }
1662 Message::Browse(msg) => {
1663 let mut is_file_double_click = false;
1664 if let pages::browse::BrowseMessage::NavigateTo(idx) = &msg {
1665 let now = std::time::Instant::now();
1666 if self.last_clicked_idx == Some(*idx) && now.duration_since(self.last_click_time).as_millis() < 500 {
1667 is_file_double_click = self.browse.entries.get(*idx).map(|e| !e.is_dir || (self.select_mode && !self.select_directory && is_project_dir(&e.path))).unwrap_or(false);
1668 }
1669 self.last_click_time = now;
1670 self.last_clicked_idx = Some(*idx);
1671 } else if let pages::browse::BrowseMessage::SelectEntry(idx) = &msg {
1672 let now = std::time::Instant::now();
1673 self.last_click_time = now;
1674 self.last_clicked_idx = Some(*idx);
1675 }
1676
1677 let is_directory_loaded = match &msg {
1678 pages::browse::BrowseMessage::DirectoryLoaded(path, _) => Some(path.clone()),
1679 _ => None,
1680 };
1681
1682 if let Some(req) = pages::browse::update(&mut self.browse, msg, &mut self.ui_context) {
1683 self.fs_service.send(req);
1684 }
1685
1686 if let Some(path) = is_directory_loaded {
1687 self.start_watching(path);
1688 // Navigating re-scans the new subtree when Space is up.
1689 self.ensure_space_scan();
1690 }
1691
1692 // Whatever the message, the preview follows the selection —
1693 // and only re-reads when it, or the selected file, changed.
1694 let selected_path = self.browse.selected_path();
1695 if let Some(path) = selected_path {
1696 self.request_preview(path, false);
1697 } else {
1698 self.clear_preview();
1699 }
1700
1701 if let Some(idx) = self.browse.selected {
1702 if let Some(entry) = self.browse.entries.get(idx) {
1703 if self.select_mode {
1704 self.ui_context[self.browse.save_name_box].text = entry.name.clone();
1705 if self.ui_context[self.browse.save_name_box].editing {
1706 self.ui_context[self.browse.save_name_box].edit_buffer = entry.name.clone();
1707 }
1708 }
1709 }
1710 } else {
1711 if self.select_mode {
1712 self.ui_context[self.browse.save_name_box].text.clear();
1713 if self.ui_context[self.browse.save_name_box].editing {
1714 self.ui_context[self.browse.save_name_box].edit_buffer.clear();
1715 }
1716 }
1717 }
1718
1719 *needs_rebuild = true;
1720 self.needs_rebuild = true;
1721
1722 if is_file_double_click {
1723 self.update(Message::SelectOpen, needs_rebuild, _exit);
1724 }
1725 }
1726 Message::Preview(msg) => {
1727 if let pages::preview::PreviewMessage::PreviewLoaded { generation, .. } = &msg {
1728 // A read the selection has moved past: a slow decode
1729 // must not paint over the file now selected.
1730 if *generation != self.preview_generation {
1731 return;
1732 }
1733 }
1734 pages::preview::update(&mut self.preview, msg);
1735 *needs_rebuild = true;
1736 self.needs_rebuild = true;
1737 }
1738 Message::Space(msg) => {
1739 pages::space::update(&mut self.space, msg);
1740 *needs_rebuild = true;
1741 self.needs_rebuild = true;
1742 }
1743 Message::SelectOpen => {
1744 if self.select_directory {
1745 let selected_path = self.browse.selected_path();
1746 let path = selected_path.filter(|p| p.is_dir()).unwrap_or_else(|| self.browse.current_dir.clone());
1747 println!("{}", path.display());
1748 std::process::exit(0);
1749 } else {
1750 let filename = if self.ui_context[self.browse.save_name_box].editing {
1751 self.ui_context[self.browse.save_name_box].edit_buffer.trim()
1752 } else {
1753 self.ui_context[self.browse.save_name_box].text.trim()
1754 };
1755 if !filename.is_empty() {
1756 let path = self.browse.current_dir.join(filename);
1757 if path.is_dir() && !is_project_dir(&path) {
1758 self.fs_service.send(services::fs::FsRequest::ReadDirectory(path));
1759 } else {
1760 if self.select_mode {
1761 println!("{}", path.display());
1762 std::process::exit(0);
1763 } else {
1764 crate::services::fs::open_file(&path);
1765 }
1766 }
1767 } else {
1768 let selected_path = self.browse.selected_path();
1769 if let Some(path) = selected_path {
1770 if path.is_dir() && !is_project_dir(&path) {
1771 self.fs_service.send(services::fs::FsRequest::ReadDirectory(path));
1772 } else {
1773 if self.select_mode {
1774 println!("{}", path.display());
1775 std::process::exit(0);
1776 } else {
1777 crate::services::fs::open_file(&path);
1778 }
1779 }
1780 }
1781 }
1782
1783 }
1784 }
1785 Message::SelectCancel => {
1786 std::process::exit(1);
1787 }
1788 Message::PromptOpenWith(path) => {
1789 let default_cmd = if let Some(mime) = crate::services::fs::get_mime_type(&path) {
1790 if let Some((_, cmd)) = crate::services::fs::get_default_application(&mime) {
1791 cmd
1792 } else {
1793 String::new()
1794 }
1795 } else {
1796 String::new()
1797 };
1798 let mut tb = cce_ui::widget::TextBox::new(default_cmd)
1799 .with_max_width(None)
1800 .with_placeholder("Program/Command");
1801 tb.focus();
1802 let textbox = self.ui_context.insert(tb);
1803 self.ui_context.set_focused_id(textbox.id());
1804 self.set_prompt(Some(PromptDialog { kind: PromptKind::OpenWith, path, textbox }));
1805 *needs_rebuild = true;
1806 self.needs_rebuild = true;
1807 }
1808 Message::PromptRename(path) => {
1809 let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
1810 // Preselect the stem, not the extension, so typing replaces
1811 // just the part people usually mean to change.
1812 let stem_chars = if path.is_dir() {
1813 name.chars().count()
1814 } else {
1815 path.file_stem().map(|s| s.to_string_lossy().chars().count()).unwrap_or(0)
1816 };
1817 let mut tb = cce_ui::widget::TextBox::new(name).with_max_width(None);
1818 tb.focus();
1819 tb.cursor_idx = stem_chars;
1820 tb.select_anchor = Some(0);
1821 tb.all_selected = false;
1822 tb.sync_editor_state();
1823 let textbox = self.ui_context.insert(tb);
1824 self.ui_context.set_focused_id(textbox.id());
1825 self.set_prompt(Some(PromptDialog { kind: PromptKind::Rename, path, textbox }));
1826 *needs_rebuild = true;
1827 self.needs_rebuild = true;
1828 }
1829 Message::PromptSubmit => {
1830 if let Some(PromptDialog { kind, path, textbox }) = self.prompt_dialog.take() {
1831 let value = self.ui_context.remove(textbox).map_or_else(String::new, |textbox| {
1832 if textbox.editing {
1833 textbox.edit_buffer.trim().to_string()
1834 } else {
1835 textbox.text.trim().to_string()
1836 }
1837 });
1838 match kind {
1839 PromptKind::OpenWith => {
1840 if !value.is_empty() {
1841 crate::services::fs::spawn_command_for_path(&value, &path);
1842 }
1843 }
1844 PromptKind::Rename => {
1845 self.update(
1846 Message::Browse(pages::browse::BrowseMessage::RenameEntry(path, value)),
1847 needs_rebuild,
1848 _exit,
1849 );
1850 }
1851 }
1852 }
1853 *needs_rebuild = true;
1854 self.needs_rebuild = true;
1855 }
1856 Message::PromptCancel => {
1857 self.set_prompt(None);
1858 *needs_rebuild = true;
1859 self.needs_rebuild = true;
1860 }
1861 Message::CopyPath(path) => {
1862 cce_ui::widget::clipboard::copy_to_clipboard(&path);
1863 }
1864 Message::OpenFile(path) => {
1865 services::fs::open_file(&path);
1866 }
1867 }
1868 }
1869
1870 fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
1871 // Pump the widget tick walk (the cce-data-editor pattern): animating
1872 // widgets register as tick receivers and report changed until their
1873 // transition lands; without this an animation freezes mid-way.
1874 if self.ui_context.tick(dt) {
1875 *needs_rebuild = true;
1876 self.needs_rebuild = true;
1877 }
1878
1879 if self.just_initialized {
1880 self.just_initialized = false;
1881 if self.save_mode {
1882 self.ui_context.focus_id(self.browse.save_name_box.id());
1883 self.ui_context.set_focused_id(self.browse.save_name_box.id());
1884 *needs_rebuild = true;
1885 self.needs_rebuild = true;
1886 }
1887 }
1888
1889 if self.ui_context.lend_h(self.paginator, |w, ctx| w.tick(dt, ctx)).unwrap_or(false) {
1890 *needs_rebuild = true;
1891 self.needs_rebuild = true;
1892 }
1893
1894 // The app-owned scrollers' wheel glide / flick coast: the wheel only
1895 // moves their target, these ticks carry the drawn offsets there, so
1896 // frames must keep coming while either is live.
1897 if self.browse.list.tick(dt) {
1898 // Rows slid under a still pointer: re-derive the hover.
1899 self.browse.list.cursor_moved(self.cursor_x, self.cursor_y);
1900 *needs_rebuild = true;
1901 self.needs_rebuild = true;
1902 }
1903 if self.preview.tick(dt) {
1904 *needs_rebuild = true;
1905 self.needs_rebuild = true;
1906 }
1907 }
1908
1909 /// Re-request the shown file's preview when the renderer is replaced.
1910 ///
1911 /// `PreviewPane` holds a **renderer** image id, and a renderer does not
1912 /// outlive its session: `cce-ui`'s `window_runner` repairs a lost Wayland
1913 /// transport by opening a new session around the same `Application`, which
1914 /// rebuilds the renderer and with it the image table. The cached id then
1915 /// names an image that no longer exists, and a draw for an unknown id is
1916 /// skipped rather than reported — so a reconnected window kept the file's
1917 /// name, size and permissions and showed an empty well where the picture
1918 /// was, until the user selected a different file.
1919 ///
1920 /// The pixels are not kept here (they are moved into the upload), so the
1921 /// repair is the same round trip the selection makes: drop the dead
1922 /// texture, ask `FsService` for the preview again, and let
1923 /// `PreviewMessage::PreviewLoaded` upload it into the live renderer.
1924 ///
1925 /// Not on the first renderer: nothing has been uploaded yet, and the
1926 /// initial directory load is already in flight.
1927 fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
1928 if !std::mem::replace(&mut self.seen_renderer, true) {
1929 return;
1930 }
1931 let had_texture = self.preview.drop_texture();
1932 if let Some(path) = self.preview.path.clone() {
1933 if had_texture {
1934 log::info!(
1935 "[preview] renderer replaced; re-reading the preview of {}",
1936 path.display()
1937 );
1938 self.request_preview(path, true);
1939 }
1940 }
1941 self.needs_rebuild = true;
1942 }
1943
1944 fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
1945 // Phase 6 single paint path: the whole frame — geometry and text — is this one list.
1946 // rebuild_layout flattens every source (browse/network page, popovers, context menu,
1947 // dialogs) into self.widgets/self.texts, with popover/dialog occlusion already folded
1948 // into each text's bounds.
1949 if self.needs_rebuild || self.width != size.width as u32 || self.height != size.height as u32 || self.scale_factor != scale {
1950 self.width = size.width as u32;
1951 self.height = size.height as u32;
1952 self.scale_factor = scale;
1953 cce_ui::scale::set_scale_factor(scale as f32);
1954 self.rebuild_layout();
1955 }
1956 use cce_ui::scene::layout::Rect;
1957 let mut pc = cce_ui::scene::paint::PaintCtx::new();
1958 let (fw, fh) = (self.width as f32, self.height as f32);
1959
1960 // The standard root plate (cce-ui `PlateSpec::window`): the DE root
1961 // material at its opacity, the shared silhouette arc, the DE roll.
1962 pc.root_plate(fw, fh);
1963
1964 // Band carves, emitted right after the plate: in chooser mode the action
1965 // bar is a band carved into the bottom. These do NOT CSG-group into the
1966 // plate's draw, despite sitting immediately behind it — a band flush with
1967 // the plate's edge suppresses three of its four walls, and an
1968 // edge-suppressed carve is never eligible (its extended walls would smear
1969 // across the whole host). The standalone overlay path shades it, which is
1970 // correct here. `CCE_PLATE_DEBUG=1` names the rule.
1971 // (The header strip's menubar-style band was removed — the top of the
1972 // plate is flush; the breadcrumb row sits directly on the surface.)
1973 if cce_ui::layout::control_relief() {
1974 let wall = cce_ui::layout::bar_wall_width();
1975 if self.select_mode {
1976 let band_h = SELECT_BAR_H + cce_ui::layout::root_plate_inset();
1977 pc.recess_edges(
1978 Rect { x: 0.0, y: fh - band_h, width: fw, height: band_h },
1979 (0.0, 0.0, 0.0, 0.0),
1980 wall,
1981 (true, false, false, false),
1982 );
1983 }
1984 }
1985
1986 for w in &self.widgets {
1987 let rect = Rect { x: w.x, y: w.y, width: w.w, height: w.h };
1988 let radii = (w.radius, w.radius, w.radius, w.radius);
1989 match w.fx.clone() {
1990 WidgetFx::Plate(depth) => pc.plate(rect, radii, &cce_ui::scene::Material::from_fill(w.color), depth),
1991 WidgetFx::Boss(depth) => pc.boss(rect, radii, depth),
1992 WidgetFx::Recess(depth) => pc.recess(rect, radii, depth),
1993 WidgetFx::RecessFocus(depth) => {
1994 let hc = cce_ui::color::highlight_primary_color();
1995 pc.recess_tinted(rect, radii, depth, [hc[0], hc[1], hc[2]]);
1996 }
1997 WidgetFx::Inset(depth) => pc.inset_plate(rect, radii, cce_ui::scene::Material::face(w.color).as_ref(), depth),
1998 WidgetFx::InsetFocus(depth) => {
1999 pc.inset_plate_tinted(rect, radii, cce_ui::scene::Material::face(w.color).as_ref(), depth, cce_ui::widget::ControlPlate::focus_tint())
2000 }
2001 WidgetFx::Image { id, alpha } => pc.image(id, rect, alpha),
2002 WidgetFx::Icon { name, clip } => {
2003 if let Some(c) = clip {
2004 pc.push_clip(c);
2005 }
2006 pc.icon(&name, rect, w.color);
2007 if clip.is_some() {
2008 pc.pop_clip();
2009 }
2010 }
2011 WidgetFx::Stroke { shape, clip } => {
2012 if let Some(c) = clip {
2013 pc.push_clip(c);
2014 }
2015 match shape {
2016 crate::pages::Stroke::Line { x1, y1, x2, y2, thickness, cap } => pc.vector(x1, y1, x2, y2, thickness, w.color, cap),
2017 crate::pages::Stroke::Arc { cx, cy, radius, thickness, start, end } => pc.arc(cx, cy, radius, thickness, start, end, w.color),
2018 crate::pages::Stroke::Disc { cx, cy, radius } => pc.circle(cx, cy, radius, w.color),
2019 }
2020 if clip.is_some() {
2021 pc.pop_clip();
2022 }
2023 }
2024 WidgetFx::Groove { ax, ay, bx, by, width, depth } => {
2025 pc.groove((ax, ay), (bx, by), width, depth, rect)
2026 }
2027 WidgetFx::Flat => {
2028 if w.radius > 0.1 {
2029 pc.rounded_rect(rect, w.radius, w.corners, w.color);
2030 } else {
2031 pc.quad(rect, w.color);
2032 }
2033 }
2034 }
2035 }
2036 for (text, font_size, x, y, col, font, bounds) in &self.texts {
2037 pc.text_with(
2038 text.clone(),
2039 *x,
2040 *y,
2041 *font_size,
2042 [
2043 (col[0] * 255.0) as u8,
2044 (col[1] * 255.0) as u8,
2045 (col[2] * 255.0) as u8,
2046 ],
2047 font.clone(),
2048 *bounds,
2049 );
2050 }
2051 // The toolkit's shared context menu (breadcrumb segments, config-bound
2052 // controls) draws into the app's display list like every popover. Its
2053 // labels carry bounds equal to the menu rect — the engine's popover
2054 // occlusion clamp exempts exactly that, so they render inside the menu
2055 // The preview pane's plate-dock affordance (cce-ui RFC 7c-2): the
2056 // title stub while collapsed, and the corner control — drawn over
2057 // the page content, under the context menu.
2058 {
2059 use cce_ui::widget::plate_dock as dock;
2060 let band = self.preview_dock_band();
2061 let stubbed = self.preview_dock.stubbed();
2062 if stubbed {
2063 let r = cce_ui::layout::plate_corner_radius();
2064 pc.plate(
2065 Rect { x: band.0, y: band.1, width: band.2, height: band.3 },
2066 (r, r, r, r),
2067 &cce_ui::scene::Material::from_fill(cce_ui::color::page_low_color()),
2068 cce_ui::layout::bevel_width().min(band.3 * 0.2),
2069 );
2070 pc.text_with(
2071 "Preview".to_string(),
2072 band.0 + cce_ui::layout::plate_padding(),
2073 band.1 + (band.3 - 13.0) / 2.0,
2074 13.0,
2075 [0xc8, 0xc8, 0xd4],
2076 None,
2077 Some([band.0, band.1, band.0 + band.2 - dock::CORNER_INSET - dock::CORNER_R, band.1 + band.3]),
2078 );
2079 }
2080 if let Some(c) = dock::corner_center(band, stubbed) {
2081 let emphasized = dock::corner_hit(c, self.cursor_x, self.cursor_y)
2082 || !self.plate_menu_actions.is_empty();
2083 dock::draw_corner_dot(&mut pc, c, emphasized);
2084 }
2085 }
2086
2087 // while page text beneath stays clamped.
2088 // The shared menu paints itself as a lit plate (cce-ui), so the
2089 // breadcrumb's copy-path menu is the same frosted glass as the row
2090 // menu above rather than the flat quads it used to stack. Labels come
2091 // with it: `paint_with_labels` carries the menu font's family, which
2092 // the hand-rolled `paint` + `text_labels()` loop here did not.
2093 cce_ui::widget::context_menu::paint_with_labels(&mut pc);
2094 if let Some([x, y, w, h]) = self.text_claim {
2095 cce_ui::text_input::claim(x, y, w, h);
2096 }
2097 Some(pc.finish())
2098 }
2099
2100 fn display_list_text(&self) -> bool {
2101 true
2102 }
2103
2104 fn clear_color(&self) -> [f32; 4] {
2105 [0.0, 0.0, 0.0, 0.0]
2106 }
2107
2108 fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
2109 self.cursor_x = pos.x;
2110 self.cursor_y = pos.y;
2111
2112 let mut changed = false;
2113
2114 // Routed dispatch (6bd shrink): one Event through the router per targeted root.
2115 let mv = cce_ui::widget::Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
2116 if self.prompt_dialog.is_some() {
2117 if let Some(PromptDialog { textbox, .. }) = &mut self.prompt_dialog {
2118 let root = textbox.id();
2119 let _ = self.ui_context.propagate_event(&mv, root);
2120 }
2121 *needs_rebuild = true;
2122 self.needs_rebuild = true;
2123 return;
2124 }
2125
2126 // The toolkit's shared context menu gets the pointer exclusively while open.
2127 if cce_ui::widget::context_menu::is_visible() {
2128 if cce_ui::widget::context_menu::cursor_moved(pos.x, pos.y) {
2129 *needs_rebuild = true;
2130 self.needs_rebuild = true;
2131 }
2132 return;
2133 }
2134
2135 if self.context_menu.visible {
2136 let cx = self.context_menu.x;
2137 let cy = self.context_menu.y;
2138 let cw = self.context_menu.w;
2139 let ch = self.context_menu.h;
2140 let was_hovered = self.context_menu.hovered;
2141 self.context_menu.hovered = None;
2142 if pos.x >= cx && pos.x <= cx + cw && pos.y >= cy && pos.y <= cy + ch {
2143 let idx = ((pos.y - cy) / ROW_H) as usize;
2144 if idx < self.context_menu.options.len() && idx > 0 {
2145 self.context_menu.hovered = Some(idx);
2146 }
2147 }
2148 if self.context_menu.hovered != was_hovered {
2149 changed = true;
2150 }
2151 if changed {
2152 *needs_rebuild = true;
2153 self.needs_rebuild = true;
2154 }
2155 return;
2156 }
2157
2158 if self.current_page == Page::Browse {
2159 if self.browse_split.cursor_moved(pos.x, pos.y) {
2160 changed = true;
2161 }
2162 } else if self.current_page == Page::Network {
2163 if self.network_split.cursor_moved(pos.x, pos.y) {
2164 changed = true;
2165 }
2166 } else if self.current_page == Page::Space {
2167 if self.space_split.cursor_moved(pos.x, pos.y) {
2168 changed = true;
2169 }
2170 }
2171
2172 if !self.select_mode {
2173 // Self-routing composite: handle_event, not propagate — the router's
2174 // children-first descent would let the embedded strip consume this.
2175 if self.ui_context.lend_h(self.paginator, |w, ctx| w.handle_event(&mv, ctx)).unwrap_or(false) {
2176 changed = true;
2177 }
2178 }
2179
2180 if self.current_page == Page::Browse {
2181 if self.select_mode {
2182 let root = self.browse.save_name_box.id();
2183 if self.ui_context.propagate_event(&mv, root) {
2184 changed = true;
2185 }
2186 }
2187 if self.browse.list.cursor_moved(pos.x, pos.y) {
2188 changed = true;
2189 }
2190 if self.browse.search_visible {
2191 let root = self.browse.search_box.id();
2192 if self.ui_context.propagate_event(&mv, root) {
2193 changed = true;
2194 }
2195 }
2196 {
2197 let root = self.browse.breadcrumb.id();
2198 if self.ui_context.propagate_event(&mv, root) {
2199 changed = true;
2200 }
2201 }
2202 } else if self.current_page == Page::Network {
2203 {
2204 let root = self.network.breadcrumb.id();
2205 if self.ui_context.propagate_event(&mv, root) {
2206 changed = true;
2207 }
2208 }
2209 // The router forwards DragUpdate to a mid-drag node grab; a plain move runs
2210 // the hover recompute. Rebuild every move while a drag is live (the router
2211 // drops drag_update's changed flag).
2212 {
2213 let root = self.network.graph.id();
2214 if self.ui_context.propagate_event(&mv, root) {
2215 changed = true;
2216 }
2217 if self.ui_context.is_dragging {
2218 changed = true;
2219 }
2220 }
2221 } else if self.current_page == Page::Space {
2222 {
2223 let root = self.space.breadcrumb.id();
2224 if self.ui_context.propagate_event(&mv, root) {
2225 changed = true;
2226 }
2227 }
2228 // Tile hover drives both the highlight outline and the footer
2229 // readout, so only a change of tile is worth a rebuild — a move
2230 // within one tile repaints nothing.
2231 let hovered = self.space.tile_at(pos.x, pos.y);
2232 if hovered != self.space.hovered {
2233 self.space.hovered = hovered;
2234 changed = true;
2235 }
2236 }
2237
2238 // Repaint only when the hovered page button actually changes. Page-button hover is
2239 // a binary color decided at rebuild time (see rebuild_layout), so idle pointer moves
2240 // don't need a redraw. Rebuilding unconditionally re-commits the translucent surface
2241 // every move, which makes the compositor re-blur the backdrop continuously.
2242 let hovered_button = self.page_buttons.iter().position(|(btn, _)| {
2243 let base = btn.base();
2244 self.cursor_x >= base.x && self.cursor_x <= base.x + base.w
2245 && self.cursor_y >= base.y && self.cursor_y <= base.y + base.h
2246 });
2247 if hovered_button != self.hovered_button {
2248 self.hovered_button = hovered_button;
2249 changed = true;
2250 }
2251
2252 if changed {
2253 *needs_rebuild = true;
2254 self.needs_rebuild = true;
2255 }
2256 }
2257 fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
2258 if button != MouseButton::Left && button != MouseButton::Right {
2259 return None;
2260 }
2261
2262 // The preview pane's plate-dock menu (cce-ui RFC 7c-2) routes BEFORE
2263 // the generic context-menu dispatch below: that path targets widget
2264 // ContextActions, not these rows. Keyed on RELEASE: in this app the
2265 // routed-widget path upstream consumes left PRESSES before the hook
2266 // (only releases reliably arrive here).
2267 if button == MouseButton::Left
2268 && state == ElementState::Released
2269 && !self.plate_menu_actions.is_empty()
2270 && cce_ui::widget::context_menu::is_visible()
2271 {
2272 let (px, py) = (pos.x as f32, pos.y as f32);
2273 let picked = cce_ui::widget::context_menu::row_at(px, py)
2274 .and_then(|row| self.plate_menu_actions.get(row).copied());
2275 cce_ui::widget::context_menu::hide();
2276 self.plate_menu_actions.clear();
2277 if let Some(action) = picked {
2278 self.dispatch_preview_dock(action);
2279 }
2280 *needs_rebuild = true;
2281 self.needs_rebuild = true;
2282 return None;
2283 }
2284
2285 // The breadcrumb's menu rows run here too, ahead of the toolkit's
2286 // dispatch — it would run Copy Path but has no notion of the view
2287 // rows. On the press as well as the release: the toolkit runs a row
2288 // on the PRESS when one reaches it, so whichever arrives first wins,
2289 // and the hide it does leaves nothing for the other.
2290 if button == MouseButton::Left {
2291 use cce_ui::widget::context_menu as cm;
2292 let live = self.breadcrumb_menu.as_ref().is_some_and(|m| cm::is_visible() && m.generation == cm::generation());
2293 if !live {
2294 self.breadcrumb_menu = None;
2295 } else if cm::hit_test(pos.x as f32, pos.y as f32) {
2296 let picked = cm::row_at(pos.x as f32, pos.y as f32)
2297 .and_then(|row| self.breadcrumb_menu.as_ref()?.rows.get(row).cloned().flatten());
2298 // A press on the header or the separator leaves the menu up.
2299 if let Some(row) = picked {
2300 cm::hide();
2301 self.breadcrumb_menu = None;
2302 *needs_rebuild = true;
2303 self.needs_rebuild = true;
2304 return match row {
2305 BreadcrumbRow::CopyPath(path) => {
2306 cce_ui::widget::clipboard::copy_to_clipboard(&path);
2307 None
2308 }
2309 BreadcrumbRow::View(page) => Some(Message::SwitchPage(page)),
2310 };
2311 }
2312 return None;
2313 }
2314 }
2315
2316 // A left RELEASE on the preview pane's corner control opens its menu
2317 // (the designer also opens on release; presses do not reliably reach
2318 // this hook — see above). No press arming: the arming protocol
2319 // disambiguates click from dock-DRAG, and a single pane has nowhere
2320 // to dock.
2321 if button == MouseButton::Left && state == ElementState::Released {
2322 use cce_ui::widget::plate_dock as dock;
2323 let band = self.preview_dock_band();
2324 if let Some(c) = dock::corner_center(band, self.preview_dock.stubbed()) {
2325 if dock::corner_hit(c, pos.x as f32, pos.y as f32) {
2326 let rows = dock::standard_menu(self.preview_dock, false);
2327 let (labels, actions): (Vec<String>, Vec<_>) = rows.into_iter().unzip();
2328 cce_ui::widget::context_menu::show(
2329 c.0 - dock::CORNER_R,
2330 c.1 + dock::CORNER_R,
2331 labels,
2332 0,
2333 self.ui_context[self.paginator].base().id(),
2334 );
2335 self.plate_menu_actions = actions;
2336 *needs_rebuild = true;
2337 self.needs_rebuild = true;
2338 return None;
2339 }
2340 }
2341 }
2342
2343 // The toolkit's shared context menu (breadcrumb, config-bound controls)
2344 // swallows the click — select or dismiss — before any widget routing.
2345 if cce_ui::widget::context_menu::is_visible() {
2346 if cce_ui::widget::context_menu::mouse_input(button, state, pos.x, pos.y, Some(&mut self.ui_context)) {
2347 *needs_rebuild = true;
2348 self.needs_rebuild = true;
2349 }
2350 return None;
2351 }
2352
2353 if let Some(PromptDialog { textbox, .. }) = &mut self.prompt_dialog {
2354 let r = prompt_rects(self.width as f32, self.height as f32);
2355 let (dialog_x, dialog_y, dialog_w, dialog_h) = (r.x, r.y, r.w, r.h);
2356 let (tb_x, tb_y, tb_w, tb_h) = r.tb;
2357 let (btn_cancel_x, btn_cancel_y, btn_cancel_w, btn_cancel_h) = r.cancel;
2358 let (btn_open_x, btn_open_y, btn_open_w, btn_open_h) = r.ok;
2359
2360 if state == ElementState::Pressed {
2361 let clicked_inside = pos.x >= dialog_x && pos.x <= dialog_x + dialog_w && pos.y >= dialog_y && pos.y <= dialog_y + dialog_h;
2362 if !clicked_inside {
2363 self.ui_context[*textbox].unfocus();
2364 self.ui_context.clear_focus();
2365 return Some(Message::PromptCancel);
2366 }
2367
2368 if button == MouseButton::Left {
2369 if pos.x >= tb_x && pos.x <= tb_x + tb_w && pos.y >= tb_y && pos.y <= tb_y + tb_h {
2370 let ev = cce_ui::widget::Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
2371 let root = textbox.id();
2372 if self.ui_context.propagate_event(&ev, root) {
2373 self.ui_context.set_focused_id(textbox.id());
2374 *needs_rebuild = true;
2375 self.needs_rebuild = true;
2376 }
2377 } else if pos.x >= btn_cancel_x && pos.x <= btn_cancel_x + btn_cancel_w && pos.y >= btn_cancel_y && pos.y <= btn_cancel_y + btn_cancel_h {
2378 self.ui_context[*textbox].unfocus();
2379 self.ui_context.clear_focus();
2380 return Some(Message::PromptCancel);
2381 } else if pos.x >= btn_open_x && pos.x <= btn_open_x + btn_open_w && pos.y >= btn_open_y && pos.y <= btn_open_y + btn_open_h {
2382 self.ui_context[*textbox].unfocus();
2383 self.ui_context.clear_focus();
2384 return Some(Message::PromptSubmit);
2385 } else {
2386 self.ui_context[*textbox].unfocus();
2387 self.ui_context.clear_focus();
2388 *needs_rebuild = true;
2389 self.needs_rebuild = true;
2390 }
2391 }
2392 } else {
2393 if button == MouseButton::Left && self.ui_context[*textbox].editing {
2394 let ev = cce_ui::widget::Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
2395 let root = textbox.id();
2396 if self.ui_context.propagate_event(&ev, root) {
2397 *needs_rebuild = true;
2398 self.needs_rebuild = true;
2399 }
2400 }
2401 }
2402 return None;
2403 }
2404
2405 if self.context_menu.visible {
2406 if state == ElementState::Pressed {
2407 let cx = self.context_menu.x;
2408 let cy = self.context_menu.y;
2409 let cw = self.context_menu.w;
2410 let ch = self.context_menu.h;
2411
2412 let mut clicked_option = None;
2413 if pos.x >= cx && pos.x <= cx + cw && pos.y >= cy && pos.y <= cy + ch {
2414 let idx = ((pos.y - cy) / ROW_H) as usize;
2415 if idx < self.context_menu.options.len() && idx > 0 {
2416 clicked_option = self.context_menu.options[idx].1.clone();
2417 }
2418 }
2419
2420 self.context_menu.visible = false;
2421 *needs_rebuild = true;
2422 self.needs_rebuild = true;
2423
2424 if let Some(action) = clicked_option {
2425 return Some(action);
2426 }
2427
2428 if button == MouseButton::Right {
2429 // Fall through to allow right-clicking another item to show a new context menu
2430 } else {
2431 return None;
2432 }
2433 } else {
2434 return None;
2435 }
2436 }
2437
2438 if button == MouseButton::Right && state == ElementState::Pressed {
2439 let crumb = match self.current_page {
2440 Page::Browse => self.browse.breadcrumb,
2441 Page::Network => self.network.breadcrumb,
2442 Page::Space => self.space.breadcrumb,
2443 };
2444 if self.ui_context[crumb].hit_test(pos.x, pos.y, &self.ui_context) {
2445 let ev = cce_ui::widget::Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
2446 let root = crumb.id();
2447 // The breadcrumb opens the toolkit's shared context menu itself
2448 // (open_context_menu → segment header + Copy Path), recording
2449 // the segment pressed. The app then shows that menu again,
2450 // where it stands and under the same header, with the view
2451 // switch added: the toolkit builds a breadcrumb's rows by type
2452 // name and takes none from the app.
2453 if self.ui_context.propagate_event(&ev, root) {
2454 use cce_ui::widget::context_menu as cm;
2455 let breadcrumb = &self.ui_context[crumb];
2456 let seg = breadcrumb.right_clicked_seg.unwrap_or(breadcrumb.path.len());
2457 let (labels, actions) = breadcrumb_menu_rows(breadcrumb.path_to_seg(seg), self.current_page);
2458 let header = cm::options().into_iter().next().unwrap_or_default();
2459 let options: Vec<String> = std::iter::once(header).chain(labels).collect();
2460 let rows = std::iter::once(None).chain(actions).collect();
2461 cm::show(cm::x(), cm::y(), options, 1, root);
2462 self.breadcrumb_menu = Some(BreadcrumbMenu { generation: cm::generation(), rows });
2463 *needs_rebuild = true;
2464 self.needs_rebuild = true;
2465 return None;
2466 }
2467 }
2468
2469 // Row context menu: hit-test the list directly — rows stopped being
2470 // page_buttons when the List became a widget (Phase 6z), so the old
2471 // button probe never fired.
2472 {
2473 let entry_idx = if self.current_page == Page::Browse {
2474 self.browse.list.row_at(pos.x, pos.y)
2475 } else {
2476 None
2477 };
2478
2479 if let Some(idx) = entry_idx {
2480 if let Some(entry) = self.browse.entries.get(idx) {
2481 let is_trash_dir = services::trash::is_trash_files_dir(&self.browse.current_dir);
2482 let header = if entry.is_dir {
2483 format!("[Directory] {}", entry.name)
2484 } else {
2485 format!("[File] {}", entry.name)
2486 };
2487
2488 let mut options = vec![
2489 (header, None),
2490 ];
2491
2492 if entry.is_dir {
2493 if is_project_dir(&entry.path) {
2494 options.push(("Open Project".to_string(), Some(Message::SelectOpen)));
2495 options.push(("Enter Directory".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(entry.path.clone())))));
2496 } else {
2497 options.push(("Open".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NavigateTo(idx)))));
2498 }
2499 } else {
2500 options.push(("Select".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::SelectEntry(idx)))));
2501 }
2502
2503 options.push(("Open with...".to_string(), Some(Message::PromptOpenWith(entry.path.clone()))));
2504 if !is_trash_dir {
2505 options.push(("Rename".to_string(), Some(Message::PromptRename(entry.path.clone()))));
2506 }
2507
2508 if is_trash_dir {
2509 options.push(("Restore".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::RestoreEntry(idx)))));
2510 options.push(("Delete Permanently".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::DeleteEntryPermanent(idx)))));
2511 options.push(("Empty Trash".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::EmptyTrash))));
2512 } else {
2513 options.push(("Delete".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::DeleteEntry(idx)))));
2514 options.push(("New Folder".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NewFolder))));
2515 if let Some(trash_files) = services::trash::files_dir() {
2516 options.push(("Open Trash".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(trash_files)))));
2517 }
2518 }
2519
2520 self.open_context_menu(pos.x, pos.y, options);
2521 *needs_rebuild = true;
2522 self.needs_rebuild = true;
2523 return None;
2524 }
2525 } else if let Some(tile) = (self.current_page == Page::Space)
2526 .then(|| self.space.tile_at(pos.x, pos.y))
2527 .flatten()
2528 .and_then(|idx| self.space.tiles.get(idx))
2529 {
2530 let options = space_tile_menu(tile);
2531 self.open_context_menu(pos.x, pos.y, options);
2532 *needs_rebuild = true;
2533 self.needs_rebuild = true;
2534 return None;
2535 } else if let Some(options) = (self.current_page == Page::Network)
2536 .then(|| {
2537 // Only a node inside the pane: a panned graph's nodes
2538 // run on under the breadcrumb and the preview.
2539 let (x, y, w, h) = self.network.laid_out_for;
2540 let inside = pos.x >= x && pos.x < x + w && pos.y >= y && pos.y < y + h;
2541 inside.then(|| self.ui_context[self.network.graph].inner().node_at(pos.x, pos.y)).flatten()
2542 })
2543 .flatten()
2544 .and_then(|idx| graph_node_menu(idx, &self.browse.current_dir, &self.browse.entries))
2545 {
2546 self.open_context_menu(pos.x, pos.y, options);
2547 *needs_rebuild = true;
2548 self.needs_rebuild = true;
2549 return None;
2550 } else if self.current_page == Page::Browse
2551 && self.browse.list.hit(pos.x, pos.y)
2552 && pos.y <= self.browse.list.y + self.browse.list.viewport_h
2553 && !services::trash::is_trash_files_dir(&self.browse.current_dir)
2554 {
2555 // Empty space below the rows: a menu for the directory itself.
2556 let name = self
2557 .browse
2558 .current_dir
2559 .file_name()
2560 .map(|n| n.to_string_lossy().into_owned())
2561 .unwrap_or_else(|| "/".to_string());
2562 let options = vec![
2563 (format!("[Directory] {}", name), None),
2564 ("New Folder".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NewFolder))),
2565 ];
2566 self.open_context_menu(pos.x, pos.y, options);
2567 *needs_rebuild = true;
2568 self.needs_rebuild = true;
2569 return None;
2570 }
2571 }
2572 }
2573
2574 let mut changed = false;
2575
2576 log::debug!("MOUSE INPUT: {:?} {:?} pos=({}, {})", button, state, pos.x, pos.y);
2577 let ev = cce_ui::widget::Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
2578 let menu_match = !self.select_mode && {
2579 // Self-routing composite: handle_event, not propagate (see pointer move).
2580 self.ui_context.lend_h(self.paginator, |w, ctx| w.handle_event(&ev, ctx)).unwrap_or(false)
2581 };
2582 if menu_match {
2583 if let Some((idx, _)) = self.ui_context[self.paginator].menu_click() {
2584 if idx < Page::ALL.len() {
2585 self.ui_context.clear_focus();
2586 self.current_page = Page::ALL[idx];
2587 }
2588 }
2589 *needs_rebuild = true;
2590 self.needs_rebuild = true;
2591 return None;
2592 }
2593
2594 // The dissolved splitter's divider: a left press grabs it (stealing keyboard
2595 // focus like the legacy ctx.set_focused_ptr / release's clear_focus pair did),
2596 // a release ends the drag. A collapsed preview owns its column width;
2597 // the divider is not draggable until the pane expands.
2598 if button == MouseButton::Left && !self.preview_dock.collapsed {
2599 let split = match self.current_page {
2600 Page::Browse => &mut self.browse_split,
2601 Page::Network => &mut self.network_split,
2602 Page::Space => &mut self.space_split,
2603 };
2604 if state == ElementState::Pressed {
2605 if split.press(pos.x, pos.y) {
2606 self.ui_context.clear_focus();
2607 *needs_rebuild = true;
2608 self.needs_rebuild = true;
2609 return None;
2610 }
2611 } else if split.release() {
2612 *needs_rebuild = true;
2613 self.needs_rebuild = true;
2614 return None;
2615 }
2616 }
2617
2618 // Well focus follows the left click, keyed on RELEASE like the
2619 // plate-dock control above: the routed-widget path consumes left
2620 // PRESSES before this hook, so only releases reliably arrive here.
2621 // Every overlay above the page (plate-dock menu, context menu, dialog,
2622 // divider grab) has already returned, so a release reaching
2623 // here is on page content: move the accent ring to the well it landed
2624 // in, then let the release do its normal work. Releases outside any
2625 // well (toolbar, plate) change nothing.
2626 if button == MouseButton::Left && state == ElementState::Released {
2627 let new_focus = if !self.preview_dock.collapsed
2628 && let Some(well) = self.preview.well_at(pos.x, pos.y)
2629 {
2630 Some(match well {
2631 cce_files::preview_pane::PreviewWell::Top => FocusedWell::PreviewTop,
2632 cce_files::preview_pane::PreviewWell::Bottom => FocusedWell::PreviewBottom,
2633 })
2634 } else {
2635 let split = match self.current_page {
2636 Page::Browse => &self.browse_split,
2637 Page::Network => &self.network_split,
2638 Page::Space => &self.space_split,
2639 };
2640 let (lx, ly, lw, lh) = split.left_rect();
2641 if pos.x >= lx && pos.x <= lx + lw && pos.y >= ly && pos.y <= ly + lh {
2642 Some(FocusedWell::Content)
2643 } else {
2644 None
2645 }
2646 };
2647 if let Some(f) = new_focus
2648 && f != self.focused_well
2649 {
2650 self.focused_well = f;
2651 *needs_rebuild = true;
2652 self.needs_rebuild = true;
2653 }
2654 }
2655
2656 if self.current_page == Page::Browse {
2657 if self.select_mode {
2658 if { let root = self.browse.save_name_box.id(); self.ui_context.propagate_event(&ev, root) } {
2659 if state == ElementState::Pressed {
2660 self.ui_context.set_focused_id(self.browse.save_name_box.id());
2661 }
2662 *needs_rebuild = true;
2663 self.needs_rebuild = true;
2664 }
2665 }
2666 if self.browse.search_visible
2667 && button == MouseButton::Left
2668 && { let root = self.browse.search_box.id(); self.ui_context.propagate_event(&ev, root) }
2669 {
2670 self.ui_context.set_focused_id(self.browse.search_box.id());
2671 *needs_rebuild = true;
2672 self.needs_rebuild = true;
2673 }
2674 if button == MouseButton::Left
2675 && self.browse.list.mouse_input(state == ElementState::Pressed, pos.x, pos.y)
2676 {
2677 *needs_rebuild = true;
2678 self.needs_rebuild = true;
2679 if let Some(idx) = self.browse.list.take_double_click() {
2680 return Some(Message::Browse(pages::browse::BrowseMessage::NavigateTo(idx)));
2681 }
2682 if let Some(idx) = self.browse.list.take_click() {
2683 return Some(Message::Browse(pages::browse::BrowseMessage::SelectEntry(idx)));
2684 }
2685 }
2686 if button == MouseButton::Left && state == ElementState::Pressed {
2687 if self.ui_context[self.browse.breadcrumb].hit_test(pos.x, pos.y, &self.ui_context) {
2688 if { let root = self.browse.breadcrumb.id(); self.ui_context.propagate_event(&ev, root) } {
2689 if let Some(seg) = self.ui_context[self.browse.breadcrumb].path_click() {
2690 let target_path = pages::browse::path_to_segment(&self.browse.current_dir, seg);
2691 self.fs_service.send(services::fs::FsRequest::ReadDirectory(target_path));
2692 *needs_rebuild = true;
2693 self.needs_rebuild = true;
2694 }
2695 }
2696 }
2697 }
2698 } else if self.current_page == Page::Network {
2699 if button == MouseButton::Left {
2700 if state == ElementState::Pressed {
2701 if self.ui_context[self.network.breadcrumb].hit_test(pos.x, pos.y, &self.ui_context) {
2702 if { let root = self.network.breadcrumb.id(); self.ui_context.propagate_event(&ev, root) } {
2703 if let Some(seg) = self.ui_context[self.network.breadcrumb].path_click() {
2704 let target_path = pages::browse::path_to_segment(&self.browse.current_dir, seg);
2705 self.fs_service.send(services::fs::FsRequest::ReadDirectory(target_path));
2706 changed = true;
2707 }
2708 }
2709 } else if {
2710 // Routed press: a node grab records the drag target; the router's
2711 // DragStart replaces the immediate drag_begin (3px threshold).
2712 let root = self.network.graph.id();
2713 self.ui_context.propagate_event(&ev, root)
2714 } {
2715 changed = true;
2716 }
2717 } else if state == ElementState::Released {
2718 // The router delivers DragEnd (commit) before the release reaches
2719 // Graph; a committed drag leaves the release arm inert.
2720 let was_dragging = self.ui_context.is_dragging;
2721 let root = self.network.graph.id();
2722 if self.ui_context.propagate_event(&ev, root) || was_dragging {
2723 changed = true;
2724 }
2725 }
2726 }
2727
2728 // Sync selection from graph to browse state
2729 let has_parent = self.browse.current_dir.parent().is_some();
2730 let offset = if has_parent { 2 } else { 1 };
2731 if let Some(node_sel) = self.ui_context[self.network.graph].selected_node() {
2732 if node_sel >= offset {
2733 let entry_idx = node_sel - offset;
2734 if self.browse.selected != Some(entry_idx) {
2735 self.browse.selected = Some(entry_idx);
2736 if let Some(entry) = self.browse.entries.get(entry_idx) {
2737 if self.select_mode {
2738 self.ui_context[self.browse.save_name_box].text = entry.name.clone();
2739 if self.ui_context[self.browse.save_name_box].editing {
2740 self.ui_context[self.browse.save_name_box].edit_buffer = entry.name.clone();
2741 }
2742 }
2743 let path = entry.path.clone();
2744 self.request_preview(path, false);
2745 }
2746 changed = true;
2747 }
2748 } else {
2749 if self.browse.selected.is_some() {
2750 self.browse.selected = None;
2751 self.clear_preview();
2752 changed = true;
2753 }
2754 }
2755 } else {
2756 if self.browse.selected.is_some() {
2757 self.browse.selected = None;
2758 self.clear_preview();
2759 changed = true;
2760 }
2761 }
2762
2763 // Sync double click navigation
2764 if let Some(dbl_idx) = self.ui_context[self.network.graph].double_clicked_node() {
2765 self.ui_context[self.network.graph].clear_double_clicked_node();
2766 if has_parent && dbl_idx == 0 {
2767 if let Some(parent) = self.browse.current_dir.parent() {
2768 let parent_path = parent.to_path_buf();
2769 self.fs_service.send(services::fs::FsRequest::ReadDirectory(parent_path));
2770 changed = true;
2771 }
2772 } else if dbl_idx >= offset {
2773 let entry_idx = dbl_idx - offset;
2774 if let Some(entry) = self.browse.entries.get(entry_idx) {
2775 if entry.is_dir && !is_project_dir(&entry.path) {
2776 let path = entry.path.clone();
2777 self.fs_service.send(services::fs::FsRequest::ReadDirectory(path));
2778 changed = true;
2779 } else if self.select_mode {
2780 self.ui_context[self.browse.save_name_box].text = entry.name.clone();
2781 if self.ui_context[self.browse.save_name_box].editing {
2782 self.ui_context[self.browse.save_name_box].edit_buffer = entry.name.clone();
2783 }
2784 return Some(Message::SelectOpen);
2785 }
2786 }
2787 }
2788 }
2789
2790 if changed {
2791 *needs_rebuild = true;
2792 self.needs_rebuild = true;
2793 }
2794 } else if self.current_page == Page::Space {
2795 if button == MouseButton::Left && state == ElementState::Pressed {
2796 if self.ui_context[self.space.breadcrumb].hit_test(pos.x, pos.y, &self.ui_context) {
2797 if { let root = self.space.breadcrumb.id(); self.ui_context.propagate_event(&ev, root) } {
2798 if let Some(seg) = self.ui_context[self.space.breadcrumb].path_click() {
2799 let target_path = pages::browse::path_to_segment(&self.browse.current_dir, seg);
2800 self.fs_service.send(services::fs::FsRequest::ReadDirectory(target_path));
2801 changed = true;
2802 }
2803 }
2804 } else if let Some(idx) = self.space.tile_at(pos.x, pos.y) {
2805 let tile_path = self.space.tiles[idx].path.clone();
2806 let is_dir = self.space.tiles[idx].is_dir;
2807
2808 // Same temporal double-click as the Browse list — the
2809 // toolkit does not deliver a double-click event.
2810 let now = std::time::Instant::now();
2811 let is_double = self.last_space_path.as_ref() == Some(&tile_path)
2812 && now.duration_since(self.last_space_click_time).as_millis() < 500;
2813 self.last_space_click_time = now;
2814 self.last_space_path = Some(tile_path.clone());
2815
2816 if is_double {
2817 if is_dir {
2818 // Navigating re-roots the map: ReadDirectory moves
2819 // current_dir, and ensure_space_scan rescans it.
2820 self.fs_service.send(services::fs::FsRequest::ReadDirectory(tile_path));
2821 } else {
2822 services::fs::open_file(&tile_path);
2823 }
2824 } else {
2825 self.space.selected_path = Some(tile_path.clone());
2826 self.request_preview(tile_path, false);
2827 }
2828 changed = true;
2829 }
2830 }
2831 if changed {
2832 *needs_rebuild = true;
2833 self.needs_rebuild = true;
2834 }
2835 }
2836
2837 if state == ElementState::Pressed {
2838 let clicked_search = self.current_page == Page::Browse && self.browse.search_visible && self.ui_context[self.browse.search_box].hit_test(pos.x, pos.y, &self.ui_context);
2839 let clicked_save_name = self.select_mode && self.current_page == Page::Browse && self.ui_context[self.browse.save_name_box].hit_test(pos.x, pos.y, &self.ui_context);
2840 if !clicked_search {
2841 self.ui_context.unfocus_id(self.browse.search_box.id());
2842 }
2843 if !clicked_save_name {
2844 self.ui_context.unfocus_id(self.browse.save_name_box.id());
2845 }
2846 if !clicked_search && !clicked_save_name {
2847 self.ui_context.clear_focus();
2848 *needs_rebuild = true;
2849 self.needs_rebuild = true;
2850 }
2851 }
2852
2853 if button == MouseButton::Left && state == ElementState::Released {
2854 for (btn, action) in &self.page_buttons {
2855 let base = btn.base();
2856 if pos.x >= base.x && pos.x <= base.x + base.w && pos.y >= base.y && pos.y <= base.y + base.h {
2857 *needs_rebuild = true;
2858 self.needs_rebuild = true;
2859 return Some(action.clone());
2860 }
2861 }
2862 }
2863
2864 None
2865 }
2866
2867 fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
2868 // Every page shares the preview pane on the right.
2869 if matches!(self.current_page, Page::Browse | Page::Network | Page::Space) {
2870 // The pane hit-tests its own laid-out rect and consumes any wheel
2871 // over its content region, scrolled or not.
2872 if let Some(changed) = (!self.preview_dock.collapsed)
2873 .then(|| self.preview.wheel(delta, pos.x as f32, pos.y as f32))
2874 .flatten()
2875 {
2876 if cce_ui::scroll_debug() {
2877 eprintln!("[scroll] files: preview consumed at ({:.0},{:.0})", pos.x, pos.y);
2878 }
2879 if changed {
2880 *needs_rebuild = true;
2881 self.needs_rebuild = true;
2882 }
2883 return;
2884 }
2885 }
2886
2887 if self.current_page == Page::Browse {
2888 let hit = self.browse.list.wheel(delta, pos.x, pos.y);
2889 if cce_ui::scroll_debug() {
2890 eprintln!("[scroll] files: browse.list.wheel at ({:.0},{:.0}) -> {hit}", pos.x, pos.y);
2891 }
2892 if hit {
2893 // A trackpad finger moves the rows now: keep the hover on the
2894 // row under the pointer (a wheel glide does this in tick).
2895 self.browse.list.cursor_moved(self.cursor_x, self.cursor_y);
2896 *needs_rebuild = true;
2897 self.needs_rebuild = true;
2898 }
2899 } else if self.current_page == Page::Network {
2900 let ev = cce_ui::widget::Event::MouseWheel { delta: *delta, x: pos.x as f32, y: pos.y as f32, local_x: pos.x as f32, local_y: pos.y as f32 };
2901 let root = self.network.graph.id();
2902 if self.ui_context.propagate_event(&ev, root) {
2903 *needs_rebuild = true;
2904 self.needs_rebuild = true;
2905 }
2906 }
2907 }
2908
2909 /// Tab walks the toolbar's plates and wells (cce-ui's navigation in
2910 /// plate terms); the file rows are the list's to walk.
2911 fn plate_navigation(&self) -> bool {
2912 true
2913 }
2914
2915 /// The geometry is cached until the next rebuild — a moved focus ring needs one.
2916 fn focus_stepped(&mut self) {
2917 self.needs_rebuild = true;
2918 }
2919
2920 fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
2921 if event.state != ElementState::Pressed {
2922 return None;
2923 }
2924
2925 if let Some(PromptDialog { textbox, .. }) = &mut self.prompt_dialog {
2926 if event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Enter) {
2927 self.ui_context[*textbox].unfocus();
2928 self.ui_context.clear_focus();
2929 return Some(Message::PromptSubmit);
2930 }
2931 if event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Escape) {
2932 self.ui_context[*textbox].unfocus();
2933 self.ui_context.clear_focus();
2934 return Some(Message::PromptCancel);
2935 }
2936 let kev = cce_ui::widget::Event::KeyInput(event.clone());
2937 let root = textbox.id();
2938 if self.ui_context.propagate_event(&kev, root) {
2939 *needs_rebuild = true;
2940 self.needs_rebuild = true;
2941 }
2942 return None;
2943 }
2944
2945 if self.current_page == Page::Network {
2946 let kev = cce_ui::widget::Event::KeyInput(event.clone());
2947 let root = self.network.graph.id();
2948 if self.ui_context.propagate_event(&kev, root) {
2949 *needs_rebuild = true;
2950 self.needs_rebuild = true;
2951 return None;
2952 }
2953 }
2954
2955 if self.context_menu.visible {
2956 if event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Escape) {
2957 self.context_menu.visible = false;
2958 *needs_rebuild = true;
2959 self.needs_rebuild = true;
2960 return None;
2961 }
2962 }
2963
2964 // The dissolved List's search keys, app-side: the open shortcut shows the strip
2965 // and focuses the box; the close shortcut hides it and clears the filter (the
2966 // legacy List set just_changed after clearing, which surfaced as an empty
2967 // SearchChanged); anything else goes to the box while it is open.
2968 if self.current_page == Page::Browse {
2969 if !self.browse.search_visible {
2970 let open_key = cce_ui::color::list_open_search_key();
2971 if cce_ui::widget::match_key_shortcut(event, &open_key) {
2972 self.browse.search_visible = true;
2973 self.ui_context.focus_id(self.browse.search_box.id());
2974 self.ui_context.set_focused_id(self.browse.search_box.id());
2975 *needs_rebuild = true;
2976 self.needs_rebuild = true;
2977 return None;
2978 }
2979 } else {
2980 let close_key = cce_ui::color::list_close_search_key();
2981 if cce_ui::widget::match_key_shortcut(event, &close_key) {
2982 self.browse.search_visible = false;
2983 self.ui_context.unfocus_id(self.browse.search_box.id());
2984 self.ui_context.clear_focus();
2985 *needs_rebuild = true;
2986 self.needs_rebuild = true;
2987 return Some(Message::Browse(pages::browse::BrowseMessage::SearchChanged(String::new())));
2988 }
2989 if { let kev = cce_ui::widget::Event::KeyInput(event.clone()); let root = self.browse.search_box.id(); self.ui_context.propagate_event(&kev, root) } {
2990 *needs_rebuild = true;
2991 self.needs_rebuild = true;
2992 if self.ui_context[self.browse.search_box].take_change() {
2993 return Some(Message::Browse(pages::browse::BrowseMessage::SearchChanged(
2994 self.ui_context[self.browse.search_box].text.clone()
2995 )));
2996 }
2997 return None;
2998 }
2999 }
3000 }
3001
3002 // If the save_name_box is focused, forward key inputs to it
3003 if self.select_mode && self.ui_context[self.browse.save_name_box].editing {
3004 if { let kev = cce_ui::widget::Event::KeyInput(event.clone()); let root = self.browse.save_name_box.id(); self.ui_context.propagate_event(&kev, root) } {
3005 *needs_rebuild = true;
3006 self.needs_rebuild = true;
3007 if event.logical_key == cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Enter) {
3008 self.ui_context.unfocus_id(self.browse.save_name_box.id());
3009 self.ui_context.clear_focus();
3010 return Some(Message::SelectOpen);
3011 }
3012 return None;
3013 }
3014 }
3015
3016 // Global key navigation. Arrow keys, Backspace, and Escape are fixed;
3017 // the rest resolve through input.kdl (see BrowseKeys).
3018 if self.current_page == Page::Browse {
3019 match &event.logical_key {
3020 cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::ArrowUp) => {
3021 if let Some(index) = pages::browse::next_selection_index(&self.browse, pages::browse::BrowseNavigation::Up) {
3022 return Some(Message::Browse(pages::browse::BrowseMessage::SelectEntry(index)));
3023 }
3024 }
3025 cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::ArrowDown) => {
3026 if let Some(index) = pages::browse::next_selection_index(&self.browse, pages::browse::BrowseNavigation::Down) {
3027 return Some(Message::Browse(pages::browse::BrowseMessage::SelectEntry(index)));
3028 }
3029 }
3030 cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Escape) => {
3031 if self.select_mode {
3032 std::process::exit(1);
3033 }
3034 }
3035 cce_ui::widget::Key::Named(cce_ui::widget::NamedKey::Backspace) => {
3036 if let Some(parent) = self.browse.current_dir.parent() {
3037 return Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(parent.to_path_buf())));
3038 }
3039 }
3040 _ => {}
3041 }
3042
3043 let m = |chord: &str| cce_ui::widget::match_key_shortcut(event, chord);
3044 if m(&self.keys.open_file) {
3045 if let Some(idx) = self.browse.selected {
3046 if let Some(entry) = self.browse.entries.get(idx) {
3047 if entry.is_dir && !is_project_dir(&entry.path) {
3048 return Some(Message::Browse(pages::browse::BrowseMessage::NavigateTo(idx)));
3049 } else {
3050 return Some(Message::SelectOpen);
3051 }
3052 }
3053 } else if self.save_mode {
3054 return Some(Message::SelectOpen);
3055 }
3056 } else if m(&self.keys.select_next) {
3057 if let Some(index) = pages::browse::next_selection_index(&self.browse, pages::browse::BrowseNavigation::Down) {
3058 return Some(Message::Browse(pages::browse::BrowseMessage::SelectEntry(index)));
3059 }
3060 } else if m(&self.keys.select_prev) {
3061 if let Some(index) = pages::browse::next_selection_index(&self.browse, pages::browse::BrowseNavigation::Up) {
3062 return Some(Message::Browse(pages::browse::BrowseMessage::SelectEntry(index)));
3063 }
3064 } else if m(&self.keys.enter_dir) {
3065 if let Some(idx) = self.browse.selected {
3066 if let Some(entry) = self.browse.entries.get(idx) {
3067 if entry.is_dir && !is_project_dir(&entry.path) {
3068 return Some(Message::Browse(pages::browse::BrowseMessage::NavigateTo(idx)));
3069 }
3070 }
3071 }
3072 } else if m(&self.keys.parent_dir) {
3073 if let Some(parent) = self.browse.current_dir.parent() {
3074 return Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(parent.to_path_buf())));
3075 }
3076 } else if m(&self.keys.delete_entry) {
3077 if let Some(idx) = self.browse.selected {
3078 return Some(Message::Browse(pages::browse::BrowseMessage::DeleteEntry(idx)));
3079 }
3080 } else if m(&self.keys.toggle_hidden) {
3081 return Some(Message::Browse(pages::browse::BrowseMessage::ToggleHidden));
3082 }
3083 }
3084
3085 None
3086 }
3087 }
3088
3089 // ── Main ────────────────────────────────────────────────────────────
3090
3091 #[tokio::main]
3092 async fn main() {
3093 cce_ui::engine::run::<FilesystemApp>();
3094 }
3095
3096 #[cfg(test)]
3097 mod tests {
3098 use super::*;
3099
3100 const WIN: [f32; 4] = [0.0, 0.0, 1000.0, 700.0];
3101
3102 fn rect_overlay(x: f32, y: f32, w: f32, h: f32) -> pages::PageContent {
3103 let mut pc = pages::PageContent::new();
3104 pc.rect([0.0, 0.0, 0.0, 1.0], x, y, w, h);
3105 pc
3106 }
3107
3108 fn plate_overlay(x: f32, y: f32, w: f32, h: f32) -> pages::PageContent {
3109 let mut pc = pages::PageContent::new();
3110 pc.plates.push(([0.0, 0.0, 0.0, 1.0], x, y, w, h, 8.0, 4.0));
3111 pc
3112 }
3113
3114 /// A box the overlay covers outright is discarded, not clipped.
3115 #[test]
3116 fn a_covered_box_is_dropped() {
3117 let menu = rect_overlay(300.0, 253.0, 430.0, 120.0);
3118 assert!(occlude_against(WIN, 320.0, 400.0, 300.0, 317.0, &[&menu]).is_none());
3119 }
3120
3121 /// An overlay's face is a plate now, so the sweep has to see plates or the
3122 /// page's text draws straight through the surface covering it.
3123 #[test]
3124 fn a_plate_occludes_like_a_rect() {
3125 let menu = plate_overlay(300.0, 253.0, 430.0, 120.0);
3126 assert!(occlude_against(WIN, 320.0, 400.0, 300.0, 317.0, &[&menu]).is_none());
3127 }
3128
3129 /// The regression. A file-list row's name sits well left of the menu, but the
3130 /// row's box runs under it, and the hovered menu row's fill dips into that
3131 /// box's band from below without spanning it horizontally. Clamping both axes
3132 /// crossed the y bounds over into an empty band and the whole line vanished,
3133 /// the name along with the part under the menu. Only the x cut is legitimate
3134 /// here: the name must survive whole.
3135 #[test]
3136 fn an_overlay_that_only_dips_in_does_not_erase_the_line() {
3137 let menu = rect_overlay(300.0, 253.0, 430.0, 120.0);
3138 let hover = rect_overlay(305.0, 294.0, 420.0, 20.0);
3139
3140 let b = occlude_against(WIN, 40.0, 420.0, 288.0, 305.0, &[&menu, &hover])
3141 .expect("a line the overlay only dips into still has a visible run");
3142
3143 assert_eq!(b[2], 300.0, "clipped at the menu's left edge");
3144 assert_eq!(b[3], WIN[3], "and NOT clipped vertically — that is the erasure");
3145 assert_eq!(b[1], WIN[1]);
3146 assert_eq!(b[0], WIN[0]);
3147 }
3148
3149 /// The y cut is still right when the overlay spans the box horizontally: no
3150 /// horizontal trim can uncover anything, so the surviving run is the strip
3151 /// above (here) or below the overlay.
3152 #[test]
3153 fn an_overlay_spanning_the_box_clips_it_vertically() {
3154 let menu = rect_overlay(300.0, 253.0, 430.0, 120.0);
3155 let b = occlude_against(WIN, 320.0, 400.0, 240.0, 257.0, &[&menu]).expect("straddles the top");
3156 assert_eq!(b[3], 253.0, "clipped to the strip above the menu");
3157 assert_eq!(b[2], WIN[2], "and not trimmed horizontally");
3158 }
3159
3160 /// Of the two sides an overlay leaves, the cut keeps the wider one — the most
3161 /// of the box that a single clip rect can still express.
3162 #[test]
3163 fn the_cut_keeps_the_wider_side() {
3164 // Overlay near the box's right end: the long run before it survives.
3165 let right = rect_overlay(380.0, 0.0, 200.0, 700.0);
3166 let b = occlude_against(WIN, 40.0, 420.0, 288.0, 305.0, &[&right]).unwrap();
3167 assert_eq!(b[2], 380.0);
3168 assert_eq!(b[0], WIN[0]);
3169
3170 // Overlay near the left end: the run after it survives instead.
3171 let left = rect_overlay(0.0, 0.0, 80.0, 700.0);
3172 let b = occlude_against(WIN, 40.0, 420.0, 288.0, 305.0, &[&left]).unwrap();
3173 assert_eq!(b[0], 80.0);
3174 assert_eq!(b[2], WIN[2]);
3175 }
3176
3177 #[test]
3178 fn the_breadcrumb_menu_offers_the_other_views() {
3179 let (labels, rows) = breadcrumb_menu_rows("/home/me".to_string(), Page::Network);
3180 assert_eq!(labels, ["Copy Path", "-", "List", "Space"]);
3181 assert_eq!(rows, [
3182 Some(BreadcrumbRow::CopyPath("/home/me".to_string())),
3183 None,
3184 Some(BreadcrumbRow::View(Page::Browse)),
3185 Some(BreadcrumbRow::View(Page::Space)),
3186 ]);
3187 for page in Page::ALL {
3188 let (labels, rows) = breadcrumb_menu_rows(String::new(), page);
3189 assert_eq!(labels.len(), rows.len(), "a row for every label");
3190 assert!(!rows.contains(&Some(BreadcrumbRow::View(page))), "{page:?} offers itself");
3191 }
3192 }
3193
3194 #[test]
3195 fn a_space_tile_menu_copies_its_path() {
3196 let tile = |name: &str, is_dir, depth, rest| pages::space::Tile {
3197 path: std::path::PathBuf::from("/root").join(name),
3198 name: name.to_string(),
3199 size: 1,
3200 is_dir,
3201 depth,
3202 rect: (0.0, 0.0, 10.0, 10.0),
3203 aggregate: None,
3204 rest,
3205 };
3206 let labels = |t| space_tile_menu(&t).into_iter().map(|(l, _)| l).collect::<Vec<_>>();
3207 assert_eq!(labels(tile("a.txt", false, 2, 0)), ["[File] a.txt", "Open", "Open with...", "Copy Path"]);
3208 assert_eq!(labels(tile("sub", true, 1, 0)), ["[Directory] sub", "Open", "Copy Path"]);
3209 assert_eq!(labels(tile("here", true, 0, 0)), ["[Directory] here", "Copy Path"]);
3210 assert_eq!(labels(tile("3 smaller items", true, 2, 3)), ["3 smaller items", "Open Folder", "Copy Folder Path"]);
3211
3212 let menu = space_tile_menu(&tile("a.txt", false, 2, 0));
3213 assert!(menu[0].1.is_none(), "the header does nothing");
3214 assert!(matches!(&menu.last().unwrap().1, Some(Message::CopyPath(p)) if p == "/root/a.txt"));
3215 }
3216
3217 #[test]
3218 fn a_graph_node_menu_follows_the_node_order() {
3219 let entry = |name: &str, is_dir| pages::browse::DirEntry {
3220 name: name.to_string(),
3221 path: std::path::PathBuf::from("/home/me").join(name),
3222 is_dir,
3223 size: 0,
3224 permissions: 0,
3225 modified: String::new(),
3226 origin: None,
3227 };
3228 let entries = [entry("docs", true), entry("a.txt", false)];
3229 let dir = std::path::Path::new("/home/me");
3230 let labels = |idx| graph_node_menu(idx, dir, &entries).map(|m| m.into_iter().map(|(l, _)| l).collect::<Vec<_>>());
3231 assert_eq!(labels(0).unwrap(), ["[Directory] home", "Open", "Copy Path"]);
3232 assert_eq!(labels(1).unwrap(), ["[Directory] me", "Copy Path"]);
3233 assert_eq!(labels(2).unwrap(), ["[Directory] docs", "Open", "Copy Path"]);
3234 assert_eq!(labels(3).unwrap(), ["[File] a.txt", "Open", "Open with...", "Copy Path"]);
3235 assert!(labels(4).is_none());
3236
3237 let menu = graph_node_menu(3, dir, &entries).unwrap();
3238 assert!(matches!(&menu.last().unwrap().1, Some(Message::CopyPath(p)) if p == "/home/me/a.txt"));
3239 // At the root there is no parent node: node 0 is the root itself.
3240 let root = graph_node_menu(0, std::path::Path::new("/"), &[]).unwrap();
3241 assert_eq!(root[0].0, "[Directory] /");
3242 assert_eq!(root.len(), 2);
3243 }
3244 }