Skip to main content

vortex_tui/browse/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Interactive TUI browser for Vortex files.
5
6use app::AppState;
7use app::KeyMode;
8use app::Tab;
9use input::InputEvent;
10use input::InputKeyCode;
11use vortex::error::VortexExpect;
12use vortex::layout::layouts::flat::Flat;
13
14pub mod app;
15pub(crate) mod input;
16pub mod ui;
17
18/// Scroll amount for single-line navigation (up/down arrows).
19const SCROLL_LINE: usize = 1;
20/// Scroll amount for page navigation (PageUp/PageDown).
21const SCROLL_PAGE: usize = 10;
22/// Scroll amount for segment grid line navigation.
23const SEGMENT_SCROLL_LINE: usize = 10;
24/// Scroll amount for segment grid page navigation.
25const SEGMENT_SCROLL_PAGE: usize = 100;
26/// Scroll amount for segment grid horizontal step.
27const SEGMENT_SCROLL_HORIZONTAL_STEP: usize = 20;
28/// Scroll amount for segment grid horizontal jump (Home/End).
29const SEGMENT_SCROLL_HORIZONTAL_JUMP: usize = 200;
30
31pub(crate) enum HandleResult {
32    Continue,
33    Exit,
34}
35
36/// Navigate the layout list up by the given amount.
37fn navigate_layout_up(app: &mut AppState, amount: usize) {
38    let amount_u16 = amount.try_into().unwrap_or(u16::MAX);
39    if app.cursor.layout().is::<Flat>() {
40        app.tree_scroll_offset = app.tree_scroll_offset.saturating_sub(amount_u16);
41    } else {
42        app.layouts_list_state.scroll_up_by(amount_u16);
43    }
44}
45
46/// Navigate the layout list down by the given amount.
47fn navigate_layout_down(app: &mut AppState, amount: usize) {
48    let amount_u16 = amount.try_into().unwrap_or(u16::MAX);
49    if app.cursor.layout().is::<Flat>() {
50        app.tree_scroll_offset = app.tree_scroll_offset.saturating_add(amount_u16);
51    } else {
52        app.layouts_list_state.scroll_down_by(amount_u16);
53    }
54}
55
56/// Handle a key event in normal input mode.
57///
58/// Returns [`HandleResult::Exit`] if the user pressed the quit key.
59#[allow(clippy::cognitive_complexity)]
60pub(crate) fn handle_normal_mode(app: &mut AppState, event: InputEvent) -> HandleResult {
61    // Check if we're in Query tab with SQL input focus - handle text input first
62    #[cfg(feature = "native")]
63    {
64        use ui::QueryFocus;
65        use ui::SortDirection;
66
67        let in_sql_input =
68            app.current_tab == Tab::Query && app.query_state.focus == QueryFocus::SqlInput;
69
70        if in_sql_input {
71            match (&event.code, event.ctrl, event.alt, event.shift) {
72                (InputKeyCode::Tab, ..) => {
73                    app.current_tab = Tab::Layout;
74                }
75                (InputKeyCode::Esc, ..) => {
76                    app.query_state.toggle_focus();
77                }
78                (InputKeyCode::Enter, ..) => {
79                    app.query_state.sort_column = None;
80                    app.query_state.sort_direction = SortDirection::None;
81                    app.query_state.prepare_initial_query();
82                    app.query_state.focus = QueryFocus::ResultsTable;
83                }
84                (InputKeyCode::Left, ..) => app.query_state.move_cursor_left(),
85                (InputKeyCode::Right, ..) => app.query_state.move_cursor_right(),
86                (InputKeyCode::Home, ..) => app.query_state.move_cursor_start(),
87                (InputKeyCode::End, ..) => app.query_state.move_cursor_end(),
88                (InputKeyCode::Char('a'), true, ..) => app.query_state.move_cursor_start(),
89                (InputKeyCode::Char('e'), true, ..) => app.query_state.move_cursor_end(),
90                (InputKeyCode::Char('u'), true, ..) => app.query_state.clear_input(),
91                (InputKeyCode::Char('b'), true, ..) => app.query_state.move_cursor_left(),
92                (InputKeyCode::Char('f'), true, ..) => app.query_state.move_cursor_right(),
93                (InputKeyCode::Char('d'), true, ..) => app.query_state.delete_char_forward(),
94                (InputKeyCode::Backspace, ..) => app.query_state.delete_char(),
95                (InputKeyCode::Delete, ..) => app.query_state.delete_char_forward(),
96                (InputKeyCode::Char(c), false, false, _) => {
97                    app.query_state.insert_char(*c);
98                }
99                _ => {}
100            }
101            return HandleResult::Continue;
102        }
103    }
104
105    match (&event.code, event.ctrl, event.alt, event.shift) {
106        (InputKeyCode::Char('q'), ..) => {
107            return HandleResult::Exit;
108        }
109        (InputKeyCode::Tab, ..) => {
110            app.current_tab = match app.current_tab {
111                Tab::Layout => Tab::Segments,
112                #[cfg(feature = "native")]
113                Tab::Segments => Tab::Query,
114                #[cfg(feature = "native")]
115                Tab::Query => Tab::Layout,
116                #[cfg(not(feature = "native"))]
117                Tab::Segments => Tab::Layout,
118            };
119        }
120
121        #[cfg(feature = "native")]
122        (InputKeyCode::Char('['), false, false, _) => {
123            if app.current_tab == Tab::Query {
124                app.query_state.prepare_prev_page();
125            }
126        }
127
128        #[cfg(feature = "native")]
129        (InputKeyCode::Char(']'), false, false, _) => {
130            if app.current_tab == Tab::Query {
131                app.query_state.prepare_next_page();
132            }
133        }
134
135        (InputKeyCode::Up, ..)
136        | (InputKeyCode::Char('k'), false, false, _)
137        | (InputKeyCode::Char('p'), true, ..) => match app.current_tab {
138            Tab::Layout => navigate_layout_up(app, SCROLL_LINE),
139            Tab::Segments => app.segment_grid_state.scroll_up(SEGMENT_SCROLL_LINE),
140            #[cfg(feature = "native")]
141            Tab::Query => {
142                app.query_state.table_state.select_previous();
143            }
144        },
145        (InputKeyCode::Down, ..)
146        | (InputKeyCode::Char('j'), false, false, _)
147        | (InputKeyCode::Char('n'), true, ..) => match app.current_tab {
148            Tab::Layout => navigate_layout_down(app, SCROLL_LINE),
149            Tab::Segments => app.segment_grid_state.scroll_down(SEGMENT_SCROLL_LINE),
150            #[cfg(feature = "native")]
151            Tab::Query => {
152                app.query_state.table_state.select_next();
153            }
154        },
155        (InputKeyCode::PageUp, ..) | (InputKeyCode::Char('v'), _, true, _) => {
156            match app.current_tab {
157                Tab::Layout => navigate_layout_up(app, SCROLL_PAGE),
158                Tab::Segments => app.segment_grid_state.scroll_up(SEGMENT_SCROLL_PAGE),
159                #[cfg(feature = "native")]
160                Tab::Query => {
161                    app.query_state.prepare_prev_page();
162                }
163            }
164        }
165        (InputKeyCode::PageDown, ..) | (InputKeyCode::Char('v'), true, ..) => {
166            match app.current_tab {
167                Tab::Layout => navigate_layout_down(app, SCROLL_PAGE),
168                Tab::Segments => app.segment_grid_state.scroll_down(SEGMENT_SCROLL_PAGE),
169                #[cfg(feature = "native")]
170                Tab::Query => {
171                    app.query_state.prepare_next_page();
172                }
173            }
174        }
175        (InputKeyCode::Home, ..) | (InputKeyCode::Char('<'), _, true, _) => match app.current_tab {
176            Tab::Layout => app.layouts_list_state.select_first(),
177            Tab::Segments => app
178                .segment_grid_state
179                .scroll_left(SEGMENT_SCROLL_HORIZONTAL_JUMP),
180            #[cfg(feature = "native")]
181            Tab::Query => {
182                app.query_state.table_state.select_first();
183            }
184        },
185        (InputKeyCode::End, ..) | (InputKeyCode::Char('>'), _, true, _) => match app.current_tab {
186            Tab::Layout => app.layouts_list_state.select_last(),
187            Tab::Segments => app
188                .segment_grid_state
189                .scroll_right(SEGMENT_SCROLL_HORIZONTAL_JUMP),
190            #[cfg(feature = "native")]
191            Tab::Query => {
192                app.query_state.table_state.select_last();
193            }
194        },
195        (InputKeyCode::Enter, ..) => {
196            if app.current_tab == Tab::Layout && app.cursor.layout().nchildren() > 0 {
197                let selected = app.layouts_list_state.selected().unwrap_or_default();
198                app.cursor = app.cursor.child(selected);
199                app.reset_layout_view_state();
200            }
201        }
202        (InputKeyCode::Left, ..)
203        | (InputKeyCode::Char('h'), false, false, _)
204        | (InputKeyCode::Char('b'), true, ..) => match app.current_tab {
205            Tab::Layout => {
206                app.cursor = app.cursor.parent();
207                app.reset_layout_view_state();
208            }
209            Tab::Segments => app
210                .segment_grid_state
211                .scroll_left(SEGMENT_SCROLL_HORIZONTAL_STEP),
212            #[cfg(feature = "native")]
213            Tab::Query => {
214                app.query_state.horizontal_scroll =
215                    app.query_state.horizontal_scroll.saturating_sub(1);
216            }
217        },
218        (InputKeyCode::Right, ..)
219        | (InputKeyCode::Char('l'), false, false, _)
220        | (InputKeyCode::Char('b'), _, true, _) => match app.current_tab {
221            Tab::Layout => {}
222            Tab::Segments => app
223                .segment_grid_state
224                .scroll_right(SEGMENT_SCROLL_HORIZONTAL_STEP),
225            #[cfg(feature = "native")]
226            Tab::Query => {
227                let max_col = app.query_state.column_count().saturating_sub(1);
228                if app.query_state.horizontal_scroll < max_col {
229                    app.query_state.horizontal_scroll += 1;
230                }
231            }
232        },
233
234        (InputKeyCode::Char('/'), ..) | (InputKeyCode::Char('s'), true, ..) => {
235            #[cfg(feature = "native")]
236            if app.current_tab == Tab::Query {
237                // Don't enter search mode from query tab
238            } else {
239                app.key_mode = KeyMode::Search;
240            }
241            #[cfg(not(feature = "native"))]
242            {
243                app.key_mode = KeyMode::Search;
244            }
245        }
246
247        #[cfg(feature = "native")]
248        (InputKeyCode::Char('s'), false, false, _) => {
249            if app.current_tab == Tab::Query {
250                let col = app.query_state.selected_column();
251                app.query_state.prepare_sort(col);
252            }
253        }
254
255        #[cfg(feature = "native")]
256        (InputKeyCode::Esc, ..) => {
257            if app.current_tab == Tab::Query {
258                app.query_state.toggle_focus();
259            }
260        }
261
262        _ => {}
263    }
264
265    HandleResult::Continue
266}
267
268/// Handle a key event in search mode.
269pub(crate) fn handle_search_mode(app: &mut AppState, event: InputEvent) -> HandleResult {
270    match (&event.code, event.ctrl, event.alt, event.shift) {
271        (InputKeyCode::Esc, ..) | (InputKeyCode::Char('g'), true, ..) => {
272            app.key_mode = KeyMode::Normal;
273            app.clear_search();
274        }
275
276        (InputKeyCode::Up, ..) | (InputKeyCode::Char('p'), true, ..) => {
277            if app.current_tab == Tab::Layout {
278                navigate_layout_up(app, SCROLL_LINE);
279            }
280        }
281        (InputKeyCode::Down, ..) | (InputKeyCode::Char('n'), true, ..) => {
282            if app.current_tab == Tab::Layout {
283                navigate_layout_down(app, SCROLL_LINE);
284            }
285        }
286        (InputKeyCode::PageUp, ..) | (InputKeyCode::Char('v'), _, true, _) => {
287            if app.current_tab == Tab::Layout {
288                navigate_layout_up(app, SCROLL_PAGE);
289            }
290        }
291        (InputKeyCode::PageDown, ..) | (InputKeyCode::Char('v'), true, ..) => {
292            if app.current_tab == Tab::Layout {
293                navigate_layout_down(app, SCROLL_PAGE);
294            }
295        }
296        (InputKeyCode::Home, ..) | (InputKeyCode::Char('<'), _, true, _) => {
297            if app.current_tab == Tab::Layout {
298                app.layouts_list_state.select_first();
299            }
300        }
301        (InputKeyCode::End, ..) | (InputKeyCode::Char('>'), _, true, _) => {
302            if app.current_tab == Tab::Layout {
303                app.layouts_list_state.select_last();
304            }
305        }
306
307        (InputKeyCode::Enter, ..) => {
308            if app.current_tab == Tab::Layout
309                && app.cursor.layout().nchildren() > 0
310                && let Some(selected) = app.layouts_list_state.selected()
311            {
312                app.cursor = match app.filter.as_ref() {
313                    None => app.cursor.child(selected),
314                    Some(filter) => {
315                        let child_idx = filter
316                            .iter()
317                            .enumerate()
318                            .filter_map(|(idx, show)| show.then_some(idx))
319                            .nth(selected)
320                            .vortex_expect("There must be a selected item in the filter");
321
322                        app.cursor.child(child_idx)
323                    }
324                };
325
326                app.reset_layout_view_state();
327                app.clear_search();
328                app.key_mode = KeyMode::Normal;
329            }
330        }
331
332        (InputKeyCode::Backspace, ..) | (InputKeyCode::Char('h'), true, ..) => {
333            app.search_filter.pop();
334        }
335
336        (InputKeyCode::Char(c), false, false, _) => {
337            app.layouts_list_state.select_first();
338            app.search_filter.push(*c);
339        }
340
341        _ => {}
342    }
343
344    HandleResult::Continue
345}
346
347// --- Native-only crossterm event loop ---
348
349#[cfg(feature = "native")]
350mod native {
351    use crossterm::event::Event;
352    use crossterm::event::EventStream;
353    use crossterm::event::KeyEventKind;
354    use futures::StreamExt;
355    use ratatui::DefaultTerminal;
356    use vortex::error::VortexResult;
357    use vortex::session::VortexSession;
358
359    use super::ui::render_app;
360    use super::*;
361
362    async fn run(mut terminal: DefaultTerminal, mut app: AppState) -> VortexResult<()> {
363        // Eagerly load data if the initial layout is flat.
364        if app.cursor.layout().is::<Flat>() {
365            app.load_flat_data().await;
366        }
367
368        let mut events = EventStream::new();
369        loop {
370            terminal.draw(|frame| render_app(&mut app, frame))?;
371
372            // Take the pending query receiver so we can select! on it
373            // without holding a mutable borrow on app.
374            let pending_rx = app.query_state.pending_rx.take();
375
376            let event = if let Some(mut rx) = pending_rx {
377                tokio::select! {
378                    event = events.next() => {
379                        // No query result yet — put the receiver back.
380                        app.query_state.pending_rx = Some(rx);
381                        event
382                    }
383                    result = &mut rx => {
384                        if let Ok(result) = result {
385                            app.query_state.apply_query_result(result);
386                        }
387                        // Re-render immediately to show updated results.
388                        continue;
389                    }
390                }
391            } else {
392                events.next().await
393            };
394
395            let Some(raw_event) = event else {
396                break;
397            };
398            let raw_event = raw_event?;
399
400            if let Event::Key(key) = raw_event {
401                if key.kind != KeyEventKind::Press {
402                    continue;
403                }
404
405                let input = InputEvent::from(key);
406                let result = match app.key_mode {
407                    KeyMode::Normal => handle_normal_mode(&mut app, input),
408                    KeyMode::Search => handle_search_mode(&mut app, input),
409                };
410
411                if matches!(result, HandleResult::Exit) {
412                    return Ok(());
413                }
414
415                // After handling, load flat data if we navigated to a FlatLayout.
416                if app.cursor.layout().is::<Flat>() && app.cached_flat_array.is_none() {
417                    app.load_flat_data().await;
418                }
419
420                // Spawn any pending query execution as a background task.
421                app.query_state.spawn_pending(&app.session, &app.file_path);
422            }
423        }
424        Ok(())
425    }
426
427    /// Launch the interactive TUI browser for a Vortex file.
428    ///
429    /// # Errors
430    ///
431    /// Returns an error if the file cannot be opened or if there's a terminal I/O error.
432    pub async fn exec_tui(
433        session: &VortexSession,
434        file: impl AsRef<std::path::Path>,
435    ) -> VortexResult<()> {
436        let app = AppState::new(session, file).await?;
437
438        let mut terminal = ratatui::init();
439        terminal.clear()?;
440
441        run(terminal, app).await?;
442
443        ratatui::restore();
444        Ok(())
445    }
446}
447
448#[cfg(feature = "native")]
449pub use native::exec_tui;