file manager
git clone https://git.lucas.co/cce-files.git
Update and align interface features
Cargo.lock | 26 ++
src/main.rs | 488 +++++++++++++++++++++++++-----
src/pages/mod.rs | 17 +-
src/pages/network.rs | 229 ++++++++++++++
src/pages/preview.rs | 107 +++++--
src/pages/{keybindings.rs => settings.rs} | 82 +++--
6 files changed, 802 insertions(+), 147 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 4b2d00c..4677852 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -206,6 +206,7 @@ dependencies = [
"raw-window-handle",
"resvg",
"serde",
+ "serde_json",
"smithay-client-toolkit",
"tokio",
"wayland-backend",
@@ -700,6 +701,12 @@ dependencies = [
"hashbrown 0.17.1",
]
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -1313,6 +1320,19 @@ dependencies = [
"syn",
]
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -2285,3 +2305,9 @@ name = "zeno"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/src/main.rs b/src/main.rs
index 30cda81..93302b0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,5 @@
mod pages;
-use std::path::PathBuf;
use wayland_client::QueueHandle;
use glyphon::{Attrs, Buffer, FontSystem, Metrics};
@@ -25,8 +24,13 @@ struct AppWidget {
struct FilesystemApp {
current_page: Page,
browse: pages::browse::BrowseState,
+ network: pages::network::NetworkState,
+ settings: pages::settings::SettingsState,
preview: pages::preview::PreviewState,
- keybindings: pages::keybindings::KeybindingsState,
+
+ // Command-line chooser options
+ select_mode: bool,
+ select_directory: bool,
// Rendering resources
widgets: Vec<AppWidget>,
@@ -40,6 +44,7 @@ struct FilesystemApp {
page_buttons: Vec<pages::ContentButton>,
cursor_x: f32,
cursor_y: f32,
+ paginator: clear_ui::widget::Paginator,
}
// ── Messages ────────────────────────────────────────────────────────
@@ -49,8 +54,9 @@ pub enum Message {
SwitchPage(Page),
Browse(pages::browse::BrowseMessage),
Preview(pages::preview::PreviewMessage),
- Keybindings(pages::keybindings::KeybindingsMessage),
KeyboardEvent(KeyEvent),
+ SelectOpen,
+ SelectCancel,
}
// ── Helpers ─────────────────────────────────────────────────────────
@@ -75,61 +81,42 @@ impl FilesystemApp {
let mut pc = pages::PageContent::new();
- // 1. Draw Sidebar Background
- let sidebar_bg = [0.086, 0.141, 0.094, 1.0]; // matching 0x16, 0x24, 0x18
- pc.rect(sidebar_bg, 0.0, 0.0, 200.0, self.height as f32);
-
- // Sidebar Divider Line (accent border)
- pc.rect([0.36, 0.56, 0.38, 1.0], 200.0, 0.0, 1.0, self.height as f32);
-
- // Sidebar Title
- pc.text("Clear Filesystem", 16.0, 16.0, 15.0, [0.36, 0.56, 0.38, 1.0]);
-
- // Sidebar buttons
- let mut btn_y = 48.0;
- for page in Page::ALL {
- let active = page == self.current_page;
- let bg = if active {
- [0.16, 0.29, 0.18, 1.0]
- } else {
- [0.12, 0.18, 0.13, 1.0]
- };
- let fg = if active {
- [0.56, 0.83, 0.56, 1.0]
- } else {
- [0.60, 0.60, 0.67, 1.0]
- };
-
- let label = format!("{} {}", page.icon(), page.label());
+ // 1. Render the Paginator widget to separate PageContent first
+ let page_idx = Page::ALL.iter().position(|&p| p == self.current_page).unwrap_or(0);
+ self.paginator.set_selected_page(page_idx);
+ let mut paginator_pc = pages::PageContent::new();
+ clear_ui::layout::render_widget(&mut paginator_pc, &mut self.paginator, 0.0, 0.0, self.width as f32, self.height as f32);
- pc.button(
- &label,
- 12.0,
- btn_y,
- 176.0,
- 36.0,
- bg,
- if active { bg } else { [0.18, 0.28, 0.20, 1.0] },
- fg,
- Message::SwitchPage(page),
- );
-
- btn_y += 42.0;
- }
-
- // 2. Draw Content Background
- let content_bg = [0.10, 0.165, 0.11, 1.0]; // matching 0x1a, 0x2a, 0x1c
- pc.rect(content_bg, 201.0, 0.0, self.width as f32 - 201.0, self.height as f32);
-
- // 3. Draw Page content
- let usable_w = self.width as f32 - 201.0 - 32.0;
+ let has_sidebar = !self.select_mode;
+ let sidebar_w = if has_sidebar { 56.0 } else { 0.0 };
+ let browse_x = if has_sidebar { 73.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 * 0.55;
let preview_w = usable_w * 0.45;
- let browse_x = 217.0;
let preview_x = browse_x + browse_w + 12.0;
let content_y = 16.0;
- let content_h = self.height as f32 - 32.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
+ };
+
+ if has_sidebar {
+ // 2. Add Content Background (where x >= 56.0) from the paginator to pc first
+ for rect in paginator_pc.rects.iter() {
+ if rect.1 >= 56.0 {
+ pc.rects.push(*rect);
+ }
+ }
+ } else {
+ // Full window page background
+ let bg_color = clear_ui::color::page_low_color();
+ pc.rect(bg_color, 0.0, 0.0, self.width as f32, self.height as f32);
+ }
+
+ // 3. Draw Page content
match self.current_page {
Page::Browse => {
let browse_pc = pages::browse::view(&mut self.browse, browse_x, content_y, browse_w, content_h);
@@ -143,13 +130,119 @@ impl FilesystemApp {
pc.texts.extend(preview_pc.texts);
pc.buttons.extend(preview_pc.buttons);
}
- Page::Keybindings => {
- let keys_pc = pages::keybindings::view(&self.keybindings, browse_x, content_y, usable_w, content_h);
+ Page::Network => {
+ let network_pc = pages::network::view(&mut self.network, &self.browse, browse_x, content_y, browse_w, content_h);
+ let preview_pc = pages::preview::view(&self.preview, preview_x, content_y, preview_w, content_h);
- pc.rects.extend(keys_pc.rects);
- pc.texts.extend(keys_pc.texts);
- pc.buttons.extend(keys_pc.buttons);
+ pc.rects.extend(network_pc.rects);
+ pc.texts.extend(network_pc.texts);
+ pc.buttons.extend(network_pc.buttons);
+
+ pc.rects.extend(preview_pc.rects);
+ pc.texts.extend(preview_pc.texts);
+ pc.buttons.extend(preview_pc.buttons);
}
+ Page::Settings => {
+ let settings_pc = pages::settings::view(&mut self.settings, browse_x, content_y, usable_w, content_h);
+
+ pc.rects.extend(settings_pc.rects);
+ pc.texts.extend(settings_pc.texts);
+ pc.buttons.extend(settings_pc.buttons);
+
+ // Sync color selector changes back to global node color
+ let col = self.settings.color_selector.color;
+ let r_f = col[0] as f32 / 255.0;
+ let g_f = col[1] as f32 / 255.0;
+ let b_f = col[2] as f32 / 255.0;
+ let linear_col = clear_ui::color::to_linear([r_f, g_f, b_f, 1.0]);
+ if clear_ui::color::node_color() != linear_col {
+ clear_ui::color::set_node_color(linear_col);
+ }
+ }
+ }
+
+ if has_sidebar {
+ // 4. Add Sidebar Background and tabs (where x < 56.0) from the paginator to pc last
+ for rect in paginator_pc.rects.iter() {
+ if rect.1 < 56.0 {
+ pc.rects.push(*rect);
+ }
+ }
+
+ // Sidebar Divider Line (accent border)
+ pc.rect([0.36, 0.56, 0.38, 1.0], 56.0, 0.0, 1.0, self.height as f32);
+
+ // Sidebar Title rendered vertically
+ pc.text("C\nL\nE\nA\nR", 18.0, 16.0, 12.0, [0.36, 0.56, 0.38, 1.0]);
+
+ // Add the paginator's texts (tab labels) on top of the sidebar background
+ pc.texts.extend(paginator_pc.texts);
+ }
+
+ // Draw bottom selection bar if select_mode is enabled
+ if self.select_mode {
+ let bar_y = self.height as f32 - select_bar_h - 16.0;
+ // Divider line
+ pc.rect([0.15, 0.20, 0.16, 1.0], 16.0, bar_y, self.width as f32 - 32.0, 1.0);
+
+ // Determine currently selected path
+ let selected_path = if let Some(idx) = self.browse.selected {
+ self.browse.entries.get(idx).map(|e| e.path.clone())
+ } else {
+ None
+ };
+
+ let disp_path = if self.select_directory {
+ selected_path.filter(|p| p.is_dir()).unwrap_or_else(|| self.browse.current_dir.clone())
+ } else {
+ selected_path.unwrap_or_else(|| self.browse.current_dir.clone())
+ };
+
+ let path_str = disp_path.to_string_lossy().to_string();
+ let label_text = if self.select_directory { "Selected Directory:" } else { "Selected File:" };
+
+ let accent = [0.36, 0.56, 0.38, 1.0];
+ let text_fg = [0.83, 0.83, 0.83, 1.0];
+
+ pc.text(label_text, 24.0, bar_y + 18.0, 12.0, accent);
+
+ // Limit path display string length
+ let max_chars = ((self.width as f32 - 380.0) / 7.0).max(10.0) as usize;
+ let path_truncated = if path_str.chars().count() > max_chars {
+ let truncated: String = path_str.chars().skip(path_str.chars().count() - max_chars + 3).collect();
+ format!("...{}", truncated)
+ } else {
+ path_str
+ };
+ pc.text(&path_truncated, 160.0, bar_y + 18.0, 12.0, text_fg);
+
+ // Cancel button
+ let cancel_x = self.width as f32 - 180.0;
+ pc.button(
+ "Cancel",
+ cancel_x,
+ bar_y + 10.0,
+ 70.0,
+ 28.0,
+ [0.25, 0.12, 0.12, 0.5],
+ [0.35, 0.15, 0.15, 0.8],
+ text_fg,
+ Message::SelectCancel,
+ );
+
+ // Open/Select button
+ let open_x = self.width as f32 - 100.0;
+ pc.button(
+ "Select",
+ open_x,
+ bar_y + 10.0,
+ 80.0,
+ 28.0,
+ accent,
+ [0.46, 0.66, 0.48, 1.0],
+ [0.10, 0.16, 0.11, 1.0],
+ Message::SelectOpen,
+ );
}
// 4. Translate PageContent into rendering quads and TextItems
@@ -231,25 +324,42 @@ impl Application for FilesystemApp {
type Message = Message;
fn new(_qh: &QueueHandle<clear_ui::engine::EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
+ // Parse command line arguments
+ let args: Vec<String> = std::env::args().collect();
+ let select_mode = args.iter().any(|arg| arg == "--select" || arg == "--select-dir");
+ let select_directory = args.iter().any(|arg| arg == "--select-dir");
+
+ // Use signature green theme for the content background of the paginator
+ clear_ui::color::set_page_low_color([0.10, 0.165, 0.11, 1.0]);
+
let browse = pages::browse::BrowseState::default();
let current_dir = browse.current_dir.clone();
+ let pages_names = Page::ALL.iter().map(|p| p.label().to_string()).collect::<Vec<_>>();
+ let paginator = clear_ui::widget::Paginator::new(56.0, pages_names)
+ .with_tab_y_offset(120.0)
+ .with_tabs_rotated(true);
+
let mut app = Self {
current_page: Page::Browse,
browse,
+ network: pages::network::NetworkState::default(),
+ settings: pages::settings::SettingsState::default(),
preview: pages::preview::PreviewState::default(),
- keybindings: pages::keybindings::KeybindingsState,
+ select_mode,
+ select_directory,
widgets: Vec::new(),
text_items: Vec::new(),
font_system: FontSystem::new(),
needs_rebuild: true,
- width: 1200,
- height: 720,
+ width: if select_mode { 900 } else { 1200 },
+ height: if select_mode { 500 } else { 720 },
scale_factor: 1.0,
sender: sender.clone(),
page_buttons: Vec::new(),
cursor_x: 0.0,
cursor_y: 0.0,
+ paginator,
};
// Start initial directory loading
@@ -264,13 +374,29 @@ impl Application for FilesystemApp {
}
fn settings(&self) -> WindowSettings {
- WindowSettings {
- title: "Clear Filesystem Interface".to_string(),
- app_id: "clear-filesystem-interface".to_string(),
- width: 1200,
- height: 720,
- fullscreen: false,
- min_size: Some((1020, 600)),
+ if self.select_mode {
+ let title = if self.select_directory {
+ "Select Directory"
+ } else {
+ "Select File"
+ };
+ WindowSettings {
+ title: title.to_string(),
+ app_id: "clear-filesystem-chooser".to_string(),
+ width: 900,
+ height: 500,
+ fullscreen: false,
+ min_size: Some((800, 400)),
+ }
+ } else {
+ WindowSettings {
+ title: "Clear Filesystem Interface".to_string(),
+ app_id: "clear-filesystem-interface".to_string(),
+ width: 1200,
+ height: 720,
+ fullscreen: false,
+ min_size: Some((1020, 600)),
+ }
}
}
@@ -278,6 +404,8 @@ impl Application for FilesystemApp {
match msg {
Message::SwitchPage(page) => {
self.current_page = page;
+ let page_idx = Page::ALL.iter().position(|&p| p == page).unwrap_or(0);
+ self.paginator.set_selected_page(page_idx);
*needs_rebuild = true;
self.needs_rebuild = true;
}
@@ -309,32 +437,60 @@ impl Application for FilesystemApp {
self.needs_rebuild = true;
}
Message::Preview(msg) => {
- let nav_path = match &msg {
- pages::preview::PreviewMessage::NavigateTo(path) => Some(path.clone()),
- _ => None,
- };
pages::preview::update(&mut self.preview, msg);
- if let Some(path) = nav_path {
- let sender_clone = self.sender.clone();
- tokio::spawn(async move {
- let entries = pages::browse::read_directory(&path);
- let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(entries)));
- });
- }
*needs_rebuild = true;
self.needs_rebuild = true;
}
- Message::Keybindings(msg) => {
- pages::keybindings::update(&mut self.keybindings, msg);
+ Message::KeyboardEvent(_) => {}
+ Message::SelectOpen => {
+ let selected_path = if let Some(idx) = self.browse.selected {
+ self.browse.entries.get(idx).map(|e| e.path.clone())
+ } else {
+ None
+ };
+ if self.select_directory {
+ let path = selected_path.filter(|p| p.is_dir()).unwrap_or_else(|| self.browse.current_dir.clone());
+ println!("{}", path.display());
+ std::process::exit(0);
+ } else if let Some(path) = selected_path {
+ if path.is_dir() {
+ let sender_clone = self.sender.clone();
+ tokio::spawn(async move {
+ let entries = pages::browse::read_directory(&path);
+ let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(entries)));
+ });
+ } else {
+ println!("{}", path.display());
+ std::process::exit(0);
+ }
+ }
+ }
+ Message::SelectCancel => {
+ std::process::exit(1);
+ }
+ }
+ }
+
+ fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+ if self.paginator.tick(dt) {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+
+ if self.current_page == Page::Settings {
+ if self.settings.color_selector.tick(dt) {
+ let col = self.settings.color_selector.color;
+ let r_f = col[0] as f32 / 255.0;
+ let g_f = col[1] as f32 / 255.0;
+ let b_f = col[2] as f32 / 255.0;
+ let linear_col = clear_ui::color::to_linear([r_f, g_f, b_f, 1.0]);
+ clear_ui::color::set_node_color(linear_col);
*needs_rebuild = true;
self.needs_rebuild = true;
}
- Message::KeyboardEvent(_) => {}
}
}
- fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
-
fn view(&mut self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, size: LogicalSize, scale: f64) {
if self.needs_rebuild || self.width != size.width as u32 || self.height != size.height as u32 || self.scale_factor != scale {
self.width = size.width as u32;
@@ -362,6 +518,10 @@ impl Application for FilesystemApp {
let mut changed = false;
+ if self.paginator.cursor_moved(pos.x, pos.y) {
+ changed = true;
+ }
+
if self.current_page == Page::Browse {
if self.browse.search_box.cursor_moved(pos.x, pos.y) {
changed = true;
@@ -369,6 +529,20 @@ impl Application for FilesystemApp {
if self.browse.list_box.cursor_moved(pos.x, pos.y) {
changed = true;
}
+ } else if self.current_page == Page::Network {
+ if self.network.graph.is_dragging() {
+ if self.network.graph.drag_update(pos.x, pos.y) {
+ changed = true;
+ }
+ } else {
+ if self.network.graph.on_cursor_moved(pos.x, pos.y) {
+ changed = true;
+ }
+ }
+ } else if self.current_page == Page::Settings {
+ if self.settings.color_selector.cursor_moved(pos.x, pos.y) {
+ changed = true;
+ }
}
// Always check if buttons hover state changed
@@ -390,6 +564,21 @@ impl Application for FilesystemApp {
return None;
}
+ let mut changed = false;
+
+ if self.paginator.mouse_input(button, state, pos.x, pos.y) {
+ if self.paginator.take_click() {
+ let idx = self.paginator.selected_page();
+ if idx < Page::ALL.len() {
+ clear_ui::widget::focus::clear_focus();
+ self.current_page = Page::ALL[idx];
+ }
+ }
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ return None;
+ }
+
if self.current_page == Page::Browse {
if self.browse.search_box.mouse_input(button, state, pos.x, pos.y) {
*needs_rebuild = true;
@@ -399,6 +588,110 @@ impl Application for FilesystemApp {
*needs_rebuild = true;
self.needs_rebuild = true;
}
+ } else if self.current_page == Page::Network {
+ if button == MouseButton::Left {
+ if state == ElementState::Pressed {
+ if self.network.graph.mouse_input(button, state, pos.x, pos.y) {
+ if self.network.graph.is_dragging() {
+ self.network.graph.drag_begin(pos.x, pos.y);
+ }
+ changed = true;
+ }
+ } else if state == ElementState::Released {
+ if self.network.graph.is_dragging() {
+ self.network.graph.drag_end();
+ changed = true;
+ } else {
+ if self.network.graph.mouse_input(button, state, pos.x, pos.y) {
+ changed = true;
+ }
+ }
+ }
+ }
+
+ // Sync selection from graph to browse state
+ let has_parent = self.browse.current_dir.parent().is_some();
+ let offset = if has_parent { 2 } else { 1 };
+ if let Some(node_sel) = self.network.graph.selected_node() {
+ if node_sel >= offset {
+ let entry_idx = node_sel - offset;
+ if self.browse.selected != Some(entry_idx) {
+ self.browse.selected = Some(entry_idx);
+ let selected_path = self.browse.entries.get(entry_idx).map(|e| e.path.clone());
+ if let Some(path) = selected_path {
+ pages::preview::update(&mut self.preview, pages::preview::PreviewMessage::SetPath { path });
+ }
+ changed = true;
+ }
+ } else {
+ if self.browse.selected.is_some() {
+ self.browse.selected = None;
+ changed = true;
+ }
+ }
+ } else {
+ if self.browse.selected.is_some() {
+ self.browse.selected = None;
+ changed = true;
+ }
+ }
+
+ // Sync double click navigation
+ if let Some(dbl_idx) = self.network.graph.double_clicked_node() {
+ self.network.graph.clear_double_clicked_node();
+ if has_parent && dbl_idx == 0 {
+ if let Some(parent) = self.browse.current_dir.parent() {
+ let parent_path = parent.to_path_buf();
+ let sender_clone = self.sender.clone();
+ tokio::spawn(async move {
+ let entries = pages::browse::read_directory(&parent_path);
+ let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(entries)));
+ });
+ changed = true;
+ }
+ } else if dbl_idx >= offset {
+ let entry_idx = dbl_idx - offset;
+ if let Some(entry) = self.browse.entries.get(entry_idx) {
+ if entry.is_dir {
+ let path = entry.path.clone();
+ let sender_clone = self.sender.clone();
+ tokio::spawn(async move {
+ let entries = pages::browse::read_directory(&path);
+ let _ = sender_clone.send(Message::Browse(pages::browse::BrowseMessage::DirectoryLoaded(entries)));
+ });
+ changed = true;
+ }
+ }
+ }
+ }
+
+ if changed {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
+ } else if self.current_page == Page::Settings {
+ if self.settings.color_selector.mouse_input(button, state, pos.x, pos.y) {
+ if self.settings.color_selector.take_click() {
+ let col = self.settings.color_selector.color;
+ let r_f = col[0] as f32 / 255.0;
+ let g_f = col[1] as f32 / 255.0;
+ let b_f = col[2] as f32 / 255.0;
+ let linear_col = clear_ui::color::to_linear([r_f, g_f, b_f, 1.0]);
+ clear_ui::color::set_node_color(linear_col);
+ }
+ changed = true;
+ }
+ // If user clicked outside color selector focus area, unfocus it
+ if state == ElementState::Pressed && !self.settings.color_selector.hit_test(pos.x, pos.y) {
+ self.settings.color_selector.unfocus();
+ clear_ui::widget::focus::clear_focus();
+ changed = true;
+ }
+
+ if changed {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
}
if state == ElementState::Pressed {
@@ -430,6 +723,11 @@ impl Application for FilesystemApp {
*needs_rebuild = true;
self.needs_rebuild = true;
}
+ } else if self.current_page == Page::Network {
+ if self.network.graph.mouse_wheel(delta, pos.x as f32, pos.y as f32) {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ }
}
}
@@ -438,6 +736,22 @@ impl Application for FilesystemApp {
return None;
}
+ if self.current_page == Page::Settings && self.settings.color_selector.editing {
+ if self.settings.color_selector.keyboard_input(event) {
+ *needs_rebuild = true;
+ self.needs_rebuild = true;
+ if !self.settings.color_selector.editing {
+ let col = self.settings.color_selector.color;
+ let r_f = col[0] as f32 / 255.0;
+ let g_f = col[1] as f32 / 255.0;
+ let b_f = col[2] as f32 / 255.0;
+ let linear_col = clear_ui::color::to_linear([r_f, g_f, b_f, 1.0]);
+ clear_ui::color::set_node_color(linear_col);
+ }
+ return None;
+ }
+ }
+
// If the search textbox is focused, forward key inputs to it
if self.current_page == Page::Browse && self.browse.search_box.editing {
if self.browse.search_box.keyboard_input(event) {
@@ -475,10 +789,18 @@ impl Application for FilesystemApp {
if let Some(entry) = self.browse.entries.get(idx) {
if entry.is_dir {
return Some(Message::Browse(pages::browse::BrowseMessage::NavigateTo(idx)));
+ } else if self.select_mode && !self.select_directory {
+ println!("{}", entry.path.display());
+ std::process::exit(0);
}
}
}
}
+ clear_ui::widget::Key::Named(clear_ui::widget::NamedKey::Escape) => {
+ if self.select_mode {
+ std::process::exit(1);
+ }
+ }
clear_ui::widget::Key::Named(clear_ui::widget::NamedKey::Backspace) => {
if let Some(parent) = self.browse.current_dir.parent() {
return Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(parent.to_path_buf())));
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 1aab8b1..32ded8e 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -1,32 +1,37 @@
pub mod browse;
pub mod preview;
-pub mod keybindings;
+pub mod network;
+pub mod settings;
use clear_ui::layout::RenderTarget;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
Browse,
- Keybindings,
+ Network,
+ Settings,
}
impl Page {
- pub const ALL: [Page; 2] = [
+ pub const ALL: [Page; 3] = [
Page::Browse,
- Page::Keybindings,
+ Page::Network,
+ Page::Settings,
];
pub fn label(self) -> &'static str {
match self {
Page::Browse => "Browse",
- Page::Keybindings => "Keys",
+ Page::Network => "Network",
+ Page::Settings => "Settings",
}
}
pub fn icon(self) -> &'static str {
match self {
Page::Browse => "📁",
- Page::Keybindings => "⌨",
+ Page::Network => "🌐",
+ Page::Settings => "⚙",
}
}
}
diff --git a/src/pages/network.rs b/src/pages/network.rs
new file mode 100644
index 0000000..e6a6195
--- /dev/null
+++ b/src/pages/network.rs
@@ -0,0 +1,229 @@
+use std::path::{Path, PathBuf};
+use crate::pages::PageContent;
+use crate::pages::browse::{BrowseState, DirEntry};
+use clear_ui::widget::{Graph, GraphNode, Widget};
+
+pub struct NetworkState {
+ pub graph: Graph,
+ pub last_dir: PathBuf,
+}
+
+impl Default for NetworkState {
+ fn default() -> Self {
+ let mut graph = Graph::new();
+ // Configure grid settings
+ graph.set_show_network_grid(true);
+ graph.set_grid_sizes(140.0, 70.0);
+ graph.set_skipped_sizes(35.0, 35.0);
+ graph.set_grid_origin(60.0, 60.0);
+ graph.set_grid_snap_enabled(true);
+ graph.set_uniform_background(false);
+ graph.set_network_opacity(0.95);
+
+ Self {
+ graph,
+ last_dir: PathBuf::new(),
+ }
+ }
+}
+
+#[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();
+
+ // 1. Parent directory (if any)
+ let parent_node_name = if let Some(parent) = current_dir.parent() {
+ let parent_name = parent
+ .file_name()
+ .map(|s| s.to_string_lossy().to_string())
+ .unwrap_or_else(|| "/".to_string());
+ let name = format!("📁 .. ({})", parent_name);
+ nodes.push(GraphNode {
+ name: name.clone(),
+ position: (3.0, 0.0),
+ parameters: Vec::new(),
+ geom_visible: true,
+ });
+ Some(name)
+ } else {
+ None
+ };
+
+ // 2. Current directory node
+ let current_name = current_dir
+ .file_name()
+ .map(|s| s.to_string_lossy().to_string())
+ .unwrap_or_else(|| "/".to_string());
+ let current_node_name = format!("📁 {}", current_name);
+
+ let current_params = if let Some(ref p_name) = parent_node_name {
+ vec![("input".to_string(), p_name.clone(), "string".to_string())]
+ } else {
+ Vec::new()
+ };
+
+ nodes.push(GraphNode {
+ name: current_node_name.clone(),
+ position: (3.0, 1.0),
+ parameters: current_params,
+ geom_visible: true,
+ });
+
+ // 3. Children nodes
+ for (idx, entry) in entries.iter().enumerate() {
+ let icon = if entry.is_dir { "📁" } else { "📄" };
+ let node_name = format!("{} {}", icon, entry.name);
+
+ // Spreading items in 7 columns starting at row 2
+ let col = (idx % 7) as f32;
+ let row = 2.0 + (idx / 7) as f32;
+
+ nodes.push(GraphNode {
+ name: node_name,
+ position: (col, row),
+ parameters: vec![("input".to_string(), current_node_name.clone(), "string".to_string())],
+ geom_visible: true,
+ });
+ }
+
+ self.graph.set_nodes(&nodes);
+ self.last_dir = current_dir.to_path_buf();
+ }
+}
+
+pub fn view(state: &mut NetworkState, browse: &BrowseState, cx: f32, cy: f32, cw: f32, ch: f32) -> PageContent {
+ let mut pc = PageContent::new();
+
+ // Check if directory changed, or if last_dir is empty, and repopulate
+ if state.last_dir != browse.current_dir || state.graph.get_nodes().is_empty() {
+ state.populate_graph(&browse.current_dir, &browse.entries);
+
+ // Map browse selection to graph node selection if any
+ let has_parent = browse.current_dir.parent().is_some();
+ let offset = if has_parent { 2 } else { 1 };
+ if let Some(sel) = browse.selected {
+ state.graph.set_selected_node(Some(sel + offset));
+ } else {
+ state.graph.set_selected_node(None);
+ }
+ } else {
+ // Sync graph selection with browse selection when they are in sync
+ let has_parent = browse.current_dir.parent().is_some();
+ let offset = if has_parent { 2 } else { 1 };
+
+ if let Some(sel) = browse.selected {
+ let expected_node_idx = sel + offset;
+ if state.graph.selected_node() != Some(expected_node_idx) {
+ if !state.graph.is_dragging() {
+ state.graph.set_selected_node(Some(expected_node_idx));
+ }
+ }
+ } else {
+ if let Some(graph_sel) = state.graph.selected_node() {
+ if graph_sel >= offset {
+ state.graph.set_selected_node(None);
+ }
+ }
+ }
+ }
+
+ // Render the Graph widget into PageContent
+ clear_ui::layout::render_widget(&mut pc, &mut state.graph, cx, cy, cw, ch);
+
+ pc
+}
+
+#[allow(dead_code)]
+pub fn update(_state: &mut NetworkState, _msg: NetworkMessage) {}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_populate_graph_has_parent() {
+ let mut state = NetworkState::default();
+ let current_dir = Path::new("/home/user/project");
+ let entries = vec![
+ DirEntry {
+ name: "file1.txt".to_string(),
+ path: PathBuf::from("/home/user/project/file1.txt"),
+ is_dir: false,
+ size: 100,
+ permissions: 0o644,
+ modified: String::new(),
+ },
+ DirEntry {
+ name: "subdir".to_string(),
+ path: PathBuf::from("/home/user/project/subdir"),
+ is_dir: true,
+ size: 4096,
+ permissions: 0o755,
+ modified: String::new(),
+ },
+ ];
+
+ state.populate_graph(current_dir, &entries);
+ let nodes = state.graph.get_nodes();
+
+ // 1 parent + 1 current + 2 children = 4 nodes
+ assert_eq!(nodes.len(), 4);
+
+ // Check node names
+ assert_eq!(nodes[0].name, "📁 .. (user)");
+ assert_eq!(nodes[1].name, "📁 project");
+ assert_eq!(nodes[2].name, "📄 file1.txt");
+ assert_eq!(nodes[3].name, "📁 subdir");
+
+ // Check connection parameters
+ // Current directory points to parent
+ assert_eq!(nodes[1].parameters.len(), 1);
+ assert_eq!(nodes[1].parameters[0].0, "input");
+ assert_eq!(nodes[1].parameters[0].1, "📁 .. (user)");
+
+ // Children point to current directory
+ assert_eq!(nodes[2].parameters.len(), 1);
+ assert_eq!(nodes[2].parameters[0].0, "input");
+ assert_eq!(nodes[2].parameters[0].1, "📁 project");
+
+ assert_eq!(nodes[3].parameters.len(), 1);
+ assert_eq!(nodes[3].parameters[0].0, "input");
+ assert_eq!(nodes[3].parameters[0].1, "📁 project");
+ }
+
+ #[test]
+ fn test_populate_graph_root_no_parent() {
+ let mut state = NetworkState::default();
+ let current_dir = Path::new("/");
+ let entries = vec![
+ DirEntry {
+ name: "bin".to_string(),
+ path: PathBuf::from("/bin"),
+ is_dir: true,
+ size: 4096,
+ permissions: 0o755,
+ modified: String::new(),
+ },
+ ];
+
+ state.populate_graph(current_dir, &entries);
+ let nodes = state.graph.get_nodes();
+
+ // No parent, so 1 current + 1 child = 2 nodes
+ assert_eq!(nodes.len(), 2);
+ assert_eq!(nodes[0].name, "📁 /");
+ assert_eq!(nodes[1].name, "📁 bin");
+
+ // Current has no parent parameter
+ assert!(nodes[0].parameters.is_empty());
+
+ // Child points to current
+ assert_eq!(nodes[1].parameters.len(), 1);
+ assert_eq!(nodes[1].parameters[0].0, "input");
+ assert_eq!(nodes[1].parameters[0].1, "📁 /");
+ }
+}
diff --git a/src/pages/preview.rs b/src/pages/preview.rs
index 7c0b034..877cf3e 100644
--- a/src/pages/preview.rs
+++ b/src/pages/preview.rs
@@ -17,12 +17,12 @@ pub struct PreviewState {
pub modified: String,
pub file_type: String,
pub target: String, // for symlinks
+ pub content_preview: Option<String>,
}
#[derive(Debug, Clone)]
pub enum PreviewMessage {
SetPath { path: PathBuf },
- NavigateTo(PathBuf),
}
// ── Helpers ─────────────────────────────────────────────────────────
@@ -90,29 +90,62 @@ fn infer_file_type(name: &str, is_dir: bool) -> String {
// ── View ────────────────────────────────────────────────────────────
-pub fn view(state: &PreviewState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
+pub fn view(state: &PreviewState, cx: f32, cy: f32, cw: f32, ch: f32) -> PageContent {
let mut pc = PageContent::new();
let text_fg = [0.83, 0.83, 0.83, 1.0];
let text_dim = [0.53, 0.53, 0.60, 1.0];
let label_fg = [0.56, 0.83, 0.56, 1.0];
- let accent_bg = [0.36, 0.56, 0.38, 1.0];
if state.path.is_none() {
pc.text("Select a file to view details", cx + 12.0, cy + 12.0, 13.0, text_dim);
return pc;
}
+ let half_h = ch * 0.5;
+
+ // 1. Top pane: File Preview
+ pc.text("Preview", cx + 12.0, cy + 12.0, 14.0, label_fg);
+
+ let bg_color = [0.07, 0.11, 0.08, 0.5];
+ pc.rect(bg_color, cx + 12.0, cy + 32.0, cw - 24.0, half_h - 40.0);
+
+ if let Some(content) = &state.content_preview {
+ let mut text_y = cy + 44.0;
+ for line in content.lines() {
+ if text_y + 14.0 > cy + half_h - 16.0 {
+ break;
+ }
+ let line_truncated = if line.chars().count() > 45 {
+ let mut s: String = line.chars().take(42).collect();
+ s.push_str("...");
+ s
+ } else {
+ line.to_string()
+ };
+ pc.text(&line_truncated, cx + 20.0, text_y, 11.0, text_fg);
+ text_y += 15.0;
+ }
+ } else {
+ pc.text("No preview available", cx + 20.0, cy + 44.0, 11.0, text_dim);
+ }
+
+ // 2. Bottom pane: Details
+ let bottom_y = cy + half_h + 12.0;
+
+ // Pane divider
+ pc.rect([0.15, 0.20, 0.16, 1.0], cx + 12.0, bottom_y - 6.0, cw - 24.0, 1.0);
+
let icon = if state.is_dir { "📁" } else { "📄" };
- // Title row
- pc.text(icon, cx + 12.0, cy + 12.0, 24.0, text_fg);
+ // Details header
+ pc.text(icon, cx + 12.0, bottom_y + 6.0, 20.0, text_fg);
let name_truncated = if state.name.len() > 30 {
format!("{}...", &state.name[..27])
} else {
state.name.clone()
};
- pc.text(&name_truncated, cx + 46.0, cy + 18.0, 18.0, text_fg);
+ pc.text(&name_truncated, cx + 42.0, bottom_y + 10.0, 16.0, text_fg);
// Metadata details
let details = [
@@ -123,7 +156,7 @@ pub fn view(state: &PreviewState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCo
("Modified", &state.modified),
];
- let mut y = cy + 54.0;
+ let mut y = bottom_y + 36.0;
for (label, val) in &details {
pc.text(label, cx + 12.0, y, 12.0, label_fg);
@@ -138,9 +171,6 @@ pub fn view(state: &PreviewState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCo
if !state.target.is_empty() {
y += 8.0;
- // Divider
- pc.rect([0.15, 0.20, 0.16, 1.0], cx + 12.0, y, cw - 24.0, 1.0);
- y += 12.0;
pc.text("Target", cx + 12.0, y, 12.0, label_fg);
let target_str = if state.target.len() > 40 {
@@ -149,24 +179,6 @@ pub fn view(state: &PreviewState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageCo
state.target.clone()
};
pc.text(&target_str, cx + 112.0, y, 12.0, text_dim);
- y += 20.0;
- }
-
- if state.is_dir {
- if let Some(path) = &state.path {
- y += 16.0;
- pc.button(
- "Open directory",
- cx + 12.0,
- y,
- 140.0,
- 32.0,
- accent_bg,
- [0.46, 0.66, 0.48, 1.0],
- [0.10, 0.16, 0.11, 1.0],
- crate::Message::Preview(PreviewMessage::NavigateTo(path.clone())),
- );
- }
}
pc
@@ -212,6 +224,43 @@ pub fn update(state: &mut PreviewState, msg: PreviewMessage) {
let path_display = path.to_string_lossy().to_string();
+ // Load content preview
+ let content_preview = if is_dir {
+ if let Ok(entries) = fs::read_dir(&path) {
+ let mut names = Vec::new();
+ for entry in entries.flatten().take(15) {
+ let name = entry.file_name().to_string_lossy().to_string();
+ let is_sub_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
+ let icon = if is_sub_dir { "📁" } else { "📄" };
+ names.push(format!("{} {}", icon, name));
+ }
+ if names.is_empty() {
+ Some("[Empty directory]".to_string())
+ } else {
+ Some(names.join("\n"))
+ }
+ } else {
+ None
+ }
+ } else if let Ok(mut file) = fs::File::open(&path) {
+ use std::io::Read;
+ let mut buf = vec![0u8; 1024];
+ if let Ok(n) = file.read(&mut buf) {
+ buf.truncate(n);
+ if let Ok(utf8_str) = String::from_utf8(buf) {
+ let lines: Vec<&str> = utf8_str.lines().take(15).collect();
+ let preview_text = lines.join("\n");
+ Some(preview_text)
+ } else {
+ Some("[Binary file content]".to_string())
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
*state = PreviewState {
path: Some(path),
path_display,
@@ -222,8 +271,8 @@ pub fn update(state: &mut PreviewState, msg: PreviewMessage) {
modified,
file_type,
target,
+ content_preview,
};
}
- PreviewMessage::NavigateTo(_) => {}
}
}
diff --git a/src/pages/keybindings.rs b/src/pages/settings.rs
similarity index 53%
rename from src/pages/keybindings.rs
rename to src/pages/settings.rs
index 348ca4f..f2ccf9c 100644
--- a/src/pages/keybindings.rs
+++ b/src/pages/settings.rs
@@ -1,28 +1,42 @@
use crate::pages::PageContent;
+use clear_ui::widget::{ColorSelector, Widget};
+use clear_ui::layout::Section;
// ── Data ────────────────────────────────────────────────────────────
-#[derive(Debug, Clone, Default)]
-pub struct KeybindingsState;
+pub struct SettingsState {
+ pub color_selector: ColorSelector,
+}
-#[derive(Debug, Clone)]
-pub enum KeybindingsMessage {}
+impl Default for SettingsState {
+ fn default() -> Self {
+ let col = clear_ui::color::node_color();
+ let col_u8 = [
+ (col[0] * 255.0) as u8,
+ (col[1] * 255.0) as u8,
+ (col[2] * 255.0) as u8,
+ ];
+ Self {
+ color_selector: ColorSelector::new(col_u8).with_label("Node Color"),
+ }
+ }
+}
-// ── Static data ────────────────────────────────────────────────────
+// ── Keybindings Structs & Data ──────────────────────────────────────
struct Binding {
keys: &'static str,
action: &'static str,
}
-struct Section {
+struct SectionData {
title: &'static str,
icon: &'static str,
bindings: &'static [Binding],
}
-const SECTIONS: &[Section] = &[
- Section {
+const SECTIONS: &[SectionData] = &[
+ SectionData {
title: "Navigation",
icon: "🧭",
bindings: &[
@@ -34,7 +48,7 @@ const SECTIONS: &[Section] = &[
Binding { keys: "Ctrl + F", action: "Focus search" },
],
},
- Section {
+ SectionData {
title: "Browse",
icon: "📁",
bindings: &[
@@ -43,7 +57,7 @@ const SECTIONS: &[Section] = &[
Binding { keys: "Search", action: "Filter files by name" },
],
},
- Section {
+ SectionData {
title: "Info",
icon: "ℹ",
bindings: &[
@@ -51,48 +65,58 @@ const SECTIONS: &[Section] = &[
Binding { keys: "Open dir button", action: "Navigate into selected directory" },
],
},
- Section {
+ SectionData {
title: "General",
icon: "⚡",
bindings: &[
- Binding { keys: "Ctrl + 1-2", action: "Switch page" },
+ Binding { keys: "Ctrl + 1-3", action: "Switch page" },
],
},
];
// ── View ────────────────────────────────────────────────────────────
-pub fn view(_state: &KeybindingsState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
+pub fn view(state: &mut SettingsState, cx: f32, cy: f32, cw: f32, _ch: f32) -> PageContent {
let mut pc = PageContent::new();
let accent = [0.36, 0.56, 0.38, 1.0];
let text_fg = [0.83, 0.83, 0.83, 1.0];
let text_dim = [0.53, 0.53, 0.60, 1.0];
- pc.text("Keybindings", cx + 12.0, cy + 12.0, 18.0, accent);
+ pc.text("Settings", cx + 12.0, cy + 12.0, 18.0, accent);
+
+ let cs_w = (cw - 24.0).min(300.0);
+ let cs_h = 24.0;
+ let cs_y = cy + 48.0 + state.color_selector.top_room();
+
+ state.color_selector.set_row_rect(cx + 12.0, cs_w);
+ clear_ui::layout::render_widget(&mut pc, &mut state.color_selector, cx + 12.0, cs_y, cs_w, cs_h);
- let mut y = cy + 42.0;
+ let y = cs_y + cs_h + 32.0;
- for section in SECTIONS {
+ // Keyboard Shortcuts Section
+ let mut sec_keys = Section::new(&mut pc, cx, y, cw, "Keyboard Shortcuts");
+ sec_keys.spacing(8.0);
+
+ for (i, section) in SECTIONS.iter().enumerate() {
// Section Header
let header_str = format!("{} {}", section.icon, section.title);
- pc.text(&header_str, cx + 12.0, y, 14.0, text_fg);
- y += 22.0;
+ sec_keys.text(&mut pc, &header_str, 12.0, 0.0, 13.0, text_fg);
+ sec_keys.spacing(20.0);
for b in section.bindings {
- pc.text(b.keys, cx + 12.0, y, 12.0, accent);
- pc.text(b.action, cx + 160.0, y, 12.0, text_dim);
- y += 18.0;
+ sec_keys.text(&mut pc, b.keys, 12.0, 0.0, 11.0, accent);
+ sec_keys.text(&mut pc, b.action, 160.0, 0.0, 11.0, text_dim);
+ sec_keys.spacing(16.0);
}
- y += 8.0;
- // Divider
- pc.rect([0.15, 0.20, 0.16, 1.0], cx + 12.0, y, cw - 24.0, 1.0);
- y += 12.0;
+ if i < SECTIONS.len() - 1 {
+ sec_keys.spacing(8.0);
+ sec_keys.separator(&mut pc);
+ sec_keys.spacing(12.0);
+ }
}
+ sec_keys.finish(&mut pc);
+
pc
}
-
-// ── Update ──────────────────────────────────────────────────────────
-
-pub fn update(_state: &mut KeybindingsState, _msg: KeybindingsMessage) {}