Skip to main content

alimentar/cli/
view.rs

1//! TUI view commands for interactive dataset viewing.
2
3use std::{io::Write, path::Path};
4
5use super::basic::load_dataset;
6use crate::tui::{DatasetAdapter, DatasetViewer};
7
8/// Interactive TUI viewer for datasets.
9pub(crate) fn cmd_view(path: &Path, initial_search: Option<&str>) -> crate::Result<()> {
10    use std::io::stdout;
11
12    use crossterm::{cursor, execute, terminal};
13
14    let dataset = load_dataset(path)?;
15    let adapter = DatasetAdapter::from_dataset(&dataset)
16        .map_err(|e| crate::Error::storage(format!("TUI adapter error: {e}")))?;
17
18    // Get terminal size
19    let (width, height) = terminal::size().unwrap_or((80, 24));
20    let mut viewer = DatasetViewer::with_dimensions(adapter, width, height.saturating_sub(2));
21
22    // Apply initial search if provided
23    if let Some(query) = initial_search {
24        viewer.search(query);
25    }
26
27    // Enter raw mode for keyboard input
28    terminal::enable_raw_mode()
29        .map_err(|e| crate::Error::storage(format!("Terminal error: {e}")))?;
30
31    let mut stdout = stdout();
32
33    // Hide cursor
34    execute!(stdout, cursor::Hide)
35        .map_err(|e| crate::Error::storage(format!("Terminal error: {e}")))?;
36
37    let result = run_tui_loop(&mut viewer, &mut stdout, path);
38
39    // Cleanup: restore terminal — log failures to avoid corrupted terminal state
40    if execute!(stdout, cursor::Show).is_err() {
41        eprintln!("Warning: failed to restore cursor visibility");
42    }
43    if terminal::disable_raw_mode().is_err() {
44        eprintln!("Warning: failed to disable raw mode — run 'reset' to fix terminal");
45    }
46
47    result
48}
49
50/// Render the TUI frame (title bar, data lines, status bar).
51fn render_frame<W: Write>(
52    viewer: &DatasetViewer,
53    stdout: &mut W,
54    path: &std::path::Path,
55) -> crate::Result<()> {
56    use crossterm::{
57        cursor, execute,
58        style::{Attribute, Print, SetAttribute},
59        terminal::{self, Clear, ClearType},
60    };
61
62    execute!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0)).map_err(term_err)?;
63
64    let (width, _) = terminal::size().unwrap_or((80, 24));
65    let title = format!(
66        " {} | {} rows | {}",
67        path.file_name().unwrap_or_default().to_string_lossy(),
68        viewer.row_count(),
69        if viewer.adapter().is_streaming() {
70            "Streaming"
71        } else {
72            "InMemory"
73        }
74    );
75    execute!(
76        stdout,
77        SetAttribute(Attribute::Reverse),
78        Print(format!("{:width$}", title, width = width as usize)),
79        SetAttribute(Attribute::Reset),
80        Print("\r\n")
81    )
82    .map_err(term_err)?;
83
84    for line in viewer.render_lines() {
85        execute!(stdout, Print(&line), Print("\r\n")).map_err(term_err)?;
86    }
87
88    let status = format!(
89        " Row {}-{} of {} | {} scroll | PgUp/PgDn page | Home/End | /search | q quit ",
90        viewer.scroll_offset() + 1,
91        (viewer.scroll_offset() + viewer.visible_row_count() as usize).min(viewer.row_count()),
92        viewer.row_count(),
93        "\u{2191}\u{2193}"
94    );
95    execute!(
96        stdout,
97        SetAttribute(Attribute::Reverse),
98        Print(format!("{:width$}", status, width = width as usize)),
99        SetAttribute(Attribute::Reset)
100    )
101    .map_err(term_err)?;
102
103    stdout.flush().map_err(term_err)?;
104    Ok(())
105}
106
107/// Handle a key event, returning true if the loop should break.
108fn handle_key_input<W: Write>(
109    viewer: &mut DatasetViewer,
110    stdout: &mut W,
111    key: crossterm::event::KeyEvent,
112) -> crate::Result<bool> {
113    use crossterm::event::{KeyCode, KeyModifiers};
114
115    match key.code {
116        KeyCode::Char('q') | KeyCode::Esc => return Ok(true),
117        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return Ok(true),
118        KeyCode::Down | KeyCode::Char('j') => viewer.scroll_down(),
119        KeyCode::Up | KeyCode::Char('k') => viewer.scroll_up(),
120        KeyCode::PageDown | KeyCode::Char(' ') => viewer.page_down(),
121        KeyCode::PageUp => viewer.page_up(),
122        KeyCode::Home | KeyCode::Char('g') => viewer.home(),
123        KeyCode::End | KeyCode::Char('G') => viewer.end(),
124        KeyCode::Char('/') => {
125            if let Some(query) = prompt_search(stdout)? {
126                viewer.search(&query);
127            }
128        }
129        _ => {}
130    }
131    Ok(false)
132}
133
134/// Run the TUI event loop.
135pub(crate) fn run_tui_loop<W: Write>(
136    viewer: &mut DatasetViewer,
137    stdout: &mut W,
138    path: &std::path::Path,
139) -> crate::Result<()> {
140    use crossterm::event::{self, Event};
141
142    loop {
143        render_frame(viewer, stdout, path)?;
144
145        if event::poll(std::time::Duration::from_millis(100)).map_err(term_err)? {
146            if let Event::Key(key) = event::read().map_err(term_err)? {
147                if handle_key_input(viewer, stdout, key)? {
148                    break;
149                }
150            }
151
152            if let Event::Resize(w, h) = event::read().map_err(term_err)? {
153                viewer.set_dimensions(w, h.saturating_sub(2));
154            }
155        }
156    }
157
158    Ok(())
159}
160
161/// Convert a crossterm error to a crate terminal error
162fn term_err(e: impl std::fmt::Display) -> crate::Error {
163    crate::Error::storage(format!("Terminal error: {e}"))
164}
165
166/// Prompt for search query.
167pub(crate) fn prompt_search<W: Write>(stdout: &mut W) -> crate::Result<Option<String>> {
168    use crossterm::{
169        cursor,
170        event::{self, Event, KeyCode},
171        execute,
172        style::Print,
173        terminal::{self, Clear, ClearType},
174    };
175
176    let (_, height) = terminal::size().unwrap_or((80, 24));
177
178    // Move to bottom and show prompt
179    execute!(
180        stdout,
181        cursor::MoveTo(0, height - 1),
182        Clear(ClearType::CurrentLine),
183        cursor::Show,
184        Print("Search: ")
185    )
186    .map_err(term_err)?;
187    stdout.flush().map_err(term_err)?;
188
189    let mut query = String::new();
190
191    loop {
192        if let Event::Key(key) = event::read().map_err(term_err)? {
193            match key.code {
194                KeyCode::Enter => {
195                    execute!(stdout, cursor::Hide).map_err(term_err)?;
196                    return Ok(if query.is_empty() { None } else { Some(query) });
197                }
198                KeyCode::Esc => {
199                    execute!(stdout, cursor::Hide).map_err(term_err)?;
200                    return Ok(None);
201                }
202                KeyCode::Backspace => {
203                    query.pop();
204                    execute!(
205                        stdout,
206                        cursor::MoveTo(8, height - 1),
207                        Clear(ClearType::UntilNewLine),
208                        Print(&query)
209                    )
210                    .map_err(term_err)?;
211                }
212                KeyCode::Char(c) => {
213                    query.push(c);
214                    execute!(stdout, Print(c)).map_err(term_err)?;
215                }
216                _ => {}
217            }
218            stdout.flush().map_err(term_err)?;
219        }
220    }
221}