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

commit805be06798d167ded2982f975ccd33e66157fe62
parent2f3622a9aa
authorLucas Galante <[email protected]>
date2026-08-25 15:18
feat: the preview pane detaches into its own window (cce-ui RFC 7c)

The first non-designer detach. The corner menu's Detach row moves the
preview pane out into a `cce-files --preview-window <sync-file>` child —
a second Application in the same binary whose root plate is the detached
role (all four corners on the window silhouette); the pane column stubs
through the same prior-fracs machinery collapse uses, and the list keeps
the width.

Process model, following the designer's rules where they exist:
- The sync file is on disk BEFORE the child spawns: parent pid, then the
  selected path. The parent rewrites it on every selection change; the
  child polls it at 100ms.
- The child's Reattach IS exit. The parent try_waits from tick (not a
  signal probe — the unreaped child would be a zombie the probe calls
  alive forever) and takes the pane back, so closing the window by any
  path reattaches: corner menu, compositor close, crash.
- The reverse rule guards orphans: the child exits when the sync file
  disappears (parent reattached or quit) or the parent pid is gone (a
  crash, or a session restore respawning it with stale state).

The child bridges the pane's PageContent onto PaintCtx through the
RenderTarget methods — the flat subset of the parent's page bridge, no
viewport clipping because the window IS the pane. Its Reattach menu is
drawn by the app: the toolkit context menu dispatches through a widget
tree this window does not have.

Verified end to end in a shadow session, all by injected pointer:
Detach spawns the child at the silhouette with live content; selecting
in the parent rewrites the sync file; the child's corner menu Reattach
exits it and the parent restores the pane and unlinks the file; an
orphaned child (parent pid dead) exits on its own within a poll.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/lib.rs            |   1 +
 src/main.rs           | 126 ++++++++++++++++++--
 src/preview_window.rs | 320 ++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 437 insertions(+), 10 deletions(-)

diff --git a/src/lib.rs b/src/lib.rs
index 7c3ce77..fafc785 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,6 @@
 pub mod pages;
 pub mod preview_pane;
+pub mod preview_window;
 pub mod row_list;
 pub mod services;
 pub mod util;
diff --git a/src/main.rs b/src/main.rs
index 45c35ad..8b416f3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -364,6 +364,14 @@ struct FilesystemApp {
     /// Rows of the OPEN plate-dock corner menu (empty = not ours); routed
     /// before the generic context-menu dispatch.
     plate_menu_actions: Vec<cce_ui::widget::plate_dock::PlateDockAction>,
+    /// The detached preview window's process, while one is out (cce-ui RFC
+    /// 7c). Reaped from tick — the child's own Reattach is exiting, so a
+    /// closed window takes the pane back by the same path.
+    preview_child: Option<std::process::Child>,
+    /// The sync file the detached preview polls: parent pid, then the
+    /// selected path. Written before the spawn, rewritten per selection,
+    /// unlinked on reattach — the child exits when it disappears.
+    preview_sync_path: Option<std::path::PathBuf>,
     // Space's double-click is tracked by path, not row index: its tiles are
     // renumbered by every relayout, so an index would not survive a resize.
     last_space_click_time: std::time::Instant,
@@ -469,13 +477,82 @@ impl FilesystemApp {
                 }
                 self.preview_dock.collapsed = false;
             }
-            // No detach model here (yet): standard_menu is called with
-            // can_detach = false, so these rows never appear.
-            PlateDockAction::Detach | PlateDockAction::Reattach => {}
+            PlateDockAction::Detach => self.detach_preview(),
+            PlateDockAction::Reattach => self.reattach_preview(),
         }
         self.needs_rebuild = true;
     }
 
