chore: update code structure for better readability and maintainability
fixes: fixt rendedring of icons and swe texts
This commit is contained in:
@@ -7,8 +7,8 @@
|
||||
"text_editor": "msedit",
|
||||
"custom_menu_items": [
|
||||
{
|
||||
"name": "Open in Vs Code",
|
||||
"command": "code $[path]",
|
||||
"name": "Nytt kommando",
|
||||
"command": "echo $[path]",
|
||||
"applies_to": "Both"
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
40
src/app.rs
40
src/app.rs
@@ -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()));
|
||||
|
||||
226
src/events.rs
226
src/events.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user