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/// Outcome of a single key press inside the search prompt.
167enum PromptStep {
168    /// Finish the prompt and return this optional query.
169    Finish(Option<String>),
170    /// Keep looping.
171    Continue,
172}
173
174/// Prompt for search query.
175pub(crate) fn prompt_search<W: Write>(stdout: &mut W) -> crate::Result<Option<String>> {
176    use crossterm::event::{self, Event};
177
178    let height = show_search_prompt(stdout)?;
179    let mut query = String::new();
180
181    loop {
182        if let Event::Key(key) = event::read().map_err(term_err)? {
183            if let PromptStep::Finish(result) =
184                handle_prompt_key(stdout, key.code, &mut query, height)?
185            {
186                return Ok(result);
187            }
188            stdout.flush().map_err(term_err)?;
189        }
190    }
191}
192
193fn show_search_prompt<W: Write>(stdout: &mut W) -> crate::Result<u16> {
194    use crossterm::{
195        cursor, execute,
196        style::Print,
197        terminal::{self, Clear, ClearType},
198    };
199
200    let (_, height) = terminal::size().unwrap_or((80, 24));
201    execute!(
202        stdout,
203        cursor::MoveTo(0, height - 1),
204        Clear(ClearType::CurrentLine),
205        cursor::Show,
206        Print("Search: ")
207    )
208    .map_err(term_err)?;
209    stdout.flush().map_err(term_err)?;
210    Ok(height)
211}
212
213fn handle_prompt_key<W: Write>(
214    stdout: &mut W,
215    code: crossterm::event::KeyCode,
216    query: &mut String,
217    height: u16,
218) -> crate::Result<PromptStep> {
219    use crossterm::{cursor, event::KeyCode, execute};
220
221    match code {
222        KeyCode::Enter => {
223            execute!(stdout, cursor::Hide).map_err(term_err)?;
224            let out = if query.is_empty() {
225                None
226            } else {
227                Some(std::mem::take(query))
228            };
229            Ok(PromptStep::Finish(out))
230        }
231        KeyCode::Esc => {
232            execute!(stdout, cursor::Hide).map_err(term_err)?;
233            Ok(PromptStep::Finish(None))
234        }
235        KeyCode::Backspace => {
236            query.pop();
237            redraw_query(stdout, query, height)?;
238            Ok(PromptStep::Continue)
239        }
240        KeyCode::Char(c) => {
241            query.push(c);
242            execute!(stdout, crossterm::style::Print(c)).map_err(term_err)?;
243            Ok(PromptStep::Continue)
244        }
245        _ => Ok(PromptStep::Continue),
246    }
247}
248
249fn redraw_query<W: Write>(stdout: &mut W, query: &str, height: u16) -> crate::Result<()> {
250    use crossterm::{
251        cursor, execute,
252        style::Print,
253        terminal::{Clear, ClearType},
254    };
255    execute!(
256        stdout,
257        cursor::MoveTo(8, height - 1),
258        Clear(ClearType::UntilNewLine),
259        Print(query)
260    )
261    .map_err(term_err)
262}