file manager
git clone https://git.lucas.co/cce-files.git
refactor: fix wheel/preview bug, dedupe helpers, harden config paths
- Fix mouse-wheel preview hit-region to use the preview widget's actual
laid-out rect instead of a hardcoded 50/50 split (broke after dragging
the list/preview splitter).
- Serialize HOME-mutating tests with serial_test to stop the parallel race.
- Deduplicate format_size/format_permissions into src/util.rs; share the
~/.local/bin command-spawn logic via spawn_command_for_path; extract
clip_to_viewport/occlude_against helpers and dialog/menu geometry.
- Add BrowseState::selected_path and path_to_segment helpers.
- Consistent XDG_CONFIG_HOME handling via cce_config_dir; safe HOME-unset
fallback in get_default_application.
- Remove dead code (NetworkMessage, Message::KeyboardEvent, unused
AppWidget/FilesystemApp fields) and both allow(dead_code) attributes.
- Add unit tests for the previously-untested browse::update handlers.
- Add CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
CLAUDE.md | 56 +++++
Cargo.toml | 3 +
src/lib.rs | 3 +-
src/main.rs | 695 ++++++++++++++++++++++++++++++---------------------
src/pages/browse.rs | 240 ++++++++++++------
src/pages/network.rs | 7 -
src/services/fs.rs | 137 +++++-----
src/util.rs | 51 ++++
8 files changed, 738 insertions(+), 454 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..5d20901
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,56 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this is
+
+`cce-files` is a Wayland-native file manager, one app in the larger **CCE** desktop-environment ecosystem (the sibling `cce-*` crates under `../`). It renders directly with wgpu + glyphon on a Wayland surface — there is no GTK/Qt/web layer. All GUI primitives come from the sibling crate **`cce-ui`** (`../cce-ui`, a path dependency), which owns the windowing/event loop, the widget toolkit, layout, fonts, and colors.
+
+## Build / run / test
+
+```sh
+make build # cargo build --release
+make install # build + install binary to ~/.local/bin/cce-files
+make run # cargo run (needs a live Wayland compositor)
+cargo test # run unit tests (fs and browse modules have them)
+cargo test test_is_project_dir_detection # run a single test by name
+```
+
+Running the binary requires a Wayland session — it will not run headless. Edition is **2024**; the `cce-ui` sibling is edition 2021. When touching layout/widget behavior, the actual widget implementations live in `../cce-ui/src/widget/`, not here.
+
+## Architecture
+
+### Elm-style app on the cce-ui engine
+`main.rs` defines `FilesystemApp`, which implements `cce_ui::engine::Application`. That trait drives everything through a message loop:
+- **`Message`** (`lib.rs`) is the single top-level event enum; page-specific messages nest inside it (`Message::Browse(BrowseMessage)`, `Message::Preview(PreviewMessage)`).
+- **`update()`** mutates state in response to a `Message` and may dispatch async work to `FsService`.
+- **`view` / `rebuild_layout()`** produce the frame. cce-ui calls `view_rounded_quads()` / `text_items()` to pull the rendered geometry.
+
+Each page has a `state` struct and a `view()` that returns a `PageContent` (`pages/mod.rs`) — a flat list of rects, texts, and buttons — which `rebuild_layout` later translates into GPU quads and glyphon `TextItem`s. Interactive pages also carry their own `Message` enum + `update()` (`pages/browse.rs`, `pages/preview.rs`); Network is view-only, driven directly from `BrowseState` and pointer/graph events, so it has no message type of its own.
+
+Shared, page-independent formatting helpers (`format_size`, `format_permissions`) live in `src/util.rs`.
+
+### FsService: all filesystem IO is async and off-thread
+`services/fs.rs` runs a Tokio task that receives `FsRequest`s (read dir, refresh, read preview, delete, load/save last dir), performs the blocking IO, and sends results back into the app as `Message`s over a `calloop::channel::Sender`. `update()` never does blocking IO directly — it sends an `FsRequest` and handles the resulting message later. A `notify` watcher (`start_watching`) debounces filesystem events and triggers `RefreshDirectory`.
+
+### rebuild_layout is the render heart (main.rs)
+`rebuild_layout()` gathers geometry from five sources — the root window, page content, popovers, the context menu, and the open-with dialog — and flattens them into `self.widgets` + `self.text_items`. Two non-obvious concerns live here:
+- **Viewport clipping**: page content is clipped to the content region so scrolled rows/text don't overflow into the breadcrumb or selection bar.
+- **Overlay occlusion**: text/buttons under a popover, context menu, or dialog are either discarded or bound-clipped so they don't bleed through overlays. This is the logic behind commits like "Fix text rendering through popovers/overlays."
+
+### Widget hierarchy uses raw pointers
+`BrowseContainer` and `NetworkContainer` are composite widgets whose children (`breadcrumb`, `list_box`, `save_name_box`, `graph`) are held as `*mut dyn Element` and wired up in `set_parent` via `ctx.register_widget` / `ctx.link_ids`. This mirrors the cce-ui widget model; the containers are `unsafe impl Send/Sync`. When adding a child widget to a container, replicate the register + link + `set_parent` sequence, and clear it in `rebuild_layout`'s teardown block.
+
+### Two pages, one preview
+- **Browse** — the `List` widget (columnar, integrated search box) plus a `Breadcrumb`. The right pane is a `Preview` widget, split from the list by a `SplitBox`.
+- **Network** — a `Graph` view of the same directory (nodes = entries), also split against the preview.
+The active page is picked by `view_dropdown` next to the breadcrumb (there is no sidebar — it was removed; `has_sidebar` is hardcoded `false`).
+
+## Domain specifics
+
+- **CCE projects**: a directory containing `state.json` or `state.kdl` is treated as a *project* (`is_project_dir`), gets MIME `application/x-cce-project`, and on double-click is opened by its handler rather than entered. "Enter Directory" in the context menu overrides this.
+- **Opening files**: `open_file()` resolves a handler via `get_mime_type` → `get_default_application`, which checks (1) `~/.config/cce/mime.kdl` custom associations, then (2) `xdg-mime` + `.desktop` parsing. Bare command names are resolved against `~/.local/bin` before falling back to `xdg-open`. Always launch via `cce_ui::process::spawn_detached`.
+- **Chooser modes**: launched with `--select`, `--select-dir`, or `--save`, the app becomes a file picker for other CCE apps — it shows a bottom action bar, prints the chosen path to stdout, and `std::process::exit(0)` on selection (or exit code 1 on cancel). This is why `SelectOpen`/`SelectCancel` call `process::exit` directly.
+- **Persistence**: the last-visited directory is saved to `~/.config/cce/cce-files/cce-files-last-dir.txt` and restored on launch.
+- **Double-click**: opening is temporal — `last_click_time` / `last_clicked_idx` in `update()` detect a double-click within 500ms rather than relying on a windowing double-click event.
+- **Fonts**: `cce_ui::create_font_system()` loads fonts from `/home/lsgalante/Dropbox/Fonts` (hardcoded in cce-ui). Set `CCE_LOAD_SYSTEM_FONTS` to also load system fonts.
diff --git a/Cargo.toml b/Cargo.toml
index 570f53d..b50d3fa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,6 +19,9 @@ kdl = "6.7.1"
+[dev-dependencies]
+serial_test = "3"
+
[lib]
name = "cce_files"
path = "src/lib.rs"
diff --git a/src/lib.rs b/src/lib.rs
index e17c894..4a19d45 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,7 +1,7 @@
pub mod pages;
pub mod services;
+pub mod util;
-use cce_ui::widget::KeyEvent;
use pages::Page;
#[derive(Debug, Clone)]
@@ -9,7 +9,6 @@ pub enum Message {
SwitchPage(Page),
Browse(pages::browse::BrowseMessage),
Preview(pages::preview::PreviewMessage),
- KeyboardEvent(KeyEvent),
SelectOpen,
SelectCancel,
PromptOpenWith(std::path::PathBuf),
diff --git a/src/main.rs b/src/main.rs
index 7e09764..1efb294 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11,18 +11,116 @@ use cce_files::{Message, pages, services};
use cce_files::pages::Page;
use cce_files::pages::browse::is_project_dir;
+// ── Layout constants ────────────────────────────────────────────────
+
+const ROW_H: f32 = 24.0; // context-menu / breadcrumb row height
+const DIALOG_W: f32 = 400.0;
+const DIALOG_H: f32 = 160.0;
+const MENU_MIN_W: f32 = 120.0;
+const MENU_CHAR_W: f32 = 7.5; // approximate glyph advance used for menu sizing
+
+/// Context-menu width/height for a given set of options.
+fn context_menu_size(options: &[(String, Option<Message>)]) -> (f32, f32) {
+ let max_len = options.iter().map(|(s, _)| s.len()).max().unwrap_or(0);
+ let w = ((max_len as f32 * MENU_CHAR_W) + 24.0).max(MENU_MIN_W);
+ let h = options.len() as f32 * ROW_H;
+ (w, h)
+}
+
+/// Computed rects for the "Open with…" modal, so layout and hit-testing agree.
+struct OpenWithRects {
+ x: f32,
+ y: f32,
+ w: f32,
+ h: f32,
+ tb: (f32, f32, f32, f32),
+ cancel: (f32, f32, f32, f32),
+ open: (f32, f32, f32, f32),
+}
+
+fn open_with_rects(win_w: f32, win_h: f32) -> OpenWithRects {
+ let x = (win_w - DIALOG_W) / 2.0;
+ let y = (win_h - DIALOG_H) / 2.0;
+ let tb_h = cce_ui::layout::textbox_height();
+ let btn_h = cce_ui::layout::button_height();
+ OpenWithRects {
+ x,
+ y,
+ w: DIALOG_W,
+ h: DIALOG_H,
+ tb: (x + 20.0, y + 60.0, DIALOG_W - 40.0, tb_h),
+ cancel: (x + DIALOG_W - 180.0, y + DIALOG_H - btn_h - 16.0, 70.0, btn_h),
+ open: (x + DIALOG_W - 100.0, y + DIALOG_H - btn_h - 16.0, 80.0, btn_h),
+ }
+}
+
+/// Clip a vertical span `[y, y+h)` to the viewport `[top, bottom)`.
+/// Returns the clipped `(y, h)`, or `None` if fully outside.
+fn clip_to_viewport(y: f32, h: f32, top: f32, bottom: f32) -> Option<(f32, f32)> {
+ if y >= bottom || y + h <= top {
+ return None;
+ }
+ let mut ny = y;
+ let mut nh = h;
+ if ny < top {
+ let diff = top - ny;
+ ny = top;
+ nh = (nh - diff).max(0.0);
+ }
+ if ny + nh > bottom {
+ nh = (bottom - ny).max(0.0);
+ }
+ Some((ny, nh))
+}
+
+/// Clip a text/label box against overlay rects so it does not bleed through
+/// popovers/menus/dialogs. Returns the adjusted clip bounds, or `None` if the
+/// box is fully covered (should be discarded).
+fn occlude_against(
+ mut bounds: [f32; 4],
+ t_min_x: f32,
+ t_max_x: f32,
+ t_min_y: f32,
+ t_max_y: f32,
+ overlays: &[&pages::PageContent],
+) -> Option<[f32; 4]> {
+ for overlay_pc in overlays {
+ for (_, ox, oy, ow, oh, _, _) in &overlay_pc.rects {
+ let o_min_x = *ox;
+ let o_max_x = *ox + *ow;
+ let o_min_y = *oy;
+ let o_max_y = *oy + *oh;
+
+ 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 {
+ 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 {
+ return None;
+ }
+ if o_min_x > t_min_x && o_min_x < t_max_x {
+ bounds[2] = bounds[2].min(o_min_x);
+ }
+ if o_max_x > t_min_x && o_max_x < t_max_x {
+ bounds[0] = bounds[0].max(o_max_x);
+ }
+ if o_min_y > t_min_y && o_min_y < t_max_y {
+ bounds[3] = bounds[3].min(o_min_y);
+ }
+ if o_max_y > t_min_y && o_max_y < t_max_y {
+ bounds[1] = bounds[1].max(o_max_y);
+ }
+ }
+ }
+ }
+ Some(bounds)
+}
+
// ── State ───────────────────────────────────────────────────────────
-#[allow(dead_code)]
struct AppWidget {
x: f32,
y: f32,
w: f32,
h: f32,
color: [f32; 4],
- hover_color: [f32; 4],
- hovering: bool,
- action: Option<Message>,
radius: f32,
corners: (bool, bool, bool, bool),
}
@@ -38,7 +136,150 @@ struct ContextMenu {
hovered: Option<usize>,
}
-#[allow(dead_code)]
+struct BrowseContainer {
+ pub base: cce_ui::widget::Widget,
+ pub parent: Option<*mut (dyn cce_ui::widget::Element + 'static)>,
+ pub breadcrumb: *mut cce_ui::widget::Breadcrumb,
+ pub list_box: *mut cce_ui::widget::List,
+ pub save_name_box: *mut cce_ui::widget::TextBox,
+ pub select_mode: bool,
+}
+
+impl cce_ui::widget::Element for BrowseContainer {
+ cce_ui::impl_widget_base!(BrowseContainer);
+
+ fn color(&self) -> [f32; 4] {
+ [0.0, 0.0, 0.0, 0.0]
+ }
+
+ fn children(&self, _ctx: &cce_ui::widget::UiContext) -> Vec<*mut (dyn cce_ui::widget::Element + 'static)> {
+ let mut list = vec![self.breadcrumb as *mut (dyn cce_ui::widget::Element + 'static), self.list_box as *mut (dyn cce_ui::widget::Element + 'static)];
+ if self.select_mode {
+ list.push(self.save_name_box as *mut (dyn cce_ui::widget::Element + 'static));
+ }
+ list
+ }
+
+ fn parent(&self, _ctx: &cce_ui::widget::UiContext) -> Option<*mut (dyn cce_ui::widget::Element + 'static)> {
+ self.parent
+ }
+
+ fn set_parent(&mut self, parent: Option<*mut (dyn cce_ui::widget::Element + 'static)>, ctx: &mut cce_ui::widget::UiContext) {
+ self.parent = parent;
+ if parent.is_some() {
+ let self_ptr = self as *mut Self;
+ let self_id = self.base.id();
+ unsafe {
+ let bc_id = (*self.breadcrumb).base().unwrap().id();
+ ctx.register_widget(bc_id, self.breadcrumb as *mut (dyn cce_ui::widget::Element + 'static));
+ ctx.link_ids(self_id, bc_id);
+ (*self.breadcrumb).set_parent(Some(self_ptr), ctx);
+
+ let lb_id = (*self.list_box).base().unwrap().id();
+ ctx.register_widget(lb_id, self.list_box as *mut (dyn cce_ui::widget::Element + 'static));
+ ctx.link_ids(self_id, lb_id);
+ (*self.list_box).set_parent(Some(self_ptr), ctx);
+
+ if self.select_mode {
+ let sn_id = (*self.save_name_box).base().unwrap().id();
+ ctx.register_widget(sn_id, self.save_name_box as *mut (dyn cce_ui::widget::Element + 'static));
+ ctx.link_ids(self_id, sn_id);
+ (*self.save_name_box).set_parent(Some(self_ptr), ctx);
+ }
+ }
+ }
+ }
+
+ fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+ self.base.x = x;
+ self.base.y = y;
+ self.base.w = w;
+ self.base.h = h;
+
+ let gap = 12.0;
+ let breadcrumb_h = cce_ui::layout::button_height();
+ let textbox_h = 24.0;
+
+ unsafe {
+ (*self.breadcrumb).set_rect(x, y, w, breadcrumb_h);
+ let list_h = if self.select_mode {
+ h - breadcrumb_h - textbox_h - 2.0 * gap
+ } else {
+ h - breadcrumb_h - gap
+ };
+ (*self.list_box).set_rect(x, y + breadcrumb_h + gap, w, list_h);
+
+ if self.select_mode {
+ (*self.save_name_box).set_rect(x, y + h - textbox_h, w, textbox_h);
+ (*self.save_name_box).set_row_rect(x, w);
+ }
+ }
+ }
+}
+
+unsafe impl Send for BrowseContainer {}
+unsafe impl Sync for BrowseContainer {}
+
+struct NetworkContainer {
+ pub base: cce_ui::widget::Widget,
+ pub parent: Option<*mut (dyn cce_ui::widget::Element + 'static)>,
+ pub breadcrumb: *mut cce_ui::widget::Breadcrumb,
+ pub graph: *mut cce_ui::widget::Graph,
+}
+
+impl cce_ui::widget::Element for NetworkContainer {
+ cce_ui::impl_widget_base!(NetworkContainer);
+
+ fn color(&self) -> [f32; 4] {
+ [0.0, 0.0, 0.0, 0.0]
+ }
+
+ fn children(&self, _ctx: &cce_ui::widget::UiContext) -> Vec<*mut (dyn cce_ui::widget::Element + 'static)> {
+ vec![self.breadcrumb as *mut (dyn cce_ui::widget::Element + 'static), self.graph as *mut (dyn cce_ui::widget::Element + 'static)]
+ }
+
+ fn parent(&self, _ctx: &cce_ui::widget::UiContext) -> Option<*mut (dyn cce_ui::widget::Element + 'static)> {
+ self.parent
+ }
+
+ fn set_parent(&mut self, parent: Option<*mut (dyn cce_ui::widget::Element + 'static)>, ctx: &mut cce_ui::widget::UiContext) {
+ self.parent = parent;
+ if parent.is_some() {
+ let self_ptr = self as *mut Self;
+ let self_id = self.base.id();
+ unsafe {
+ let bc_id = (*self.breadcrumb).base().unwrap().id();
+ ctx.register_widget(bc_id, self.breadcrumb as *mut (dyn cce_ui::widget::Element + 'static));
+ ctx.link_ids(self_id, bc_id);
+ (*self.breadcrumb).set_parent(Some(self_ptr), ctx);
+
+ let g_id = (*self.graph).base().unwrap().id();
+ ctx.register_widget(g_id, self.graph as *mut (dyn cce_ui::widget::Element + 'static));
+ ctx.link_ids(self_id, g_id);
+ (*self.graph).set_parent(Some(self_ptr), ctx);
+ }
+ }
+ }
+
+ fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+ self.base.x = x;
+ self.base.y = y;
+ self.base.w = w;
+ self.base.h = h;
+
+ let gap = 12.0;
+ let breadcrumb_h = cce_ui::layout::button_height();
+
+ unsafe {
+ (*self.breadcrumb).set_rect(x, y, w, breadcrumb_h);
+ (*self.graph).set_rect(x, y + breadcrumb_h + gap, w, h - breadcrumb_h - gap);
+ }
+ }
+}
+
+unsafe impl Send for NetworkContainer {}
+unsafe impl Sync for NetworkContainer {}
+
struct FilesystemApp {
current_page: Page,
browse: pages::browse::BrowseState,
@@ -58,7 +299,6 @@ struct FilesystemApp {
width: u32,
height: u32,
scale_factor: f64,
- sender: calloop::channel::Sender<Message>,
page_buttons: Vec<(cce_ui::widget::Button, Message)>,
cursor_x: f32,
cursor_y: f32,
@@ -71,6 +311,10 @@ struct FilesystemApp {
context_menu: ContextMenu,
open_with_dialog: Option<(std::path::PathBuf, cce_ui::widget::TextBox)>,
root_window: cce_ui::widget::Backplate,
+ browse_splitter: cce_ui::widget::SplitBox,
+ network_splitter: cce_ui::widget::SplitBox,
+ browse_container: BrowseContainer,
+ network_container: NetworkContainer,
last_click_time: std::time::Instant,
last_clicked_idx: Option<usize>,
}
@@ -146,6 +390,11 @@ impl FilesystemApp {
self.view_dropdown.clear_children(&mut self.ui_context); self.view_dropdown.set_parent(None, &mut self.ui_context);
self.preview.clear_children(&mut self.ui_context); self.preview.set_parent(None, &mut self.ui_context);
+ self.browse_splitter.set_parent(None, &mut self.ui_context);
+ self.network_splitter.set_parent(None, &mut self.ui_context);
+ self.browse_container.clear_children(&mut self.ui_context); self.browse_container.set_parent(None, &mut self.ui_context);
+ self.network_container.clear_children(&mut self.ui_context); self.network_container.set_parent(None, &mut self.ui_context);
+
self.browse.save_name_box.clear_children(&mut self.ui_context); self.browse.save_name_box.set_parent(None, &mut self.ui_context);
self.browse.list_box.clear_children(&mut self.ui_context); self.browse.list_box.set_parent(None, &mut self.ui_context);
self.browse.breadcrumb.clear_children(&mut self.ui_context); self.browse.breadcrumb.set_parent(None, &mut self.ui_context);
@@ -161,9 +410,6 @@ impl FilesystemApp {
let sidebar_w = if has_sidebar { self.paginator.sidebar_w() } else { 0.0 };
let browse_x = if has_sidebar { sidebar_w + 17.0 } else { 16.0 };
let usable_w = self.width as f32 - sidebar_w - (if has_sidebar { 1.0 } else { 0.0 }) - 32.0;
- let browse_w = (usable_w - 12.0) * 0.5;
- let preview_w = (usable_w - 12.0) * 0.5;
- let preview_x = browse_x + browse_w + 12.0;
let content_y = 16.0;
let select_bar_h = 48.0;
@@ -177,19 +423,23 @@ impl FilesystemApp {
link_parent_child(&mut self.root_window, &mut self.paginator, &mut self.ui_context);
}
link_parent_child(&mut self.root_window, &mut self.view_dropdown, &mut self.ui_context);
- link_parent_child(&mut self.root_window, &mut self.preview, &mut self.ui_context);
match self.current_page {
Page::Browse => {
- link_parent_child(&mut self.root_window, &mut self.browse.breadcrumb, &mut self.ui_context);
- link_parent_child(&mut self.root_window, &mut self.browse.list_box, &mut self.ui_context);
- if self.select_mode {
- link_parent_child(&mut self.root_window, &mut self.browse.save_name_box, &mut self.ui_context);
+ if self.browse_splitter.children.is_empty() {
+ self.browse_splitter.add_child_with_proportion(&mut self.browse_container, 0.49, 100.0);
+ self.browse_splitter.add_child_with_proportion(&mut self.preview, 0.51, 100.0);
}
+ link_parent_child(&mut self.root_window, &mut self.browse_splitter, &mut self.ui_context);
+ self.browse_splitter.set_rect(browse_x, content_y, usable_w, content_h);
}
Page::Network => {
- link_parent_child(&mut self.root_window, &mut self.network.breadcrumb, &mut self.ui_context);
- link_parent_child(&mut self.root_window, &mut self.network.graph, &mut self.ui_context);
+ if self.network_splitter.children.is_empty() {
+ self.network_splitter.add_child_with_proportion(&mut self.network_container, 0.49, 100.0);
+ self.network_splitter.add_child_with_proportion(&mut self.preview, 0.51, 100.0);
+ }
+ link_parent_child(&mut self.root_window, &mut self.network_splitter, &mut self.ui_context);
+ self.network_splitter.set_rect(browse_x, content_y, usable_w, content_h);
}
}
@@ -204,9 +454,6 @@ impl FilesystemApp {
self.paginator.set_selected_page(page_idx);
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);
}
- if self.current_page == Page::Browse || self.current_page == Page::Network {
- cce_ui::layout::render_widget(&mut dummy_pc, &mut self.preview, preview_x, content_y, preview_w, content_h, &mut self.ui_context);
- }
// Render root window recursively
let mut window_pc = pages::PageContent::new();
@@ -216,15 +463,16 @@ impl FilesystemApp {
let mut pc = pages::PageContent::new();
match self.current_page {
Page::Browse => {
-
- let browse_pc = pages::browse::view(&mut self.browse, &mut self.view_dropdown, browse_x, content_y, browse_w, content_h, self.select_mode, &mut self.ui_context);
+ let (bx, by, bw, bh) = self.browse_container.rect();
+ let browse_pc = pages::browse::view(&mut self.browse, &mut self.view_dropdown, bx, by, bw, bh, self.select_mode, &mut self.ui_context);
pc.rects.extend(browse_pc.rects);
pc.texts.extend(browse_pc.texts);
pc.buttons.extend(browse_pc.buttons);
}
Page::Network => {
- let network_pc = pages::network::view(&mut self.network, &self.browse, &mut self.view_dropdown, browse_x, content_y, browse_w, content_h, &mut self.ui_context);
+ let (nx, ny, nw, nh) = self.network_container.rect();
+ let network_pc = pages::network::view(&mut self.network, &self.browse, &mut self.view_dropdown, nx, ny, nw, nh, &mut self.ui_context);
pc.rects.extend(network_pc.rects);
pc.texts.extend(network_pc.texts);
@@ -292,12 +540,12 @@ impl FilesystemApp {
context_menu_pc.rect(cce_ui::color::popover_bg_color(), cx + 1.0, cy + 1.0, cw - 2.0, ch - 2.0);
// Hover highlight
if let Some(h_idx) = self.context_menu.hovered {
- let iy = cy + h_idx as f32 * 24.0;
+ let iy = cy + h_idx as f32 * ROW_H;
context_menu_pc.rect([0.20, 0.40, 0.65, 0.6], cx + 2.0, iy + 2.0, cw - 4.0, 20.0);
}
// Text options
for (idx, (opt, _)) in self.context_menu.options.iter().enumerate() {
- let iy = cy + idx as f32 * 24.0 + (24.0 - 12.0) / 2.0;
+ let iy = cy + idx as f32 * ROW_H + (ROW_H - 12.0) / 2.0;
let text_color = if idx == 0 {
[0.44, 0.44, 0.47, 1.0]
} else if self.context_menu.hovered == Some(idx) {
@@ -312,10 +560,11 @@ impl FilesystemApp {
// Gather open-with dialog backdrop & dialog panel if active (open_with_dialog uses textbox rendering manually but we can gather its other quads/texts)
let mut dialog_pc = pages::PageContent::new();
if let Some((_path, textbox)) = &mut self.open_with_dialog {
- let dialog_w = 400.0;
- let dialog_h = 160.0;
- let dialog_x = (self.width as f32 - dialog_w) / 2.0;
- let dialog_y = (self.height as f32 - dialog_h) / 2.0;
+ let r = open_with_rects(self.width as f32, self.height as f32);
+ let (dialog_x, dialog_y, dialog_w, dialog_h) = (r.x, r.y, r.w, r.h);
+ let (tb_x, tb_y, tb_w, tb_h) = r.tb;
+ let (btn_cancel_x, btn_cancel_y, btn_cancel_w, btn_cancel_h) = r.cancel;
+ let (btn_open_x, btn_open_y, btn_open_w, btn_open_h) = r.open;
// Semi-transparent backdrop overlay
dialog_pc.rect([0.02, 0.02, 0.03, 0.6], 0.0, 0.0, self.width as f32, self.height as f32);
@@ -330,24 +579,8 @@ impl FilesystemApp {
dialog_pc.text("Enter command:", dialog_x + 20.0, dialog_y + 42.0, 11.0, [0.54, 0.54, 0.58, 1.0]);
// Set textbox position dynamically using configured textbox height
- let tb_x = dialog_x + 20.0;
- let tb_y = dialog_y + 60.0;
- let tb_w = dialog_w - 40.0;
- let tb_h = cce_ui::layout::textbox_height();
textbox.set_rect(tb_x, tb_y, tb_w, tb_h);
- // Render Buttons: Cancel & Open
- let btn_h = cce_ui::layout::button_height();
- let btn_cancel_x = dialog_x + dialog_w - 180.0;
- let btn_cancel_y = dialog_y + dialog_h - btn_h - 16.0;
- let btn_cancel_w = 70.0;
- let btn_cancel_h = btn_h;
-
- let btn_open_x = dialog_x + dialog_w - 100.0;
- let btn_open_y = dialog_y + dialog_h - btn_h - 16.0;
- let btn_open_w = 80.0;
- let btn_open_h = btn_h;
-
let cancel_hover = self.cursor_x >= btn_cancel_x && self.cursor_x <= btn_cancel_x + btn_cancel_w
&& self.cursor_y >= btn_cancel_y && self.cursor_y <= btn_cancel_y + btn_cancel_h;
let cancel_bg = if cancel_hover { [0.35, 0.15, 0.15, 0.8] } else { [0.25, 0.12, 0.12, 0.5] };
@@ -373,18 +606,9 @@ impl FilesystemApp {
let mut wh = *h;
if is_page_content {
- let viewport_top = content_y;
- let viewport_bottom = content_y + content_h;
- if wy >= viewport_bottom || wy + wh <= viewport_top {
- continue;
- }
- if wy < viewport_top {
- let diff = viewport_top - wy;
- wy = viewport_top;
- wh = (wh - diff).max(0.0);
- }
- if wy + wh > viewport_bottom {
- wh = (viewport_bottom - wy).max(0.0);
+ match clip_to_viewport(wy, wh, content_y, content_y + content_h) {
+ Some((cy, ch)) => { wy = cy; wh = ch; }
+ None => continue,
}
}
@@ -394,11 +618,8 @@ impl FilesystemApp {
w: ww,
h: wh,
color: *c,
- hover_color: *c,
- hovering: false,
radius: *r,
corners: *corners,
- action: None,
});
}
for (btn, action) in &pc_part.buttons {
@@ -415,18 +636,9 @@ impl FilesystemApp {
let mut wh = base.h;
if is_page_content {
- let viewport_top = content_y;
- let viewport_bottom = content_y + content_h;
- if wy >= viewport_bottom || wy + wh <= viewport_top {
- continue;
- }
- if wy < viewport_top {
- let diff = viewport_top - wy;
- wy = viewport_top;
- wh = (wh - diff).max(0.0);
- }
- if wy + wh > viewport_bottom {
- wh = (viewport_bottom - wy).max(0.0);
+ match clip_to_viewport(wy, wh, content_y, content_y + content_h) {
+ Some((cy, ch)) => { wy = cy; wh = ch; }
+ None => continue,
}
}
@@ -440,11 +652,8 @@ impl FilesystemApp {
w: ww,
h: wh,
color: col,
- hover_color: hover_bg,
- hovering,
radius: 4.0, // standard button radius
corners: (true, true, true, true),
- action: Some(action.clone()),
});
let text_x = if btn.justify == cce_ui::widget::Justification::Left {
@@ -455,63 +664,32 @@ impl FilesystemApp {
};
let text_y = base.y + (base.h - label_size * 1.4) / 2.0;
- let mut final_button_bounds = if is_page_content {
- Some([
- 0.0,
- content_y,
- self.width as f32,
- content_y + content_h,
- ])
+ let start_bounds = if is_page_content {
+ [0.0, content_y, self.width as f32, content_y + content_h]
} else {
- None
+ [0.0, 0.0, self.width as f32, self.height as f32]
};
-
- let mut current_bounds = final_button_bounds.unwrap_or([0.0, 0.0, self.width as f32, self.height as f32]);
- let mut discard = false;
let text_w = label.chars().count() as f32 * label_size * 0.65;
let text_h = label_size * 1.4;
- let t_min_x = text_x;
- let t_max_x = text_x + text_w;
- let t_min_y = text_y;
- let t_max_y = text_y + text_h;
-
- if part_idx < 2 {
- for overlay_pc in [&popover_pc, &context_menu_pc, &dialog_pc] {
- for (_, ox, oy, ow, oh, _, _) in &overlay_pc.rects {
- let o_min_x = *ox;
- let o_max_x = *ox + *ow;
- let o_min_y = *oy;
- let o_max_y = *oy + *oh;
-
- 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 {
- 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 {
- discard = true;
- break;
- }
- if o_min_x > t_min_x && o_min_x < t_max_x {
- current_bounds[2] = current_bounds[2].min(o_min_x);
- }
- if o_max_x > t_min_x && o_max_x < t_max_x {
- current_bounds[0] = current_bounds[0].max(o_max_x);
- }
- if o_min_y > t_min_y && o_min_y < t_max_y {
- current_bounds[3] = current_bounds[3].min(o_min_y);
- }
- if o_max_y > t_min_y && o_max_y < t_max_y {
- current_bounds[1] = current_bounds[1].max(o_max_y);
- }
- }
- }
- if discard {
- break;
- }
+ let occluded = if part_idx < 2 {
+ occlude_against(
+ start_bounds,
+ text_x,
+ text_x + text_w,
+ text_y,
+ text_y + text_h,
+ &[&popover_pc, &context_menu_pc, &dialog_pc],
+ )
+ } else {
+ Some(start_bounds)
+ };
+ let final_button_bounds = match occluded {
+ Some(b) => Some(b),
+ None => {
+ page_buttons.push((btn.clone(), action.clone()));
+ continue;
}
- }
- if discard {
- page_buttons.push((btn.clone(), action.clone()));
- continue;
- }
- final_button_bounds = Some(current_bounds);
+ };
text_items.push(TextItem::new(
&mut self.font_system,
@@ -531,7 +709,7 @@ impl FilesystemApp {
page_buttons.push((btn.clone(), action.clone()));
}
for (text, size, x, y, col, font, bounds) in &pc_part.texts {
- let mut final_bounds = if is_page_content {
+ let clamped_bounds = if is_page_content {
let viewport_top = content_y;
let viewport_bottom = content_y + content_h;
match bounds {
@@ -552,51 +730,25 @@ impl FilesystemApp {
*bounds
};
- let mut current_bounds = final_bounds.unwrap_or([0.0, 0.0, self.width as f32, self.height as f32]);
- let mut discard = false;
+ let start_bounds = clamped_bounds.unwrap_or([0.0, 0.0, self.width as f32, self.height as f32]);
let text_w = text.chars().count() as f32 * size * 0.65;
let text_h = *size * 1.4;
- let t_min_x = *x;
- let t_max_x = *x + text_w;
- let t_min_y = *y;
- let t_max_y = *y + text_h;
-
- if part_idx < 2 {
- for overlay_pc in [&popover_pc, &context_menu_pc, &dialog_pc] {
- for (_, ox, oy, ow, oh, _, _) in &overlay_pc.rects {
- let o_min_x = *ox;
- let o_max_x = *ox + *ow;
- let o_min_y = *oy;
- let o_max_y = *oy + *oh;
-
- 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 {
- 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 {
- discard = true;
- break;
- }
- if o_min_x > t_min_x && o_min_x < t_max_x {
- current_bounds[2] = current_bounds[2].min(o_min_x);
- }
- if o_max_x > t_min_x && o_max_x < t_max_x {
- current_bounds[0] = current_bounds[0].max(o_max_x);
- }
- if o_min_y > t_min_y && o_min_y < t_max_y {
- current_bounds[3] = current_bounds[3].min(o_min_y);
- }
- if o_max_y > t_min_y && o_max_y < t_max_y {
- current_bounds[1] = current_bounds[1].max(o_max_y);
- }
- }
- }
- if discard {
- break;
- }
- }
- }
- if discard {
- continue;
- }
- final_bounds = Some(current_bounds);
+ let occluded = if part_idx < 2 {
+ occlude_against(
+ start_bounds,
+ *x,
+ *x + text_w,
+ *y,
+ *y + text_h,
+ &[&popover_pc, &context_menu_pc, &dialog_pc],
+ )
+ } else {
+ Some(start_bounds)
+ };
+ let final_bounds = match occluded {
+ Some(b) => Some(b),
+ None => continue,
+ };
text_items.push(TextItem::new(
&mut self.font_system,
@@ -656,6 +808,15 @@ impl Application for FilesystemApp {
}
}
}
+ if self.current_page == Page::Browse {
+ if self.browse_splitter.dragging_idx.is_some() || self.browse_splitter.hovered_idx.is_some() {
+ return false;
+ }
+ } else if self.current_page == Page::Network {
+ if self.network_splitter.dragging_idx.is_some() || self.network_splitter.hovered_idx.is_some() {
+ return false;
+ }
+ }
// 5. Fallback to ui_context's check for registered widgets
self.ui_context.is_movable_backplate_at(px, py)
}
@@ -701,7 +862,6 @@ impl Application for FilesystemApp {
width: initial_w,
height: initial_h,
scale_factor: 1.0,
- sender: sender.clone(),
page_buttons: Vec::new(),
cursor_x: 0.0,
cursor_y: 0.0,
@@ -722,10 +882,34 @@ impl Application for FilesystemApp {
},
open_with_dialog: None,
root_window,
+ browse_splitter: cce_ui::widget::SplitBox::new(cce_ui::widget::SplitDirection::Horizontal, 12.0),
+ network_splitter: cce_ui::widget::SplitBox::new(cce_ui::widget::SplitDirection::Horizontal, 12.0),
+ browse_container: BrowseContainer {
+ base: cce_ui::widget::Widget::new(),
+ parent: None,
+ breadcrumb: std::ptr::null_mut(),
+ list_box: std::ptr::null_mut(),
+ save_name_box: std::ptr::null_mut(),
+ select_mode,
+ },
+ network_container: NetworkContainer {
+ base: cce_ui::widget::Widget::new(),
+ parent: None,
+ breadcrumb: std::ptr::null_mut(),
+ graph: std::ptr::null_mut(),
+ },
last_click_time: std::time::Instant::now(),
last_clicked_idx: None,
};
+ // Initialize container references
+ app.browse_container.breadcrumb = &mut app.browse.breadcrumb;
+ app.browse_container.list_box = &mut app.browse.list_box;
+ app.browse_container.save_name_box = &mut app.browse.save_name_box;
+
+ app.network_container.breadcrumb = &mut app.network.breadcrumb;
+ app.network_container.graph = &mut app.network.graph;
+
// Start initial directory loading via FsService
app.fs_service.send(services::fs::FsRequest::ReadLastDir);
@@ -801,11 +985,7 @@ impl Application for FilesystemApp {
}
// If NavigateTo or SelectEntry happened, update Preview path
- let selected_path = if let Some(idx) = self.browse.selected {
- self.browse.entries.get(idx).map(|e| e.path.clone())
- } else {
- None
- };
+ let selected_path = self.browse.selected_path();
if let Some(path) = selected_path {
self.fs_service.send(services::fs::FsRequest::ReadPreview(path));
} else {
@@ -842,14 +1022,9 @@ impl Application for FilesystemApp {
*needs_rebuild = true;
self.needs_rebuild = true;
}
- Message::KeyboardEvent(_) => {}
Message::SelectOpen => {
if self.select_directory {
- let selected_path = if let Some(idx) = self.browse.selected {
- self.browse.entries.get(idx).map(|e| e.path.clone())
- } else {
- None
- };
+ let selected_path = self.browse.selected_path();
let path = selected_path.filter(|p| p.is_dir()).unwrap_or_else(|| self.browse.current_dir.clone());
println!("{}", path.display());
std::process::exit(0);
@@ -872,11 +1047,7 @@ impl Application for FilesystemApp {
}
}
} else {
- let selected_path = if let Some(idx) = self.browse.selected {
- self.browse.entries.get(idx).map(|e| e.path.clone())
- } else {
- None
- };
+ let selected_path = self.browse.selected_path();
if let Some(path) = selected_path {
if path.is_dir() && !is_project_dir(&path) {
self.fs_service.send(services::fs::FsRequest::ReadDirectory(path));
@@ -923,25 +1094,7 @@ impl Application for FilesystemApp {
textbox.text.trim().to_string()
};
if !cmd_str.is_empty() {
- let parts: Vec<&str> = cmd_str.split_whitespace().collect();
- if !parts.is_empty() {
- let program = parts[0];
- let mut program_path = std::path::PathBuf::from(program);
- if !program_path.is_absolute() && !program.contains('/') {
- if let Ok(home) = std::env::var("HOME") {
- let local_bin = std::path::PathBuf::from(home).join(".local").join("bin").join(program);
- if local_bin.exists() {
- program_path = local_bin;
- }
- }
- }
- let mut command = std::process::Command::new(program_path);
- for arg in &parts[1..] {
- command.arg(arg);
- }
- command.arg(&path);
- let _ = cce_ui::process::spawn_detached(command);
- }
+ crate::services::fs::spawn_command_for_path(&cmd_str, &path);
}
}
*needs_rebuild = true;
@@ -1029,7 +1182,7 @@ impl Application for FilesystemApp {
let was_hovered = self.context_menu.hovered;
self.context_menu.hovered = None;
if pos.x >= cx && pos.x <= cx + cw && pos.y >= cy && pos.y <= cy + ch {
- let idx = ((pos.y - cy) / 24.0) as usize;
+ let idx = ((pos.y - cy) / ROW_H) as usize;
if idx < self.context_menu.options.len() && idx > 0 {
self.context_menu.hovered = Some(idx);
}
@@ -1044,6 +1197,16 @@ impl Application for FilesystemApp {
return;
}
+ if self.current_page == Page::Browse {
+ if self.browse_splitter.on_cursor_moved(pos.x, pos.y, &mut self.ui_context) {
+ changed = true;
+ }
+ } else if self.current_page == Page::Network {
+ if self.network_splitter.on_cursor_moved(pos.x, pos.y, &mut self.ui_context) {
+ changed = true;
+ }
+ }
+
if !self.select_mode && self.paginator.cursor_moved(pos.x, pos.y, &mut self.ui_context) {
changed = true;
}
@@ -1099,26 +1262,11 @@ impl Application for FilesystemApp {
}
if let Some((_path, textbox)) = &mut self.open_with_dialog {
- let dialog_w = 400.0;
- let dialog_h = 160.0;
- let dialog_x = (self.width as f32 - dialog_w) / 2.0;
- let dialog_y = (self.height as f32 - dialog_h) / 2.0;
-
- let tb_x = dialog_x + 20.0;
- let tb_y = dialog_y + 60.0;
- let tb_w = dialog_w - 40.0;
- let tb_h = cce_ui::layout::textbox_height();
-
- let btn_h = cce_ui::layout::button_height();
- let btn_cancel_x = dialog_x + dialog_w - 180.0;
- let btn_cancel_y = dialog_y + dialog_h - btn_h - 16.0;
- let btn_cancel_w = 70.0;
- let btn_cancel_h = btn_h;
-
- let btn_open_x = dialog_x + dialog_w - 100.0;
- let btn_open_y = dialog_y + dialog_h - btn_h - 16.0;
- let btn_open_w = 80.0;
- let btn_open_h = btn_h;
+ let r = open_with_rects(self.width as f32, self.height as f32);
+ let (dialog_x, dialog_y, dialog_w, dialog_h) = (r.x, r.y, r.w, r.h);
+ let (tb_x, tb_y, tb_w, tb_h) = r.tb;
+ let (btn_cancel_x, btn_cancel_y, btn_cancel_w, btn_cancel_h) = r.cancel;
+ let (btn_open_x, btn_open_y, btn_open_w, btn_open_h) = r.open;
if state == ElementState::Pressed {
let clicked_inside = pos.x >= dialog_x && pos.x <= dialog_x + dialog_w && pos.y >= dialog_y && pos.y <= dialog_y + dialog_h;
@@ -1170,7 +1318,7 @@ impl Application for FilesystemApp {
let mut clicked_option = None;
if pos.x >= cx && pos.x <= cx + cw && pos.y >= cy && pos.y <= cy + ch {
- let idx = ((pos.y - cy) / 24.0) as usize;
+ let idx = ((pos.y - cy) / ROW_H) as usize;
if idx < self.context_menu.options.len() && idx > 0 {
clicked_option = self.context_menu.options[idx].1.clone();
}
@@ -1208,9 +1356,7 @@ impl Application for FilesystemApp {
("Copy Path".to_string(), Some(Message::CopyPath(path_str))),
];
- let max_len = options.iter().map(|(s, _)| s.len()).max().unwrap_or(0);
- let menu_w = ((max_len as f32 * 7.5) + 24.0).max(120.0);
- let menu_h = options.len() as f32 * 24.0;
+ let (menu_w, menu_h) = context_menu_size(&options);
self.context_menu = ContextMenu {
visible: true,
@@ -1272,9 +1418,7 @@ impl Application for FilesystemApp {
options.push(("Delete".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::DeleteEntry(idx)))));
// Calculate width
- let max_len = options.iter().map(|(s, _)| s.len()).max().unwrap_or(0);
- let menu_w = ((max_len as f32 * 7.5) + 24.0).max(120.0);
- let menu_h = options.len() as f32 * 24.0;
+ let (menu_w, menu_h) = context_menu_size(&options);
self.context_menu = ContextMenu {
visible: true,
@@ -1319,6 +1463,20 @@ impl Application for FilesystemApp {
return None;
}
+ if self.current_page == Page::Browse {
+ if self.browse_splitter.mouse_input(button, state, pos.x, pos.y, &mut self.ui_context) {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ return None;
+ }
+ } else if self.current_page == Page::Network {
+ if self.network_splitter.mouse_input(button, state, pos.x, pos.y, &mut self.ui_context) {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ return None;
+ }
+ }
+
if self.current_page == Page::Browse {
if self.select_mode {
if self.browse.save_name_box.mouse_input(button, state, pos.x, pos.y, &mut self.ui_context) {
@@ -1343,21 +1501,7 @@ impl Application for FilesystemApp {
if self.browse.breadcrumb.hit_test(pos.x, pos.y, &self.ui_context) {
if self.browse.breadcrumb.mouse_input(button, state, pos.x, pos.y, &mut self.ui_context) {
if let Some(seg) = self.browse.breadcrumb.path_click() {
- let mut target_path = std::path::PathBuf::new();
- let mut current_idx = 0;
- for component in self.browse.current_dir.components() {
- target_path.push(component);
- if component == std::path::Component::RootDir {
- if seg == 0 {
- break;
- }
- } else {
- current_idx += 1;
- if current_idx == seg {
- break;
- }
- }
- }
+ let target_path = pages::browse::path_to_segment(&self.browse.current_dir, seg);
self.fs_service.send(services::fs::FsRequest::ReadDirectory(target_path));
*needs_rebuild = true;
self.needs_rebuild = true;
@@ -1371,21 +1515,7 @@ impl Application for FilesystemApp {
if self.network.breadcrumb.hit_test(pos.x, pos.y, &self.ui_context) {
if self.network.breadcrumb.mouse_input(button, state, pos.x, pos.y, &mut self.ui_context) {
if let Some(seg) = self.network.breadcrumb.path_click() {
- let mut target_path = std::path::PathBuf::new();
- let mut current_idx = 0;
- for component in self.browse.current_dir.components() {
- target_path.push(component);
- if component == std::path::Component::RootDir {
- if seg == 0 {
- break;
- }
- } else {
- current_idx += 1;
- if current_idx == seg {
- break;
- }
- }
- }
+ let target_path = pages::browse::path_to_segment(&self.browse.current_dir, seg);
self.fs_service.send(services::fs::FsRequest::ReadDirectory(target_path));
changed = true;
}
@@ -1423,10 +1553,7 @@ impl Application for FilesystemApp {
self.browse.save_name_box.edit_buffer = entry.name.clone();
}
}
- let selected_path = Some(entry.path.clone());
- if let Some(path) = selected_path {
- self.fs_service.send(services::fs::FsRequest::ReadPreview(path));
- }
+ self.fs_service.send(services::fs::FsRequest::ReadPreview(entry.path.clone()));
}
changed = true;
}
@@ -1510,27 +1637,17 @@ impl Application for FilesystemApp {
fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
if self.current_page == Page::Browse || self.current_page == Page::Network {
- let has_sidebar = false;
- let sidebar_w = if has_sidebar { self.paginator.sidebar_w() } else { 0.0 };
- let browse_x = if has_sidebar { sidebar_w + 17.0 } else { 16.0 };
- let usable_w = self.width as f32 - sidebar_w - (if has_sidebar { 1.0 } else { 0.0 }) - 32.0;
- let browse_w = (usable_w - 12.0) * 0.5;
- let preview_w = (usable_w - 12.0) * 0.5;
- let preview_x = browse_x + browse_w + 12.0;
- let content_y = 16.0;
-
- let select_bar_h = 48.0;
- let content_h = if self.select_mode {
- self.height as f32 - 32.0 - select_bar_h
- } else {
- self.height as f32 - 32.0
- };
-
- let half_h = content_h * 0.5;
- let px = preview_x + 12.0;
- let py = content_y + 32.0;
- let pw = preview_w - 24.0;
- let ph = half_h - 40.0;
+ // Hit-test against the preview widget's actual laid-out rect. Recomputing a
+ // hardcoded 50/50 split here was wrong once the list/preview splitter had been
+ // dragged off-center, so wheel events over the preview were misrouted.
+ let (prev_x, prev_y, prev_w, prev_h) = self.preview.rect();
+ let content_h = prev_h;
+
+ // Inner content-preview region (below the metadata header).
+ let px = prev_x + 12.0;
+ let py = prev_y + 32.0;
+ let pw = prev_w - 24.0;
+ let ph = prev_h * 0.5 - 40.0;
if pos.x as f32 >= px && pos.x as f32 <= px + pw && pos.y as f32 >= py && pos.y as f32 <= py + ph {
if self.preview.handle_mouse_wheel(delta, content_h) {
diff --git a/src/pages/browse.rs b/src/pages/browse.rs
index 045147d..d554a21 100644
--- a/src/pages/browse.rs
+++ b/src/pages/browse.rs
@@ -76,6 +76,11 @@ impl Default for BrowseState {
}
impl BrowseState {
+ /// Path of the currently selected entry, if any.
+ pub fn selected_path(&self) -> Option<PathBuf> {
+ self.selected.and_then(|idx| self.entries.get(idx).map(|e| e.path.clone()))
+ }
+
pub fn update_breadcrumb(&mut self) {
let mut segments = Vec::new();
for component in self.current_dir.components() {
@@ -112,38 +117,34 @@ pub fn is_project_dir(path: &Path) -> bool {
path.is_dir() && (path.join("state.json").exists() || path.join("state.kdl").exists())
}
+/// Reconstruct the ancestor path for a clicked breadcrumb segment index.
+/// `seg == 0` is the root `/`; each subsequent index adds one path component.
+pub fn path_to_segment(current_dir: &Path, seg: usize) -> PathBuf {
+ let mut target_path = PathBuf::new();
+ let mut current_idx = 0;
+ for component in current_dir.components() {
+ target_path.push(component);
+ if component == std::path::Component::RootDir {
+ if seg == 0 {
+ break;
+ }
+ } else {
+ current_idx += 1;
+ if current_idx == seg {
+ break;
+ }
+ }
+ }
+ target_path
+}
+
// ── Helpers ─────────────────────────────────────────────────────────
pub fn read_directory(path: &Path) -> Vec<DirEntry> {
crate::services::fs::read_directory_internal(path)
}
-fn format_size(size: u64) -> String {
- if size < 1024 {
- format!("{} B", size)
- } else if size < 1024 * 1024 {
- format!("{:.1} K", size as f64 / 1024.0)
- } else if size < 1024 * 1024 * 1024 {
- format!("{:.1} M", size as f64 / (1024.0 * 1024.0))
- } else {
- format!("{:.1} G", size as f64 / (1024.0 * 1024.0 * 1024.0))
- }
-}
-
-fn format_permissions(mode: u32) -> String {
- let mut s = String::with_capacity(10);
- s.push(if mode & 0o40000 != 0 { 'd' } else { '-' });
- s.push(if mode & 0o400 != 0 { 'r' } else { '-' });
- s.push(if mode & 0o200 != 0 { 'w' } else { '-' });
- s.push(if mode & 0o100 != 0 { 'x' } else { '-' });
- s.push(if mode & 0o040 != 0 { 'r' } else { '-' });
- s.push(if mode & 0o020 != 0 { 'w' } else { '-' });
- s.push(if mode & 0o010 != 0 { 'x' } else { '-' });
- s.push(if mode & 0o004 != 0 { 'r' } else { '-' });
- s.push(if mode & 0o002 != 0 { 'w' } else { '-' });
- s.push(if mode & 0o001 != 0 { 'x' } else { '-' });
- s
-}
+use crate::util::{format_size, format_permissions};
fn entry_icon(is_dir: bool, name: &str) -> &'static str {
if is_dir {
@@ -458,22 +459,6 @@ mod tests {
assert_eq!(next_selection_index(&state, BrowseNavigation::Down), Some(2));
}
- #[test]
- fn format_size_units() {
- assert_eq!(format_size(0), "0 B");
- assert_eq!(format_size(512), "512 B");
- assert_eq!(format_size(1024), "1.0 K");
- assert_eq!(format_size(1048576), "1.0 M");
- assert_eq!(format_size(1073741824), "1.0 G");
- }
-
- #[test]
- fn format_permissions_string() {
- assert_eq!(format_permissions(0o40755), "drwxr-xr-x");
- assert_eq!(format_permissions(0o100644), "-rw-r--r--");
- assert_eq!(format_permissions(0o644), "-rw-r--r--");
- }
-
#[test]
fn apply_filters_hides_dotfiles() {
let mut state = BrowseState::default();
@@ -499,6 +484,130 @@ mod tests {
assert_eq!(state.entries.len(), 2);
}
+ fn entry(name: &str, path: &str, is_dir: bool) -> DirEntry {
+ DirEntry {
+ name: name.to_string(),
+ path: PathBuf::from(path),
+ is_dir,
+ size: 0,
+ permissions: 0o644,
+ modified: String::new(),
+ }
+ }
+
+ #[test]
+ fn search_changed_filters_entries() {
+ let mut state = BrowseState::default();
+ state.all_entries = vec![entry("apple", "/a/apple", false), entry("banana", "/a/banana", false)];
+ let req = update(&mut state, BrowseMessage::SearchChanged("ban".to_string()));
+ assert!(req.is_none());
+ assert_eq!(state.entries.len(), 1);
+ assert_eq!(state.entries[0].name, "banana");
+ assert_eq!(state.selected, Some(0));
+ }
+
+ #[test]
+ fn navigate_to_path_reads_directory() {
+ let mut state = BrowseState::default();
+ let req = update(&mut state, BrowseMessage::NavigateToPath(PathBuf::from("/tmp")));
+ assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(p)) if p == PathBuf::from("/tmp")));
+ }
+
+ #[test]
+ fn navigate_to_plain_directory_reads_it() {
+ let dir = std::env::temp_dir().join(format!("cce_nav_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
+ std::fs::create_dir_all(&dir).unwrap();
+ let mut state = BrowseState::default();
+ state.entries = vec![entry("sub", dir.to_str().unwrap(), true)];
+ let req = update(&mut state, BrowseMessage::NavigateTo(0));
+ assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(_))));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn navigate_to_project_directory_does_not_enter() {
+ let dir = std::env::temp_dir().join(format!("cce_proj_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("state.json"), "{}").unwrap();
+ let mut state = BrowseState::default();
+ state.entries = vec![entry("proj", dir.to_str().unwrap(), true)];
+ let req = update(&mut state, BrowseMessage::NavigateTo(0));
+ assert!(req.is_none());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn navigate_to_file_does_nothing() {
+ let mut state = BrowseState::default();
+ state.entries = vec![entry("f.txt", "/a/f.txt", false)];
+ let req = update(&mut state, BrowseMessage::NavigateTo(0));
+ assert!(req.is_none());
+ }
+
+ #[test]
+ fn directory_loaded_populates_and_saves() {
+ let mut state = BrowseState::default();
+ let entries = vec![entry("x", "/d/x", false), entry("y", "/d/y", true)];
+ let req = update(&mut state, BrowseMessage::DirectoryLoaded(PathBuf::from("/d"), entries));
+ assert_eq!(state.current_dir, PathBuf::from("/d"));
+ assert_eq!(state.entries.len(), 2);
+ assert!(matches!(req, Some(crate::services::fs::FsRequest::SaveLastDir(p)) if p == PathBuf::from("/d")));
+ }
+
+ #[test]
+ fn directory_refreshed_preserves_selection_by_path() {
+ let mut state = BrowseState::default();
+ state.current_dir = PathBuf::from("/d");
+ state.all_entries = vec![entry("a", "/d/a", false), entry("b", "/d/b", false)];
+ apply_filters(&mut state);
+ state.selected = Some(1); // "b"
+ let new_entries = vec![entry("b", "/d/b", false), entry("a", "/d/a", false), entry("c", "/d/c", false)];
+ let req = update(&mut state, BrowseMessage::DirectoryRefreshed(PathBuf::from("/d"), new_entries));
+ assert!(req.is_none());
+ assert_eq!(
+ state.selected.and_then(|i| state.entries.get(i)).map(|e| e.name.as_str()),
+ Some("b")
+ );
+ }
+
+ #[test]
+ fn directory_refreshed_ignores_other_dir() {
+ let mut state = BrowseState::default();
+ state.current_dir = PathBuf::from("/d");
+ state.all_entries = vec![entry("a", "/d/a", false)];
+ apply_filters(&mut state);
+ let req = update(&mut state, BrowseMessage::DirectoryRefreshed(PathBuf::from("/other"), vec![entry("z", "/other/z", false)]));
+ assert!(req.is_none());
+ assert_eq!(state.entries.len(), 1);
+ assert_eq!(state.entries[0].name, "a");
+ }
+
+ #[test]
+ fn toggle_hidden_shows_dotfiles() {
+ let mut state = BrowseState::default();
+ state.all_entries = vec![entry(".hidden", "/a/.hidden", false), entry("visible", "/a/visible", false)];
+ apply_filters(&mut state);
+ assert_eq!(state.entries.len(), 1);
+ let req = update(&mut state, BrowseMessage::ToggleHidden);
+ assert!(req.is_none());
+ assert!(state.show_hidden);
+ assert_eq!(state.entries.len(), 2);
+ }
+
+ #[test]
+ fn last_dir_loaded_some_reads_that_dir() {
+ let mut state = BrowseState::default();
+ let req = update(&mut state, BrowseMessage::LastDirLoaded(Some(PathBuf::from("/some/dir"))));
+ assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(p)) if p == PathBuf::from("/some/dir")));
+ }
+
+ #[test]
+ fn last_dir_loaded_none_falls_back() {
+ let mut state = BrowseState::default();
+ let req = update(&mut state, BrowseMessage::LastDirLoaded(None));
+ assert!(matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(_))));
+ }
+
#[test]
fn test_is_project_dir_detection() {
let unique_dir = std::env::temp_dir().join(format!("clear_test_dir_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
@@ -534,6 +643,7 @@ mod tests {
}
#[test]
+ #[serial_test::serial]
fn test_directory_persistence() {
let temp_dir = std::env::temp_dir();
let original_home = std::env::var("HOME");
@@ -638,44 +748,12 @@ mod tests {
#[test]
fn test_component_reconstruction() {
- let current_dir = PathBuf::from("/home/lsgalante/documents");
-
- // Let's say seg is 1 (meaning "home/")
- let seg = 1;
- let mut target_path = std::path::PathBuf::new();
- let mut current_idx = 0;
- for component in current_dir.components() {
- target_path.push(component);
- if component == std::path::Component::RootDir {
- if seg == 0 {
- break;
- }
- } else {
- current_idx += 1;
- if current_idx == seg {
- break;
- }
- }
- }
- assert_eq!(target_path, PathBuf::from("/home"));
-
- // Let's say seg is 0 (meaning "/")
- let seg = 0;
- let mut target_path = std::path::PathBuf::new();
- let mut current_idx = 0;
- for component in current_dir.components() {
- target_path.push(component);
- if component == std::path::Component::RootDir {
- if seg == 0 {
- break;
- }
- } else {
- current_idx += 1;
- if current_idx == seg {
- break;
- }
- }
- }
- assert_eq!(target_path, PathBuf::from("/"));
+ let current_dir = PathBuf::from("/home/user/documents");
+
+ // seg 0 is the root, then each index adds a component.
+ assert_eq!(path_to_segment(¤t_dir, 0), PathBuf::from("/"));
+ assert_eq!(path_to_segment(¤t_dir, 1), PathBuf::from("/home"));
+ assert_eq!(path_to_segment(¤t_dir, 2), PathBuf::from("/home/user"));
+ assert_eq!(path_to_segment(¤t_dir, 3), PathBuf::from("/home/user/documents"));
}
}
diff --git a/src/pages/network.rs b/src/pages/network.rs
index b62819a..b27f557 100644
--- a/src/pages/network.rs
+++ b/src/pages/network.rs
@@ -32,10 +32,6 @@ impl Default for NetworkState {
}
}
-#[allow(dead_code)]
-#[derive(Debug, Clone)]
-pub enum NetworkMessage {}
-
impl NetworkState {
pub fn populate_graph(&mut self, current_dir: &Path, entries: &[DirEntry]) {
let mut nodes = Vec::new();
@@ -170,9 +166,6 @@ pub fn view(state: &mut NetworkState, browse: &BrowseState, view_dropdown: &mut
pc
}
-#[allow(dead_code)]
-pub fn update(_state: &mut NetworkState, _msg: NetworkMessage) {}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/services/fs.rs b/src/services/fs.rs
index ffe5aa4..c481f24 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -3,6 +3,7 @@ use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use tokio::sync::mpsc;
use crate::pages::browse::DirEntry;
+use crate::util::{format_size, format_permissions};
use image::GenericImageView;
use cce_ui::widget::ImagePreviewData;
@@ -287,33 +288,6 @@ fn load_preview_data_internal(path: &Path) -> PreviewData {
}
}
-fn format_size(size: u64) -> String {
- if size < 1024 {
- format!("{} B", size)
- } else if size < 1024 * 1024 {
- format!("{:.1} K", size as f64 / 1024.0)
- } else if size < 1024 * 1024 * 1024 {
- format!("{:.1} M", size as f64 / (1024.0 * 1024.0))
- } else {
- format!("{:.1} G", size as f64 / (1024.0 * 1024.0 * 1024.0))
- }
-}
-
-fn format_permissions(mode: u32) -> String {
- let mut s = String::with_capacity(10);
- s.push(if mode & 0o40000 != 0 { 'd' } else { '-' });
- s.push(if mode & 0o400 != 0 { 'r' } else { '-' });
- s.push(if mode & 0o200 != 0 { 'w' } else { '-' });
- s.push(if mode & 0o100 != 0 { 'x' } else { '-' });
- s.push(if mode & 0o040 != 0 { 'r' } else { '-' });
- s.push(if mode & 0o020 != 0 { 'w' } else { '-' });
- s.push(if mode & 0o010 != 0 { 'x' } else { '-' });
- s.push(if mode & 0o004 != 0 { 'r' } else { '-' });
- s.push(if mode & 0o002 != 0 { 'w' } else { '-' });
- s.push(if mode & 0o001 != 0 { 'x' } else { '-' });
- s
-}
-
fn infer_file_type(name: &str, is_dir: bool) -> String {
if is_dir {
return "Directory".to_string();
@@ -348,19 +322,18 @@ fn infer_file_type(name: &str, is_dir: bool) -> String {
}
}
-fn get_last_dir_file_path() -> Option<PathBuf> {
- let dir = if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
- if !xdg_config.is_empty() {
- PathBuf::from(xdg_config)
- } else {
- let home = std::env::var("HOME").ok()?;
- PathBuf::from(home).join(".config")
- }
- } else {
- let home = std::env::var("HOME").ok()?;
- PathBuf::from(home).join(".config")
+/// Base config directory for cce: `$XDG_CONFIG_HOME/cce`, else `$HOME/.config/cce`.
+/// Returns `None` only when neither variable is usable.
+pub fn cce_config_dir() -> Option<PathBuf> {
+ let base = match std::env::var("XDG_CONFIG_HOME") {
+ Ok(xdg) if !xdg.is_empty() => PathBuf::from(xdg),
+ _ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
};
- let dir = dir.join("cce").join("cce-files");
+ Some(base.join("cce"))
+}
+
+fn get_last_dir_file_path() -> Option<PathBuf> {
+ let dir = cce_config_dir()?.join("cce-files");
let _ = fs::create_dir_all(&dir);
Some(dir.join("cce-files-last-dir.txt"))
}
@@ -410,8 +383,7 @@ pub fn get_mime_type(path: &Path) -> Option<String> {
}
pub fn load_kdl_associations() -> Option<std::collections::HashMap<String, String>> {
- let home = std::env::var("HOME").ok()?;
- let path = PathBuf::from(home).join(".config").join("cce").join("mime.kdl");
+ let path = cce_config_dir()?.join("mime.kdl");
if !path.exists() {
return None;
}
@@ -469,13 +441,15 @@ pub fn get_default_application(mime: &str) -> Option<(String, String)> {
return None;
}
- // Search for .desktop file in common directories
- let home = std::env::var("HOME").ok().unwrap_or_default();
- let search_paths = vec![
- PathBuf::from(&home).join(".local/share/applications"),
+ // Search for .desktop file in common directories. Skip the user-local path
+ // entirely when HOME is unset rather than emitting a bogus relative path.
+ let mut search_paths = vec![
PathBuf::from("/usr/share/applications"),
PathBuf::from("/usr/local/share/applications"),
];
+ if let Ok(home) = std::env::var("HOME") {
+ search_paths.insert(0, PathBuf::from(home).join(".local/share/applications"));
+ }
let mut desktop_path = None;
for dir in search_paths {
@@ -517,35 +491,38 @@ pub fn get_default_application(mime: &str) -> Option<(String, String)> {
}
}
-pub fn open_file(path: &Path) {
- let mut opened = false;
- if let Some(mime) = get_mime_type(path) {
- if let Some((_, cmd)) = get_default_application(&mime) {
- if !cmd.is_empty() {
- let parts: Vec<&str> = cmd.split_whitespace().collect();
- if !parts.is_empty() {
- let program = parts[0];
- let mut program_path = PathBuf::from(program);
- if !program_path.is_absolute() && !program.contains('/') {
- if let Ok(home) = std::env::var("HOME") {
- let local_bin = PathBuf::from(home).join(".local").join("bin").join(program);
- if local_bin.exists() {
- program_path = local_bin;
- }
- }
- }
- let mut command = std::process::Command::new(program_path);
- for arg in &parts[1..] {
- command.arg(arg);
- }
- command.arg(path);
- if cce_ui::process::spawn_detached(command).is_ok() {
- opened = true;
- }
- }
+/// Parse a command string, resolve a bare program name against `~/.local/bin`,
+/// append `path` as the final argument, and spawn it detached.
+/// Returns `true` if a process was spawned.
+pub fn spawn_command_for_path(cmd: &str, path: &Path) -> bool {
+ let parts: Vec<&str> = cmd.split_whitespace().collect();
+ if parts.is_empty() {
+ return false;
+ }
+ let program = parts[0];
+ let mut program_path = PathBuf::from(program);
+ if !program_path.is_absolute() && !program.contains('/') {
+ if let Ok(home) = std::env::var("HOME") {
+ let local_bin = PathBuf::from(home).join(".local").join("bin").join(program);
+ if local_bin.exists() {
+ program_path = local_bin;
}
}
}
+ let mut command = std::process::Command::new(program_path);
+ for arg in &parts[1..] {
+ command.arg(arg);
+ }
+ command.arg(path);
+ cce_ui::process::spawn_detached(command).is_ok()
+}
+
+pub fn open_file(path: &Path) {
+ let opened = get_mime_type(path)
+ .and_then(|mime| get_default_application(&mime))
+ .map(|(_, cmd)| !cmd.is_empty() && spawn_command_for_path(&cmd, path))
+ .unwrap_or(false);
+
if !opened {
let mut command = std::process::Command::new("xdg-open");
command.arg(path);
@@ -558,6 +535,7 @@ mod tests {
use super::*;
#[test]
+ #[serial_test::serial]
fn test_kdl() {
let unique_dir_name = format!("cce_test_kdl_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
let temp_path = std::env::temp_dir().join(unique_dir_name);
@@ -567,18 +545,27 @@ mod tests {
std::fs::write(&mime_file, "associations { association \"text/plain\" \"cce-text-editor\" }").unwrap();
let old_home = std::env::var("HOME").ok();
- unsafe { std::env::set_var("HOME", &temp_path); }
-
+ let old_xdg = std::env::var("XDG_CONFIG_HOME").ok();
+ unsafe {
+ std::env::set_var("HOME", &temp_path);
+ std::env::remove_var("XDG_CONFIG_HOME");
+ }
+
let assoc = load_kdl_associations();
-
+
unsafe {
if let Some(ref h) = old_home {
std::env::set_var("HOME", h);
} else {
std::env::remove_var("HOME");
}
+ if let Some(ref x) = old_xdg {
+ std::env::set_var("XDG_CONFIG_HOME", x);
+ } else {
+ std::env::remove_var("XDG_CONFIG_HOME");
+ }
}
-
+
let _ = std::fs::remove_dir_all(&temp_path);
println!("Parsed associations: {:?}", assoc);
diff --git a/src/util.rs b/src/util.rs
new file mode 100644
index 0000000..fdd3fae
--- /dev/null
+++ b/src/util.rs
@@ -0,0 +1,51 @@
+//! Small formatting helpers shared across pages and services.
+
+/// Format a byte count as a short human-readable size (e.g. `1.5 M`).
+pub fn format_size(size: u64) -> String {
+ if size < 1024 {
+ format!("{} B", size)
+ } else if size < 1024 * 1024 {
+ format!("{:.1} K", size as f64 / 1024.0)
+ } else if size < 1024 * 1024 * 1024 {
+ format!("{:.1} M", size as f64 / (1024.0 * 1024.0))
+ } else {
+ format!("{:.1} G", size as f64 / (1024.0 * 1024.0 * 1024.0))
+ }
+}
+
+/// Format a Unix mode into a 10-character `drwxr-xr-x`-style permission string.
+pub fn format_permissions(mode: u32) -> String {
+ let mut s = String::with_capacity(10);
+ s.push(if mode & 0o40000 != 0 { 'd' } else { '-' });
+ s.push(if mode & 0o400 != 0 { 'r' } else { '-' });
+ s.push(if mode & 0o200 != 0 { 'w' } else { '-' });
+ s.push(if mode & 0o100 != 0 { 'x' } else { '-' });
+ s.push(if mode & 0o040 != 0 { 'r' } else { '-' });
+ s.push(if mode & 0o020 != 0 { 'w' } else { '-' });
+ s.push(if mode & 0o010 != 0 { 'x' } else { '-' });
+ s.push(if mode & 0o004 != 0 { 'r' } else { '-' });
+ s.push(if mode & 0o002 != 0 { 'w' } else { '-' });
+ s.push(if mode & 0o001 != 0 { 'x' } else { '-' });
+ s
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn format_size_units() {
+ assert_eq!(format_size(0), "0 B");
+ assert_eq!(format_size(512), "512 B");
+ assert_eq!(format_size(1024), "1.0 K");
+ assert_eq!(format_size(1048576), "1.0 M");
+ assert_eq!(format_size(1073741824), "1.0 G");
+ }
+
+ #[test]
+ fn format_permissions_string() {
+ assert_eq!(format_permissions(0o40755), "drwxr-xr-x");
+ assert_eq!(format_permissions(0o100644), "-rw-r--r--");
+ assert_eq!(format_permissions(0o644), "-rw-r--r--");
+ }
+}