Add file operations and UI components for file management application

This commit is contained in:
2026-03-28 02:55:16 +01:00
parent d52b163417
commit e6fdfa1aa3
12 changed files with 3010 additions and 1 deletions

413
src/app.rs Normal file
View File

@@ -0,0 +1,413 @@
use std::path::PathBuf;
use crate::config::Config;
use crate::file_ops::{self, FileEntry};
#[derive(Debug, Clone, PartialEq)]
pub enum Focus {
Sidebar,
FileView,
PathInput,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ClipboardOp {
Copy,
Cut,
}
#[derive(Debug, Clone)]
pub struct ClipboardEntry {
pub paths: Vec<PathBuf>,
pub op: ClipboardOp,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ContextMenuItem {
Copy,
Cut,
Paste,
Rename,
Properties,
NewFile,
NewDir,
Delete,
AddFavorite,
RemoveFavorite(String),
Separator,
NewSubmenu,
}
#[derive(Debug, Clone)]
pub struct ContextMenu {
pub x: u16,
pub y: u16,
pub items: Vec<ContextMenuItem>,
pub selected: usize,
pub show_new_submenu: bool,
pub sub_selected: Option<usize>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DialogMode {
None,
Rename(String),
NewFile(String),
NewDir(String),
Properties(PropertiesInfo),
Error(String),
Confirm(String, ConfirmAction),
}
#[derive(Debug, Clone, PartialEq)]
pub enum ConfirmAction {
Delete,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PropertiesInfo {
pub name: String,
pub path: String,
pub size: u64,
pub file_count: u64,
pub permissions: String,
pub owner_user: String,
pub owner_group: String,
pub is_multi: bool,
}
pub struct App {
pub current_path: PathBuf,
pub entries: Vec<FileEntry>,
pub selected_indices: Vec<usize>,
pub scroll_offset: usize,
pub sidebar_scroll: usize,
pub focus: Focus,
pub config: Config,
pub clipboard: Option<ClipboardEntry>,
pub context_menu: Option<ContextMenu>,
pub dialog: DialogMode,
pub path_input: String,
pub path_input_cursor: usize,
pub show_hidden: bool,
pub status_message: Option<String>,
pub last_click_entry: Option<(usize, std::time::Instant)>,
pub sidebar_selected: Option<usize>,
pub rename_original: Option<PathBuf>,
}
impl App {
pub fn new() -> Self {
let config = Config::load();
let start_path = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/"));
let entries = file_ops::read_dir(&start_path);
let path_input = start_path.to_string_lossy().to_string();
Self {
current_path: start_path,
entries,
selected_indices: Vec::new(),
scroll_offset: 0,
sidebar_scroll: 0,
focus: Focus::FileView,
config,
clipboard: None,
context_menu: None,
dialog: DialogMode::None,
path_input,
path_input_cursor: 0,
show_hidden: false,
status_message: None,
last_click_entry: None,
sidebar_selected: None,
rename_original: None,
}
}
pub fn navigate_to(&mut self, path: PathBuf) {
if path.is_dir() {
self.current_path = path.clone();
self.entries = file_ops::read_dir(&path);
if !self.show_hidden {
self.entries.retain(|e| !e.is_hidden);
}
self.selected_indices.clear();
self.scroll_offset = 0;
self.path_input = path.to_string_lossy().to_string();
self.path_input_cursor = self.path_input.len();
self.context_menu = None;
} else {
self.set_status(format!("Not a directory: {}", path.display()));
}
}
pub fn refresh(&mut self) {
self.entries = file_ops::read_dir(&self.current_path.clone());
if !self.show_hidden {
self.entries.retain(|e| !e.is_hidden);
}
// Keep valid selections
let count = self.entries.len();
self.selected_indices.retain(|&i| i < count);
}
pub fn set_status(&mut self, msg: String) {
self.status_message = Some(msg);
}
pub fn navigate_up(&mut self) {
if let Some(parent) = self.current_path.parent() {
let p = parent.to_path_buf();
self.navigate_to(p);
}
}
pub fn toggle_selection(&mut self, index: usize) {
if self.selected_indices.contains(&index) {
self.selected_indices.retain(|&i| i != index);
} else {
self.selected_indices.push(index);
}
}
pub fn select_only(&mut self, index: usize) {
self.selected_indices = vec![index];
}
pub fn select_all(&mut self) {
self.selected_indices = (0..self.entries.len()).collect();
}
pub fn copy_selected(&mut self) {
let paths: Vec<PathBuf> = self
.selected_indices
.iter()
.filter_map(|&i| self.entries.get(i))
.map(|e| e.path.clone())
.collect();
if !paths.is_empty() {
self.clipboard = Some(ClipboardEntry { paths, op: ClipboardOp::Copy });
self.set_status("Copied to clipboard".to_string());
}
}
pub fn cut_selected(&mut self) {
let paths: Vec<PathBuf> = self
.selected_indices
.iter()
.filter_map(|&i| self.entries.get(i))
.map(|e| e.path.clone())
.collect();
if !paths.is_empty() {
self.clipboard = Some(ClipboardEntry { paths, op: ClipboardOp::Cut });
self.set_status("Cut to clipboard".to_string());
}
}
/// Paste into given destination directory
pub fn paste_into(&mut self, dst: &std::path::Path) {
let Some(clipboard) = self.clipboard.clone() else {
self.set_status("Nothing in clipboard".to_string());
return;
};
let mut errors = Vec::new();
for src in &clipboard.paths {
let result = match clipboard.op {
ClipboardOp::Copy => file_ops::copy_entry(src, dst),
ClipboardOp::Cut => file_ops::move_entry(src, dst),
};
if let Err(e) = result {
errors.push(e);
}
}
if clipboard.op == ClipboardOp::Cut {
self.clipboard = None;
}
if errors.is_empty() {
self.set_status("Paste successful".to_string());
} else {
self.set_status(format!("Errors: {}", errors.join(", ")));
}
self.refresh();
}
pub fn delete_selected(&mut self) {
let paths: Vec<PathBuf> = self
.selected_indices
.iter()
.filter_map(|&i| self.entries.get(i))
.map(|e| e.path.clone())
.collect();
let mut errors = Vec::new();
for path in paths {
if let Err(e) = file_ops::delete_entry(&path) {
errors.push(e);
}
}
self.selected_indices.clear();
if errors.is_empty() {
self.set_status("Deleted".to_string());
} else {
self.set_status(format!("Delete errors: {}", errors.join(", ")));
}
self.refresh();
}
pub fn rename_entry(&mut self, entry_path: &std::path::Path, new_name: &str) {
let new_name = new_name.trim();
if new_name.is_empty() {
return;
}
// Prevent path traversal
if new_name.contains('/') || new_name.contains('\\') || new_name == ".." || new_name == "." {
self.set_status("Invalid name".to_string());
return;
}
let new_path = entry_path
.parent()
.unwrap_or(std::path::Path::new("."))
.join(new_name);
if let Err(e) = std::fs::rename(entry_path, &new_path) {
self.set_status(format!("Rename error: {}", e));
} else {
self.set_status(format!("Renamed to {}", new_name));
}
self.refresh();
}
pub fn create_file(&mut self, name: &str) {
let name = name.trim();
if name.is_empty() { return; }
if name.contains('/') || name.contains('\\') {
self.set_status("Invalid filename".to_string());
return;
}
let path = self.current_path.join(name);
if let Err(e) = std::fs::File::create(&path) {
self.set_status(format!("Error: {}", e));
} else {
self.set_status(format!("Created file {}", name));
}
self.refresh();
}
pub fn create_dir(&mut self, name: &str) {
let name = name.trim();
if name.is_empty() { return; }
if name.contains('/') || name.contains('\\') {
self.set_status("Invalid directory name".to_string());
return;
}
let path = self.current_path.join(name);
if let Err(e) = std::fs::create_dir(&path) {
self.set_status(format!("Error: {}", e));
} else {
self.set_status(format!("Created directory {}", name));
}
self.refresh();
}
pub fn compute_properties(&mut self) {
if self.selected_indices.is_empty() {
return;
}
let is_multi = self.selected_indices.len() > 1;
if is_multi {
let mut total_size: u64 = 0;
let mut total_files: u64 = 0;
for &i in &self.selected_indices {
if let Some(e) = self.entries.get(i) {
let (s, c) = file_ops::recursive_info(&e.path);
total_size += s;
total_files += c;
}
}
self.dialog = DialogMode::Properties(PropertiesInfo {
name: format!("{} items selected", self.selected_indices.len()),
path: self.current_path.to_string_lossy().to_string(),
size: total_size,
file_count: total_files,
permissions: String::from("N/A"),
owner_user: String::from("N/A"),
owner_group: String::from("N/A"),
is_multi: true,
});
} else {
let i = self.selected_indices[0];
if let Some(e) = self.entries.get(i) {
let (size, file_count) = file_ops::recursive_info(&e.path);
let permissions = file_ops::file_permissions(&e.path);
let (owner_user, owner_group) = file_ops::file_owner(&e.path);
self.dialog = DialogMode::Properties(PropertiesInfo {
name: e.name.clone(),
path: e.path.to_string_lossy().to_string(),
size,
file_count,
permissions,
owner_user,
owner_group,
is_multi: false,
});
}
}
}
pub fn build_context_menu(&self, x: u16, y: u16, is_sidebar_fav: bool, fav_path: Option<String>) -> ContextMenu {
let has_selection = !self.selected_indices.is_empty();
let has_clipboard = self.clipboard.is_some();
let can_paste = has_clipboard && (self.selected_target_dir().is_some() || true);
let mut items = Vec::new();
if is_sidebar_fav {
if let Some(path) = fav_path {
items.push(ContextMenuItem::RemoveFavorite(path));
}
} else {
if has_selection {
items.push(ContextMenuItem::Copy);
items.push(ContextMenuItem::Cut);
}
if can_paste {
items.push(ContextMenuItem::Paste);
}
if has_selection {
items.push(ContextMenuItem::Separator);
items.push(ContextMenuItem::Rename);
items.push(ContextMenuItem::Properties);
items.push(ContextMenuItem::Separator);
items.push(ContextMenuItem::Delete);
}
items.push(ContextMenuItem::Separator);
items.push(ContextMenuItem::NewSubmenu);
items.push(ContextMenuItem::AddFavorite);
}
ContextMenu { x, y, items, selected: 0, show_new_submenu: false, sub_selected: None }
}
/// Returns target directory for paste: selected dir or current path
pub fn selected_target_dir(&self) -> Option<PathBuf> {
if self.selected_indices.len() == 1 {
let i = self.selected_indices[0];
if let Some(e) = self.entries.get(i) {
if e.is_dir() {
return Some(e.path.clone());
}
}
}
Some(self.current_path.clone())
}
pub fn visible_entry_count(&self) -> usize {
self.entries.len()
}
}

48
src/config.rs Normal file
View File

@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct Config {
pub favorites: Vec<String>,
}
impl Config {
fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("tui-fm").join("config.json"))
}
pub fn load() -> Self {
if let Some(path) = Self::config_path() {
if let Ok(data) = std::fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str::<Config>(&data) {
return config;
}
}
}
Config::default()
}
pub fn save(&self) {
if let Some(path) = Self::config_path() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(data) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(&path, data);
}
}
}
pub fn add_favorite(&mut self, path: &str) {
let s = path.to_string();
if !self.favorites.contains(&s) {
self.favorites.push(s);
self.save();
}
}
pub fn remove_favorite(&mut self, path: &str) {
self.favorites.retain(|f| f != path);
self.save();
}
}

