file manager
git clone https://git.lucas.co/cce-files.git
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
+ }
+}