diff --git a/local/recipes/tui/tlc/source/locales/de.yml b/local/recipes/tui/tlc/source/locales/de.yml index 6e7954ee57..6ee5ef2301 100644 --- a/local/recipes/tui/tlc/source/locales/de.yml +++ b/local/recipes/tui/tlc/source/locales/de.yml @@ -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" diff --git a/local/recipes/tui/tlc/source/locales/en.yml b/local/recipes/tui/tlc/source/locales/en.yml index 0018245f14..97d0c29af9 100644 --- a/local/recipes/tui/tlc/source/locales/en.yml +++ b/local/recipes/tui/tlc/source/locales/en.yml @@ -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" diff --git a/local/recipes/tui/tlc/source/locales/es.yml b/local/recipes/tui/tlc/source/locales/es.yml index 3e54e36a90..781abba56b 100644 --- a/local/recipes/tui/tlc/source/locales/es.yml +++ b/local/recipes/tui/tlc/source/locales/es.yml @@ -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" diff --git a/local/recipes/tui/tlc/source/locales/fr.yml b/local/recipes/tui/tlc/source/locales/fr.yml index 53a7995a7f..f627ac433d 100644 --- a/local/recipes/tui/tlc/source/locales/fr.yml +++ b/local/recipes/tui/tlc/source/locales/fr.yml @@ -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" diff --git a/local/recipes/tui/tlc/source/locales/ja.yml b/local/recipes/tui/tlc/source/locales/ja.yml index 34743e99e2..c278da2a7e 100644 --- a/local/recipes/tui/tlc/source/locales/ja.yml +++ b/local/recipes/tui/tlc/source/locales/ja.yml @@ -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" diff --git a/local/recipes/tui/tlc/source/locales/zh-CN.yml b/local/recipes/tui/tlc/source/locales/zh-CN.yml index 5b49e9e8be..9cb91ce0eb 100644 --- a/local/recipes/tui/tlc/source/locales/zh-CN.yml +++ b/local/recipes/tui/tlc/source/locales/zh-CN.yml @@ -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" diff --git a/local/recipes/tui/tlc/source/src/filemanager/edit_history.rs b/local/recipes/tui/tlc/source/src/filemanager/edit_history.rs new file mode 100644 index 0000000000..5cf81c8ba8 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/edit_history.rs @@ -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` (most-recent first) +/// and a cursor. +pub struct EditHistoryDialog { + /// The history entries, most-recent first. + entries: Vec, + /// 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) -> 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 = 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")) + ); + } +} diff --git a/local/recipes/tui/tlc/source/src/filemanager/filtered_view.rs b/local/recipes/tui/tlc/source/src/filemanager/filtered_view.rs new file mode 100644 index 0000000000..b8d0eca192 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/filtered_view.rs @@ -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, + /// 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, +} + +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, + /// Stderr decoded as UTF-8 (lossy). + stderr: String, +} + +fn run_filter(cmd: &str, input: &[u8]) -> std::io::Result { + 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"); + } +} diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index 2087a72277..fa4a007c3e 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -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; diff --git a/local/recipes/tui/tlc/source/src/filemanager/screen_list.rs b/local/recipes/tui/tlc/source/src/filemanager/screen_list.rs new file mode 100644 index 0000000000..491f561499 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/screen_list.rs @@ -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) -> Self { + Self { + label, + detail: detail.into(), + } + } +} + +/// Screen-list dialog. +pub struct ScreenListDialog { + /// Currently-active screens. Empty when no overlay is open. + screens: Vec, +} + +impl ScreenListDialog { + /// Create a new dialog from the active-screen snapshot. + #[must_use] + pub fn with_screens(screens: Vec) -> 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 = 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); + } +}