chore: update code structure for better readability and maintainability

fixes: fixt rendedring of icons and swe texts
This commit is contained in:
2026-03-29 03:19:33 +02:00
parent b5c18d493c
commit 318928ded3
6 changed files with 791 additions and 255 deletions

View File

@@ -96,11 +96,15 @@ pub struct App {
pub last_click_entry: Option<(usize, std::time::Instant)>,
pub sidebar_selected: Option<usize>,
pub rename_original: Option<PathBuf>,
// Path input text selection
pub path_input_sel_anchor: Option<usize>, // selection anchor (start of selection)
// Settings editor state
pub settings_selected_row: usize, // which row is highlighted in settings
pub settings_editing_field: Option<SettingsField>, // which field is being edited
pub settings_editing_buf: String, // current text in the edited field
pub settings_custom_selected: usize, // which custom item row is selected
// Terminal capabilities
pub unicode_support: bool,
}
// Which field inside the settings editor is being edited
@@ -111,6 +115,37 @@ pub enum SettingsField {
CustomCommand(usize),
}
/// Detect whether the terminal supports Unicode/emoji rendering.
/// Checks environment variables commonly set in SSH sessions and basic terminals.
fn detect_unicode_support() -> bool {
// Explicit override: TUI_FM_ASCII=1 forces ASCII mode
if std::env::var("TUI_FM_ASCII").is_ok() {
return false;
}
// NO_COLOR convention: respect minimal output preference
if std::env::var("NO_COLOR").is_ok() {
return false;
}
// Dumb/basic terminals: no unicode
let term = std::env::var("TERM").unwrap_or_default();
if term == "dumb" || term.is_empty() {
return false;
}
// Check LANG/LC_ALL for UTF-8 support — if explicitly non-UTF-8, disable
let lang = std::env::var("LANG")
.or_else(|_| std::env::var("LC_ALL"))
.unwrap_or_default()
.to_uppercase();
if !lang.is_empty() && !lang.contains("UTF") && !lang.contains("UTF-8") && lang != "C.UTF-8" {
// Only disable if LANG is explicitly set to a non-UTF locale
if lang.contains('.') && !lang.contains("UTF") {
return false;
}
}
// Most modern terminals (including SSH sessions with proper TERM set) support unicode
true
}
impl App {
pub fn new() -> Self {
let config = Config::load();
@@ -137,10 +172,12 @@ impl App {
last_click_entry: None,
sidebar_selected: None,
rename_original: None,
path_input_sel_anchor: None,
settings_selected_row: 0,
settings_editing_field: None,
settings_editing_buf: String::new(),
settings_custom_selected: 0,
unicode_support: detect_unicode_support(),
}
}
@@ -154,7 +191,8 @@ impl App {
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.path_input_cursor = self.path_input.chars().count();
self.path_input_sel_anchor = None;
self.context_menu = None;
} else {
self.set_status(format!("Not a directory: {}", path.display()));

View File

@@ -110,32 +110,137 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
// Path input mode
if app.focus == Focus::PathInput {
let char_count = app.path_input.chars().count();
// Helper: delete the selected range and collapse cursor to start
let delete_selection = |app: &mut App| -> bool {
if let Some(anchor) = app.path_input_sel_anchor {
let start = anchor.min(app.path_input_cursor);
let end = anchor.max(app.path_input_cursor);
if start != end {
let before: String = app.path_input.chars().take(start).collect();
let after: String = app.path_input.chars().skip(end).collect();
app.path_input = format!("{}{}", before, after);
app.path_input_cursor = start;
app.path_input_sel_anchor = None;
return true;
}
}
false
};
// Ctrl shortcuts in path input
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('a') => {
// Select all
app.path_input_sel_anchor = Some(0);
app.path_input_cursor = char_count;
return false;
}
KeyCode::Char('c') => {
// Copy selection (no-op for now, terminal clipboard not easily accessible)
return false;
}
KeyCode::Char('v') => {
// Paste: delete selection first, then insert would happen via terminal paste
delete_selection(app);
return false;
}
_ => {}
}
}
// Shift+arrow for selection
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Esc => { app.focus = Focus::FileView; }
KeyCode::Esc => {
app.focus = Focus::FileView;
app.path_input_sel_anchor = None;
}
KeyCode::Enter => {
let p = std::path::PathBuf::from(&app.path_input);
app.navigate_to(p);
app.focus = Focus::FileView;
app.path_input_sel_anchor = None;
}
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;
if !delete_selection(app) {
if app.path_input_cursor > 0 {
let remove_pos = app.path_input_cursor - 1;
let new: String = app.path_input.chars().enumerate()
.filter(|&(i, _)| i != remove_pos)
.map(|(_, c)| c)
.collect();
app.path_input = new;
app.path_input_cursor -= 1;
}
}
app.path_input_sel_anchor = None;
}
KeyCode::Delete => {
if !delete_selection(app) {
if app.path_input_cursor < char_count {
let new: String = app.path_input.chars().enumerate()
.filter(|&(i, _)| i != app.path_input_cursor)
.map(|(_, c)| c)
.collect();
app.path_input = new;
}
}
app.path_input_sel_anchor = None;
}
KeyCode::Left => {
if shift {
if app.path_input_sel_anchor.is_none() {
app.path_input_sel_anchor = Some(app.path_input_cursor);
}
} else {
app.path_input_sel_anchor = None;
}
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; }
if shift {
if app.path_input_sel_anchor.is_none() {
app.path_input_sel_anchor = Some(app.path_input_cursor);
}
} else {
app.path_input_sel_anchor = None;
}
if app.path_input_cursor < char_count { app.path_input_cursor += 1; }
}
KeyCode::Home => {
if shift {
if app.path_input_sel_anchor.is_none() {
app.path_input_sel_anchor = Some(app.path_input_cursor);
}
} else {
app.path_input_sel_anchor = None;
}
app.path_input_cursor = 0;
}
KeyCode::End => {
if shift {
if app.path_input_sel_anchor.is_none() {
app.path_input_sel_anchor = Some(app.path_input_cursor);
}
} else {
app.path_input_sel_anchor = None;
}
app.path_input_cursor = char_count;
}
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);
// Typing replaces selection
delete_selection(app);
let byte_pos: usize = app.path_input.chars()
.take(app.path_input_cursor)
.map(|ch| ch.len_utf8())
.sum();
app.path_input.insert(byte_pos, c);
app.path_input_cursor += 1;
app.path_input_sel_anchor = None;
}
_ => {}
}
@@ -450,10 +555,13 @@ pub fn settings_click(app: &mut App, col: u16, row: u16, dlg: ratatui::layout::R
// 2 : separator
// 3 : "Anpassade menyval" header
// 4 : column header (Namn / Kommando / Typ) not clickable
// 5 .. 4+custom_count : custom items (keyboard sel = list_row - 1)
// 5+custom_count (or 6 if empty) : [+ Ny] [Ta bort]
// 6+custom_count (or 7 if empty) : [Spara]
let btn_list_row: usize = if custom_count == 0 { 6 } else { 5 + custom_count };
// 5 .. 5+custom_count-1 : custom items
// 5 (or 5+custom_count if >0): "(inga)" placeholder if empty
// 5+max(n,1) : separator before buttons
// 6+max(n,1) : [+ Ny] [Ta bort]
// 7+max(n,1) : [Spara]
// 8+max(n,1) : key hint
let btn_list_row: usize = if custom_count == 0 { 7 } else { 6 + custom_count };
let save_list_row: usize = btn_list_row + 1;
if list_row == 1 {
@@ -464,15 +572,22 @@ pub fn settings_click(app: &mut App, col: u16, row: u16, dlg: ratatui::layout::R
let i = list_row - 5;
app.settings_selected_row = 4 + i;
app.settings_custom_selected = i;
let col_rel = col.saturating_sub(inner_x);
let name_w = inner_w / 3;
let cmd_w = inner_w / 3;
if col_rel < name_w {
let col_rel = col.saturating_sub(inner_x) as usize;
// Match draw_settings column layout: " NAME | CMD | TYPE"
// Fixed: 2 indent + 3 sep + 3 sep = 8, type_w = 5
let w = inner_w.saturating_sub(2) as usize;
let type_w: usize = 5;
let name_cmd_w = w.saturating_sub(8 + type_w);
let name_w = name_cmd_w / 2;
let cmd_w = name_cmd_w.saturating_sub(name_w);
let name_end = 2 + name_w; // " NAME"
let cmd_end = name_end + 3 + cmd_w; // " | CMD"
if col_rel < name_end {
if let Some(ci) = app.config.custom_menu_items.get(i) {
app.settings_editing_buf = ci.name.clone();
app.settings_editing_field = Some(SettingsField::CustomName(i));
}
} else if col_rel < name_w + cmd_w {
} else if col_rel < cmd_end {
if let Some(ci) = app.config.custom_menu_items.get(i) {
app.settings_editing_buf = ci.command.clone();
app.settings_editing_field = Some(SettingsField::CustomCommand(i));
@@ -484,16 +599,17 @@ pub fn settings_click(app: &mut App, col: u16, row: u16, dlg: ratatui::layout::R
} else if list_row == btn_list_row {
app.settings_selected_row = 4 + custom_count;
let col_rel = col.saturating_sub(inner_x) as usize;
if col_rel < 8 {
// [+ Ny]
// "[ + Ny ]" is 8 chars at cols 0-7, "[ Ta bort ]" starts at col 10
if col_rel < 10 {
// [ + Ny ]
app.config.custom_menu_items.push(CustomMenuItem {
name: String::from("Nytt kommando"),
command: String::from("echo $[path]"),
applies_to: AppliesTo::Both,
});
app.config.save();
} else if col_rel < 26 {
// [Ta bort markerad]
} else if col_rel < 22 {
// [ Ta bort ]
let sel = app.settings_custom_selected;
if sel < app.config.custom_menu_items.len() {
app.config.custom_menu_items.remove(sel);
@@ -514,6 +630,7 @@ fn handle_mouse(app: &mut App, mouse: crossterm::event::MouseEvent) {
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::Drag(MouseButton::Left) => handle_mouse_drag(app, mouse.column, mouse.row),
MouseEventKind::Moved => handle_mouse_move(app, mouse.column, mouse.row),
_ => {}
}
@@ -600,7 +717,7 @@ pub enum SidebarItem {
}
fn handle_left_click(app: &mut App, col: u16, row: u16) {
const MENU_W: u16 = 24;
const MENU_W: u16 = 26; // must match menu_width in draw_context_menu
const SUB_W: u16 = 18;
const SUB_H: u16 = 4;
@@ -687,9 +804,29 @@ fn handle_left_click(app: &mut App, col: u16, row: u16) {
return;
}
// Check path input area
if row == pi.y && col >= pi.x && col < pi.x + pi.width {
// Check path input area - click to edit and place cursor
if row >= pi.y && row < pi.y + pi.height && col >= pi.x && col < pi.x + pi.width {
app.focus = Focus::PathInput;
app.path_input_sel_anchor = None;
// Try to compute the click position within the path text.
// The bottom bar layout is: hint_area | 1 gap | "Sökväg: " | text | gear
let inner_x = pi.x + 1; // skip border
let inner_w = pi.width.saturating_sub(2);
let hint_width = (inner_w / 3).min(40);
let input_x = inner_x + hint_width + 1;
let label_display_w = 8u16; // "Sökväg: " = 8 display cols
let text_start_x = input_x + label_display_w;
if col >= text_start_x {
let click_offset = (col - text_start_x) as usize;
let char_count = app.path_input.chars().count();
let input_w = inner_w.saturating_sub(hint_width + 1 + 6);
let avail = input_w.saturating_sub(label_display_w) as usize;
let scroll_start = if app.path_input_cursor > avail { app.path_input_cursor - avail } else { 0 };
let new_cursor = (scroll_start + click_offset).min(char_count);
app.path_input_cursor = new_cursor;
}
return;
}
@@ -779,7 +916,7 @@ fn handle_mouse_move(app: &mut App, col: u16, row: u16) {
let inner_y = dlg.y + 1;
let list_row = (row - inner_y) as usize;
let custom_count = app.config.custom_menu_items.len();
let btn_lr = if custom_count == 0 { 6 } else { 5 + custom_count };
let btn_lr = if custom_count == 0 { 7 } else { 6 + custom_count };
let save_lr = btn_lr + 1;
if list_row == 1 {
app.settings_selected_row = 1;
@@ -794,7 +931,7 @@ fn handle_mouse_move(app: &mut App, col: u16, row: u16) {
return;
}
const MENU_W: u16 = 24;
const MENU_W: u16 = 26; // must match menu_width in draw_context_menu
const SUB_W: u16 = 18;
let Some(ref mut menu) = app.context_menu else { return; };
@@ -834,3 +971,36 @@ fn handle_mouse_move(app: &mut App, col: u16, row: u16) {
}
}
}
/// Handle mouse drag for text selection in the path input bar.
fn handle_mouse_drag(app: &mut App, col: u16, row: u16) {
if app.focus != Focus::PathInput { return; }
let layout = ui::compute_layout(app);
let pi = layout.path_input;
// Only handle drags within the path input area
if row < pi.y || row >= pi.y + pi.height { return; }
if col < pi.x || col >= pi.x + pi.width { return; }
// Set anchor if not already set (drag started)
if app.path_input_sel_anchor.is_none() {
app.path_input_sel_anchor = Some(app.path_input_cursor);
}
// Compute cursor position from click column
let inner_x = pi.x + 1;
let inner_w = pi.width.saturating_sub(2);
let hint_width = (inner_w / 3).min(40);
let input_x = inner_x + hint_width + 1;
let label_display_w = 8u16;
let text_start_x = input_x + label_display_w;
if col >= text_start_x {
let click_offset = (col - text_start_x) as usize;
let char_count = app.path_input.chars().count();
let avail = inner_w.saturating_sub(hint_width + 1 + 6).saturating_sub(label_display_w) as usize;
let scroll_start = if app.path_input_cursor > avail { app.path_input_cursor - avail } else { 0 };
app.path_input_cursor = (scroll_start + click_offset).min(char_count);
}
}

View File

@@ -49,6 +49,35 @@ impl FileEntry {
}
}
/// ASCII fallback icons for terminals that don't support emoji (e.g. SSH with limited fonts)
pub fn icon_ascii(&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" | "py" | "js" | "ts" | "c" | "cpp" | "h" | "hpp"
| "sh" | "bash" | "zsh" | "fish" => "[s]",
"png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" | "ico" => "[i]",
"mp4" | "mkv" | "avi" | "mov" | "webm" => "[v]",
"mp3" | "flac" | "ogg" | "wav" | "aac" => "[a]",
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" => "[z]",
"pdf" => "[p]",
"txt" | "md" | "log" => "[t]",
"json" | "yaml" | "yml" | "toml" | "xml" => "[c]",
"exe" | "bin" | "out" | "run" => "[x]",
_ => "[ ]",
}
}
}
}
pub fn is_dir(&self) -> bool {
self.file_type == FileType::Directory
}

747
src/ui.rs

File diff suppressed because it is too large Load Diff