From dc9465fc1e9bae7bb2ac37df3ffdca5b425ff761 Mon Sep 17 00:00:00 2001 From: vasilito Date: Thu, 18 Jun 2026 15:26:30 +0300 Subject: [PATCH] tui/tlc: restore recovered UI and input work --- local/recipes/tui/tlc/source/locales/en.yml | 9 + local/recipes/tui/tlc/source/src/app.rs | 135 +++++++++- .../recipes/tui/tlc/source/src/editor/mod.rs | 24 +- .../src/filemanager/connection_manager.rs | 28 +-- .../tlc/source/src/filemanager/copy_dialog.rs | 103 +++++--- .../source/src/filemanager/delete_dialog.rs | 28 +-- .../tui/tlc/source/src/filemanager/exec.rs | 28 +-- .../tui/tlc/source/src/filemanager/find.rs | 30 +-- .../tui/tlc/source/src/filemanager/help.rs | 29 +-- .../tui/tlc/source/src/filemanager/hotlist.rs | 28 +-- .../tui/tlc/source/src/filemanager/info.rs | 28 +-- .../source/src/filemanager/layout_dialog.rs | 2 +- .../tui/tlc/source/src/filemanager/link.rs | 62 ++--- .../source/src/filemanager/mkdir_dialog.rs | 28 +-- .../tui/tlc/source/src/filemanager/mod.rs | 237 +++++++++++++++--- .../tlc/source/src/filemanager/move_dialog.rs | 103 +++++--- .../tui/tlc/source/src/filemanager/panel.rs | 7 + .../source/src/filemanager/quickcd_dialog.rs | 32 +-- .../tlc/source/src/filemanager/quit_dialog.rs | 23 +- .../tlc/source/src/filemanager/usermenu.rs | 28 +-- .../tui/tlc/source/src/terminal/event.rs | 44 +++- .../tui/tlc/source/src/terminal/mod.rs | 1 + .../tui/tlc/source/src/terminal/popup.rs | 79 ++++++ .../recipes/tui/tlc/source/src/viewer/mod.rs | 146 +++++++++-- .../recipes/tui/tlc/source/src/viewer/text.rs | 33 ++- 25 files changed, 835 insertions(+), 460 deletions(-) create mode 100644 local/recipes/tui/tlc/source/src/terminal/popup.rs diff --git a/local/recipes/tui/tlc/source/locales/en.yml b/local/recipes/tui/tlc/source/locales/en.yml index 0dca299615..692bff180d 100644 --- a/local/recipes/tui/tlc/source/locales/en.yml +++ b/local/recipes/tui/tlc/source/locales/en.yml @@ -49,6 +49,15 @@ dialog_title_reload: "Reload" dialog_label_copy_to: "Copy to" dialog_label_move_to: "Move to" dialog_label_link_to: "Link to" +dialog_label_source_mask: "Source mask" +dialog_label_using_shell_patterns: "Using shell patterns" +dialog_label_follow_links: "Follow links" +dialog_label_preserve_attributes: "Preserve attributes" +dialog_label_dive_into_subdirs: "Dive into subdir if exists" +dialog_label_stable_symlinks: "Stable symlinks" +dialog_label_from: "From:" +dialog_label_items: "Items:" +dialog_title_quickcd: "Quick cd" dialog_label_new_directory: "New directory" dialog_label_find: "Find" dialog_label_filter: "Filter" diff --git a/local/recipes/tui/tlc/source/src/app.rs b/local/recipes/tui/tlc/source/src/app.rs index f1ad9a5a90..d186827af9 100644 --- a/local/recipes/tui/tlc/source/src/app.rs +++ b/local/recipes/tui/tlc/source/src/app.rs @@ -9,11 +9,12 @@ //! 4. On `Cmd::Quit` (or terminal EOF), tear down cleanly. use std::path::PathBuf; +use std::io::Write; use std::time::Duration; use anyhow::Result; -use termion::event::Key as TermKey; -use termion::input::TermRead; +use termion::event::{Event as TermEvent, Key as TermKey}; +use termion::input::TermReadEventsAndRaw; use crate::config::Config; use crate::filemanager::FileManager; @@ -52,7 +53,7 @@ impl Application { // Main event loop — blocking stdin, raw mode is active. let stdin = std::io::stdin(); - let mut keys = stdin.lock().keys(); + let mut events = stdin.lock().events_and_raw(); loop { // Handle pending external execution (subshell or command line) // before reading the next key. This runs every iteration @@ -62,14 +63,22 @@ impl Application { render(&mut tui, &mut fm)?; } - let tk = match keys.next() { - Some(Ok(k)) => k, + let (event, raw) = match events.next() { + Some(Ok(pair)) => pair, Some(Err(e)) => { log::debug!("input error: {e}"); continue; } None => break, }; + if should_log_raw_event(&event) { + debug_raw_event(&event, &raw); + } + let tk = match event { + TermEvent::Key(k) => k, + TermEvent::Mouse(_) => continue, + TermEvent::Unsupported(_) => continue, + }; let key = translate_key(tk); // Esc closes whatever overlay is active, or quits if none. @@ -353,6 +362,14 @@ fn apply_movement(fm: &mut FileManager, key: Key) { Key { code: 0x21DF, mods } if mods.is_empty() => fm.active_panel_mut().cursor_page_down(), Key { code: 0x21A1, mods } if mods.is_empty() => fm.active_panel_mut().cursor_home(), Key { code: 0x21A0, mods } if mods.is_empty() => fm.active_panel_mut().cursor_end(), + Key { code: 0x2191, mods } if mods == Modifiers::SHIFT => mark_range_after_move(fm, |p| p.cursor_up()), + Key { code: 0x2193, mods } if mods == Modifiers::SHIFT => mark_range_after_move(fm, |p| p.cursor_down()), + Key { code: 0x2192, mods } if mods == Modifiers::SHIFT => mark_range_after_move(fm, |p| p.cursor_down()), + Key { code: 0x2190, mods } if mods == Modifiers::SHIFT => mark_range_after_move(fm, |p| p.cursor_up()), + Key { code: 0x21A1, mods } if mods == Modifiers::SHIFT => mark_range_after_move(fm, |p| p.cursor_home()), + Key { code: 0x21A0, mods } if mods == Modifiers::SHIFT => mark_range_after_move(fm, |p| p.cursor_end()), + Key { code: 0x21DE, mods } if mods == Modifiers::SHIFT => mark_range_after_move(fm, |p| p.cursor_page_up()), + Key { code: 0x21DF, mods } if mods == Modifiers::SHIFT => mark_range_after_move(fm, |p| p.cursor_page_down()), // h / j / k / l (vim-style). Key { code: c, mods } if mods.is_empty() && (c == b'h' as u32 || c == b'k' as u32) => { fm.active_panel_mut().cursor_up() @@ -380,6 +397,58 @@ fn apply_movement(fm: &mut FileManager, key: Key) { } } +fn mark_range_after_move(fm: &mut FileManager, move_cursor: F) +where + F: FnOnce(&mut crate::filemanager::panel::Panel), +{ + let panel = fm.active_panel_mut(); + let old = panel.cursor(); + move_cursor(panel); + let new = panel.cursor(); + let (start, end) = if old <= new { (old, new) } else { (new, old) }; + for idx in start..=end { + panel.mark_at(idx); + } +} + +fn should_log_raw_event(event: &TermEvent) -> bool { + matches!( + event, + TermEvent::Key( + TermKey::ShiftLeft + | TermKey::AltLeft + | TermKey::CtrlLeft + | TermKey::ShiftRight + | TermKey::AltRight + | TermKey::CtrlRight + | TermKey::ShiftUp + | TermKey::AltUp + | TermKey::CtrlUp + | TermKey::ShiftDown + | TermKey::AltDown + | TermKey::CtrlDown + | TermKey::CtrlHome + | TermKey::CtrlEnd + | TermKey::BackTab + ) | TermEvent::Unsupported(_) + ) +} + +fn debug_raw_event(event: &TermEvent, raw: &[u8]) { + let hex = raw + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" "); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("/tmp/tlc-debug.log") + { + let _ = writeln!(f, "{event:?} raw=[{hex}]"); + } +} + /// CLI argument struct (re-exported at crate root). #[derive(Debug, Clone, Default)] pub struct Cli { @@ -421,6 +490,18 @@ fn longest_common_prefix(strings: &[String]) -> String { #[cfg(test)] mod tests { use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir(prefix: &str) -> std::path::PathBuf { + let id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("tlc-{prefix}-{id}")); + fs::create_dir_all(&dir).unwrap(); + dir + } #[test] fn cli_load_config_returns_ok_or_default() { @@ -436,4 +517,48 @@ mod tests { assert!(km.lookup(Key::ctrl('l')).is_some()); assert!(km.lookup(Key::TAB).is_some()); } + + #[test] + fn shift_down_marks_current_and_next_entry() { + let dir = temp_dir("shift-down"); + fs::write(dir.join("a.txt"), b"a").unwrap(); + fs::write(dir.join("b.txt"), b"b").unwrap(); + + let cfg = Config::default(); + let mut fm = FileManager::new(&dir, &cfg).unwrap(); + fm.active_panel_mut().cursor_down(); + + apply_movement( + &mut fm, + Key { + code: 0x2193, + mods: crate::key::Modifiers::SHIFT, + }, + ); + + assert_eq!(fm.active_panel().marked_count(), 2); + } + + #[test] + fn shift_end_marks_to_bottom() { + let dir = temp_dir("shift-end"); + fs::write(dir.join("a.txt"), b"a").unwrap(); + fs::write(dir.join("b.txt"), b"b").unwrap(); + fs::write(dir.join("c.txt"), b"c").unwrap(); + + let cfg = Config::default(); + let mut fm = FileManager::new(&dir, &cfg).unwrap(); + fm.active_panel_mut().cursor_down(); + + apply_movement( + &mut fm, + Key { + code: 0x21A0, + mods: crate::key::Modifiers::SHIFT, + }, + ); + + assert_eq!(fm.active_panel().cursor(), fm.active_panel().len() - 1); + assert_eq!(fm.active_panel().marked_count(), fm.active_panel().len() - 1); + } } diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 4ff543f102..114b5ff824 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -29,11 +29,12 @@ use std::path::{Path, PathBuf}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::key::{Key, Modifiers}; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_percent_rect, render_popup}; pub mod bookmark; pub mod buffer; @@ -892,17 +893,8 @@ impl Editor { // Save-before-close prompt overlay. if matches!(self.mode, Mode::Prompt(PromptKind::SaveBeforeClose)) { - let popup = centered_rect(area, 0.5, 0.25); - frame.render_widget(Clear, popup); - let block = Block::default().borders(Borders::ALL).title(Span::styled( - format!(" {} ", crate::locale::t("dialog_save_changes")), - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = centered_percent_rect(area, 0.5, 0.25); + let inner = render_popup(frame, popup, crate::locale::t("dialog_save_changes"), theme); let p = Paragraph::new(Line::from(vec![ Span::styled("Press ", Style::default().fg(theme.hidden)), Span::styled( @@ -993,14 +985,6 @@ impl Editor { } } -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - /// Backwards-compat shim for the old `open_file` API. The new API /// is to construct an [`Editor`] directly via [`Editor::open`]. pub fn open_file(file: &str, line: Option) -> anyhow::Result<()> { diff --git a/local/recipes/tui/tlc/source/src/filemanager/connection_manager.rs b/local/recipes/tui/tlc/source/src/filemanager/connection_manager.rs index 416970cc87..4cf81a2312 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/connection_manager.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/connection_manager.rs @@ -20,12 +20,13 @@ use std::path::PathBuf; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; +use ratatui::widgets::{List, ListItem, Paragraph, Wrap}; use ratatui::Frame; use serde::{Deserialize, Serialize}; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_percent_rect, render_popup}; /// A single saved connection profile. /// @@ -308,21 +309,8 @@ impl ConnectionManagerDialog { /// `theme` supplies the title, list, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_rect(area, self.width_pct, self.height_pct); - frame.render_widget(Clear, popup); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - format!(" {} ", crate::locale::t("dialog_title_connection")), - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = centered_percent_rect(area, self.width_pct, self.height_pct); + let inner = render_popup(frame, popup, crate::locale::t("dialog_title_connection"), theme); let chunks = Layout::default() .direction(Direction::Vertical) @@ -425,14 +413,6 @@ fn truncate(s: &str, max: usize) -> String { } } -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs index a56b9626b6..89ee6492bd 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs @@ -14,13 +14,14 @@ use std::path::{Path, PathBuf}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::popup::{mc_copy_move_rect, render_button_row, render_popup}; use crate::widget::input::Input; /// F5 copy dialog. @@ -143,35 +144,26 @@ impl CopyDialog { /// `theme` supplies the title, header, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_rect(area, self.width_pct, self.height_pct); - frame.render_widget(Clear, popup); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - format!(" {} ", crate::locale::t("dialog_title_copy")), - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = mc_copy_move_rect(area, 13); + let inner = render_popup(frame, popup, crate::locale::t("dialog_title_copy"), theme); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(2), // header + Constraint::Length(1), // from + Constraint::Length(1), // mask + Constraint::Length(1), // shell patterns Constraint::Length(3), // input + Constraint::Length(4), // options + Constraint::Length(1), // buttons Constraint::Min(1), // hint ]) .split(inner); - let header_text = if self.marked_count <= 1 { + let from_text = if self.marked_count <= 1 { format!( - "{} {} :", - crate::locale::t("dialog_title_copy"), + "{} {}", + crate::locale::t("dialog_label_from"), self.src .first() .map(|p| p.display().to_string()) @@ -179,19 +171,68 @@ impl CopyDialog { ) } else { format!( - "{} {} items:", - crate::locale::t("dialog_title_copy"), + "{} {}", + crate::locale::t("dialog_label_items"), self.marked_count ) }; - let header = Line::from(Span::styled(header_text, Style::default().fg(theme.foreground))); - frame.render_widget(Paragraph::new(header), chunks[0]); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + from_text, + Style::default().fg(theme.foreground), + ))), + chunks[0], + ); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + format!("{}: ", crate::locale::t("dialog_label_source_mask")), + Style::default().fg(theme.hidden), + ), + Span::styled("*", Style::default().fg(theme.foreground)), + ])), + chunks[1], + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!("[ ] {}", crate::locale::t("dialog_label_using_shell_patterns")), + Style::default().fg(theme.hidden), + ))), + chunks[2], + ); let mut input = Input::new() .label(crate::locale::t("dialog_label_copy_to")) .text(self.dst_input.value().to_string()); input = input.focused(); - input.render(frame, chunks[1], theme); + input.render(frame, chunks[3], theme); + + let opts = vec![ + Line::from(Span::styled( + format!("[ ] {}", crate::locale::t("dialog_label_follow_links")), + Style::default().fg(theme.hidden), + )), + Line::from(Span::styled( + format!("[ ] {}", crate::locale::t("dialog_label_preserve_attributes")), + Style::default().fg(theme.hidden), + )), + Line::from(Span::styled( + format!("[ ] {}", crate::locale::t("dialog_label_dive_into_subdirs")), + Style::default().fg(theme.hidden), + )), + Line::from(Span::styled( + format!("[ ] {}", crate::locale::t("dialog_label_stable_symlinks")), + Style::default().fg(theme.hidden), + )), + ]; + frame.render_widget(Paragraph::new(opts), chunks[4]); + render_button_row( + frame, + chunks[5], + theme, + &crate::locale::t("dialog_action_confirm"), + &crate::locale::t("dialog_action_cancel"), + ); let hint = Line::from(vec![ Span::styled("Enter", Style::default().fg(theme.warning)), @@ -205,18 +246,10 @@ impl CopyDialog { Style::default().fg(theme.hidden), ), ]); - frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]); + frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[6]); } } -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs index 8bfc8d1691..cf8d3a0d84 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs @@ -12,11 +12,12 @@ use std::path::PathBuf; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_cols_rect, render_popup}; /// F8 delete confirmation dialog. pub struct DeleteDialog { @@ -89,21 +90,8 @@ impl DeleteDialog { /// dialog follows the active skin. The destructive title uses the /// `theme.error` slot to keep the danger cue. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_rect(area, self.width_pct, self.height_pct); - frame.render_widget(Clear, popup); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.error)) - .title(Span::styled( - format!(" {} ", crate::locale::t("dialog_title_delete")), - Style::default() - .fg(theme.foreground) - .bg(theme.error) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = centered_cols_rect(area, 56, 10); + let inner = render_popup(frame, popup, crate::locale::t("dialog_title_delete"), theme); let chunks = Layout::default() .direction(Direction::Vertical) @@ -167,14 +155,6 @@ impl DeleteDialog { } } -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/filemanager/exec.rs b/local/recipes/tui/tlc/source/src/filemanager/exec.rs index e688b9bdca..387c9d58f1 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/exec.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/exec.rs @@ -29,11 +29,12 @@ use std::thread; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_percent_rect, render_popup}; /// Outcome of feeding a key to [`ExecDialog`]. #[derive(Debug, Clone, PartialEq, Eq)] @@ -243,21 +244,8 @@ impl ExecDialog { /// `theme` supplies the title, status, and body colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_rect(area, 0.85, 0.85); - frame.render_widget(Clear, popup); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - format!(" Exec: {} ", self.cmd), - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = centered_percent_rect(area, 0.85, 0.85); + let inner = render_popup(frame, popup, format!("Exec: {}", self.cmd), theme); let chunks = Layout::default() .direction(Direction::Vertical) @@ -489,14 +477,6 @@ fn merge_pipes_streams( all } -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/filemanager/find.rs b/local/recipes/tui/tlc/source/src/filemanager/find.rs index 8ec89d0d57..6202cc2aa9 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/find.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/find.rs @@ -27,12 +27,13 @@ use std::sync::Arc; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; +use ratatui::widgets::{List, ListItem, Paragraph, Wrap}; use ratatui::Frame; use walkdir::WalkDir; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_percent_rect, render_popup}; use crate::widget::input::Input; /// How many bytes of a file the Content-mode search reads at most. @@ -271,26 +272,13 @@ impl FindDialog { /// `theme` supplies the title, list, hint, and status colours so /// the dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_rect(area, 0.7, 0.7); - frame.render_widget(Clear, popup); - let title = format!( " {} ({} mode) ", crate::locale::t("dialog_title_find"), self.mode.label() ); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - title, - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = centered_percent_rect(area, 0.7, 0.7); + let inner = render_popup(frame, popup, title, theme); let chunks = Layout::default() .direction(Direction::Vertical) @@ -400,16 +388,6 @@ impl FindDialog { } } -// -- Helpers ---------------------------------------------------------------- - -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - /// Name-mode search: glob or substring match against the file's name. fn name_search( root: &Path, diff --git a/local/recipes/tui/tlc/source/src/filemanager/help.rs b/local/recipes/tui/tlc/source/src/filemanager/help.rs index e12eab4530..68f38d6ebf 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/help.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/help.rs @@ -17,12 +17,13 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; +use ratatui::widgets::{List, ListItem, Paragraph}; use ratatui::Frame; use crate::key::Key; use crate::keymap::default_keymap; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_percent_rect, render_popup}; /// A single binding shown in the help list: the key description /// (e.g. `"F3"` or `"Ctrl-Q"`) and the human-readable command name @@ -308,21 +309,8 @@ impl HelpDialog { /// Render the dialog into `frame`, centered on `area`. pub fn render(&self, frame: &mut Frame, area: Rect) { - let popup = centered_rect(area, self.width_pct, self.height_pct); - frame.render_widget(Clear, popup); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(self.theme.border)) - .title(Span::styled( - format!(" {} ", self.title), - Style::default() - .fg(self.theme.title_fg) - .bg(self.theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = centered_percent_rect(area, self.width_pct, self.height_pct); + let inner = render_popup(frame, popup, self.title.clone(), &self.theme); let chunks = Layout::default() .direction(Direction::Vertical) @@ -425,15 +413,6 @@ impl HelpDialog { } } -/// Center a popup of `width_pct` × `height_pct` of `area`. -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/filemanager/hotlist.rs b/local/recipes/tui/tlc/source/src/filemanager/hotlist.rs index 13f7538156..926f750c36 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/hotlist.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/hotlist.rs @@ -16,13 +16,14 @@ use std::path::{Path, PathBuf}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; +use ratatui::widgets::{List, ListItem, Paragraph, Wrap}; use ratatui::Frame; use serde::{Deserialize, Serialize}; use crate::key::Key; use crate::paths::expand; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_percent_rect, render_popup}; use crate::widget::input::Input; /// A single hotlist entry: a label and a target directory path. @@ -324,21 +325,8 @@ impl HotlistDialog { /// `theme` supplies the title, list, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_rect(area, 0.6, 0.7); - frame.render_widget(Clear, popup); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - format!(" {} ", crate::locale::t("dialog_title_hotlist")), - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = centered_percent_rect(area, 0.6, 0.7); + let inner = render_popup(frame, popup, crate::locale::t("dialog_title_hotlist"), theme); let chunks = Layout::default() .direction(Direction::Vertical) @@ -410,14 +398,6 @@ impl Default for HotlistDialog { } } -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/filemanager/info.rs b/local/recipes/tui/tlc/source/src/filemanager/info.rs index 79d9aa173b..e896ee45df 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/info.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/info.rs @@ -15,11 +15,12 @@ use std::path::PathBuf; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; use crate::ops::info::FileInfo; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_cols_rect, render_popup}; /// F11 file info dialog. #[derive(Debug, Clone)] @@ -69,21 +70,8 @@ impl InfoDialog { /// `theme` supplies the title, body, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_rect(area, self.width_pct, self.height_pct); - frame.render_widget(Clear, popup); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - format!(" {} ", self.title), - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = centered_cols_rect(area, 64, area.height.saturating_sub(4).min(18).max(8)); + let inner = render_popup(frame, popup, self.title.clone(), theme); let chunks = Layout::default() .direction(Direction::Vertical) @@ -122,14 +110,6 @@ impl InfoDialog { } } -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/filemanager/layout_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/layout_dialog.rs index e933b33786..0662a46d5a 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/layout_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/layout_dialog.rs @@ -336,7 +336,7 @@ mod tests { let result = d.handle_key(Key::ENTER); match result { LayoutResult::Confirm(s) => { - assert!(!s.show_hintbar); + assert!(s.show_hintbar); assert!(s.equal_split); assert!(s.show_menubar); assert!(s.show_keybar); diff --git a/local/recipes/tui/tlc/source/src/filemanager/link.rs b/local/recipes/tui/tlc/source/src/filemanager/link.rs index bed28f484c..429bcabc46 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/link.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/link.rs @@ -13,13 +13,14 @@ use std::path::{Path, PathBuf}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_cols_rect, render_button_row, render_popup}; use crate::widget::input::Input; /// Which kind of link to create. @@ -153,37 +154,23 @@ impl LinkDialog { /// `theme` supplies the title, header, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_rect(area, self.width_pct, self.height_pct); - frame.render_widget(Clear, popup); - - let title = match self.kind { - crate::filemanager::link::LinkKind::Hard => format!( - " {} ", - crate::locale::t("dialog_title_link") - ), - crate::filemanager::link::LinkKind::Sym => format!( - " {} ", - crate::locale::t("dialog_title_symlink") - ), - }; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - title, - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = centered_cols_rect(area, 64, 8); + let inner = render_popup( + frame, + popup, + match self.kind { + LinkKind::Hard => crate::locale::t("dialog_title_link"), + LinkKind::Sym => crate::locale::t("dialog_title_symlink"), + }, + theme, + ); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // header Constraint::Length(3), // input + Constraint::Length(1), // buttons Constraint::Min(1), // hint ]) .split(inner); @@ -192,9 +179,7 @@ impl LinkDialog { Span::styled("Source: ", Style::default().fg(theme.hidden)), Span::styled( self.src.display().to_string(), - Style::default() - .fg(theme.foreground) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme.foreground), ), ]); frame.render_widget(Paragraph::new(header), chunks[0]); @@ -214,6 +199,13 @@ impl LinkDialog { } input = input.focused(); input.render(frame, chunks[1], theme); + render_button_row( + frame, + chunks[2], + theme, + &crate::locale::t("dialog_action_create"), + &crate::locale::t("dialog_action_cancel"), + ); let hint = Line::from(vec![ Span::styled("Enter", Style::default().fg(theme.warning)), @@ -227,7 +219,7 @@ impl LinkDialog { Style::default().fg(theme.hidden), ), ]); - frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]); + frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[3]); } } @@ -247,14 +239,6 @@ fn default_dst_for(src: &Path, kind: LinkKind) -> PathBuf { } } -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs index 61b585ac8c..2af02810e0 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs @@ -16,11 +16,12 @@ use std::path::{Path, PathBuf}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_cols_rect, render_popup}; use crate::widget::input::Input; /// F7 mkdir dialog. @@ -139,21 +140,8 @@ impl MkDirDialog { /// `theme` supplies the title, header, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_rect(area, self.width_pct, self.height_pct); - frame.render_widget(Clear, popup); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - format!(" {} ", crate::locale::t("dialog_title_mkdir")), - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = centered_cols_rect(area, (area.width / 2).max(34), 8); + let inner = render_popup(frame, popup, crate::locale::t("dialog_title_mkdir"), theme); let chunks = Layout::default() .direction(Direction::Vertical) @@ -197,14 +185,6 @@ impl MkDirDialog { } } -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index 3df58b6044..099c205617 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -654,6 +654,12 @@ impl FileManager { ))); Ok(true) } + Cmd::CompareDirs => { + self.status + .set_message("Compare directories: not restored yet"); + Ok(true) + } + Cmd::ViewerNextFile | Cmd::ViewerPrevFile => Ok(true), } } @@ -1205,6 +1211,68 @@ impl FileManager { self.dialog = None; } + fn apply_layout_outcome(&mut self, o: layout_dialog::LayoutResult) { + use layout_dialog::LayoutResult; + match o { + LayoutResult::Confirm(settings) => { + self.runtime.equal_split = Some(settings.equal_split); + self.runtime.show_menubar = Some(settings.show_menubar); + self.runtime.show_keybar = Some(settings.show_keybar); + self.runtime.show_hintbar = Some(settings.show_hintbar); + self.runtime.show_cmdline = Some(settings.show_cmdline); + self.status.set_message("Layout options updated"); + if self.runtime.auto_save_setup() { + let _ = self.save_config(); + } + } + LayoutResult::Cancel | LayoutResult::Running => {} + } + self.dialog = None; + } + + fn apply_panel_options_outcome(&mut self, o: panel_options::PanelOptionsResult) { + use panel_options::PanelOptionsResult; + match o { + PanelOptionsResult::Confirm(settings) => { + self.runtime.mix_all_files = Some(settings.mix_all_files); + self.runtime.mark_moves_down = Some(settings.mark_moves_down); + self.runtime.show_mini_status = Some(settings.show_mini_status); + if self.left.show_hidden() != settings.show_hidden { + let _ = self.left.toggle_hidden(); + } + if self.right.show_hidden() != settings.show_hidden { + let _ = self.right.toggle_hidden(); + } + self.cfg.show_hidden = settings.show_hidden; + self.status.set_message("Panel options updated"); + if self.runtime.auto_save_setup() { + let _ = self.save_config(); + } + } + PanelOptionsResult::Cancel | PanelOptionsResult::Running => {} + } + self.dialog = None; + } + + fn apply_config_outcome(&mut self, o: config_dialog::ConfigResult) { + use config_dialog::ConfigResult; + match o { + ConfigResult::Confirm(settings) => { + self.runtime.esc_exit_mode = Some(settings.esc_exit_mode); + self.runtime.verbose_ops = Some(settings.verbose_ops); + self.runtime.auto_save_setup = Some(settings.auto_save_setup); + self.runtime.safe_delete = Some(settings.safe_delete); + self.runtime.pause_after_run = Some(settings.pause_after_run); + self.status.set_message("Configuration updated"); + if self.runtime.auto_save_setup() { + let _ = self.save_config(); + } + } + ConfigResult::Cancel | ConfigResult::Running => {} + } + self.dialog = None; + } + /// Apply a find dialog outcome (Open/View/Edit/Cd path). fn apply_find_outcome(&mut self, o: find::FindOutcome) { use find::FindOutcome; @@ -1317,7 +1385,10 @@ impl FileManager { Some(DialogState::Find(_)) | Some(DialogState::Hotlist(_)) | Some(DialogState::Tree(_)) - | Some(DialogState::UserMenu(_)) => { + | Some(DialogState::UserMenu(_)) + | Some(DialogState::Layout(_)) + | Some(DialogState::PanelOptions(_)) + | Some(DialogState::Config(_)) => { // No-op: those dialogs clear themselves. } // The Help dialog also clears itself in `handle_dialog_key` @@ -1704,6 +1775,9 @@ impl FileManager { DialogState::UnselectGroup(d) => d.render(frame, area, &self.theme), DialogState::QuickCd(d) => d.render(frame, area, &self.theme), DialogState::Overwrite(d) => d.render(frame, area, &self.theme), + DialogState::Layout(d) => d.render(frame, area, &self.theme), + DialogState::PanelOptions(d) => d.render(frame, area, &self.theme), + DialogState::Config(d) => d.render(frame, area, &self.theme), } } } @@ -1726,9 +1800,10 @@ impl FileManager { } }; // Adjust each panel's scroll so the cursor is visible. - let inner_h = left_a.height.saturating_sub(2) as usize; - self.left.ensure_cursor_visible(inner_h); - self.right.ensure_cursor_visible(inner_h); + let left_rows = panel_body_rows(left_a); + let right_rows = panel_body_rows(right_a); + self.left.ensure_cursor_visible(left_rows); + self.right.ensure_cursor_visible(right_rows); self.render_panel(frame, left_a, &self.left, self.active == Active::Left); self.render_panel(frame, right_a, &self.right, self.active == Active::Right); } @@ -1788,10 +1863,34 @@ impl FileManager { let inner = block.inner(area); frame.render_widget(block, area); - let height = (inner.height as usize).saturating_sub(1).max(1); + if inner.width == 0 || inner.height == 0 { + return; + } + + let show_meta = inner.height >= 3; + let show_footer = inner.height >= 2; + let body_y = inner.y + u16::from(show_meta); + let footer_h = u16::from(show_footer); + let body_height = inner + .height + .saturating_sub(u16::from(show_meta)) + .saturating_sub(footer_h) + .max(1); + let height = body_height as usize; let top = panel.top(); let cursor = panel.cursor(); let mode = panel.listing_mode(); + + if show_meta { + frame.render_widget( + Paragraph::new(Span::styled( + panel_meta_text(panel), + Style::default().fg(self.theme.hidden), + )), + Rect::new(inner.x, inner.y, inner.width, 1), + ); + } + let items: Vec = (0..height) .map(|row| { let idx = top + row; @@ -1814,44 +1913,24 @@ impl FileManager { .fg(self.theme.foreground) .bg(self.theme.background), ); - let list_area = Rect { - height: height as u16, - ..inner - }; + let list_area = Rect::new(inner.x, body_y, inner.width, body_height); frame.render_widget(list, list_area); - // Mini-status line at the bottom of the panel. - let total = panel.entry_count(); - let dirs = panel.dir_count(); - let files = total.saturating_sub(dirs); - let marked = panel.marked_count(); - let size = panel.total_size(); - let mark_str = if marked > 0 { - format!(", {marked} marked") - } else { - String::new() - }; - let mini = format!( - " {files}f {dirs}d{mark_str} {} ", - format_size(size) - ); - let mini_area = Rect { - y: inner.y + height as u16, - height: 1, - ..inner - }; - frame.render_widget( - Paragraph::new(Span::styled( - mini, - Style::default().fg(self.theme.hidden), - )), - mini_area, - ); + if show_footer { + let footer_y = inner.y + inner.height.saturating_sub(1); + frame.render_widget( + Paragraph::new(Span::styled( + panel_footer_text(panel), + Style::default().fg(self.theme.hidden), + )), + Rect::new(inner.x, footer_y, inner.width, 1), + ); + } // Cursor marker: a `>` on the leftmost column for the active panel. if active { - let cursor_y = inner.y + (cursor.saturating_sub(top)) as u16; - if cursor_y < inner.y + inner.height { + let cursor_y = body_y + (cursor.saturating_sub(top)) as u16; + if cursor_y < body_y + body_height { let marker = Paragraph::new(Span::styled( ">", Style::default() @@ -1953,6 +2032,57 @@ fn format_size(bytes: u64) -> String { } } +fn panel_body_rows(area: Rect) -> usize { + let inner_height = area.height.saturating_sub(2); + let meta = usize::from(inner_height >= 3); + let footer = usize::from(inner_height >= 2); + inner_height.saturating_sub((meta + footer) as u16).max(1) as usize +} + +fn panel_meta_text(panel: &Panel) -> String { + let filter = panel + .filter() + .map(|f| format!(" Filter:{f}")) + .unwrap_or_default(); + let hidden = if panel.show_hidden() { " Hidden" } else { "" }; + let reverse = if panel.sort_reverse() { " desc" } else { "" }; + format!( + " {} Sort:{}{} {} items{}{} ", + panel.listing_mode().label(), + panel.sort_field_name(), + reverse, + panel.entry_count(), + hidden, + filter + ) +} + +fn panel_footer_text(panel: &Panel) -> String { + let total = panel.entry_count(); + let dirs = panel.dir_count(); + let files = total.saturating_sub(dirs); + let marked = panel.marked_count(); + let stats = if marked > 0 { + format!("{files}f {dirs}d {marked} marked") + } else { + format!("{files}f {dirs}d") + }; + let current = panel + .entries() + .get(panel.cursor()) + .map(|e| { + if e.name == ".." { + "../".to_string() + } else if e.is_dir() { + format!("{}/", e.name) + } else { + format!("{} {}", e.name, format_size(e.stat.size)) + } + }) + .unwrap_or_default(); + format!(" {stats} {current} ") +} + /// Render the file manager into a frame at the given area. pub fn render(fm: &mut FileManager, frame: &mut Frame, area: Rect) { fm.render(frame, area); @@ -1985,6 +2115,37 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn panel_meta_text_shows_mode_marks_and_filter() { + let dir = std::env::temp_dir().join("tlc-panel-meta-test"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("alpha.txt"), b"x").unwrap(); + let cfg = empty_cfg(); + let mut panel = Panel::new(&dir, &cfg.filemanager).unwrap(); + panel.set_filter("alp"); + let meta = panel_meta_text(&panel); + assert!(meta.contains("Full")); + assert!(meta.contains("Sort:")); + assert!(meta.contains("Filter:alp")); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn panel_footer_text_shows_current_entry_and_size() { + let dir = std::env::temp_dir().join("tlc-panel-footer-test"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("alpha.txt"), b"abcd").unwrap(); + let cfg = empty_cfg(); + let mut panel = Panel::new(&dir, &cfg.filemanager).unwrap(); + panel.cursor_down(); + let footer = panel_footer_text(&panel); + assert!(footer.contains("alpha.txt")); + assert!(footer.contains("4B")); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn switch_focus_and_swap() { let dir = std::env::temp_dir().join("tlc-fm-swap-test"); diff --git a/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs index 37a3bcf42a..74e718aff5 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs @@ -14,13 +14,14 @@ use std::path::{Path, PathBuf}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::popup::{mc_copy_move_rect, render_button_row, render_popup}; use crate::widget::input::Input; /// F6 move dialog. @@ -143,35 +144,26 @@ impl MoveDialog { /// `theme` supplies the title, header, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_rect(area, self.width_pct, self.height_pct); - frame.render_widget(Clear, popup); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - format!(" {} ", crate::locale::t("dialog_title_move")), - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = mc_copy_move_rect(area, 13); + let inner = render_popup(frame, popup, crate::locale::t("dialog_title_move"), theme); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(2), // header + Constraint::Length(1), // from/items + Constraint::Length(1), // mask + Constraint::Length(1), // shell patterns Constraint::Length(3), // input + Constraint::Length(4), // options + Constraint::Length(1), // buttons Constraint::Min(1), // hint ]) .split(inner); - let header_text = if self.marked_count <= 1 { + let from_text = if self.marked_count <= 1 { format!( - "{} {} :", - crate::locale::t("dialog_title_move"), + "{} {}", + crate::locale::t("dialog_label_from"), self.src .first() .map(|p| p.display().to_string()) @@ -179,19 +171,68 @@ impl MoveDialog { ) } else { format!( - "{} {} items:", - crate::locale::t("dialog_title_move"), + "{} {}", + crate::locale::t("dialog_label_items"), self.marked_count ) }; - let header = Line::from(Span::styled(header_text, Style::default().fg(theme.foreground))); - frame.render_widget(Paragraph::new(header), chunks[0]); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + from_text, + Style::default().fg(theme.foreground), + ))), + chunks[0], + ); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + format!("{}: ", crate::locale::t("dialog_label_source_mask")), + Style::default().fg(theme.hidden), + ), + Span::styled("*", Style::default().fg(theme.foreground)), + ])), + chunks[1], + ); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!("[ ] {}", crate::locale::t("dialog_label_using_shell_patterns")), + Style::default().fg(theme.hidden), + ))), + chunks[2], + ); let mut input = Input::new() .label(crate::locale::t("dialog_label_move_to")) .text(self.dst_input.value().to_string()); input = input.focused(); - input.render(frame, chunks[1], theme); + input.render(frame, chunks[3], theme); + + let opts = vec![ + Line::from(Span::styled( + format!("[ ] {}", crate::locale::t("dialog_label_follow_links")), + Style::default().fg(theme.hidden), + )), + Line::from(Span::styled( + format!("[ ] {}", crate::locale::t("dialog_label_preserve_attributes")), + Style::default().fg(theme.hidden), + )), + Line::from(Span::styled( + format!("[ ] {}", crate::locale::t("dialog_label_dive_into_subdirs")), + Style::default().fg(theme.hidden), + )), + Line::from(Span::styled( + format!("[ ] {}", crate::locale::t("dialog_label_stable_symlinks")), + Style::default().fg(theme.hidden), + )), + ]; + frame.render_widget(Paragraph::new(opts), chunks[4]); + render_button_row( + frame, + chunks[5], + theme, + &crate::locale::t("dialog_action_confirm"), + &crate::locale::t("dialog_action_cancel"), + ); let hint = Line::from(vec![ Span::styled("Enter", Style::default().fg(theme.warning)), @@ -205,18 +246,10 @@ impl MoveDialog { Style::default().fg(theme.hidden), ), ]); - frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]); + frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[6]); } } -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/filemanager/panel.rs b/local/recipes/tui/tlc/source/src/filemanager/panel.rs index c1a2c30a62..968fe11d02 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/panel.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/panel.rs @@ -304,6 +304,13 @@ impl Panel { } } + /// Mark the entry at `index`. + pub fn mark_at(&mut self, index: usize) { + if let Some(e) = self.entries.get(index) { + self.marked.insert(e.name.clone()); + } + } + /// Mark entries matching `pattern` (Unix glob). pub fn mark_pattern(&mut self, pattern: &str) { for e in &self.entries { diff --git a/local/recipes/tui/tlc/source/src/filemanager/quickcd_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/quickcd_dialog.rs index 1515156caa..e988d56e65 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/quickcd_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/quickcd_dialog.rs @@ -7,13 +7,14 @@ use std::path::PathBuf; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_cols_rect, render_popup}; use crate::widget::input::Input; /// Outcome of the quick-cd dialog after a key press. @@ -143,27 +144,12 @@ impl QuickCdDialog { /// Render the dialog centered on screen. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let dialog_w = 60u16.min(area.width); - let dialog_h = 7u16.min(area.height); - let x = area.x + (area.width - dialog_w) / 2; - let y = area.y + (area.height - dialog_h) / 2; - let dlg_area = Rect::new(x, y, dialog_w, dialog_h); - - frame.render_widget(Clear, dlg_area); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - " Quick cd ", - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - - let inner = block.inner(dlg_area); - frame.render_widget(block, dlg_area); + let width = 60.min(area.width.saturating_sub(2).max(1)); + let height = 6.min(area.height.saturating_sub(1).max(1)); + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height + 1); + let dlg_area = centered_cols_rect(Rect::new(x, y, width, height), width, height); + let inner = render_popup(frame, dlg_area, crate::locale::t("dialog_title_quickcd"), theme); let chunks = Layout::default() .direction(Direction::Vertical) diff --git a/local/recipes/tui/tlc/source/src/filemanager/quit_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/quit_dialog.rs index 3d367bdebd..fd84903619 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/quit_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/quit_dialog.rs @@ -8,11 +8,12 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_cols_rect, render_popup}; /// Which button has focus. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -82,24 +83,8 @@ impl QuitDialog { /// Render the dialog centered on screen. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let w = 44.min(area.width.saturating_sub(2)); - let h = 7.min(area.height.saturating_sub(2)); - let x = area.x + (area.width - w) / 2; - let y = area.y + (area.height - h) / 2; - let dlg = Rect::new(x, y, w, h); - frame.render_widget(Clear, dlg); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - " Quit TLC ", - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(dlg); - frame.render_widget(block, dlg); + let dlg = centered_cols_rect(area, 44, 7); + let inner = render_popup(frame, dlg, "Quit TLC", theme); let chunks = Layout::default() .direction(Direction::Vertical) diff --git a/local/recipes/tui/tlc/source/src/filemanager/usermenu.rs b/local/recipes/tui/tlc/source/src/filemanager/usermenu.rs index 94c61696e3..5ad2fc8f21 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/usermenu.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/usermenu.rs @@ -24,12 +24,13 @@ use std::path::{Path, PathBuf}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; +use ratatui::widgets::{List, ListItem, Paragraph, Wrap}; use ratatui::Frame; use crate::filemanager::percent::{expand_percent, PercentCtx}; use crate::key::Key; use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_percent_rect, render_popup}; /// One entry in the user menu. #[derive(Debug, Clone, PartialEq, Eq)] @@ -380,27 +381,14 @@ impl UserMenuDialog { /// `theme` supplies the title, list, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_rect(area, 0.7, 0.7); - frame.render_widget(Clear, popup); - let title = format!( " {}: {} ({}) ", crate::locale::t("dialog_title_user_menu"), self.for_file.display(), self.condition ); - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.title_fg)) - .title(Span::styled( - title, - Style::default() - .fg(theme.title_fg) - .bg(theme.title_bg) - .add_modifier(Modifier::BOLD), - )); - let inner = block.inner(popup); - frame.render_widget(block, popup); + let popup = centered_percent_rect(area, 0.7, 0.7); + let inner = render_popup(frame, popup, title, theme); let chunks = Layout::default() .direction(Direction::Vertical) @@ -580,14 +568,6 @@ fn expand(command: &str, file: &Path) -> String { out } -fn centered_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { - let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; - let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; - let x = area.x + area.width.saturating_sub(w) / 2; - let y = area.y + area.height.saturating_sub(h) / 2; - Rect::new(x, y, w, h) -} - #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/terminal/event.rs b/local/recipes/tui/tlc/source/src/terminal/event.rs index 414413d52a..2ad20d4a9a 100644 --- a/local/recipes/tui/tlc/source/src/terminal/event.rs +++ b/local/recipes/tui/tlc/source/src/terminal/event.rs @@ -8,21 +8,35 @@ use termion::event::Key as TermKey; -use crate::key::Key; +use crate::key::{Key, Modifiers}; /// Translate a single termion [`TermKey`] into a tlc [`Key`]. pub fn translate_key(k: TermKey) -> Key { match k { TermKey::Backspace => Key::BACKSPACE, TermKey::Left => arrow(0x2190), + TermKey::ShiftLeft => modified(0x2190, Modifiers::SHIFT), + TermKey::AltLeft => modified(0x2190, Modifiers::ALT), + TermKey::CtrlLeft => modified(0x2190, Modifiers::CTRL), TermKey::Right => arrow(0x2192), + TermKey::ShiftRight => modified(0x2192, Modifiers::SHIFT), + TermKey::AltRight => modified(0x2192, Modifiers::ALT), + TermKey::CtrlRight => modified(0x2192, Modifiers::CTRL), TermKey::Up => arrow(0x2191), + TermKey::ShiftUp => modified(0x2191, Modifiers::SHIFT), + TermKey::AltUp => modified(0x2191, Modifiers::ALT), + TermKey::CtrlUp => modified(0x2191, Modifiers::CTRL), TermKey::Down => arrow(0x2193), + TermKey::ShiftDown => modified(0x2193, Modifiers::SHIFT), + TermKey::AltDown => modified(0x2193, Modifiers::ALT), + TermKey::CtrlDown => modified(0x2193, Modifiers::CTRL), TermKey::Home => named(0x21A1), + TermKey::CtrlHome => modified(0x21A1, Modifiers::CTRL), TermKey::End => named(0x21A0), + TermKey::CtrlEnd => modified(0x21A0, Modifiers::CTRL), TermKey::PageUp => named(0x21DE), TermKey::PageDown => named(0x21DF), - TermKey::BackTab => Key::TAB, + TermKey::BackTab => modified(0x09, Modifiers::SHIFT), TermKey::Delete => named(0x7F), TermKey::Insert => named(0xECB4), TermKey::F(n) => f_key(n), @@ -55,6 +69,10 @@ fn named(c: u32) -> Key { } } +fn modified(c: u32, mods: Modifiers) -> Key { + Key { code: c, mods } +} + /// Function keys live in a high Unicode private-use range (0xF100..=0xF10B) /// to avoid collisions with printable text AND with the arrow / navigation /// range (0x2190..0x21A0) and editor's special-key range (0x21A0..0x21DF). @@ -131,6 +149,28 @@ mod tests { assert_eq!(translate_key(TermKey::Down).code, 0x2193); } + #[test] + fn translate_modified_arrows() { + let left = translate_key(TermKey::ShiftLeft); + assert_eq!(left.code, 0x2190); + assert!(left.mods.contains(Modifiers::SHIFT)); + + let right = translate_key(TermKey::CtrlRight); + assert_eq!(right.code, 0x2192); + assert!(right.mods.contains(Modifiers::CTRL)); + + let up = translate_key(TermKey::AltUp); + assert_eq!(up.code, 0x2191); + assert!(up.mods.contains(Modifiers::ALT)); + } + + #[test] + fn backtab_preserves_shift() { + let k = translate_key(TermKey::BackTab); + assert_eq!(k.code, Key::TAB.code); + assert!(k.mods.contains(Modifiers::SHIFT)); + } + /// CRITICAL INTEGRATION TEST: a real `TermKey::F(3)` from /// termion must translate to a code that the keymap finds. /// This was the bug that made every F-key binding dead at diff --git a/local/recipes/tui/tlc/source/src/terminal/mod.rs b/local/recipes/tui/tlc/source/src/terminal/mod.rs index 7feb040248..ca790fc117 100644 --- a/local/recipes/tui/tlc/source/src/terminal/mod.rs +++ b/local/recipes/tui/tlc/source/src/terminal/mod.rs @@ -8,6 +8,7 @@ pub mod color; pub mod event; +pub mod popup; pub mod status; use std::io::{self, Write}; diff --git a/local/recipes/tui/tlc/source/src/terminal/popup.rs b/local/recipes/tui/tlc/source/src/terminal/popup.rs new file mode 100644 index 0000000000..b1cc315cbf --- /dev/null +++ b/local/recipes/tui/tlc/source/src/terminal/popup.rs @@ -0,0 +1,79 @@ +//! Shared popup helpers for centered modal surfaces. + +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear}; +use ratatui::Frame; + +use crate::terminal::color::Theme; + +/// Center a popup by percentage of the parent area. +#[must_use] +pub fn centered_percent_rect(area: Rect, width_pct: f32, height_pct: f32) -> Rect { + let w = (area.width as f32 * width_pct.clamp(0.1, 1.0)) as u16; + let h = (area.height as f32 * height_pct.clamp(0.1, 1.0)) as u16; + let x = area.x + area.width.saturating_sub(w) / 2; + let y = area.y + area.height.saturating_sub(h) / 2; + Rect::new(x, y, w, h) +} + +/// Center a popup by absolute column width and row height. +#[must_use] +pub fn centered_cols_rect(area: Rect, width: u16, height: u16) -> Rect { + let w = width.min(area.width.saturating_sub(2).max(1)); + let h = height.min(area.height.saturating_sub(2).max(1)); + let x = area.x + area.width.saturating_sub(w) / 2; + let y = area.y + area.height.saturating_sub(h) / 2; + Rect::new(x, y, w, h) +} + +/// MC-like one-input dialog geometry. +#[must_use] +pub fn mc_input_rect(area: Rect, height: u16) -> Rect { + centered_cols_rect(area, (area.width / 2).max(34), height) +} + +/// MC-like copy/move dialog geometry. +#[must_use] +pub fn mc_copy_move_rect(area: Rect, height: u16) -> Rect { + centered_cols_rect(area, ((area.width * 2) / 3).max(68), height) +} + +/// Render a standard popup shell and return its inner area. +pub fn render_popup(frame: &mut Frame, area: Rect, title: impl Into, theme: &Theme) -> Rect { + frame.render_widget(Clear, area); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .style(Style::default().bg(theme.background)) + .title(Span::styled( + format!(" {} ", title.into()), + Style::default().fg(theme.title_fg).bg(theme.title_bg), + )); + let inner = block.inner(area); + frame.render_widget(block, area); + inner +} + +/// Render a simple MC-style button row. +pub fn render_button_row( + frame: &mut Frame, + area: Rect, + theme: &Theme, + primary: &str, + secondary: &str, +) { + let line = Line::from(vec![ + Span::styled( + format!("<{}>", primary), + Style::default().fg(theme.cursor_fg).bg(theme.cursor_bg), + ), + Span::raw(" "), + Span::styled( + format!("[{}]", secondary), + Style::default().fg(theme.foreground).bg(theme.background), + ), + ]); + frame.render_widget(ratatui::widgets::Paragraph::new(line), area); +} diff --git a/local/recipes/tui/tlc/source/src/viewer/mod.rs b/local/recipes/tui/tlc/source/src/viewer/mod.rs index 4c1d4afad8..cf8f902460 100644 --- a/local/recipes/tui/tlc/source/src/viewer/mod.rs +++ b/local/recipes/tui/tlc/source/src/viewer/mod.rs @@ -17,7 +17,10 @@ pub mod text; use std::path::PathBuf; use anyhow::Result; -use ratatui::layout::Rect; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::key::Key; @@ -159,31 +162,112 @@ impl Viewer { } } + fn current_line(&self) -> u64 { + self.goto + .resolve(self.cursor, goto::GotoKind::Offset) + .map(|t| t.line) + .unwrap_or(1) + } + + fn header_text(&self) -> String { + let mode = match self.mode { + ViewMode::Text => "Text", + ViewMode::Hex => "Hex", + }; + let wrap = if self.wrap { "Wrap:on" } else { "Wrap:off" }; + format!( + " {} {} {} {} ", + crate::locale::t("dialog_title_viewer"), + self.path.display(), + mode, + wrap + ) + } + + fn footer_text(&self) -> String { + match self.mode { + ViewMode::Text => format!( + " Line {} / {} Offset {} {} ", + self.current_line(), + self.goto.line_count().max(1), + self.cursor, + format_size(self.size()) + ), + ViewMode::Hex => format!( + " Offset 0x{:x} / 0x{:x} {} ", + self.cursor, + self.size(), + format_size(self.size()) + ), + } + } + /// Render the viewer to a ratatui frame in the current mode. /// /// `theme` supplies the title bar colour and is forwarded to the /// text / hex sub-renderers so the viewer follows the active skin. pub fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { - let title = format!( - " {} {} ", - crate::locale::t("dialog_title_viewer"), - self.path.display() - ); - let block = ratatui::widgets::Block::default() - .borders(ratatui::widgets::Borders::ALL) - .title(ratatui::text::Span::styled( - title, - ratatui::style::Style::default() + if area.width == 0 || area.height == 0 { + return; + } + + let constraints = if area.height >= 3 { + [ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ] + } else { + [ + Constraint::Length(0), + Constraint::Min(1), + Constraint::Length(0), + ] + }; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + if chunks[0].height > 0 { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + self.header_text(), + Style::default() + .fg(theme.title_fg) + .bg(theme.title_bg) + .add_modifier(Modifier::BOLD), + ))), + chunks[0], + ); + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled( + format!(" {} ", self.path.display()), + Style::default() .fg(theme.title_fg) .bg(theme.title_bg) - .add_modifier(ratatui::style::Modifier::BOLD), + .add_modifier(Modifier::BOLD), )); - let inner = block.inner(area); - frame.render_widget(block, area); + let inner = block.inner(chunks[1]); + frame.render_widget(block, chunks[1]); match self.mode { ViewMode::Text => crate::viewer::text::render(self, frame, inner, theme), ViewMode::Hex => hex::render(self, frame, inner, theme), } + + if chunks[2].height > 0 { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + self.footer_text(), + Style::default().fg(theme.hidden).bg(theme.background), + ))), + chunks[2], + ); + } } /// Handle a key event. Returns `true` if the viewer was closed @@ -262,9 +346,23 @@ pub fn open_file(file: &str) -> Result<()> { Ok(()) } +fn format_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes}B") + } else if bytes < 1024 * 1024 { + format!("{:.1}K", bytes as f64 / 1024.0) + } else if bytes < 1024 * 1024 * 1024 { + format!("{:.1}M", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.1}G", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } +} + #[cfg(test)] mod tests { use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; fn make_text(name: &str, data: &[u8]) -> PathBuf { let dir = std::env::temp_dir().join("tlc-viewer-mod-test"); @@ -304,4 +402,24 @@ mod tests { assert_eq!(t.line, 3); assert_eq!(t.offset, 4); } + + #[test] + fn viewer_header_and_footer_include_mode_and_position() { + let p = make_text("chrome.txt", b"a\nb\nc\n"); + let mut v = Viewer::open(&p).unwrap(); + v.cursor = 2; + assert!(v.header_text().contains("Text")); + assert!(v.footer_text().contains("Line")); + } + + #[test] + fn viewer_render_handles_small_terminal() { + let p = make_text("small.txt", b"one\ntwo\n"); + let mut v = Viewer::open(&p).unwrap(); + let backend = TestBackend::new(20, 2); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| v.render(frame, frame.area(), &Theme::by_name("default-dark"))) + .unwrap(); + } } diff --git a/local/recipes/tui/tlc/source/src/viewer/text.rs b/local/recipes/tui/tlc/source/src/viewer/text.rs index 4b6b921a9b..2857127a3c 100644 --- a/local/recipes/tui/tlc/source/src/viewer/text.rs +++ b/local/recipes/tui/tlc/source/src/viewer/text.rs @@ -526,13 +526,20 @@ mod tests { .unwrap(); let buffer = terminal.backend().buffer().clone(); - // "bar" starts at byte offset 4, column 4 of the first line. - // The block has a 1-wide left border. The inner area starts - // at column 1. The gutter (4 spaces, "1", divider "│") takes - // 6 columns inside the block. So in the full buffer, the - // content line begins at column 1 + 6 = 7. The "b" of "bar" - // is at column 7 + 4 = 11. - let cell = buffer.cell((11, 1)).expect("cell at (11, 1) exists"); + // The viewer shell may add header/footer rows, so do not + // hard-code one exact coordinate. Find the rendered 'b' with + // the warning background instead. + let highlighted_b = (0..buffer.area.height) + .flat_map(|y| (0..buffer.area.width).map(move |x| (x, y))) + .find_map(|(x, y)| { + let cell = buffer.cell((x, y))?; + (cell.symbol() == "b" && cell.style().bg == Some(theme.warning)) + .then_some((x, y)) + }) + .expect("highlighted 'b' should exist in rendered buffer"); + let cell = buffer + .cell(highlighted_b) + .expect("highlighted 'b' cell exists"); let style = cell.style(); // The "b" of the highlighted "bar" must have the theme's // warning background; the gutter/foreground styling is @@ -546,14 +553,20 @@ mod tests { // Sanity: the match is exactly 3 bytes wide. The 'a' and // 'r' on the same line must also be highlighted. - let cell_a = buffer.cell((12, 1)).expect("cell at (12, 1) exists"); - let cell_r = buffer.cell((13, 1)).expect("cell at (13, 1) exists"); + let cell_a = buffer + .cell((highlighted_b.0 + 1, highlighted_b.1)) + .expect("cell after highlighted b exists"); + let cell_r = buffer + .cell((highlighted_b.0 + 2, highlighted_b.1)) + .expect("cell after highlighted a exists"); assert_eq!(cell_a.style().bg, Some(theme.warning)); assert_eq!(cell_r.style().bg, Some(theme.warning)); // And the 'f' of "foo" right before "bar" must NOT be // highlighted. - let cell_f = buffer.cell((10, 1)).expect("cell at (10, 1) exists"); + let cell_f = buffer + .cell((highlighted_b.0 - 1, highlighted_b.1)) + .expect("cell before highlighted b exists"); assert_ne!( cell_f.style().bg, Some(theme.warning),