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 }