+    /// Move the preview pane out into its own window (cce-ui RFC 7c): write
+    /// the sync file FIRST (the designer's rule — shared state is on disk
+    /// before the child starts), spawn ourselves in `--preview-window` mode,
+    /// and stub the pane. The pane column narrows exactly as collapse does,
+    /// through the same prior-fracs machinery.
+    fn detach_preview(&mut self) {
+        if self.preview_dock.detached {
+            return;
+        }
+        let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
+        let sync = std::path::Path::new(&dir)
+            .join(format!("cce-files-preview-{}", std::process::id()));
+        if let Err(e) = self.write_preview_sync(&sync) {
+            log::error!("preview detach: cannot write sync file: {e}");
+            return;
+        }
+        let exe = match std::env::current_exe() {
+            Ok(e) => e,
+            Err(e) => {
+                log::error!("preview detach: cannot locate own executable: {e}");
+                return;
+            }
+        };
+        match std::process::Command::new(exe).arg("--preview-window").arg(&sync).spawn() {
+            Ok(child) => {
+                self.preview_child = Some(child);
+                self.preview_sync_path = Some(sync);
+                self.preview_prior_fracs =
+                    Some((self.browse_split.frac, self.network_split.frac, self.space_split.frac));
+                self.preview_dock.detached = true;
+            }
+            // Leave the pane in place if the child never started, rather than
+            // hiding it into a window that does not exist (the designer's rule).
+            Err(e) => {
+                let _ = std::fs::remove_file(&sync);
+                log::error!("preview detach: spawn failed: {e}");
+            }
+        }
+    }
+
+    /// Take the pane back. Killing the child is best-effort — a window the
+    /// user already closed is simply gone, and reattach must work anyway.
+    fn reattach_preview(&mut self) {
+        if let Some(mut child) = self.preview_child.take() {
+            let _ = child.kill();
+            // Reap, or the process table keeps a zombie for the session.
+            let _ = child.wait();
+        }
+        if let Some(p) = self.preview_sync_path.take() {
+            let _ = std::fs::remove_file(p);
+        }
+        self.preview_dock.detached = false;
+        if let Some((b, n, sp)) = self.preview_prior_fracs.take() {
+            self.browse_split.frac = b;
+            self.network_split.frac = n;
+            self.space_split.frac = sp;
+        }
+    }
+
+    /// (Re)write the detached preview's sync file: parent pid, then the
+    /// selected path when there is one.
+    fn write_preview_sync(&self, path: &std::path::Path) -> std::io::Result<()> {
+        let mut content = format!("{}\n", std::process::id());
+        if let Some(sel) = self.browse.selected_path() {
+            content.push_str(&sel.to_string_lossy());
+            content.push('\n');
+        }
+        std::fs::write(path, content)
+    }
+
     fn rebuild_layout(&mut self) {
 
         self.ui_context.clear_hierarchy();
@@ -546,7 +623,7 @@ impl FilesystemApp {
         // Collapsed preview (plate-dock, cce-ui RFC 7c-2): every page's pane
         // column narrows to the stub's width — re-forced each layout so a
         // window resize keeps the stub fixed while the list takes the rest.
-        if self.preview_dock.collapsed {
+        if self.preview_dock.stubbed() {
             for split in [&mut self.browse_split, &mut self.network_split, &mut self.space_split] {
                 let combined = (split.w - split.gap).max(1.0);
                 split.frac = (1.0 - PREVIEW_STUB_W / combined).clamp(0.0, 1.0);
@@ -589,10 +666,11 @@ impl FilesystemApp {
                         Page::Network => &self.network_split,
                         Page::Space => &self.space_split,
                     };
-                    // A collapsed preview draws neither divider nor content —
-                    // the stub band and its corner control paint in
-                    // display_list, over everything (cce-ui RFC 7c-2).
-                    if !self.preview_dock.collapsed {
+                    // A stubbed preview (collapsed or detached) draws neither
+                    // divider nor content — the stub band and its corner
+                    // control paint in display_list, over everything (cce-ui
+                    // RFC 7c-2/7c).
+                    if !self.preview_dock.stubbed() {
                         if let Some((dx, dy, dw, dh, dc)) = split.divider_quad() {
                             plain_pc.rects.push((dc, dx, dy, dw, dh, 0.0, (true, true, true, true)));
                         }
@@ -1107,6 +1185,8 @@ impl Application for FilesystemApp {
             preview_dock: Default::default(),
             preview_prior_fracs: None,
             plate_menu_actions: Vec::new(),
+            preview_child: None,
+            preview_sync_path: None,
             select_mode,
             select_directory,
             save_mode,
@@ -1245,6 +1325,13 @@ impl Application for FilesystemApp {
                 } else {
                     pages::preview::update(&mut self.preview, pages::preview::PreviewMessage::Clear);
                 }
+                // A detached preview follows the selection through its sync
+                // file (it polls; see preview_window.rs).
+                if let Some(sync) = self.preview_sync_path.clone() {
+                    if let Err(e) = self.write_preview_sync(&sync) {
+                        log::warn!("preview sync write failed: {e}");
+                    }
+                }
 
                 if let Some(idx) = self.browse.selected {
                     if let Some(entry) = self.browse.entries.get(idx) {
@@ -1394,6 +1481,19 @@ impl Application for FilesystemApp {
             *needs_rebuild = true;
             self.needs_rebuild = true;
         }
+
+        // Notice a detached preview the user closed themselves and take the
+        // pane back — its Reattach IS exiting. try_wait, NOT a signal probe:
+        // the child is ours and unreaped, so once it exits it is a zombie the
+        // probe would report alive forever (the designer's lesson, verbatim).
+        if let Some(child) = self.preview_child.as_mut() {
+            if matches!(child.try_wait(), Ok(Some(_)) | Err(_)) {
+                self.preview_child = None;
+                self.reattach_preview();
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+            }
+        }
     }
 
     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
@@ -1757,7 +1857,7 @@ impl Application for FilesystemApp {
             let band = self.preview_dock_band();
             if let Some(c) = dock::corner_center(band, self.preview_dock.stubbed()) {
                 if dock::corner_hit(c, pos.x as f32, pos.y as f32) {
-                    let rows = dock::standard_menu(self.preview_dock, false);
+                    let rows = dock::standard_menu(self.preview_dock, true);
                     let (labels, actions): (Vec<String>, Vec<_>) = rows.into_iter().unzip();
                     cce_ui::widget::context_menu::show(
                         c.0 - dock::CORNER_R,
@@ -1995,7 +2095,7 @@ impl Application for FilesystemApp {
         // focus like the legacy ctx.set_focused_ptr / release's clear_focus pair did),
         // a release ends the drag. A collapsed preview owns its column width;
         // the divider is not draggable until the pane expands.
-        if button == MouseButton::Left && !self.preview_dock.collapsed {
+        if button == MouseButton::Left && !self.preview_dock.stubbed() {
             let split = match self.current_page {
                 Page::Browse => &mut self.browse_split,
                 Page::Network => &mut self.network_split,
@@ -2437,5 +2537,11 @@ impl Application for FilesystemApp {
 
 #[tokio::main]
 async fn main() {
+    // The detached preview window (cce-ui RFC 7c) — same binary, second
+    // Application; see the lib's preview_window.rs for the process/sync model.
+    if std::env::args().any(|a| a == "--preview-window") {
+        cce_ui::engine::run::<cce_files::preview_window::PreviewWindowApp>();
+        return;
+    }
     cce_ui::engine::run::<FilesystemApp>();
 }
diff --git a/src/preview_window.rs b/src/preview_window.rs
new file mode 100644
index 0000000..98a5bf1
--- /dev/null
+++ b/src/preview_window.rs
@@ -0,0 +1,320 @@
+//! The detached preview window — cce-files run as `--preview-window <sync-file>`
+//! (cce-ui RFC 7c: the first non-designer detach).
+//!
+//! Process model (app policy, mirroring the designer's): the parent writes the
+//! sync file BEFORE spawning this process and rewrites it on every selection
+//! change; this window polls it. Reattach is EXIT — the parent `try_wait`s its
+//! child from tick and takes the pane back when it goes, so closing this
+//! window by any means (corner menu, compositor close, crash) reattaches. The
+//! same rule runs in reverse: this process exits when the sync file disappears
+//! (the parent reattached or quit) or the parent pid is gone (it crashed, or a
+//! session restore respawned us without one) — a preview with no parent is an
+//! orphan, not a window worth keeping.
+//!
+//! Sync-file format, one field per line: parent pid, then the selected path
+//! (absent while nothing is selected).
+
+use crate::preview_pane::PreviewPane;
+use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
+use wayland_client::QueueHandle;
+use cce_ui::widget::plate_dock as dock;
+
+/// Poll cadence for the sync file. Selection changes are user-paced; 100ms is
+/// invisible against the parent's own preview-load latency.
+const POLL_S: f32 = 0.1;
+
+/// One menu row's height — matches the toolkit context menu's row pitch.
+const MENU_ROW_H: f32 = 24.0;
+const MENU_W: f32 = 120.0;
+
+pub struct PreviewWindowApp {
+    preview: PreviewPane,
+    fs_service: crate::services::fs::FsService,
+    sync_path: std::path::PathBuf,
+    parent_pid: Option<u32>,
+    shown_path: Option<std::path::PathBuf>,
+    poll_accum: f32,
+    width: u32,
+    height: u32,
+    cursor: (f32, f32),
+    /// The Reattach menu (drawn by this app — the toolkit context menu
+    /// dispatches through a widget tree this window does not have): the
+    /// top-left of the open menu, `None` while closed.
+    menu_at: Option<(f32, f32)>,
+    needs_rebuild: bool,
+}
+
+impl PreviewWindowApp {
+    /// The rect the pane fills and the corner control anchors to.
+    fn pane_rect(&self) -> (f32, f32, f32, f32) {
+        let pad = cce_ui::layout::root_plate_padding();
+        (
+            pad,
+            pad,
+            (self.width as f32 - 2.0 * pad).max(1.0),
+            (self.height as f32 - 2.0 * pad).max(1.0),
+        )
+    }
+
+    fn dock_state() -> dock::PlateDockState {
+        dock::PlateDockState { collapsed: false, detached: true }
+    }
+
+    /// Re-read the sync file; exit when it (or the parent) is gone.
+    fn poll_sync(&mut self, needs_rebuild: &mut bool) {
+        let content = match std::fs::read_to_string(&self.sync_path) {
+            Ok(c) => c,
+            // The parent reattached (it unlinks on reattach and on its own
+            // exit path) or never existed (stale session restore).
+            Err(_) => std::process::exit(0),
+        };
+        let mut lines = content.lines();
+        if self.parent_pid.is_none() {
+            self.parent_pid = lines.next().and_then(|l| l.trim().parse().ok());
+        } else {
+            lines.next();
+        }
+        if let Some(pid) = self.parent_pid {
+            if !std::path::Path::new(&format!("/proc/{pid}")).exists() {
+                std::process::exit(0);
+            }
+        }
+        let path = lines.next().map(str::trim).filter(|l| !l.is_empty());
+        let path = path.map(std::path::PathBuf::from);
+        if path != self.shown_path {
+            self.shown_path = path.clone();
+            match path {
+                Some(p) => self
+                    .fs_service
+                    .send(crate::services::fs::FsRequest::ReadPreview(p)),
+                None => crate::pages::preview::update(
+                    &mut self.preview,
+                    crate::pages::preview::PreviewMessage::Clear,
+                ),
+            }
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+    }
+}
+
+impl Application for PreviewWindowApp {
+    type Message = crate::Message;
+
+    fn new(
+        _qh: &QueueHandle<cce_ui::engine::EngineState<Self>>,
+        sender: calloop::channel::Sender<Self::Message>,
+    ) -> Self {
+        let args: Vec<String> = std::env::args().collect();
+        let sync_path = args
+            .iter()
+            .position(|a| a == "--preview-window")
+            .and_then(|i| args.get(i + 1))
+            .map(std::path::PathBuf::from)
+            .unwrap_or_default();
+        cce_ui::scale::set_scale_factor(1.0);
+        cce_ui::scale::set_app_id("cce-files-preview".to_string());
+        let mut app = Self {
+            preview: PreviewPane::default(),
+            fs_service: crate::services::fs::FsService::new(sender),
+            sync_path,
+            parent_pid: None,
+            shown_path: None,
+            poll_accum: 0.0,
+            width: 460,
+            height: 680,
+            cursor: (0.0, 0.0),
+            menu_at: None,
+            needs_rebuild: true,
+        };
+        // First read now, not a poll tick later: the parent wrote the file
+        // before spawning, so the pane has content on its first frame.
+        let mut rb = false;
+        app.poll_sync(&mut rb);
+        app
+    }
+
+    fn settings(&self) -> WindowSettings {
+        WindowSettings {
+            title: "Preview".to_string(),
+            // The cce- prefix keys the compositor's decorated-window treatment
+            // (silhouette clip, blur-behind, shadow), same as the parent.
+            app_id: "cce-files-preview".to_string(),
+            width: 460,
+            height: 680,
+            fullscreen: false,
+            min_size: Some((280, 320)),
+        }
+    }
+
+    fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
+        if let crate::Message::Preview(m) = msg {
+            crate::pages::preview::update(&mut self.preview, m);
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+    }
+
+    fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+        self.poll_accum += dt;
+        if self.poll_accum >= POLL_S {
+            self.poll_accum = 0.0;
+            self.poll_sync(needs_rebuild);
+        }
+    }
+
+    fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        let (px, py) = (pos.x as f32, pos.y as f32);
+        // Redraw across the dot's emphasis flips, cheaply: only when hover
+        // over the control changes, not on every motion.
+        let before = dock::corner_center(self.pane_rect(), false)
+            .map(|c| dock::corner_hit(c, self.cursor.0, self.cursor.1));
+        let after = dock::corner_center(self.pane_rect(), false)
+            .map(|c| dock::corner_hit(c, px, py));
+        self.cursor = (px, py);
+        if before != after {
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+        }
+    }
+
+    fn handle_mouse_input(
+        &mut self,
+        button: MouseButton,
+        state: ElementState,
+        pos: LogicalPosition,
+        needs_rebuild: &mut bool,
+    ) -> Option<Self::Message> {
+        if button != MouseButton::Left || state != ElementState::Released {
+            return None;
+        }
+        let (px, py) = (pos.x as f32, pos.y as f32);
+        if let Some((mx, my)) = self.menu_at {
+            // One row: Reattach — which for the detached window IS exit; the
+            // parent reaps the child and takes the pane back (see module doc).
+            if px >= mx && px <= mx + MENU_W && py >= my && py <= my + MENU_ROW_H {
+                std::process::exit(0);
+            }
+            self.menu_at = None;
+            *needs_rebuild = true;
+            self.needs_rebuild = true;
+            return None;
+        }
+        if let Some(c) = dock::corner_center(self.pane_rect(), false) {
+            if dock::corner_hit(c, px, py) {
+                self.menu_at = Some((c.0 - dock::CORNER_R, c.1 + dock::CORNER_R));
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+            }
+        }
+        None
+    }
+
+    fn handle_mouse_wheel(
+        &mut self,
+        delta: &MouseScrollDelta,
+        pos: LogicalPosition,
+        needs_rebuild: &mut bool,
+    ) {
+        if let Some(changed) = self.preview.wheel(delta, pos.x as f32, pos.y as f32) {
+            if changed {
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+            }
+        }
+    }
+
+    fn handle_key_input(&mut self, _event: &KeyEvent, _needs_rebuild: &mut bool) -> Option<Self::Message> {
+        None
+    }
+
+    fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
+        use cce_ui::scene::layout::Rect;
+        self.width = size.width as u32;
+        self.height = size.height as u32;
+        cce_ui::scale::set_scale_factor(scale as f32);
+
+        let mut pc = cce_ui::scene::paint::PaintCtx::new();
+
+        // The root plate: a detached pane's plate IS the window's base surface
+        // (PlateSpec::detached's role flip) — all four corners on the
+        // silhouette, fill mirroring the parent's root plate.
+        let mut plate = cce_ui::color::page_low_color();
+        if plate[3] > 0.001 {
+            plate[3] = cce_ui::color::root_plate_opacity();
+        }
+        pc.plate_spec(&cce_ui::scene::paint::PlateSpec {
+            rect: Rect { x: 0.0, y: 0.0, width: size.width as f32, height: size.height as f32 },
+            color: plate,
+            blur: false,
+            window_corners: (true, true, true, true),
+            depth: cce_ui::layout::bevel_width(),
+        });
+
+        // The pane, through its own PageContent, bridged onto the PaintCtx via
+        // the RenderTarget methods (PaintCtx implements the trait) — the small
+        // flat subset of the parent's page bridge, with no viewport clipping
+        // because this window IS the pane.
+        let (rx, ry, rw, rh) = self.pane_rect();
+        self.preview.set_rect(rx, ry, rw, rh);
+        let mut content = crate::pages::PageContent::new();
+        self.preview.push_prims(&mut content);
+        {
+            use cce_ui::layout::RenderTarget;
+            let crate::pages::PageContent { rects, texts, buttons: _, reliefs, grooves, images } = content;
+            for (c, x, y, w, h, r, corners) in rects {
+                pc.rect_with_radius_corners(c, x, y, w, h, r, corners);
+            }
+            for (x, y, w, h, radius, depth, kind) in reliefs {
+                let rect = Rect { x, y, width: w, height: h };
+                let radii = (radius, radius, radius, radius);
+                match kind {
+                    crate::pages::RELIEF_RAISED => pc.boss(rect, radii, depth),
+                    _ => pc.recess(rect, radii, depth),
+                }
+            }
+            for (ax, ay, bx, by, w, d, hx, hy, hw, hh) in grooves {
+                pc.groove((ax, ay), (bx, by), w, d, Rect { x: hx, y: hy, width: hw, height: hh });
+            }
+            for (id, x, y, w, h, alpha) in images {
+                pc.image(id, Rect { x, y, width: w, height: h }, alpha);
+            }
+            for (text, sz, x, y, col, font, bounds) in texts {
+                match font {
+                    Some(f) => pc.text_with_font_and_bounds(&text, x, y, sz, col, &f, bounds),
+                    None => pc.text_with_bounds(&text, x, y, sz, col, bounds),
+                }
+            }
+        }
+
+        // The corner control, over everything the pane drew.
+        if let Some(c) = dock::corner_center((rx, ry, rw, rh), false) {
+            let emphasized = dock::corner_hit(c, self.cursor.0, self.cursor.1) || self.menu_at.is_some();
+            dock::draw_corner_dot(&mut pc, c, emphasized);
+        }
+
+        // The one-row Reattach menu.
+        if let Some((mx, my)) = self.menu_at {
+            let rows = dock::standard_menu(Self::dock_state(), false);
+            pc.quad(Rect { x: mx, y: my, width: MENU_W, height: MENU_ROW_H * rows.len() as f32 },
+                cce_ui::color::popover_bg_color());
+            for (i, (label, _)) in rows.iter().enumerate() {
+                let hy = my + i as f32 * MENU_ROW_H;
+                if self.cursor.0 >= mx && self.cursor.0 <= mx + MENU_W
+                    && self.cursor.1 >= hy && self.cursor.1 <= hy + MENU_ROW_H
+                {
+                    pc.quad(Rect { x: mx, y: hy, width: MENU_W, height: MENU_ROW_H },
+                        [0.20, 0.40, 0.65, 0.6]);
+                }
+                pc.text(label, mx + 8.0, hy + 6.0, 12.0, [204, 204, 217]);
+            }
+        }
+
+        Some(pc.finish())
+    }
+
+    fn display_list_text(&self) -> bool {
+        true
+    }
+}