diff --git a/build/linux/config.json b/build/linux/config.json index ce4c12a..e49577b 100644 --- a/build/linux/config.json +++ b/build/linux/config.json @@ -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" }, { diff --git a/build/linux/tui-fm b/build/linux/tui-fm index 97a6cd3..22c1280 100755 Binary files a/build/linux/tui-fm and b/build/linux/tui-fm differ diff --git a/src/app.rs b/src/app.rs index c556419..0cb36ba 100644 --- a/src/app.rs +++ b/src/app.rs @@ -96,11 +96,15 @@ pub struct App { pub last_click_entry: Option<(usize, std::time::Instant)>, pub sidebar_selected: Option, pub rename_original: Option, + // Path input text selection + pub path_input_sel_anchor: Option, // selection anchor (start of selection) // Settings editor state pub settings_selected_row: usize, // which row is highlighted in settings pub settings_editing_field: Option, // 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())); diff --git a/src/events.rs b/src/events.rs index cd16765..e7e0e37 100644 --- a/src/events.rs +++ b/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); + } +} diff --git a/src/file_ops.rs b/src/file_ops.rs index 3a0aa60..41ea7ff 100644 --- a/src/file_ops.rs +++ b/src/file_ops.rs @@ -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 } diff --git a/src/ui.rs b/src/ui.rs index a595a9a..5078027 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,18 +1,53 @@ use ratatui::{ - layout::{Constraint, Direction, Layout, Margin, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{ - Block, Borders, Cell, Clear, List, ListItem, Paragraph, + Block, Borders, Cell, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, TableState, Wrap, }, Frame, }; +use unicode_width::UnicodeWidthStr; use crate::app::{App, ContextMenuItem, DialogMode, Focus}; use crate::file_ops::{self, format_size}; +/// Compute the display width of a string, accounting for emojis (2 columns) and +/// other wide characters. Falls back to `str.len()` only when `unicode_width` +/// returns 0 for a non-empty string (shouldn't happen in practice). +fn display_width(s: &str) -> usize { + UnicodeWidthStr::width(s) +} + +/// Truncate a string to fit within `max_cols` display columns. +/// Accounts for wide characters (emojis etc). +fn truncate_display(s: &str, max_cols: usize) -> String { + let mut width = 0usize; + let mut end = 0usize; + for ch in s.chars() { + let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if width + cw > max_cols { + break; + } + width += cw; + end += ch.len_utf8(); + } + s[..end].to_string() +} + +/// Build a padded Span for a file-view name cell. +/// The icon (possibly 2-wide emoji) is in its own Span so that ratatui +/// measures each span independently — no column drift. +fn icon_name_spans<'a>(icon: &'a str, name: &'a str, style: Style) -> Vec> { + vec![ + Span::styled(icon, style), + Span::styled(" ", style), + Span::styled(name, style), + ] +} + #[derive(Clone)] pub struct LayoutAreas { pub sidebar: Rect, @@ -23,12 +58,8 @@ pub struct LayoutAreas { /// 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) } @@ -78,8 +109,8 @@ pub fn draw(f: &mut Frame, app: &mut App) { 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::NewFile(text) => draw_input_dialog(f, "Ny fil", text, area), + DialogMode::NewDir(text) => draw_input_dialog(f, "Ny mapp", 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), @@ -104,25 +135,48 @@ fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) { let inner = block.inner(area); f.render_widget(block, area); - let mut items: Vec = Vec::new(); + let header_s = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let go_up_s = Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD); + let hint_s = Style::default().fg(Color::DarkGray); + let btn_s = Style::default().fg(Color::Green).add_modifier(Modifier::BOLD); - // Up-level button - items.push(ListItem::new(Line::from(vec![ - Span::styled(" ↑ Gå upp", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)), - ]))); + let sep_str: String = if app.unicode_support { + "\u{2500}".repeat(inner.width as usize) + } else { + "-".repeat(inner.width as usize) + }; - // Favorites section - items.push(ListItem::new(Line::from(vec![ - Span::styled("FAVORITER", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - ]))); + // Build a flat list of Lines (one per row). + // Rendered individually as Paragraph per row so that each row's Rect is isolated — + // a 2-wide emoji in one row cannot affect the x-origin of any other row. + let mut lines: Vec = Vec::new(); + + // ── Go-up button ────────────────────────────────────────────────────────── + if app.unicode_support { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("⬆", go_up_s), + Span::styled(" Gå upp", go_up_s), + ])); + } else { + lines.push(Line::from(Span::styled(" ^ Gå upp", go_up_s))); + } + + // ── Favorites ───────────────────────────────────────────────────────────── + if app.unicode_support { + lines.push(Line::from(vec![ + Span::styled("⭐ ", header_s), + Span::styled("FAVORITER", header_s), + ])); + } else { + lines.push(Line::from(Span::styled("FAVORITER", header_s))); + } if app.config.favorites.is_empty() { - items.push(ListItem::new(Line::from(vec![ - Span::styled(" (inga)", Style::default().fg(Color::DarkGray)), - ]))); + lines.push(Line::from(Span::styled(" (inga)", hint_s))); } else { for fav in &app.config.favorites { - let name = std::path::Path::new(fav) + let raw_name = std::path::Path::new(fav) .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| fav.clone()); @@ -132,62 +186,90 @@ fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) { } else { Style::default().fg(Color::White) }; - items.push(ListItem::new(Line::from(vec![ - Span::styled(format!(" ★ {}", name), style), - ]))); + // ★ U+2605 is East-Asian-Width Narrow → 1 display column, safe for alignment + let star = if app.unicode_support { "\u{2605}" } else { "*" }; + let icon_w = display_width(star); + let max_name = (inner.width as usize).saturating_sub(icon_w + 2); // sp + star + sp + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", star), style), + Span::styled(truncate_display(&raw_name, max_name), style), + ])); } } - items.push(ListItem::new(Line::from(vec![ - Span::styled("─────────────────", Style::default().fg(Color::DarkGray)), - ]))); + lines.push(Line::from(Span::styled(sep_str.clone(), hint_s))); - // Root directories - items.push(ListItem::new(Line::from(vec![ - Span::styled("SÖKVÄGAR", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - ]))); + // ── Root directories ────────────────────────────────────────────────────── + if app.unicode_support { + lines.push(Line::from(vec![ + Span::styled("📂 ", header_s), + Span::styled("SÖKVÄGAR", header_s), + ])); + } else { + lines.push(Line::from(Span::styled("SÖKVÄGAR", header_s))); + } let roots = file_ops::get_root_dirs(); for root in &roots { - let label = root.to_string_lossy(); + let label = root.to_string_lossy().to_string(); 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), - ]))); + + if app.unicode_support { + // 📁 U+1F4C1 is 2 display columns wide. + // Placing it in its own Span lets ratatui's Paragraph measure the span + // width via unicode-width and position subsequent spans correctly. + // max label = inner_width - (1 space + 2 emoji cols + 1 space) + let max_label = (inner.width as usize).saturating_sub(4); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("\u{1F4C1}", style), // 📁 — 2-wide, isolated span + Span::raw(" "), + Span::styled(truncate_str(&label, max_label), style), + ])); + } else { + lines.push(Line::from(vec![ + Span::styled(format!(" > {}", label), style), + ])); + } } - items.push(ListItem::new(Line::from(vec![ - Span::styled("─────────────────", Style::default().fg(Color::DarkGray)), - ]))); + lines.push(Line::from(Span::styled(sep_str, hint_s))); - // Add favorite button - items.push(ListItem::new(Line::from(vec![ - Span::styled(" [+ Favorit]", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), - ]))); + // ── Add Favorite button ─────────────────────────────────────────────────── + if app.unicode_support { + lines.push(Line::from(vec![ + Span::styled(" ➕ ", btn_s), + Span::styled("Favorit", btn_s), + ])); + } else { + lines.push(Line::from(Span::styled(" [+ Favorit]", btn_s))); + } - // Apply scroll offset - let visible: Vec = items - .into_iter() - .skip(app.sidebar_scroll) - .take(inner.height as usize) - .collect(); - let total_sidebar = visible.len() + app.sidebar_scroll; + let total = lines.len(); + let visible_h = inner.height as usize; - let list = List::new(visible); - f.render_widget(list, inner); + // Render each visible line as its own 1-row Paragraph. + // This isolates every row: no wide-char in row N can drift row N+1. + for (i, line) in lines.iter().skip(app.sidebar_scroll).enumerate() { + if i >= visible_h { break; } + f.render_widget( + Paragraph::new(line.clone()), + Rect::new(inner.x, inner.y + i as u16, inner.width, 1), + ); + } - // Scrollbar for sidebar - if total_sidebar > inner.height as usize { - let mut sb_state = ScrollbarState::new(total_sidebar).position(app.sidebar_scroll); + // Scrollbar + if total > visible_h { + let mut sb_state = ScrollbarState::new(total).position(app.sidebar_scroll); let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) .begin_symbol(None) .end_symbol(None) - .thumb_symbol("█"); + .thumb_symbol(if app.unicode_support { "\u{2588}" } else { "#" }); f.render_stateful_widget( scrollbar, area.inner(Margin { vertical: 1, horizontal: 0 }), @@ -204,14 +286,13 @@ fn draw_file_view(f: &mut Frame, app: &App, area: Rect) { Style::default().fg(Color::DarkGray) }; - // Build rows from ALL entries (TableState offset handles which rows are visible) let rows: Vec = app .entries .iter() .enumerate() .map(|(idx, entry)| { let is_selected = app.selected_indices.contains(&idx); - let name_cell = format!("{} {}", entry.icon(), entry.name); + let icon = if app.unicode_support { entry.icon() } else { entry.icon_ascii() }; let size_cell = entry.formatted_size(); let style = if is_selected { @@ -222,9 +303,15 @@ fn draw_file_view(f: &mut Frame, app: &App, area: Rect) { Style::default().fg(Color::White) }; + // Use a Line with separate icon/name spans so ratatui + // measures the emoji width (2 cols) independently. + let name_line = Line::from(icon_name_spans(icon, &entry.name, style)); + Row::new(vec![ - Cell::from(name_cell), - Cell::from(size_cell).style(Style::default().fg(if is_selected { Color::White } else { Color::DarkGray })), + Cell::from(name_line), + Cell::from(size_cell).style( + Style::default().fg(if is_selected { Color::White } else { Color::DarkGray }) + ), ]) .style(style) }) @@ -243,7 +330,6 @@ fn draw_file_view(f: &mut Frame, app: &App, area: Rect) { .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) @@ -256,16 +342,16 @@ fn draw_file_view(f: &mut Frame, app: &App, area: Rect) { f.render_stateful_widget(table, area, &mut table_state); - // Scrollbar — rendered over the right border of the block + // Scrollbar let total = app.entries.len(); - let visible = area.height.saturating_sub(3) as usize; // -2 borders -1 header + let visible = area.height.saturating_sub(3) as usize; 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("█"); + .begin_symbol(Some(if app.unicode_support { "\u{25B2}" } else { "^" })) + .end_symbol(Some(if app.unicode_support { "\u{25BC}" } else { "v" })) + .track_symbol(Some(if app.unicode_support { "\u{2502}" } else { "|" })) + .thumb_symbol(if app.unicode_support { "\u{2588}" } else { "#" }); f.render_stateful_widget( scrollbar, area.inner(Margin { vertical: 1, horizontal: 0 }), @@ -277,10 +363,11 @@ fn draw_file_view(f: &mut Frame, app: &App, area: Rect) { 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_w = sel_text.len() as u16 + 2; let badge_area = Rect::new( - inner.x + inner.width.saturating_sub(sel_text.len() as u16 + 2), + inner.x + inner.width.saturating_sub(badge_w), inner.y, - sel_text.len() as u16 + 2, + badge_w, 1, ); f.render_widget( @@ -308,7 +395,7 @@ fn draw_bottom_bar(f: &mut Frame, app: &App, area: Rect) { let hint = app .status_message .as_deref() - .unwrap_or("Tab=fokus Backspace=upp Ctrl+C/X/V=kopiera/klipp/klistra q=avsluta"); + .unwrap_or("Tab=fokus Bksp=upp C-c/x/v=kopiera 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); @@ -319,77 +406,122 @@ fn draw_bottom_bar(f: &mut Frame, app: &App, area: Rect) { hint_area, ); - // Right: path input (leave 5 chars on the far right for gear button) + // Right: path input (leave room for gear button) let input_x = inner.x + hint_width + 1; - let input_width = inner.width.saturating_sub(hint_width + 1 + 5); + let input_width = inner.width.saturating_sub(hint_width + 1 + 6); if input_width < 4 { return; } let input_area = Rect::new(input_x, inner.y, input_width, inner.height); + // Draw text box background + let textbox_bg = Style::default().bg(Color::DarkGray); + let blank_line = " ".repeat(input_width as usize); + f.render_widget( + Paragraph::new(blank_line.as_str()).style(textbox_bg), + input_area, + ); + let label = "Sökväg: "; - let label_len = label.len() as u16; + let label_len = display_width(label) 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); + // Determine selection range + let sel_range = app.path_input_sel_anchor.map(|anchor| { + let start = anchor.min(cursor_pos); + let end = anchor.max(cursor_pos); + (start, end) + }); + let mut spans = vec![ - Span::styled(label, Style::default().fg(Color::Yellow)), + Span::styled(label, Style::default().fg(Color::Yellow).bg(Color::DarkGray)), ]; 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 { - "" - }; + let scroll_start = if cursor_pos > avail { cursor_pos - avail } else { 0 }; - 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))); + if let Some((sel_start, sel_end)) = sel_range { + // Render with selection highlight + let sel_style = Style::default().fg(Color::White).bg(Color::Blue); + let normal_style = Style::default().fg(Color::White).bg(Color::DarkGray); + let cursor_style = Style::default().fg(Color::Black).bg(Color::White); + + let mut col = 0; + for (_byte_idx, ch) in display_text.char_indices() { + let abs_pos = scroll_start + col; + let is_cursor = col == cursor_in_display; + let is_selected = abs_pos >= sel_start && abs_pos < sel_end; + + let style = if is_cursor { + cursor_style + } else if is_selected { + sel_style + } else { + normal_style + }; + + spans.push(Span::styled(ch.to_string(), style)); + col += 1; + } + // Cursor at end of text + if cursor_in_display >= display_text.chars().count() { + spans.push(Span::styled(" ", cursor_style)); + } + } else { + // No selection - normal rendering with cursor + let before: String = display_text.chars().take(cursor_in_display).collect(); + let cursor_char = display_text + .chars() + .nth(cursor_in_display) + .map(|c| c.to_string()) + .unwrap_or_else(|| " ".to_string()); + let after: String = display_text.chars().skip(cursor_in_display + 1).collect(); + + spans.push(Span::styled(before, Style::default().fg(Color::White).bg(Color::DarkGray))); + spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(Color::White))); + spans.push(Span::styled(after, Style::default().fg(Color::White).bg(Color::DarkGray))); + } } else { - spans.push(Span::styled(&display_text, Style::default().fg(Color::White))); + spans.push(Span::styled(display_text, Style::default().fg(Color::White).bg(Color::DarkGray))); } let para = Paragraph::new(Line::from(spans)); f.render_widget(para, input_area); // Gear button at bottom-right - let gear_x = inner.x + inner.width.saturating_sub(4); + let gear_x = inner.x + inner.width.saturating_sub(5); + let (gear_label, gear_w) = if app.unicode_support { + (" \u{2699} ", 4u16) + } else { + (" [S] ", 5u16) + }; let gear_style = if app.dialog == DialogMode::Settings { Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::Yellow) + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) }; f.render_widget( - Paragraph::new(" ⚙ ").style(gear_style), - Rect::new(gear_x, inner.y, 3, 1), + Paragraph::new(gear_label).style(gear_style), + Rect::new(gear_x, inner.y, gear_w, 1), ); } fn scroll_input_text(text: &str, cursor: usize, avail: usize) -> (String, usize) { - let len = text.len(); - if len <= avail { + let char_count = text.chars().count(); + if char_count <= 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 end = (start + avail).min(char_count); + let slice: String = text.chars().skip(start).take(end - start).collect(); let cursor_in_display = cursor.saturating_sub(start); - (slice.to_string(), cursor_in_display) + (slice, cursor_in_display) } -fn draw_context_menu(f: &mut Frame, _app: &App, menu: crate::app::ContextMenu) { +fn draw_context_menu(f: &mut Frame, app: &App, menu: crate::app::ContextMenu) { let items = &menu.items; if items.is_empty() { return; } @@ -418,69 +550,103 @@ fn draw_context_menu(f: &mut Frame, _app: &App, menu: crate::app::ContextMenu) { let row_rect = Rect::new(inner.x, inner.y + i as u16, inner.width, 1); let is_sel = i == menu.selected; - let (owned_text, style): (String, Style) = match item { + // Use 2-char ASCII icon codes — emoji (U+1F000+) are 2-wide in terminals and + // cause ghost characters and column drift when partially overwritten. + let (icon, label, item_style): (&str, &str, Style) = match item { ContextMenuItem::Copy => - (" \u{1F4CB} Kopiera ".into(), Style::default()), + ("C ", "Kopiera", Style::default()), ContextMenuItem::Cut => - (" \u{2702} Klipp ut ".into(), Style::default()), + ("X ", "Klipp ut", Style::default()), ContextMenuItem::Paste => - (" \u{1F4CC} Klistra in ".into(), Style::default()), + ("V ", "Klistra in", Style::default()), ContextMenuItem::Rename => - (" \u{270F} Byt namn ".into(), Style::default()), + ("R ", "Byt namn", Style::default()), ContextMenuItem::Properties => - (" \u{2139} Egenskaper ".into(), Style::default()), + ("i ", "Egenskaper", Style::default()), ContextMenuItem::Delete => - (" \u{1F5D1} Ta bort ".into(), Style::default().fg(Color::Red)), + ("D ", "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 > ".into(), Style::default().fg(Color::Green)) + ("+ ", "Ny >", Style::default().fg(Color::Green)) } ContextMenuItem::AddFavorite => - (" \u{2605} Lagg till fav ".into(), Style::default().fg(Color::Yellow)), + ("* ", "Lägg till fav", Style::default().fg(Color::Yellow)), ContextMenuItem::RemoveFavorite(_) => - (" \u{2716} Ta bort favorit ".into(), Style::default().fg(Color::Red)), - ContextMenuItem::Separator => - (" -------------------- ".into(), Style::default().fg(Color::DarkGray)), + ("- ", "Ta bort fav", Style::default().fg(Color::Red)), + ContextMenuItem::Separator => { + let sep = if app.unicode_support { + "\u{2500}".repeat(inner.width as usize) + } else { + "-".repeat(inner.width as usize) + }; + f.render_widget( + Paragraph::new(sep).style(Style::default().fg(Color::DarkGray)), + row_rect, + ); + continue; + } ContextMenuItem::NewFile => - (" \u{1F4C4} Ny fil ".into(), Style::default().fg(Color::Green)), + ("f ", "Ny fil", Style::default().fg(Color::Green)), ContextMenuItem::NewDir => - (" \u{1F4C1} Ny mapp ".into(), Style::default().fg(Color::Green)), + ("d ", "Ny mapp", Style::default().fg(Color::Green)), ContextMenuItem::Custom(idx) => { - let name = _app.config.custom_menu_items.get(*idx) - .map(|ci| format!(" > {:<20}", ci.name)) - .unwrap_or_else(|| " > ?".into()); - (name, Style::default().fg(Color::LightMagenta)) + let name = app.config.custom_menu_items.get(*idx) + .map(|ci| ci.name.as_str()) + .unwrap_or("?"); + let style = Style::default().fg(Color::LightMagenta); + let bg = if is_sel { Style::default().bg(Color::Blue).fg(Color::White) } else { style }; + let text = format!(" > {}", truncate_str(name, inner.width as usize - 4)); + f.render_widget(Paragraph::new(text).style(bg), row_rect); + continue; } }; - let bg = if is_sel && !matches!(item, ContextMenuItem::Separator) { + let bg = if is_sel { Style::default().bg(Color::Blue).fg(Color::White) } else { - style + item_style }; - f.render_widget(Paragraph::new(owned_text.as_str()).style(bg), row_rect); + + let text = format!(" {} {}", icon, label); + f.render_widget(Paragraph::new(text).style(bg), row_rect); } 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); + let sub_w: u16 = 18; + let sub_x = (x + menu_width).min(term_area.width.saturating_sub(sub_w)); + let sub_area = Rect::new(sub_x, new_sub_row, sub_w, 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 s0 = if menu.sub_selected == Some(0) { Style::default().bg(Color::Blue).fg(Color::White) } else { Style::default().fg(Color::White) }; - let s1 = 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(" \u{1F4C4} Ny fil ").style(s0), Rect::new(sub_inner.x, sub_inner.y, sub_inner.width, 1)); - f.render_widget(Paragraph::new(" \u{1F4C1} Ny mapp ").style(s1), Rect::new(sub_inner.x, sub_inner.y + 1, sub_inner.width, 1)); + + let s0 = if menu.sub_selected == Some(0) { + Style::default().bg(Color::Blue).fg(Color::White) + } else { + Style::default().fg(Color::White) + }; + let s1 = 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(" f Ny fil ").style(s0), + Rect::new(sub_inner.x, sub_inner.y, sub_inner.width, 1), + ); + f.render_widget( + Paragraph::new(" d Ny mapp ").style(s1), + Rect::new(sub_inner.x, sub_inner.y + 1, sub_inner.width, 1), + ); } } /// Compute the settings dialog rect anchored to the bottom-right corner. pub fn settings_dialog_rect(right_edge: u16) -> ratatui::layout::Rect { - let dlg_width: u16 = 70; - let dlg_height: u16 = 28; + let dlg_width: u16 = 72; + let dlg_height: u16 = 26; let x = right_edge.saturating_sub(dlg_width + 1); let (_, term_h) = crossterm::terminal::size().unwrap_or((120, 40)); let y = term_h.saturating_sub(dlg_height + 3); @@ -498,72 +664,107 @@ fn draw_settings(f: &mut Frame, app: &App, right_edge: u16) { ); f.render_widget(Clear, area); + + let title = if app.unicode_support { + " \u{2699} Inställningar " + } else { + " [S] Inställningar " + }; let block = Block::default() - .title(" \u{2699} Installningar (Esc=stang | n=ny | d=ta bort) ") + .title(title) + .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Yellow)); let inner = block.inner(area); f.render_widget(block, area); - let sel = app.settings_selected_row; + let sel = app.settings_selected_row; let editing = &app.settings_editing_field; - let buf = &app.settings_editing_buf; + let buf = &app.settings_editing_buf; - let label_s = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let value_s = Style::default().fg(Color::White); - let sel_bg = Style::default().bg(Color::DarkGray).fg(Color::White); - let hint_s = Style::default().fg(Color::DarkGray); + let section_s = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let value_s = Style::default().fg(Color::White); + let field_s = Style::default().fg(Color::White).bg(Color::DarkGray); + let sel_s = Style::default().bg(Color::Blue).fg(Color::White); + let hint_s = Style::default().fg(Color::DarkGray); + let sep_s = Style::default().fg(Color::DarkGray); + + let w = inner.width.saturating_sub(2) as usize; + let sep_line: String = if app.unicode_support { + "\u{2500}".repeat(w) + } else { + "-".repeat(w) + }; let mut row_y = inner.y; - // Row 0: TextEditor section label + // ── Row 0: Texteditor section label ────────────────────────────────────── f.render_widget( - Paragraph::new("Texteditor (standardapp for textfiler)").style(label_s), - Rect::new(inner.x + 1, row_y, inner.width.saturating_sub(2), 1), + Paragraph::new("Texteditor (standardapp för textfiler)").style(section_s), + Rect::new(inner.x + 1, row_y, w as u16, 1), ); row_y += 1; - // Row 1: text editor value / edit field + // ── Row 1: text editor value / edit field ───────────────────────────────── let is_editing_editor = matches!(editing, Some(crate::app::SettingsField::TextEditor)); - let editor_style = if sel == 1 && !is_editing_editor { sel_bg } else { value_s }; - let editor_text = if is_editing_editor { - format!("{}_", buf) + let (editor_text, editor_style) = if is_editing_editor { + // Show edit buffer with cursor + let field_w = w.saturating_sub(1); + let txt = truncate_str(&format!("{}_", buf), field_w); + (format!(" {:= inner.y + inner.height.saturating_sub(3) { break; } let row_idx = 4 + i; @@ -580,54 +781,103 @@ fn draw_settings(f: &mut Frame, app: &App, right_edge: u16) { ci.command.clone() }; - let row_style = if is_row_sel { sel_bg } else { value_s }; - let name_col = truncate_str(&name_str, col_w); - let cmd_col = truncate_str(&cmd_str, col_w); - let row_text = format!(" {: 0 { + f.render_widget( + Paragraph::new(" n=ny d=ta bort").style(hint_s), + Rect::new(hint_x, row_y, hint_w, 1), + ); + } row_y += 1; } - // Save button row + // ── Save button ─────────────────────────────────────────────────────────── if row_y < inner.y + inner.height.saturating_sub(1) { let is_save_sel = sel == 5 + custom_count; + let save_label = "[ Spara inställningar ]"; let save_s = if is_save_sel { Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) }; + // Center the save button + let save_x = inner.x + 1 + (w.saturating_sub(save_label.len())) as u16 / 2; f.render_widget( - Paragraph::new("[ Spara installningar ]").style(save_s), - Rect::new(inner.x + 1, row_y, inner.width.saturating_sub(2), 1), + Paragraph::new(save_label).style(save_s), + Rect::new(save_x, row_y, save_label.len() as u16, 1), + ); + row_y += 1; + } + + // ── Key hint at bottom ──────────────────────────────────────────────────── + if row_y < inner.y + inner.height { + f.render_widget( + Paragraph::new("Esc=stäng Upp/Ner=navigera Enter=aktivera/redigera") + .style(hint_s) + .alignment(Alignment::Center), + Rect::new(inner.x, row_y, 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 dlg_width = 54u16.min(area.width.saturating_sub(4)); + let dlg_height = 6u16; 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); @@ -640,22 +890,34 @@ fn draw_input_dialog(f: &mut Frame, title: &str, current: &str, area: Rect) { let inner = block.inner(dlg); f.render_widget(block, dlg); - // Input field with cursor - let display = format!("{}_", current); + // Input field with cursor highlight on dark background + let field_w = inner.width.saturating_sub(2) as usize; + let visible = truncate_str(current, field_w.saturating_sub(1)); + let cursor_pos = visible.len(); + let pad_len = field_w.saturating_sub(cursor_pos + 1); + let padding = " ".repeat(pad_len); + + let field_line = Line::from(vec![ + Span::styled(visible.as_str(), Style::default().fg(Color::White).bg(Color::DarkGray)), + Span::styled(" ", Style::default().fg(Color::Black).bg(Color::White)), + Span::styled(padding.as_str(), Style::default().bg(Color::DarkGray)), + ]); 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), + Paragraph::new(field_line), + Rect::new(inner.x + 1, inner.y + 1, field_w as u16, 1), ); + + // Hint centered at bottom 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), + Paragraph::new("Enter = bekräfta Esc = avbryt") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center), + Rect::new(inner.x, inner.y + 3, inner.width, 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_width = 60u16.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; @@ -669,48 +931,55 @@ fn draw_properties(f: &mut Frame, info: crate::app::PropertiesInfo, area: Rect) let inner = block.inner(dlg); f.render_widget(block, dlg); - let label_style = Style::default().fg(Color::Yellow); + let label_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); 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 sep_style = Style::default().fg(Color::DarkGray); + let label_w = 13usize; + let val_w = (inner.width as usize).saturating_sub(label_w + 4); - let rows: &[(&str, String)] = &[ - ("Namn", info.name.clone()), - ("Sökväg", truncate_str(&info.path, val_w)), + let rows: Vec<(&str, String)> = vec![ + ("Namn", truncate_str(&info.name, val_w)), + ("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("-") }), + ("Filer", if info.is_multi || info.file_count > 0 { info.file_count.to_string() } else { "-".to_string() }), ("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; } + if i as u16 + 1 >= inner.height.saturating_sub(2) { break; } let row_y = inner.y + i as u16; - // Label + let line = Line::from(vec![ + Span::styled(format!(" {: String { - if s.len() <= max { + let w = display_width(s); + if w <= max { s.to_string() } else if max <= 3 { - s[..max].to_string() + truncate_display(s, max) } else { - format!("{}...", &s[..max.saturating_sub(3)]) + format!("{}...", truncate_display(s, max.saturating_sub(3))) } }