593
src/events.rs Normal file
View File

@@ -0,0 +1,593 @@
use std::io;
use std::time::{Duration, Instant};
use crossterm::event::{
self, Event, KeyCode, KeyModifiers, MouseButton, MouseEventKind,
};
use ratatui::{backend::Backend, Terminal};
use crate::app::{App, ConfirmAction, ContextMenuItem, DialogMode, Focus};
use crate::ui;
pub fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<()> {
loop {
terminal.draw(|f| ui::draw(f, app))?;
if event::poll(Duration::from_millis(50))? {
let ev = event::read()?;
if handle_event(app, ev) {
break;
}
}
}
Ok(())
}
/// Returns true if the app should quit
fn handle_event(app: &mut App, ev: Event) -> bool {
match ev {
Event::Key(key) => handle_key(app, key),
Event::Mouse(mouse) => {
handle_mouse(app, mouse);
false
}
_ => false,
}
}
fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
// Close context menu on any key
if app.context_menu.is_some() {
match key.code {
KeyCode::Esc => { app.context_menu = None; return false; }
KeyCode::Up => {
if let Some(ref mut m) = app.context_menu {
if m.selected > 0 { m.selected -= 1; }
}
return false;
}
KeyCode::Down => {
if let Some(ref mut m) = app.context_menu {
let max = m.items.len().saturating_sub(1);
if m.selected < max { m.selected += 1; }
}
return false;
}
KeyCode::Enter => {
execute_context_menu(app);
return false;
}
_ => { app.context_menu = None; return false; }
}
}
// Handle dialogs
match &app.dialog.clone() {
DialogMode::Rename(current) => {
return handle_text_dialog_key(app, key, current.clone(), |app, s| {
if let Some(path) = app.rename_original.clone() {
app.rename_entry(&path, &s);
}
app.rename_original = None;
});
}
DialogMode::NewFile(current) => {
return handle_text_dialog_key(app, key, current.clone(), |app, s| {
app.create_file(&s);
});
}
DialogMode::NewDir(current) => {
return handle_text_dialog_key(app, key, current.clone(), |app, s| {
app.create_dir(&s);
});
}
DialogMode::Properties(_) | DialogMode::Error(_) => {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q')) {
app.dialog = DialogMode::None;
}
return false;
}
DialogMode::Confirm(_, action) => {
let action = action.clone();
match key.code {
KeyCode::Char('y') | KeyCode::Enter => {
app.dialog = DialogMode::None;
match action {
ConfirmAction::Delete => app.delete_selected(),
}
}
_ => { app.dialog = DialogMode::None; }
}
return false;
}
DialogMode::None => {}
}
// Path input mode
if app.focus == Focus::PathInput {
match key.code {
KeyCode::Esc => { app.focus = Focus::FileView; }
KeyCode::Enter => {
let p = std::path::PathBuf::from(&app.path_input);
app.navigate_to(p);
app.focus = Focus::FileView;
}
KeyCode::Backspace => {
if app.path_input_cursor > 0 {
let cursor = app.path_input_cursor;
app.path_input.remove(cursor - 1);
app.path_input_cursor -= 1;
}
}
KeyCode::Left => {
if app.path_input_cursor > 0 { app.path_input_cursor -= 1; }
}
KeyCode::Right => {
if app.path_input_cursor < app.path_input.len() { app.path_input_cursor += 1; }
}
KeyCode::Home => { app.path_input_cursor = 0; }
KeyCode::End => { app.path_input_cursor = app.path_input.len(); }
KeyCode::Char(c) => {
let cursor = app.path_input_cursor;
app.path_input.insert(cursor, c);
app.path_input_cursor += 1;
}
_ => {}
}
return false;
}
// Global keys
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') => { app.copy_selected(); return false; }
KeyCode::Char('x') => { app.cut_selected(); return false; }
KeyCode::Char('v') => {
let dst = app.selected_target_dir().unwrap_or_else(|| app.current_path.clone());
app.paste_into(&dst.clone());
return false;
}
KeyCode::Char('a') => { app.select_all(); return false; }
KeyCode::Char('h') => {
app.show_hidden = !app.show_hidden;
app.refresh();
return false;
}
_ => {}
}
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Tab => {
app.focus = match app.focus {
Focus::Sidebar => Focus::FileView,
Focus::FileView => Focus::PathInput,
Focus::PathInput => Focus::Sidebar,
};
}
KeyCode::Backspace => {
app.navigate_up();
}
KeyCode::F(2) => {
if let Some(&i) = app.selected_indices.first() {
if let Some(e) = app.entries.get(i) {
app.rename_original = Some(e.path.clone());
app.dialog = DialogMode::Rename(e.name.clone());
}
}
}
KeyCode::Delete => {
if !app.selected_indices.is_empty() {
let msg = format!("Delete {} item(s)? (y/n)", app.selected_indices.len());
app.dialog = DialogMode::Confirm(msg, ConfirmAction::Delete);
}
}
KeyCode::Enter => {
if app.focus == Focus::FileView {
if let Some(&i) = app.selected_indices.first() {
if let Some(e) = app.entries.get(i).cloned() {
if e.is_dir() {
app.navigate_to(e.path.clone());
}
}
}
}
}
KeyCode::Up => {
if app.focus == Focus::FileView {
scroll_selection(app, -1);
}
}
KeyCode::Down => {
if app.focus == Focus::FileView {
scroll_selection(app, 1);
}
}
_ => {}
}
false
}
fn scroll_selection(app: &mut App, delta: i32) {
let count = app.entries.len();
if count == 0 { return; }
let current = app.selected_indices.first().copied().unwrap_or(0) as i32;
let new_idx = (current + delta).max(0).min(count as i32 - 1) as usize;
app.selected_indices = vec![new_idx];
// Ensure visible
if new_idx < app.scroll_offset {
app.scroll_offset = new_idx;
}
}
fn handle_text_dialog_key<F>(
app: &mut App,
key: crossterm::event::KeyEvent,
current: String,
on_confirm: F,
) -> bool
where
F: FnOnce(&mut App, String),
{
match key.code {
KeyCode::Esc => {
app.dialog = DialogMode::None;
app.rename_original = None;
}
KeyCode::Enter => {
let s = current.clone();
app.dialog = DialogMode::None;
on_confirm(app, s);
}
KeyCode::Backspace => {
let mut s = current;
s.pop();
update_dialog_text(app, s);
}
KeyCode::Char(c) => {
let mut s = current;
s.push(c);
update_dialog_text(app, s);
}
_ => {}
}
false
}
fn update_dialog_text(app: &mut App, new_text: String) {
match &app.dialog {
DialogMode::Rename(_) => app.dialog = DialogMode::Rename(new_text),
DialogMode::NewFile(_) => app.dialog = DialogMode::NewFile(new_text),
DialogMode::NewDir(_) => app.dialog = DialogMode::NewDir(new_text),
_ => {}
}
}
fn execute_context_menu(app: &mut App) {
let menu = app.context_menu.clone();
if let Some(m) = menu {
let item = m.items.get(m.selected).cloned();
app.context_menu = None;
if let Some(item) = item {
match item {
ContextMenuItem::Copy => app.copy_selected(),
ContextMenuItem::Cut => app.cut_selected(),
ContextMenuItem::Paste => {
let dst = app.selected_target_dir().unwrap_or_else(|| app.current_path.clone());
app.paste_into(&dst.clone());
}
ContextMenuItem::Rename => {
if let Some(&i) = app.selected_indices.first() {
if let Some(e) = app.entries.get(i) {
app.rename_original = Some(e.path.clone());
app.dialog = DialogMode::Rename(e.name.clone());
}
}
}
ContextMenuItem::Properties => app.compute_properties(),
ContextMenuItem::NewFile => app.dialog = DialogMode::NewFile(String::new()),
ContextMenuItem::NewDir => app.dialog = DialogMode::NewDir(String::new()),
ContextMenuItem::Delete => {
if !app.selected_indices.is_empty() {
let msg = format!("Delete {} item(s)? (y/n)", app.selected_indices.len());
app.dialog = DialogMode::Confirm(msg, ConfirmAction::Delete);
}
}
ContextMenuItem::AddFavorite => {
let path = app.current_path.to_string_lossy().to_string();
app.config.add_favorite(&path);
app.set_status("Added to favorites".to_string());
}
ContextMenuItem::RemoveFavorite(path) => {
app.config.remove_favorite(&path);
app.set_status("Removed from favorites".to_string());
}
ContextMenuItem::NewSubmenu => {
// Toggle submenu - handled in UI
}
ContextMenuItem::Separator => {}
}
}
}
}
fn handle_mouse(app: &mut App, mouse: crossterm::event::MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollDown => handle_scroll(app, mouse.column, mouse.row, 3),
MouseEventKind::ScrollUp => handle_scroll(app, mouse.column, mouse.row, -3),
MouseEventKind::Down(MouseButton::Left) => handle_left_click(app, mouse.column, mouse.row),
MouseEventKind::Down(MouseButton::Right) => handle_right_click(app, mouse.column, mouse.row),
MouseEventKind::Moved => handle_mouse_move(app, mouse.column, mouse.row),
_ => {}
}
}
fn handle_scroll(app: &mut App, col: u16, _row: u16, delta: i32) {
if col < 20 {
// Sidebar scroll
let roots_len = crate::file_ops::get_root_dirs().len();
let favs_len = app.config.favorites.len();
let total = favs_len + roots_len + 5; // headings+padding
let max = total.saturating_sub(10);
let new_off = (app.sidebar_scroll as i32 + delta).max(0).min(max as i32) as usize;
app.sidebar_scroll = new_off;
} else {
// File view scroll
let count = app.entries.len();
let new_off = (app.scroll_offset as i32 + delta).max(0).min(count as i32) as usize;
app.scroll_offset = new_off;
}
}
/// Hit test: given terminal coords, determine which area and entry index was clicked
pub fn hit_test_file_view(app: &App, col: u16, row: u16, layout: &ui::LayoutAreas) -> Option<usize> {
let area = layout.file_view;
// File list starts after the header row (row+2 from top of area)
if col < area.x || col >= area.x + area.width { return None; }
if row < area.y + 2 || row >= area.y + area.height { return None; }
let list_row = (row - area.y - 2) as usize;
let idx = list_row + app.scroll_offset;
if idx < app.entries.len() { Some(idx) } else { None }
}
pub fn hit_test_sidebar(app: &App, col: u16, row: u16, layout: &ui::LayoutAreas) -> Option<SidebarItem> {
let area = layout.sidebar;
if col < area.x || col >= area.x + area.width { return None; }
// row must be inside the inner content area (skip top border at area.y)
if row <= area.y || row >= area.y + area.height { return None; }
// Map terminal row → list item index, accounting for top border and scroll
let list_idx = app.sidebar_scroll + (row - area.y - 1) as usize;
let favs_len = app.config.favorites.len();
// When empty, "(inga)" placeholder occupies 1 slot in the rendered list
let display_favs = favs_len.max(1);
let roots = crate::file_ops::get_root_dirs();
// idx 0 : [↑ Gå upp]
if list_idx == 0 { return Some(SidebarItem::GoUp); }
// idx 1 : FAVORITER heading
if list_idx == 1 { return None; }
// idx 2 .. 1+display_favs : favorites (or "(inga)" placeholder)
if list_idx >= 2 && list_idx <= 1 + display_favs {
let fav_idx = list_idx - 2;
if fav_idx < favs_len {
return Some(SidebarItem::Favorite(fav_idx, app.config.favorites[fav_idx].clone()));
}
return None; // "(inga)" placeholder not clickable
}
// idx 2+display_favs : separator
if list_idx == 2 + display_favs { return None; }
// idx 3+display_favs : SÖKVÄGAR heading
if list_idx == 3 + display_favs { return None; }
// idx 4+display_favs .. 3+display_favs+roots_len : root dirs
let roots_start = 4 + display_favs;
if list_idx >= roots_start && list_idx < roots_start + roots.len() {
let idx = list_idx - roots_start;
return Some(SidebarItem::Root(roots[idx].clone()));
}
// idx roots_start+roots_len : separator
// idx roots_start+roots_len+1 : [+ Favorit]
if list_idx == roots_start + roots.len() + 1 {
return Some(SidebarItem::AddFavorite);
}
None
}
pub enum SidebarItem {
Favorite(usize, String),
Root(std::path::PathBuf),
AddFavorite,
GoUp,
}
fn handle_left_click(app: &mut App, col: u16, row: u16) {
const MENU_W: u16 = 24;
const SUB_W: u16 = 18;
const SUB_H: u16 = 4;
if let Some(m) = app.context_menu.clone() {
let mx = m.x;
let my = m.y;
let items_len = m.items.len() as u16;
let menu_height = items_len + 2; // top + bottom border
let in_main = col >= mx && col < mx + MENU_W
&& row >= my && row < my + menu_height;
// Check if click lands in the submenu (only visible when NewSubmenu is highlighted)
let ns_idx = m.items.iter().position(|i| matches!(i, ContextMenuItem::NewSubmenu));
let in_sub = if let Some(ns_pos) = ns_idx {
let (term_w, _) = crossterm::terminal::size().unwrap_or((120, 40));
let sub_x = (mx + MENU_W).min(term_w.saturating_sub(SUB_W));
let sub_y = my + 1 + ns_pos as u16;
col >= sub_x && col < sub_x + SUB_W
&& row > sub_y && row < sub_y + SUB_H // > to skip top border row
} else {
false
};
if in_sub {
let ns_pos = ns_idx.unwrap();
let sub_y = my + 1 + ns_pos as u16;
// skip the top border row of the submenu box
if row <= sub_y { app.context_menu = None; return; }
let sub_item = row.saturating_sub(sub_y + 1) as usize;
app.context_menu = None;
match sub_item {
0 => app.dialog = DialogMode::NewFile(String::new()),
1 => app.dialog = DialogMode::NewDir(String::new()),
_ => {}
}
return;
}
if in_main {
// Items live between the two border rows
if row > my && row <= my + items_len {
let item_row = (row - my - 1) as usize;
if let Some(ref mut menu) = app.context_menu {
menu.selected = item_row.min(menu.items.len().saturating_sub(1));
}
execute_context_menu(app);
} else {
// Clicked on a border row dismiss
app.context_menu = None;
}
return;
}
// Clicked outside everything dismiss
app.context_menu = None;
}
let layout = ui::compute_layout(app);
// Check path input area
let pi = layout.path_input;
if row == pi.y && col >= pi.x && col < pi.x + pi.width {
app.focus = Focus::PathInput;
return;
}
// Check sidebar
if col < layout.sidebar.x + layout.sidebar.width {
app.focus = Focus::Sidebar;
if let Some(item) = hit_test_sidebar(app, col, row, &layout) {
match item {
SidebarItem::Favorite(_, path) => {
let p = std::path::PathBuf::from(&path);
app.navigate_to(p);
}
SidebarItem::Root(p) => {
app.navigate_to(p);
}
SidebarItem::AddFavorite => {
let path = app.current_path.to_string_lossy().to_string();
app.config.add_favorite(&path);
app.set_status("Added to favorites".to_string());
}
SidebarItem::GoUp => {
app.navigate_up();
}
}
}
return;
}
// Check file view
app.focus = Focus::FileView;
if let Some(idx) = hit_test_file_view(app, col, row, &layout) {
let now = Instant::now();
let is_double = if let Some((last_idx, last_time)) = app.last_click_entry {
last_idx == idx && now.duration_since(last_time) < Duration::from_millis(400)
} else {
false
};
app.last_click_entry = Some((idx, now));
if is_double {
app.last_click_entry = None;
if let Some(e) = app.entries.get(idx).cloned() {
if e.is_dir() {
app.navigate_to(e.path.clone());
}
}
} else {
app.select_only(idx);
}
}
}
fn handle_right_click(app: &mut App, col: u16, row: u16) {
let layout = ui::compute_layout(app);
// Right-click on sidebar favorite
if col < layout.sidebar.x + layout.sidebar.width {
if let Some(item) = hit_test_sidebar(app, col, row, &layout) {
if let SidebarItem::Favorite(_, path) = item {
let menu = app.build_context_menu(col, row, true, Some(path));
app.context_menu = Some(menu);
return;
}
}
return;
}
// Right-click on file
if let Some(idx) = hit_test_file_view(app, col, row, &layout) {
if !app.selected_indices.contains(&idx) {
app.select_only(idx);
}
}
let menu = app.build_context_menu(col, row, false, None);
app.context_menu = Some(menu);
}
fn handle_mouse_move(app: &mut App, col: u16, row: u16) {
const MENU_W: u16 = 24;
const SUB_W: u16 = 18;
let Some(ref mut menu) = app.context_menu else { return; };
let mx = menu.x;
let my = menu.y;
let items_len = menu.items.len() as u16;
// Hovering over a main-menu item (inside the border)
if col > mx && col < mx + MENU_W - 1
&& row > my && row <= my + items_len
{
let item_idx = (row - my - 1) as usize;
if item_idx < menu.items.len() {
menu.selected = item_idx;
menu.sub_selected = None;
}
return;
}
// When NewSubmenu is highlighted, check if the mouse moves into the flyout
if let Some(ns_pos) = menu.items.iter().position(|i| matches!(i, ContextMenuItem::NewSubmenu)) {
if menu.selected == ns_pos {
let (term_w, _) = crossterm::terminal::size().unwrap_or((120, 40));
let sub_x = (mx + MENU_W).min(term_w.saturating_sub(SUB_W));
let sub_y = my + 1 + ns_pos as u16;
if col >= sub_x && col < sub_x + SUB_W
&& row >= sub_y && row < sub_y + 4
{
// Keep NewSubmenu highlighted and track which flyout row is hovered
if row > sub_y && row < sub_y + 3 {
menu.sub_selected = Some((row - sub_y - 1) as usize);
}
return;
}
}
}
}

