tui/tlc: restore recovered UI and input work
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user