tlc: add screen_list, edit_history, filtered_view dialog modules
This commit is contained in:
@@ -113,3 +113,13 @@ status_key_help: "F1 Hilfe — siehe `tlc help` für die Tastenbelegung"
|
||||
dialog_title_skin: "Skin-Auswahl"
|
||||
dialog_label_skin_current: "Aktuell"
|
||||
dialog_action_skin_apply: "anwenden"
|
||||
dialog_title_external_panelize: "External panelize"
|
||||
dialog_title_vfs_list: "Active VFS list"
|
||||
dialog_title_jobs: "Background jobs"
|
||||
dialog_title_screen_list: "Screen list"
|
||||
dialog_title_edit_history: "View/edit history"
|
||||
dialog_title_filtered_view: "Filtered view"
|
||||
dialog_title_compare_dirs: "Compare directories"
|
||||
vfs_list_empty: "No active VFS connections"
|
||||
status_compare_result: "Compare: %{left} marked in left, %{right} marked in right"
|
||||
status_not_symlink: "Not a symlink"
|
||||
|
||||
@@ -113,3 +113,13 @@ status_key_help: "F1 help — see `tlc help` for the key map"
|
||||
dialog_title_skin: "Skin selection"
|
||||
dialog_label_skin_current: "Current"
|
||||
dialog_action_skin_apply: "apply"
|
||||
dialog_title_external_panelize: "External panelize"
|
||||
dialog_title_vfs_list: "Active VFS list"
|
||||
dialog_title_jobs: "Background jobs"
|
||||
dialog_title_screen_list: "Screen list"
|
||||
dialog_title_edit_history: "View/edit history"
|
||||
dialog_title_filtered_view: "Filtered view"
|
||||
dialog_title_compare_dirs: "Compare directories"
|
||||
vfs_list_empty: "No active VFS connections"
|
||||
status_compare_result: "Compare: %{left} marked in left, %{right} marked in right"
|
||||
status_not_symlink: "Not a symlink"
|
||||
|
||||
@@ -113,3 +113,13 @@ status_key_help: "F1 ayuda — vea `tlc help` para el mapa de teclas"
|
||||
dialog_title_skin: "Selección de skin"
|
||||
dialog_label_skin_current: "Actual"
|
||||
dialog_action_skin_apply: "aplicar"
|
||||
dialog_title_external_panelize: "External panelize"
|
||||
dialog_title_vfs_list: "Active VFS list"
|
||||
dialog_title_jobs: "Background jobs"
|
||||
dialog_title_screen_list: "Screen list"
|
||||
dialog_title_edit_history: "View/edit history"
|
||||
dialog_title_filtered_view: "Filtered view"
|
||||
dialog_title_compare_dirs: "Compare directories"
|
||||
vfs_list_empty: "No active VFS connections"
|
||||
status_compare_result: "Compare: %{left} marked in left, %{right} marked in right"
|
||||
status_not_symlink: "Not a symlink"
|
||||
|
||||
@@ -113,3 +113,13 @@ status_key_help: "F1 aide — voir `tlc help` pour le mappage des touches"
|
||||
dialog_title_skin: "Sélection du skin"
|
||||
dialog_label_skin_current: "Actuel"
|
||||
dialog_action_skin_apply: "appliquer"
|
||||
dialog_title_external_panelize: "External panelize"
|
||||
dialog_title_vfs_list: "Active VFS list"
|
||||
dialog_title_jobs: "Background jobs"
|
||||
dialog_title_screen_list: "Screen list"
|
||||
dialog_title_edit_history: "View/edit history"
|
||||
dialog_title_filtered_view: "Filtered view"
|
||||
dialog_title_compare_dirs: "Compare directories"
|
||||
vfs_list_empty: "No active VFS connections"
|
||||
status_compare_result: "Compare: %{left} marked in left, %{right} marked in right"
|
||||
status_not_symlink: "Not a symlink"
|
||||
|
||||
@@ -113,3 +113,13 @@ status_key_help: "F1 ヘルプ — キーマップは `tlc help` を参照"
|
||||
dialog_title_skin: "スキンの選択"
|
||||
dialog_label_skin_current: "現在"
|
||||
dialog_action_skin_apply: "適用"
|
||||
dialog_title_external_panelize: "External panelize"
|
||||
dialog_title_vfs_list: "Active VFS list"
|
||||
dialog_title_jobs: "Background jobs"
|
||||
dialog_title_screen_list: "Screen list"
|
||||
dialog_title_edit_history: "View/edit history"
|
||||
dialog_title_filtered_view: "Filtered view"
|
||||
dialog_title_compare_dirs: "Compare directories"
|
||||
vfs_list_empty: "No active VFS connections"
|
||||
status_compare_result: "Compare: %{left} marked in left, %{right} marked in right"
|
||||
status_not_symlink: "Not a symlink"
|
||||
|
||||
@@ -113,3 +113,13 @@ status_key_help: "F1 帮助 — 键映射请参阅 `tlc help`"
|
||||
dialog_title_skin: "皮肤选择"
|
||||
dialog_label_skin_current: "当前"
|
||||
dialog_action_skin_apply: "应用"
|
||||
dialog_title_external_panelize: "External panelize"
|
||||
dialog_title_vfs_list: "Active VFS list"
|
||||
dialog_title_jobs: "Background jobs"
|
||||
dialog_title_screen_list: "Screen list"
|
||||
dialog_title_edit_history: "View/edit history"
|
||||
dialog_title_filtered_view: "Filtered view"
|
||||
dialog_title_compare_dirs: "Compare directories"
|
||||
vfs_list_empty: "No active VFS connections"
|
||||
status_compare_result: "Compare: %{left} marked in left, %{right} marked in right"
|
||||
status_not_symlink: "Not a symlink"
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Edit history dialog (Alt-E).
|
||||
//!
|
||||
//! Shows the active panel's directory history as a selectable list.
|
||||
//! Pressing Enter navigates the active panel to the chosen entry;
|
||||
//! Esc closes the dialog. The list is shown most-recent first.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{List, ListItem, 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 a keypress in the edit-history dialog.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditHistoryOutcome {
|
||||
/// Still open; keep processing keys.
|
||||
Running,
|
||||
/// User pressed Enter on a non-empty entry. The dialog supplies
|
||||
/// the chosen path; the dispatcher navigates the active panel.
|
||||
Navigate(PathBuf),
|
||||
/// User pressed Esc — close the dialog and do nothing.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// Edit-history dialog. Wraps a `Vec<PathBuf>` (most-recent first)
|
||||
/// and a cursor.
|
||||
pub struct EditHistoryDialog {
|
||||
/// The history entries, most-recent first.
|
||||
entries: Vec<PathBuf>,
|
||||
/// Cursor row.
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl EditHistoryDialog {
|
||||
/// Build a new dialog from a history vector. The vector is
|
||||
/// reversed so the most-recent entry is at index 0.
|
||||
#[must_use]
|
||||
pub fn from_history(history: Vec<PathBuf>) -> Self {
|
||||
let mut entries = history;
|
||||
entries.reverse();
|
||||
Self {
|
||||
entries,
|
||||
cursor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// All history entries, most-recent first.
|
||||
#[must_use]
|
||||
pub fn entries(&self) -> &[PathBuf] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Number of history entries.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// True if the dialog has nothing to show.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Cursor index.
|
||||
#[must_use]
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
fn move_cursor(&mut self, delta: isize) {
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = self.entries.len() as isize;
|
||||
let mut next = self.cursor as isize + delta;
|
||||
if next < 0 {
|
||||
next = 0;
|
||||
}
|
||||
if next >= n {
|
||||
next = n - 1;
|
||||
}
|
||||
self.cursor = next as usize;
|
||||
}
|
||||
|
||||
/// Handle a key event. Returns the resulting [`EditHistoryOutcome`].
|
||||
pub fn handle_key(&mut self, key: Key) -> EditHistoryOutcome {
|
||||
match key {
|
||||
Key::ESCAPE => EditHistoryOutcome::Cancel,
|
||||
Key::ENTER => self
|
||||
.entries
|
||||
.get(self.cursor)
|
||||
.cloned()
|
||||
.map_or(EditHistoryOutcome::Running, EditHistoryOutcome::Navigate),
|
||||
k if k.code == 0x2191 => {
|
||||
self.move_cursor(-1);
|
||||
EditHistoryOutcome::Running
|
||||
}
|
||||
k if k.code == 0x2193 => {
|
||||
self.move_cursor(1);
|
||||
EditHistoryOutcome::Running
|
||||
}
|
||||
_ => EditHistoryOutcome::Running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the dialog into `frame`, centered on `area`.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let title = "Directory history".to_string();
|
||||
let popup = centered_percent_rect(area, 0.7, 0.6);
|
||||
let inner = render_popup(frame, popup, title, theme);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let header = Line::from(vec![
|
||||
Span::styled("Entries: ", Style::default().fg(theme.hidden)),
|
||||
Span::styled(
|
||||
self.entries.len().to_string(),
|
||||
Style::default()
|
||||
.fg(theme.foreground)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(header), chunks[0]);
|
||||
|
||||
if self.entries.is_empty() {
|
||||
let empty_msg = Line::from(Span::styled(
|
||||
"(no history)",
|
||||
Style::default().fg(theme.hidden),
|
||||
));
|
||||
frame.render_widget(Paragraph::new(empty_msg), chunks[1]);
|
||||
} else {
|
||||
let items: Vec<ListItem> = self
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, path)| {
|
||||
let style = if idx == self.cursor {
|
||||
Style::default()
|
||||
.fg(theme.cursor_fg)
|
||||
.bg(theme.cursor_bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.foreground)
|
||||
};
|
||||
ListItem::new(Span::styled(path.display().to_string(), style))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items);
|
||||
frame.render_widget(list, chunks[1]);
|
||||
}
|
||||
|
||||
let hint = Line::from(vec![
|
||||
Span::styled("Enter", Style::default().fg(theme.warning)),
|
||||
Span::styled(" navigate ", Style::default().fg(theme.hidden)),
|
||||
Span::styled("Esc", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {}", crate::locale::t("dialog_action_cancel")),
|
||||
Style::default().fg(theme.hidden),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_history_reverses_to_most_recent_first() {
|
||||
let h = vec![
|
||||
PathBuf::from("/a"),
|
||||
PathBuf::from("/b"),
|
||||
PathBuf::from("/c"),
|
||||
];
|
||||
let d = EditHistoryDialog::from_history(h);
|
||||
assert_eq!(d.entries()[0], PathBuf::from("/c"));
|
||||
assert_eq!(d.entries()[2], PathBuf::from("/a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dialog_renders_without_panic() {
|
||||
let d = EditHistoryDialog::from_history(Vec::new());
|
||||
assert!(d.is_empty());
|
||||
let backend = ratatui::backend::TestBackend::new(120, 30);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
let theme = crate::terminal::color::DEFAULT_THEME;
|
||||
terminal
|
||||
.draw(|f| d.render(f, f.area(), &theme))
|
||||
.expect("render must not panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_returns_cancel() {
|
||||
let mut d = EditHistoryDialog::from_history(vec![PathBuf::from("/x")]);
|
||||
assert_eq!(d.handle_key(Key::ESCAPE), EditHistoryOutcome::Cancel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_navigates_to_cursor() {
|
||||
let mut d = EditHistoryDialog::from_history(vec![
|
||||
PathBuf::from("/a"),
|
||||
PathBuf::from("/b"),
|
||||
PathBuf::from("/c"),
|
||||
]);
|
||||
assert_eq!(
|
||||
d.handle_key(Key::ENTER),
|
||||
EditHistoryOutcome::Navigate(PathBuf::from("/c"))
|
||||
);
|
||||
d.handle_key(Key {
|
||||
code: 0x2193,
|
||||
mods: crate::key::Modifiers::empty(),
|
||||
});
|
||||
assert_eq!(
|
||||
d.handle_key(Key::ENTER),
|
||||
EditHistoryOutcome::Navigate(PathBuf::from("/b"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Filtered view dialog (Alt-!).
|
||||
//!
|
||||
//! Pipes the cursor file through a user-supplied shell command and
|
||||
//! opens the resulting stdout in the viewer. The command runs via
|
||||
//! `/bin/sh -c` with the file's content on stdin; the captured
|
||||
//! stdout becomes the bytes of an in-memory
|
||||
//! [`crate::viewer::source::FileSource::Inline`] which is then
|
||||
//! handed to [`crate::viewer::Viewer::from_source`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
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};
|
||||
use crate::widget::input::Input;
|
||||
|
||||
/// Outcome of a keypress in the filtered-view dialog.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FilteredViewOutcome {
|
||||
/// Still open; keep processing keys.
|
||||
Running,
|
||||
/// User pressed Enter on a non-empty command. The dialog has
|
||||
/// already run the command; the captured stdout is supplied
|
||||
/// for the caller to open in the viewer.
|
||||
Apply {
|
||||
/// Captured stdout bytes.
|
||||
stdout: Vec<u8>,
|
||||
/// Captured stderr (UTF-8 lossy), surfaced in the status line.
|
||||
stderr: String,
|
||||
/// The path the filter was applied to (for the viewer's title bar).
|
||||
source_path: PathBuf,
|
||||
},
|
||||
/// User pressed Esc — close the dialog and do nothing.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// Filtered-view dialog. Prompts for a shell command, then runs it
|
||||
/// with the cursor file on stdin and reports the captured output.
|
||||
pub struct FilteredViewDialog {
|
||||
/// Command input field.
|
||||
command: Input,
|
||||
/// The path to pipe into the command's stdin.
|
||||
source_path: PathBuf,
|
||||
/// Last error / status to surface to the user.
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl FilteredViewDialog {
|
||||
/// Build a new dialog with `source_path` as the input to the
|
||||
/// future filter.
|
||||
#[must_use]
|
||||
pub fn new(source_path: PathBuf) -> Self {
|
||||
let command = Input::new()
|
||||
.label("Filter command")
|
||||
.placeholder("grep pattern | head -100");
|
||||
Self {
|
||||
command,
|
||||
source_path,
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current command string.
|
||||
#[must_use]
|
||||
pub fn command(&self) -> &str {
|
||||
self.command.value()
|
||||
}
|
||||
|
||||
/// The path that will be piped to the filter.
|
||||
#[must_use]
|
||||
pub fn source_path(&self) -> &PathBuf {
|
||||
&self.source_path
|
||||
}
|
||||
|
||||
/// Last error, if any.
|
||||
#[must_use]
|
||||
pub fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
|
||||
/// Handle a key event. Returns the resulting [`FilteredViewOutcome`].
|
||||
pub fn handle_key(&mut self, key: Key) -> FilteredViewOutcome {
|
||||
match key {
|
||||
Key::ESCAPE => FilteredViewOutcome::Cancel,
|
||||
Key::ENTER => self.run_command(),
|
||||
_ => {
|
||||
let _ = self.command.handle_key(key);
|
||||
FilteredViewOutcome::Running
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_command(&mut self) -> FilteredViewOutcome {
|
||||
let cmd = self.command.value().trim().to_string();
|
||||
if cmd.is_empty() {
|
||||
self.last_error = Some("filter command is empty".to_string());
|
||||
return FilteredViewOutcome::Running;
|
||||
}
|
||||
let input_bytes = match std::fs::read(&self.source_path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
self.last_error = Some(format!("read: {e}"));
|
||||
return FilteredViewOutcome::Running;
|
||||
}
|
||||
};
|
||||
match run_filter(&cmd, &input_bytes) {
|
||||
Ok(out) => {
|
||||
self.last_error = if out.stderr.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("stderr: {}", out.stderr.trim_end()))
|
||||
};
|
||||
FilteredViewOutcome::Apply {
|
||||
stdout: out.stdout,
|
||||
stderr: out.stderr,
|
||||
source_path: self.source_path.clone(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.last_error = Some(format!("spawn: {e}"));
|
||||
FilteredViewOutcome::Running
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the dialog into `frame`, centered on `area`.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let title = "Filtered view".to_string();
|
||||
let popup = centered_percent_rect(area, 0.7, 0.4);
|
||||
let inner = render_popup(frame, popup, title, theme);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Length(3), // input
|
||||
Constraint::Length(1), // hint
|
||||
Constraint::Min(2), // status
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let header = Line::from(vec![
|
||||
Span::styled("Pipe: ", Style::default().fg(theme.hidden)),
|
||||
Span::styled(
|
||||
self.source_path.display().to_string(),
|
||||
Style::default().fg(theme.foreground),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(header), chunks[0]);
|
||||
|
||||
let value = self.command.value().to_string();
|
||||
let mut input = Input::new()
|
||||
.label("Filter command")
|
||||
.placeholder("grep pattern | head -100");
|
||||
if !value.is_empty() {
|
||||
input = input.text(value);
|
||||
}
|
||||
input = input.focused();
|
||||
input.render(frame, chunks[1], theme);
|
||||
|
||||
let hint = Line::from(vec![
|
||||
Span::styled("Enter", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {} ", crate::locale::t("dialog_action_select")),
|
||||
Style::default().fg(theme.hidden),
|
||||
),
|
||||
Span::styled("Esc", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {}", crate::locale::t("dialog_action_cancel")),
|
||||
Style::default().fg(theme.hidden),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]);
|
||||
|
||||
let status_text = self.last_error.as_deref().unwrap_or("");
|
||||
let status_color = if self.last_error.is_some() {
|
||||
theme.error
|
||||
} else {
|
||||
theme.hidden
|
||||
};
|
||||
let body = Paragraph::new(Line::from(Span::styled(
|
||||
status_text.to_string(),
|
||||
Style::default()
|
||||
.fg(status_color)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
)))
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(body, chunks[3]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Captured output of a filter invocation.
|
||||
struct CapturedOutput {
|
||||
/// Captured stdout bytes.
|
||||
stdout: Vec<u8>,
|
||||
/// Stderr decoded as UTF-8 (lossy).
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
fn run_filter(cmd: &str, input: &[u8]) -> std::io::Result<CapturedOutput> {
|
||||
let mut child = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use std::io::Write;
|
||||
// Best-effort: ignore broken-pipe errors because some
|
||||
// filters (e.g. `head`) close stdin early.
|
||||
let _ = stdin.write_all(input);
|
||||
}
|
||||
let output = child.wait_with_output()?;
|
||||
Ok(CapturedOutput {
|
||||
stdout: output.stdout,
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dialog_new_has_empty_command() {
|
||||
let d = FilteredViewDialog::new(PathBuf::from("/x"));
|
||||
assert_eq!(d.command(), "");
|
||||
assert_eq!(d.source_path(), &PathBuf::from("/x"));
|
||||
assert!(d.last_error().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_returns_cancel() {
|
||||
let mut d = FilteredViewDialog::new(PathBuf::from("/x"));
|
||||
assert_eq!(d.handle_key(Key::ESCAPE), FilteredViewOutcome::Cancel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_empty_command_records_error() {
|
||||
let mut d = FilteredViewDialog::new(PathBuf::from("/x"));
|
||||
assert_eq!(d.handle_key(Key::ENTER), FilteredViewOutcome::Running);
|
||||
assert_eq!(d.last_error(), Some("filter command is empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_command_runs_and_captures_stdout() {
|
||||
let dir = std::env::temp_dir().join("tlc-fv-test");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let src = dir.join("input.txt");
|
||||
std::fs::write(&src, b"hello\nworld\nfoo\nbar\n").unwrap();
|
||||
let mut d = FilteredViewDialog::new(src.clone());
|
||||
for c in "grep foo".chars() {
|
||||
d.handle_key(Key::from_char(c));
|
||||
}
|
||||
match d.handle_key(Key::ENTER) {
|
||||
FilteredViewOutcome::Apply { stdout, stderr, source_path } => {
|
||||
assert_eq!(source_path, src);
|
||||
let text = String::from_utf8_lossy(&stdout).into_owned();
|
||||
assert!(text.contains("foo"), "stdout = {text:?}");
|
||||
assert!(stderr.is_empty());
|
||||
}
|
||||
other => panic!("expected Apply, got {other:?}"),
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_render_does_not_panic() {
|
||||
let d = FilteredViewDialog::new(PathBuf::from("/x/y.rs"));
|
||||
let backend = ratatui::backend::TestBackend::new(120, 30);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
let theme = crate::terminal::color::DEFAULT_THEME;
|
||||
terminal
|
||||
.draw(|f| d.render(f, f.area(), &theme))
|
||||
.expect("render must not panic");
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ pub mod confirm_dialog;
|
||||
pub mod connection_manager;
|
||||
pub mod copy_dialog;
|
||||
pub mod delete_dialog;
|
||||
pub mod edit_history;
|
||||
pub mod exec;
|
||||
pub mod external_panelize;
|
||||
pub mod filtered_view;
|
||||
pub mod find;
|
||||
pub mod help;
|
||||
pub mod hotlist;
|
||||
@@ -32,6 +34,7 @@ pub mod permission;
|
||||
pub mod quit_dialog;
|
||||
pub mod quickcd_dialog;
|
||||
pub mod rename;
|
||||
pub mod screen_list;
|
||||
pub mod skin_dialog;
|
||||
pub mod sort_dialog;
|
||||
pub mod tree;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Screen list dialog (Alt-`).
|
||||
//!
|
||||
//! Shows the currently active virtual "screens" — full-screen
|
||||
//! overlays that the user has open on top of the panel pair. In TLC's
|
||||
//! architecture the set of possible overlays is fixed:
|
||||
//!
|
||||
//! 1. The full-screen editor (F4).
|
||||
//! 2. The full-screen viewer (F3).
|
||||
//! 3. The exec-output dialog (`start_exec` foreground command).
|
||||
//! 4. The F9 menu bar.
|
||||
//! 5. Any active modal dialog (copy/move/delete/...).
|
||||
//!
|
||||
//! The dialog is read-only: the user can only dismiss it with Esc.
|
||||
//! This matches MC's `Alt-`` "Screen list" dialog, which in MC also
|
||||
//! drives focus-switching between screens; TLC keeps the dialog
|
||||
//! minimal because the overlays are dismissed via their own hotkeys
|
||||
//! (Esc, F10, etc.) anyway.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{List, ListItem, 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 a keypress in the screen-list dialog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScreenListOutcome {
|
||||
/// Still open; keep processing keys.
|
||||
Running,
|
||||
/// User pressed Esc — close the dialog.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// One currently-active screen.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ActiveScreen {
|
||||
/// Short label, e.g. `"Editor"`, `"Viewer"`, `"Exec"`.
|
||||
pub label: &'static str,
|
||||
/// Optional extra detail, e.g. the path being edited. Rendered
|
||||
/// after the label.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl ActiveScreen {
|
||||
/// Build a screen row from a label and detail string.
|
||||
#[must_use]
|
||||
pub fn new(label: &'static str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen-list dialog.
|
||||
pub struct ScreenListDialog {
|
||||
/// Currently-active screens. Empty when no overlay is open.
|
||||
screens: Vec<ActiveScreen>,
|
||||
}
|
||||
|
||||
impl ScreenListDialog {
|
||||
/// Create a new dialog from the active-screen snapshot.
|
||||
#[must_use]
|
||||
pub fn with_screens(screens: Vec<ActiveScreen>) -> Self {
|
||||
Self { screens }
|
||||
}
|
||||
|
||||
/// All currently-active screens.
|
||||
#[must_use]
|
||||
pub fn screens(&self) -> &[ActiveScreen] {
|
||||
&self.screens
|
||||
}
|
||||
|
||||
/// True if no screens are active.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.screens.is_empty()
|
||||
}
|
||||
|
||||
/// Number of active screens.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.screens.len()
|
||||
}
|
||||
|
||||
/// Handle a key event. Returns the resulting [`ScreenListOutcome`].
|
||||
pub fn handle_key(&mut self, key: Key) -> ScreenListOutcome {
|
||||
match key {
|
||||
Key::ESCAPE => ScreenListOutcome::Cancel,
|
||||
_ => ScreenListOutcome::Running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the dialog into `frame`, centered on `area`.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let title = "Active screens".to_string();
|
||||
let popup = centered_percent_rect(area, 0.6, 0.5);
|
||||
let inner = render_popup(frame, popup, title, theme);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let header = Line::from(vec![
|
||||
Span::styled("Active: ", Style::default().fg(theme.hidden)),
|
||||
Span::styled(
|
||||
self.screens.len().to_string(),
|
||||
Style::default()
|
||||
.fg(theme.foreground)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(header), chunks[0]);
|
||||
|
||||
if self.screens.is_empty() {
|
||||
let empty_msg = Line::from(Span::styled(
|
||||
"(no active screens)",
|
||||
Style::default().fg(theme.hidden),
|
||||
));
|
||||
frame.render_widget(Paragraph::new(empty_msg), chunks[1]);
|
||||
} else {
|
||||
let items: Vec<ListItem> = self
|
||||
.screens
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let display = if s.detail.is_empty() {
|
||||
s.label.to_string()
|
||||
} else {
|
||||
format!("{}: {}", s.label, s.detail)
|
||||
};
|
||||
ListItem::new(Span::styled(
|
||||
display,
|
||||
Style::default().fg(theme.foreground),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items);
|
||||
frame.render_widget(list, chunks[1]);
|
||||
}
|
||||
|
||||
let hint = Line::from(vec![
|
||||
Span::styled("Esc", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {}", crate::locale::t("dialog_action_cancel")),
|
||||
Style::default().fg(theme.hidden),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_dialog_renders_without_panic() {
|
||||
let d = ScreenListDialog::with_screens(Vec::new());
|
||||
assert!(d.is_empty());
|
||||
let backend = ratatui::backend::TestBackend::new(120, 30);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
let theme = crate::terminal::color::DEFAULT_THEME;
|
||||
terminal
|
||||
.draw(|f| d.render(f, f.area(), &theme))
|
||||
.expect("render must not panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populated_dialog_renders_without_panic() {
|
||||
let d = ScreenListDialog::with_screens(vec![
|
||||
ActiveScreen::new("Editor", "/tmp/foo.rs"),
|
||||
ActiveScreen::new("Viewer", "/tmp/bar.log"),
|
||||
]);
|
||||
assert!(!d.is_empty());
|
||||
assert_eq!(d.len(), 2);
|
||||
let backend = ratatui::backend::TestBackend::new(120, 30);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
let theme = crate::terminal::color::DEFAULT_THEME;
|
||||
terminal
|
||||
.draw(|f| d.render(f, f.area(), &theme))
|
||||
.expect("render must not panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_returns_cancel() {
|
||||
let mut d = ScreenListDialog::with_screens(Vec::new());
|
||||
assert_eq!(d.handle_key(Key::ESCAPE), ScreenListOutcome::Cancel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_keys_return_running() {
|
||||
let mut d = ScreenListDialog::with_screens(Vec::new());
|
||||
assert_eq!(d.handle_key(Key::ENTER), ScreenListOutcome::Running);
|
||||
assert_eq!(d.handle_key(Key::from_char('q')), ScreenListOutcome::Running);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user