267
src/file_ops.rs Normal file
View File

@@ -0,0 +1,267 @@
use std::path::{Path, PathBuf};
use std::fs;
#[derive(Clone, Debug, PartialEq)]
pub enum FileType {
Directory,
File,
Symlink,
}
#[derive(Clone, Debug)]
pub struct FileEntry {
pub name: String,
pub path: PathBuf,
pub file_type: FileType,
pub size: u64,
pub is_hidden: bool,
}
impl FileEntry {
pub fn icon(&self) -> &'static str {
match &self.file_type {
FileType::Directory => "📁",
FileType::Symlink => "🔗",
FileType::File => {
let ext = self
.name
.rsplit('.')
.next()
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"rs" => "🦀",
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" | "ico" => "🖼",
"mp4" | "mkv" | "avi" | "mov" | "webm" => "🎬",
"mp3" | "flac" | "ogg" | "wav" | "aac" => "🎵",
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" => "📦",
"pdf" => "📕",
"txt" | "md" | "log" => "📄",
"json" | "yaml" | "yml" | "toml" | "xml" => "",
"sh" | "bash" | "zsh" | "fish" => "🐚",
"py" => "🐍",
"js" | "ts" => "📜",
"c" | "cpp" | "h" | "hpp" => "🔧",
"exe" | "bin" | "out" | "run" => "",
_ => "📎",
}
}
}
}
pub fn is_dir(&self) -> bool {
self.file_type == FileType::Directory
}
pub fn formatted_size(&self) -> String {
if self.file_type == FileType::Directory {
return String::from("<dir>");
}
format_size(self.size)
}
}
pub fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{} KB", bytes / KB)
} else {
format!("{} B", bytes)
}
}
pub fn read_dir(path: &Path) -> Vec<FileEntry> {
let mut entries = Vec::new();
let Ok(dir) = fs::read_dir(path) else {
return entries;
};
for entry in dir.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let path = entry.path();
let is_hidden = name.starts_with('.');
let (file_type, size) = if let Ok(meta) = entry.metadata() {
let ft = if meta.is_symlink() {
FileType::Symlink
} else if meta.is_dir() {
FileType::Directory
} else {
FileType::File
};
(ft, meta.len())
} else {
(FileType::File, 0)
};
entries.push(FileEntry { name, path, file_type, size, is_hidden });
}
// Sort: dirs first, then files, both alphabetically
entries.sort_by(|a, b| {
match (&a.file_type, &b.file_type) {
(FileType::Directory, FileType::Directory) => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
(FileType::Directory, _) => std::cmp::Ordering::Less,
(_, FileType::Directory) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
}
});
entries
}
/// Recursively compute total size and file count of a path
pub fn recursive_info(path: &Path) -> (u64, u64) {
let mut total_size: u64 = 0;
let mut file_count: u64 = 0;
if path.is_file() {
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
return (size, 1);
}
if let Ok(dir) = fs::read_dir(path) {
for entry in dir.flatten() {
let p = entry.path();
if p.is_dir() {
let (s, c) = recursive_info(&p);
total_size += s;
file_count += c;
} else {
total_size += p.metadata().map(|m| m.len()).unwrap_or(0);
file_count += 1;
}
}
}
(total_size, file_count)
}
#[cfg(unix)]
pub fn file_permissions(path: &Path) -> String {
use std::os::unix::fs::PermissionsExt;
let mode = path.metadata().map(|m| m.permissions().mode()).unwrap_or(0);
format!("{:o}", mode & 0o777)
}
#[cfg(windows)]
pub fn file_permissions(_path: &Path) -> String {
String::from("N/A")
}
pub fn file_owner(path: &Path) -> (String, String) {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let Ok(meta) = path.metadata() {
let uid = meta.uid();
let gid = meta.gid();
let user_name = lookup_name("/etc/passwd", uid)
.map(|n| format!("{} ({})", n, uid))
.unwrap_or_else(|| uid.to_string());
let group_name = lookup_name("/etc/group", gid)
.map(|n| format!("{} ({})", n, gid))
.unwrap_or_else(|| gid.to_string());
return (user_name, group_name);
}
}
#[cfg(windows)]
{
let _ = path;
}
(String::from("N/A"), String::from("N/A"))
}
/// Parse /etc/passwd or /etc/group and return the name for a given numeric id.
/// Both files share the same format: `name:x:id:...`
#[cfg(unix)]
fn lookup_name(db_file: &str, id: u32) -> Option<String> {
let content = std::fs::read_to_string(db_file).ok()?;
for line in content.lines() {
let mut parts = line.splitn(4, ':');
let name = parts.next()?;
parts.next(); // password/placeholder
let entry_id: u32 = parts.next()?.parse().ok()?;
if entry_id == id {
return Some(name.to_string());
}
}
None
}
/// Copy a file or directory recursively
pub fn copy_entry(src: &Path, dst_dir: &Path) -> Result<(), String> {
let name = src.file_name().ok_or("No filename")?;
let dst = dst_dir.join(name);
if src.is_dir() {
fs_extra::dir::copy(
src,
dst_dir,
&fs_extra::dir::CopyOptions::new(),
)
.map(|_| ())
.map_err(|e| e.to_string())
} else {
fs::copy(src, &dst).map(|_| ()).map_err(|e| e.to_string())
}
}
/// Move (cut/paste) a file or directory
pub fn move_entry(src: &Path, dst_dir: &Path) -> Result<(), String> {
let name = src.file_name().ok_or("No filename")?;
let dst = dst_dir.join(name);
if src.is_dir() {
fs_extra::dir::move_dir(
src,
dst_dir,
&fs_extra::dir::CopyOptions::new(),
)
.map(|_| ())
.map_err(|e| e.to_string())
} else {
fs::rename(src, &dst).map_err(|e| e.to_string())
}
}
pub fn delete_entry(path: &Path) -> Result<(), String> {
if path.is_dir() {
fs::remove_dir_all(path).map_err(|e| e.to_string())
} else {
fs::remove_file(path).map_err(|e| e.to_string())
}
}
pub fn get_root_dirs() -> Vec<PathBuf> {
#[cfg(unix)]
{
let roots = ["/", "/home", "/etc", "/usr", "/var", "/opt", "/tmp"];
roots
.iter()
.filter_map(|r| {
let p = PathBuf::from(r);
if p.exists() { Some(p) } else { None }
})
.collect()
}
#[cfg(windows)]
{
let mut drives = Vec::new();
for letter in b'A'..=b'Z' {
let d = format!("{}:\\", letter as char);
let p = PathBuf::from(&d);
if p.exists() {
drives.push(p);
}
}
drives
}
}

