tlc: add screen_list, edit_history, filtered_view dialog modules

This commit is contained in:
2026-06-19 06:34:02 +03:00
parent 28b9621f17
commit 9c5c5733cc
10 changed files with 786 additions and 0 deletions
@@ -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);
}
}