tui/tlc: restore recovered UI and input work

This commit is contained in:
2026-06-18 15:26:30 +03:00
parent cb8b093564
commit dc9465fc1e
25 changed files with 835 additions and 460 deletions
@@ -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"
+130 -5
View File
@@ -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<F>(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::<Vec<_>>()
.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);
}
}
+4 -20
View File
@@ -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<u64>) -> anyhow::Result<()> {
@@ -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::*;
@@ -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::*;
@@ -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::*;
@@ -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::*;
@@ -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,
@@ -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::*;
@@ -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::*;
@@ -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::*;
@@ -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);
@@ -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::*;
@@ -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::*;
@@ -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<ListItem> = (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");
@@ -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::*;
@@ -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 {
@@ -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)
@@ -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)
@@ -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::*;
@@ -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
@@ -8,6 +8,7 @@
pub mod color;
pub mod event;
pub mod popup;
pub mod status;
use std::io::{self, Write};
@@ -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<String>, 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);
}
+132 -14
View File
@@ -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();
}
}
+23 -10
View File
@@ -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),