41
src/main.rs Normal file
View File

@@ -0,0 +1,41 @@
mod app;
mod config;
mod file_ops;
mod ui;
mod events;
use std::io;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use app::App;
use events::run_app;
fn main() -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
let res = run_app(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
eprintln!("Error: {:?}", err);
}
Ok(())
}

614
src/ui.rs Normal file
View File

@@ -0,0 +1,614 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, Borders, Cell, Clear, List, ListItem, Paragraph,
Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
Table, TableState, Wrap,
},
Frame,
};
use crate::app::{App, ContextMenuItem, DialogMode, Focus};
use crate::file_ops::{self, format_size};
#[derive(Clone)]
pub struct LayoutAreas {
pub sidebar: Rect,
pub file_view: Rect,
pub status_bar: Rect,
pub path_input: Rect,
}
/// Compute the layout areas for hit testing (without a frame)
pub fn compute_layout(_app: &App) -> LayoutAreas {
// We use a dummy size; in real rendering ratatui provides the actual size.
// For hit-testing, we just need approximate positions.
// We'll use the terminal size from crossterm.
let (width, height) = crossterm::terminal::size().unwrap_or((120, 40));
let full = Rect::new(0, 0, width, height);
split_layout(full)
}
fn split_layout(area: Rect) -> LayoutAreas {
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5),
Constraint::Length(3),
])
.split(area);
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(22),
Constraint::Min(10),
])
.split(main_chunks[0]);
let bottom_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(10),
])
.split(main_chunks[1]);
LayoutAreas {
sidebar: content_chunks[0],
file_view: content_chunks[1],
status_bar: main_chunks[1],
path_input: bottom_chunks[0],
}
}
pub fn draw(f: &mut Frame, app: &mut App) {
let area = f.area();
let layout = split_layout(area);
draw_sidebar(f, app, layout.sidebar);
draw_file_view(f, app, layout.file_view);
draw_bottom_bar(f, app, layout.status_bar);
if let Some(ref menu) = app.context_menu.clone() {
draw_context_menu(f, app, menu.clone());
}
match &app.dialog.clone() {
DialogMode::Rename(text) => draw_input_dialog(f, "Byt namn", text, area),
DialogMode::NewFile(text) => draw_input_dialog(f, "Ny fil - Ange namn:", text, area),
DialogMode::NewDir(text) => draw_input_dialog(f, "Ny mapp - Ange namn:", text, area),
DialogMode::Properties(info) => draw_properties(f, info.clone(), area),
DialogMode::Error(msg) => draw_error_dialog(f, msg, area),
DialogMode::Confirm(msg, _) => draw_confirm_dialog(f, msg, area),
DialogMode::None => {}
}
}
fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::Sidebar;
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.title(" Navigering ")
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(area);
f.render_widget(block, area);
let mut items: Vec<ListItem> = Vec::new();
// Up-level button
items.push(ListItem::new(Line::from(vec![
Span::styled(" ↑ Gå upp", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
])));
// Favorites section
items.push(ListItem::new(Line::from(vec![
Span::styled("FAVORITER", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
])));
if app.config.favorites.is_empty() {
items.push(ListItem::new(Line::from(vec![
Span::styled(" (inga)", Style::default().fg(Color::DarkGray)),
])));
} else {
for fav in &app.config.favorites {
let name = std::path::Path::new(fav)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| fav.clone());
let is_current = fav == &app.current_path.to_string_lossy().to_string();
let style = if is_current {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
items.push(ListItem::new(Line::from(vec![
Span::styled(format!("{}", name), style),
])));
}
}
items.push(ListItem::new(Line::from(vec![
Span::styled("─────────────────", Style::default().fg(Color::DarkGray)),
])));
// Root directories
items.push(ListItem::new(Line::from(vec![
Span::styled("SÖKVÄGAR", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
])));
let roots = file_ops::get_root_dirs();
for root in &roots {
let label = root.to_string_lossy();
let is_current = root == &app.current_path;
let style = if is_current {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
items.push(ListItem::new(Line::from(vec![
Span::styled(format!(" 📁 {}", label), style),
])));
}
items.push(ListItem::new(Line::from(vec![
Span::styled("─────────────────", Style::default().fg(Color::DarkGray)),
])));
// Add favorite button
items.push(ListItem::new(Line::from(vec![
Span::styled(" [+ Favorit]", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
])));
// Apply scroll offset
let visible: Vec<ListItem> = items
.into_iter()
.skip(app.sidebar_scroll)
.take(inner.height as usize)
.collect();
let total_sidebar = visible.len() + app.sidebar_scroll;
let list = List::new(visible);
f.render_widget(list, inner);
// Scrollbar for sidebar
if total_sidebar > inner.height as usize {
let mut sb_state = ScrollbarState::new(total_sidebar).position(app.sidebar_scroll);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.thumb_symbol("");
f.render_stateful_widget(
scrollbar,
area.inner(Margin { vertical: 1, horizontal: 0 }),
&mut sb_state,
);
}
}
fn draw_file_view(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::FileView;
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
// Build rows from ALL entries (TableState offset handles which rows are visible)
let rows: Vec<Row> = app
.entries
.iter()
.enumerate()
.map(|(idx, entry)| {
let is_selected = app.selected_indices.contains(&idx);
let name_cell = format!("{} {}", entry.icon(), entry.name);
let size_cell = entry.formatted_size();
let style = if is_selected {
Style::default().bg(Color::Blue).fg(Color::White).add_modifier(Modifier::BOLD)
} else if entry.is_dir() {
Style::default().fg(Color::LightBlue)
} else {
Style::default().fg(Color::White)
};
Row::new(vec![
Cell::from(name_cell),
Cell::from(size_cell).style(Style::default().fg(if is_selected { Color::White } else { Color::DarkGray })),
])
.style(style)
})
.collect();
let header = Row::new(vec![
Cell::from("Namn"),
Cell::from("Storlek"),
])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD | Modifier::UNDERLINED))
.height(1);
let path_title = format!(" {} ", app.current_path.to_string_lossy());
let block = Block::default()
.title(path_title)
.borders(Borders::ALL)
.border_style(border_style);
// Column widths: name takes remaining space, size is fixed
let widths = [Constraint::Min(10), Constraint::Length(10)];
let table = Table::new(rows, widths)
.header(header)
.block(block)
.column_spacing(1);
let mut table_state = TableState::default();
*table_state.offset_mut() = app.scroll_offset;
f.render_stateful_widget(table, area, &mut table_state);
// Scrollbar — rendered over the right border of the block
let total = app.entries.len();
let visible = area.height.saturating_sub(3) as usize; // -2 borders -1 header
if total > visible {
let mut sb_state = ScrollbarState::new(total).position(app.scroll_offset);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some(""))
.end_symbol(Some(""))
.track_symbol(Some(""))
.thumb_symbol("");
f.render_stateful_widget(
scrollbar,
area.inner(Margin { vertical: 1, horizontal: 0 }),
&mut sb_state,
);
}
// Selection count badge
if !app.selected_indices.is_empty() {
let sel_text = format!(" {} markerade ", app.selected_indices.len());
let inner = Block::default().borders(Borders::ALL).inner(area);
let badge_area = Rect::new(
inner.x + inner.width.saturating_sub(sel_text.len() as u16 + 2),
inner.y,
sel_text.len() as u16 + 2,
1,
);
f.render_widget(
Paragraph::new(sel_text).style(Style::default().fg(Color::Cyan).bg(Color::DarkGray)),
badge_area,
);
}
}
fn draw_bottom_bar(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::PathInput;
let outer_block = Block::default()
.borders(Borders::ALL)
.border_style(if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
});
let inner = outer_block.inner(area);
f.render_widget(outer_block, area);
// Left: status message or hint
let hint = app
.status_message
.as_deref()
.unwrap_or("Tab=fokus Backspace=upp Ctrl+C/X/V=kopiera/klipp/klistra q=avsluta");
let hint_width = (inner.width / 3).min(hint.len() as u16 + 2);
let hint_area = Rect::new(inner.x, inner.y, hint_width, inner.height);
f.render_widget(
Paragraph::new(hint)
.style(Style::default().fg(Color::DarkGray))
.wrap(Wrap { trim: true }),
hint_area,
);
// Right: path input
let input_x = inner.x + hint_width + 1;
let input_width = inner.width.saturating_sub(hint_width + 1);
if input_width < 4 { return; }
let input_area = Rect::new(input_x, inner.y, input_width, inner.height);
let label = "Sökväg: ";
let label_len = label.len() as u16;
let cursor_pos = app.path_input_cursor;
let avail = input_width.saturating_sub(label_len) as usize;
// Scroll the input text to show cursor
let (display_text, cursor_in_display) =
scroll_input_text(&app.path_input, cursor_pos, avail);
let mut spans = vec![
Span::styled(label, Style::default().fg(Color::Yellow)),
];
if focused {
// Render with cursor highlight
let before = &display_text[..cursor_in_display.min(display_text.len())];
let cursor_char = display_text
.chars()
.nth(cursor_in_display)
.map(|c| c.to_string())
.unwrap_or_else(|| " ".to_string());
let after = if cursor_in_display + 1 <= display_text.len() {
&display_text[cursor_in_display + cursor_char.len()..]
} else {
""
};
spans.push(Span::styled(before, Style::default().fg(Color::White)));
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(Color::White)));
spans.push(Span::styled(after, Style::default().fg(Color::White)));
} else {
spans.push(Span::styled(&display_text, Style::default().fg(Color::White)));
}
let para = Paragraph::new(Line::from(spans));
f.render_widget(para, input_area);
}
fn scroll_input_text(text: &str, cursor: usize, avail: usize) -> (String, usize) {
let len = text.len();
if len <= avail {
return (text.to_string(), cursor);
}
// Scroll to show cursor
let start = if cursor > avail { cursor - avail } else { 0 };
let end = (start + avail).min(len);
let slice = &text[start..end];
let cursor_in_display = cursor.saturating_sub(start);
(slice.to_string(), cursor_in_display)
}
fn draw_context_menu(f: &mut Frame, app: &App, menu: crate::app::ContextMenu) {
let items = &menu.items;
if items.is_empty() { return; }
// Calculate menu dimensions
let menu_width: u16 = 24;
let menu_height = items.len() as u16 + 2;
let term_area = f.area();
let x = menu.x.min(term_area.width.saturating_sub(menu_width));
let y = menu.y.min(term_area.height.saturating_sub(menu_height));
let menu_area = Rect::new(x, y, menu_width, menu_height);
f.render_widget(Clear, menu_area);
let block = Block::default()
.title(" Meny ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(menu_area);
f.render_widget(block, menu_area);
let mut show_new_sub = false;
let mut new_sub_row: u16 = 0;
for (i, item) in items.iter().enumerate() {
let row = Rect::new(inner.x, inner.y + i as u16, inner.width, 1);
let is_sel = i == menu.selected;
let (text, style) = match item {
ContextMenuItem::Copy => (" 📋 Kopiera ", Style::default()),
ContextMenuItem::Cut => (" ✂ Klipp ut ", Style::default()),
ContextMenuItem::Paste => (" 📌 Klistra in ", Style::default()),
ContextMenuItem::Rename => (" ✏ Byt namn ", Style::default()),
ContextMenuItem::Properties => (" Egenskaper ", Style::default()),
ContextMenuItem::Delete => (" 🗑 Ta bort ", Style::default().fg(Color::Red)),
ContextMenuItem::NewSubmenu => {
if is_sel { show_new_sub = true; new_sub_row = inner.y + i as u16; }
(" Ny ▶ ", Style::default().fg(Color::Green))
}
ContextMenuItem::AddFavorite => (" ★ Lägg till fav ", Style::default().fg(Color::Yellow)),
ContextMenuItem::RemoveFavorite(_) => (" ✖ Ta bort favorit ", Style::default().fg(Color::Red)),
ContextMenuItem::Separator => (" ──────────────── ", Style::default().fg(Color::DarkGray)),
ContextMenuItem::NewFile => (" 📄 Ny fil ", Style::default().fg(Color::Green)),
ContextMenuItem::NewDir => (" 📁 Ny mapp ", Style::default().fg(Color::Green)),
};
let bg = if is_sel && !matches!(item, ContextMenuItem::Separator) {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
style
};
f.render_widget(
Paragraph::new(text).style(bg),
row,
);
}
// Draw submenu for "Ny" if selected
if show_new_sub {
let sub_x = x + menu_width;
let sub_area = Rect::new(sub_x.min(term_area.width.saturating_sub(18)), new_sub_row, 18, 4);
f.render_widget(Clear, sub_area);
let sub_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green));
let sub_inner = sub_block.inner(sub_area);
f.render_widget(sub_block, sub_area);
let sub_item0_style = if menu.sub_selected == Some(0) {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default().fg(Color::White)
};
let sub_item1_style = if menu.sub_selected == Some(1) {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default().fg(Color::White)
};
f.render_widget(
Paragraph::new(" 📄 Ny fil ").style(sub_item0_style),
Rect::new(sub_inner.x, sub_inner.y, sub_inner.width, 1),
);
f.render_widget(
Paragraph::new(" 📁 Ny mapp ").style(sub_item1_style),
Rect::new(sub_inner.x, sub_inner.y + 1, sub_inner.width, 1),
);
}
}
fn draw_input_dialog(f: &mut Frame, title: &str, current: &str, area: Rect) {
let dlg_width = 50u16.min(area.width.saturating_sub(4));
let dlg_height = 5u16;
let x = (area.width.saturating_sub(dlg_width)) / 2;
let y = (area.height.saturating_sub(dlg_height)) / 2;
let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg);
let block = Block::default()
.title(format!(" {} ", title))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(dlg);
f.render_widget(block, dlg);
// Input field with cursor
let display = format!("{}_", current);
f.render_widget(
Paragraph::new(display)
.style(Style::default().fg(Color::White)),
Rect::new(inner.x + 1, inner.y + 1, inner.width.saturating_sub(2), 1),
);
f.render_widget(
Paragraph::new("Enter=bekräfta Esc=avbryt")
.style(Style::default().fg(Color::DarkGray)),
Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1),
);
}
fn draw_properties(f: &mut Frame, info: crate::app::PropertiesInfo, area: Rect) {
let dlg_width = 56u16.min(area.width.saturating_sub(4));
let dlg_height = 13u16;
let x = (area.width.saturating_sub(dlg_width)) / 2;
let y = (area.height.saturating_sub(dlg_height)) / 2;
let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg);
let block = Block::default()
.title(" Egenskaper ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(dlg);
f.render_widget(block, dlg);
let label_style = Style::default().fg(Color::Yellow);
let value_style = Style::default().fg(Color::White);
let hint_style = Style::default().fg(Color::DarkGray);
let label_w = 14usize;
let val_w = (inner.width as usize).saturating_sub(label_w + 2);
let rows: &[(&str, String)] = &[
("Namn", info.name.clone()),
("Sökväg", truncate_str(&info.path, val_w)),
("Storlek", format_size(info.size).to_string()),
("Filer", if info.is_multi || info.file_count > 0 { info.file_count.to_string() } else { String::from("-") }),
("Rättigheter", info.permissions.clone()),
("Ägare", info.owner_user.clone()),
("Grupp", info.owner_group.clone()),
];
for (i, (label, value)) in rows.iter().enumerate() {
if i as u16 + 1 >= inner.height { break; }
let row_y = inner.y + i as u16;
// Label
f.render_widget(
Paragraph::new(format!("{:<width$}", label, width = label_w)).style(label_style),
Rect::new(inner.x + 1, row_y, label_w as u16, 1),
);
// Value
f.render_widget(
Paragraph::new(value.as_str()).style(value_style),
Rect::new(inner.x + 1 + label_w as u16, row_y, val_w as u16, 1),
);
}
// Hint at the bottom
let hint_y = inner.y + inner.height.saturating_sub(1);
f.render_widget(
Paragraph::new("Stäng: Enter / Esc").style(hint_style),
Rect::new(inner.x + 1, hint_y, inner.width.saturating_sub(2), 1),
);
}
fn draw_error_dialog(f: &mut Frame, msg: &str, area: Rect) {
let dlg_width = 50u16.min(area.width.saturating_sub(4));
let dlg_height = 5u16;
let x = (area.width.saturating_sub(dlg_width)) / 2;
let y = (area.height.saturating_sub(dlg_height)) / 2;
let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg);
let block = Block::default()
.title(" Fel ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red));
let inner = block.inner(dlg);
f.render_widget(block, dlg);
f.render_widget(
Paragraph::new(msg).style(Style::default().fg(Color::Red)),
Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(2), 1),
);
f.render_widget(
Paragraph::new("Stäng: Enter / Esc").style(Style::default().fg(Color::DarkGray)),
Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1),
);
}
fn draw_confirm_dialog(f: &mut Frame, msg: &str, area: Rect) {
let dlg_width = 50u16.min(area.width.saturating_sub(4));
let dlg_height = 5u16;
let x = (area.width.saturating_sub(dlg_width)) / 2;
let y = (area.height.saturating_sub(dlg_height)) / 2;
let dlg = Rect::new(x, y, dlg_width, dlg_height);
f.render_widget(Clear, dlg);
let block = Block::default()
.title(" Bekräfta ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let inner = block.inner(dlg);
f.render_widget(block, dlg);
f.render_widget(
Paragraph::new(msg).style(Style::default().fg(Color::White)),
Rect::new(inner.x + 1, inner.y, inner.width.saturating_sub(2), 1),
);
f.render_widget(
Paragraph::new("y=ja Annan tangent=avbryt").style(Style::default().fg(Color::DarkGray)),
Rect::new(inner.x + 1, inner.y + 2, inner.width.saturating_sub(2), 1),
);
}
fn truncate_str(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else if max <= 3 {
s[..max].to_string()
} else {
format!("{}...", &s[..max.saturating_sub(3)])
}
}