git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

src/main.rs (126.3K)

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