git.lucas.co / cce-files
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 }