From 35330a5e5f651272ec2692c4e2069807ec806fc4 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 17 Dec 2025 15:50:16 -0500 Subject: [PATCH] make `vortex-tui` a library Signed-off-by: Connor Tsui --- vortex-tui/Cargo.toml | 6 +- vortex-tui/src/browse/app.rs | 162 +++++++++++++++------- vortex-tui/src/browse/mod.rs | 194 +++++++++++++-------------- vortex-tui/src/browse/ui/layouts.rs | 9 +- vortex-tui/src/browse/ui/mod.rs | 8 ++ vortex-tui/src/browse/ui/segments.rs | 123 ++++++++++------- vortex-tui/src/convert.rs | 40 ++++-- vortex-tui/src/inspect.rs | 33 +++-- vortex-tui/src/lib.rs | 106 +++++++++++++++ vortex-tui/src/main.rs | 83 +----------- vortex-tui/src/tree.rs | 32 +++-- 11 files changed, 478 insertions(+), 318 deletions(-) create mode 100644 vortex-tui/src/lib.rs diff --git a/vortex-tui/Cargo.toml b/vortex-tui/Cargo.toml index 01b3553acf2..dbe705de271 100644 --- a/vortex-tui/Cargo.toml +++ b/vortex-tui/Cargo.toml @@ -2,7 +2,7 @@ name = "vortex-tui" authors = { workspace = true } categories = { workspace = true } -description = "a small but might tool for working with Vortex files" +description = "a small but mighty tool for working with Vortex files" edition = { workspace = true } homepage = { workspace = true } include = { workspace = true } @@ -33,6 +33,10 @@ vortex = { workspace = true, features = ["tokio"] } [lints] workspace = true +[lib] +name = "vortex_tui" +path = "src/lib.rs" + [[bin]] name = "vx" path = "src/main.rs" diff --git a/vortex-tui/src/browse/app.rs b/vortex-tui/src/browse/app.rs index 38b8ab64da7..790f067c620 100644 --- a/vortex-tui/src/browse/app.rs +++ b/vortex-tui/src/browse/app.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +//! Application state and data structures for the TUI browser. + use std::path::Path; use std::sync::Arc; @@ -22,25 +24,31 @@ use vortex::layout::layouts::flat::FlatVTable; use vortex::layout::layouts::zoned::ZonedVTable; use vortex::layout::segments::SegmentId; use vortex::layout::segments::SegmentSource; +use vortex::session::VortexSession; -use crate::SESSION; -use crate::browse::ui::SegmentGridState; +use super::ui::SegmentGridState; +/// The currently active tab in the TUI browser. #[derive(Default, Copy, Clone, Eq, PartialEq)] pub enum Tab { - /// The layout tree browser. + /// The layout tree browser tab. + /// + /// Shows the hierarchical structure of layouts in the Vortex file and allows navigation + /// through the layout tree. #[default] Layout, - /// Show a segment map of the file + /// The segment map tab. + /// + /// Displays a visual representation of how segments are laid out in the file. Segments, - // TODO(aduffy): SQL query page powered by DF - // Query, } -/// A pointer into the `Layout` hierarchy that can be advanced. +/// A navigable pointer into the layout hierarchy of a Vortex file. /// -/// The pointer wraps an InitialRead. +/// The cursor maintains the current position within the layout tree and provides methods to +/// navigate up and down the hierarchy. It also provides access to layout metadata and segment +/// information at the current position. pub struct LayoutCursor { path: Vec, footer: Footer, @@ -50,6 +58,7 @@ pub struct LayoutCursor { } impl LayoutCursor { + /// Create a new cursor pointing at the root layout. pub fn new(footer: Footer, segment_source: Arc) -> Self { Self { path: Vec::new(), @@ -60,6 +69,9 @@ impl LayoutCursor { } } + /// Create a new cursor at a specific path within the layout tree. + /// + /// The path is a sequence of child indices to traverse from the root. pub fn new_with_path( footer: Footer, segment_source: Arc, @@ -83,8 +95,7 @@ impl LayoutCursor { } } - /// Create a new LayoutCursor indexing into the n-th child of the layout at the current - /// cursor position. + /// Create a new cursor pointing at the n-th child of the current layout. pub fn child(&self, n: usize) -> Self { let mut path = self.path.clone(); path.push(n); @@ -92,6 +103,9 @@ impl LayoutCursor { Self::new_with_path(self.footer.clone(), self.segment_source.clone(), path) } + /// Create a new cursor pointing at the parent of the current layout. + /// + /// If already at the root, returns a cursor pointing at the root. pub fn parent(&self) -> Self { let mut path = self.path.clone(); path.pop(); @@ -101,7 +115,9 @@ impl LayoutCursor { /// Get the size of the array flatbuffer for this layout. /// - /// NOTE: this is only safe to run against a FLAT layout. + /// # Panics + /// + /// Panics if the current layout is not a [`FlatVTable`] layout. pub fn flatbuffer_size(&self) -> usize { let segment_id = self.layout.as_::().segment_id(); let segment = block_on(self.segment_source.request(segment_id)).vortex_unwrap(); @@ -111,18 +127,18 @@ impl LayoutCursor { .len() } - /// Get information about the flat layout metadata. + /// Get a human-readable description of the flat layout metadata. + /// + /// # Panics /// - /// NOTE: this is only safe to run against a FLAT layout. + /// Panics if the current layout is not a [`FlatVTable`] layout. pub fn flat_layout_metadata_info(&self) -> String { let flat_layout = self.layout.as_::(); let metadata = FlatVTable::metadata(flat_layout); - // Check if array_encoding_tree is present and get its size match metadata.0.array_encoding_tree.as_ref() { Some(tree) => { let size = tree.len(); - // Truncate to a single line - show the size and presence format!( "Flat Metadata: array_encoding_tree present ({} bytes)", size @@ -132,6 +148,7 @@ impl LayoutCursor { } } + /// Get the total size in bytes of all segments reachable from this layout. pub fn total_size(&self) -> usize { self.layout_segments() .iter() @@ -147,83 +164,138 @@ impl LayoutCursor { .collect() } - /// Predicate true when the cursor is currently activated over a stats table + /// Returns `true` if the cursor is currently pointing at a statistics table. + /// + /// A statistics table is the second child of a [`ZonedVTable`] layout. pub fn is_stats_table(&self) -> bool { let parent = self.parent(); parent.layout().is::() && self.path.last().copied().unwrap_or_default() == 1 } + /// Get the data type of the current layout. pub fn dtype(&self) -> &DType { self.layout.dtype() } + /// Get a reference to the current layout. pub fn layout(&self) -> &LayoutRef { &self.layout } + /// Get the segment specification for a given segment ID. pub fn segment_spec(&self, id: SegmentId) -> &SegmentSpec { &self.segment_map[*id as usize] } } +/// The current input mode of the TUI. +/// +/// Different modes change how keyboard input is interpreted. #[derive(Default, PartialEq, Eq)] pub enum KeyMode { - /// Normal mode. + /// Normal navigation mode. /// - /// The default mode of the TUI when you start it up. Allows for browsing through layout hierarchies. + /// The default mode when the TUI starts. Allows browsing through the layout hierarchy using + /// arrow keys, vim-style navigation (`h`/`j`/`k`/`l`), and various shortcuts. #[default] Normal, - /// Searching mode. + + /// Search/filter mode. /// - /// Triggered by a user when entering `/`, subsequent key presses will be used to craft a live-updating filter - /// of the current input element. + /// Activated by pressing `/` or `Ctrl-S`. In this mode, key presses are used to build a fuzzy + /// search filter that narrows down the displayed layout children. Press `Esc` or `Ctrl-G` to + /// exit search mode. Search, } -/// State saved across all Tabs. +/// The complete application state for the TUI browser. /// -/// Holding them all allows us to switch between tabs without resetting view state. +/// This struct holds all state needed to render and interact with the TUI, including: +/// - The Vortex session and file being browsed +/// - Navigation state (current cursor position, selected tab) +/// - Input mode and search filter state +/// - UI state for lists and grids +/// +/// The state is preserved when switching between tabs, allowing users to return to their previous +/// position. pub struct AppState<'a> { + /// The Vortex session used to read array data during rendering. + pub session: &'a VortexSession, + + /// The current input mode (normal navigation or search). pub key_mode: KeyMode, + + /// The current search filter string (only used in search mode). pub search_filter: String, + + /// A boolean mask indicating which children match the current search filter. + /// + /// `None` when no filter is active, `Some(vec)` when filtering where `vec[i]` indicates + /// whether child `i` should be shown. pub filter: Option>, + /// The open Vortex file being browsed. pub vxf: VortexFile, + + /// The current position in the layout hierarchy. pub cursor: LayoutCursor, + + /// The currently selected tab. pub current_tab: Tab, - /// List state for the Layouts view + /// Selection state for the layout children list. pub layouts_list_state: ListState, + + /// State for the segment grid display. pub segment_grid_state: SegmentGridState<'a>, + + /// The size of the last rendered frame. pub frame_size: Size, - /// Scroll offset for the encoding tree display in FlatLayout view + /// Vertical scroll offset for the encoding tree display in flat layout view. pub tree_scroll_offset: u16, } -impl AppState<'_> { +impl<'a> AppState<'a> { + /// Create a new application state by opening a Vortex file. + /// + /// # Errors + /// + /// Returns an error if the file cannot be opened or read. + pub async fn new( + session: &'a VortexSession, + path: impl AsRef, + ) -> VortexResult> { + let vxf = session.open_options().open(path.as_ref()).await?; + + let cursor = LayoutCursor::new(vxf.footer().clone(), vxf.segment_source()); + + Ok(AppState { + session, + vxf, + cursor, + key_mode: KeyMode::default(), + search_filter: String::new(), + filter: None, + current_tab: Tab::default(), + layouts_list_state: ListState::default().with_selected(Some(0)), + segment_grid_state: SegmentGridState::default(), + frame_size: Size::new(0, 0), + tree_scroll_offset: 0, + }) + } + + /// Clear the current search filter and return to showing all children. pub fn clear_search(&mut self) { self.search_filter.clear(); self.filter.take(); } -} -/// Create an app backed from a file path. -pub async fn create_file_app<'a>(path: impl AsRef) -> VortexResult> { - let vxf = SESSION.open_options().open(path.as_ref()).await?; - - let cursor = LayoutCursor::new(vxf.footer().clone(), vxf.segment_source()); - - Ok(AppState { - vxf, - cursor, - key_mode: KeyMode::default(), - search_filter: String::new(), - filter: None, - current_tab: Tab::default(), - layouts_list_state: ListState::default().with_selected(Some(0)), - segment_grid_state: SegmentGridState::default(), - frame_size: Size::new(0, 0), - tree_scroll_offset: 0, - }) + /// Reset the layout view state after navigating to a different layout. + /// + /// This resets the list selection to the first item and clears any scroll offset. + pub fn reset_layout_view_state(&mut self) { + self.layouts_list_state = ListState::default().with_selected(Some(0)); + self.tree_scroll_offset = 0; + } } diff --git a/vortex-tui/src/browse/mod.rs b/vortex-tui/src/browse/mod.rs index 2523a2e7577..63299af6b3e 100644 --- a/vortex-tui/src/browse/mod.rs +++ b/vortex-tui/src/browse/mod.rs @@ -1,26 +1,40 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +//! Interactive TUI browser for Vortex files. + use std::path::Path; use app::AppState; use app::KeyMode; use app::Tab; -use app::create_file_app; +use crossterm::event; use crossterm::event::Event; use crossterm::event::KeyCode; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; -use crossterm::event::{self}; use ratatui::DefaultTerminal; -use ratatui::widgets::ListState; use ui::render_app; use vortex::error::VortexExpect; use vortex::error::VortexResult; use vortex::layout::layouts::flat::FlatVTable; +use vortex::session::VortexSession; + +pub mod app; +pub mod ui; -mod app; -mod ui; +/// Scroll amount for single-line navigation (up/down arrows). +const SCROLL_LINE: usize = 1; +/// Scroll amount for page navigation (PageUp/PageDown). +const SCROLL_PAGE: usize = 10; +/// Scroll amount for segment grid line navigation. +const SEGMENT_SCROLL_LINE: usize = 10; +/// Scroll amount for segment grid page navigation. +const SEGMENT_SCROLL_PAGE: usize = 100; +/// Scroll amount for segment grid horizontal step. +const SEGMENT_SCROLL_HORIZONTAL_STEP: usize = 20; +/// Scroll amount for segment grid horizontal jump (Home/End). +const SEGMENT_SCROLL_HORIZONTAL_JUMP: usize = 200; // Use the VortexResult and potentially launch a Backtrace. async fn run(mut terminal: DefaultTerminal, mut app: AppState<'_>) -> VortexResult<()> { @@ -47,107 +61,99 @@ enum HandleResult { Exit, } +/// Navigate the layout list up by the given amount. +fn navigate_layout_up(app: &mut AppState, amount: usize) { + let amount_u16 = amount.try_into().unwrap_or(u16::MAX); + if app.cursor.layout().is::() { + app.tree_scroll_offset = app.tree_scroll_offset.saturating_sub(amount_u16); + } else { + app.layouts_list_state.scroll_up_by(amount_u16); + } +} + +/// Navigate the layout list down by the given amount. +fn navigate_layout_down(app: &mut AppState, amount: usize) { + let amount_u16 = amount.try_into().unwrap_or(u16::MAX); + if app.cursor.layout().is::() { + app.tree_scroll_offset = app.tree_scroll_offset.saturating_add(amount_u16); + } else { + app.layouts_list_state.scroll_down_by(amount_u16); + } +} + fn handle_normal_mode(app: &mut AppState, event: Event) -> HandleResult { if let Event::Key(key) = event && key.kind == KeyEventKind::Press { match (key.code, key.modifiers) { (KeyCode::Char('q'), _) => { - // Close the process down. return HandleResult::Exit; } (KeyCode::Tab, _) => { - // toggle between tabs app.current_tab = match app.current_tab { Tab::Layout => Tab::Segments, Tab::Segments => Tab::Layout, }; } (KeyCode::Up | KeyCode::Char('k'), _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { - // We send the key-up to the list state if we're looking at - // the Layouts tab. match app.current_tab { - Tab::Layout => { - if app.cursor.layout().is::() { - app.tree_scroll_offset = app.tree_scroll_offset.saturating_sub(1); - } else { - app.layouts_list_state.select_previous(); - } - } - Tab::Segments => app.segment_grid_state.scroll_up(10), + Tab::Layout => navigate_layout_up(app, SCROLL_LINE), + Tab::Segments => app.segment_grid_state.scroll_up(SEGMENT_SCROLL_LINE), } } (KeyCode::Down | KeyCode::Char('j'), _) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => match app.current_tab { - Tab::Layout => { - if app.cursor.layout().is::() { - app.tree_scroll_offset = app.tree_scroll_offset.saturating_add(1); - } else { - app.layouts_list_state.select_next(); - } - } - Tab::Segments => app.segment_grid_state.scroll_down(10), + Tab::Layout => navigate_layout_down(app, SCROLL_LINE), + Tab::Segments => app.segment_grid_state.scroll_down(SEGMENT_SCROLL_LINE), }, (KeyCode::PageUp, _) | (KeyCode::Char('v'), KeyModifiers::ALT) => { match app.current_tab { - Tab::Layout => { - if app.cursor.layout().is::() { - app.tree_scroll_offset = app.tree_scroll_offset.saturating_sub(10); - } else { - app.layouts_list_state.scroll_up_by(10); - } - } - Tab::Segments => app.segment_grid_state.scroll_up(100), + Tab::Layout => navigate_layout_up(app, SCROLL_PAGE), + Tab::Segments => app.segment_grid_state.scroll_up(SEGMENT_SCROLL_PAGE), } } (KeyCode::PageDown, _) | (KeyCode::Char('v'), KeyModifiers::CONTROL) => { match app.current_tab { - Tab::Layout => { - if app.cursor.layout().is::() { - app.tree_scroll_offset = app.tree_scroll_offset.saturating_add(10); - } else { - app.layouts_list_state.scroll_down_by(10); - } - } - Tab::Segments => app.segment_grid_state.scroll_down(100), + Tab::Layout => navigate_layout_down(app, SCROLL_PAGE), + Tab::Segments => app.segment_grid_state.scroll_down(SEGMENT_SCROLL_PAGE), } } (KeyCode::Home, _) | (KeyCode::Char('<'), KeyModifiers::ALT) => match app.current_tab { Tab::Layout => app.layouts_list_state.select_first(), - Tab::Segments => app.segment_grid_state.scroll_left(200), + Tab::Segments => app + .segment_grid_state + .scroll_left(SEGMENT_SCROLL_HORIZONTAL_JUMP), }, (KeyCode::End, _) | (KeyCode::Char('>'), KeyModifiers::ALT) => match app.current_tab { Tab::Layout => app.layouts_list_state.select_last(), - Tab::Segments => app.segment_grid_state.scroll_right(200), + Tab::Segments => app + .segment_grid_state + .scroll_right(SEGMENT_SCROLL_HORIZONTAL_JUMP), }, (KeyCode::Enter, _) => { if app.current_tab == Tab::Layout && app.cursor.layout().nchildren() > 0 { // Descend into the layout subtree for the selected child. let selected = app.layouts_list_state.selected().unwrap_or_default(); app.cursor = app.cursor.child(selected); - - // Reset the list scroll state and tree scroll offset. - app.layouts_list_state = ListState::default().with_selected(Some(0)); - app.tree_scroll_offset = 0; + app.reset_layout_view_state(); } } (KeyCode::Left | KeyCode::Char('h'), _) - | (KeyCode::Char('b'), KeyModifiers::CONTROL) => { - match app.current_tab { - Tab::Layout => { - // Ascend back up to the Parent node - app.cursor = app.cursor.parent(); - // Reset the list scroll state and tree scroll offset. - app.layouts_list_state = ListState::default().with_selected(Some(0)); - app.tree_scroll_offset = 0; - } - Tab::Segments => app.segment_grid_state.scroll_left(20), + | (KeyCode::Char('b'), KeyModifiers::CONTROL) => match app.current_tab { + Tab::Layout => { + app.cursor = app.cursor.parent(); + app.reset_layout_view_state(); } - } + Tab::Segments => app + .segment_grid_state + .scroll_left(SEGMENT_SCROLL_HORIZONTAL_STEP), + }, (KeyCode::Right | KeyCode::Char('l'), _) | (KeyCode::Char('b'), KeyModifiers::ALT) => { match app.current_tab { Tab::Layout => {} - Tab::Segments => app.segment_grid_state.scroll_right(20), + Tab::Segments => app + .segment_grid_state + .scroll_right(SEGMENT_SCROLL_HORIZONTAL_STEP), } } @@ -155,7 +161,6 @@ fn handle_normal_mode(app: &mut AppState, event: Event) -> HandleResult { app.key_mode = KeyMode::Search; } - // Most events not handled _ => {} } } @@ -167,33 +172,28 @@ fn handle_search_mode(app: &mut AppState, event: Event) -> HandleResult { if let Event::Key(key) = event { match (key.code, key.modifiers) { (KeyCode::Esc, _) | (KeyCode::Char('g'), KeyModifiers::CONTROL) => { - // Exit search mode. - // Kill the search bar and search filtering and return to normal input processing. app.key_mode = KeyMode::Normal; app.clear_search(); } - // Use same navigation as Normal mode (KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { - // We send the key-up to the list state if we're looking at - // the Layouts tab. if app.current_tab == Tab::Layout { - app.layouts_list_state.scroll_up_by(1); + navigate_layout_up(app, SCROLL_LINE); } } (KeyCode::Down, _) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { if app.current_tab == Tab::Layout { - app.layouts_list_state.scroll_down_by(1); + navigate_layout_down(app, SCROLL_LINE); } } (KeyCode::PageUp, _) | (KeyCode::Char('v'), KeyModifiers::ALT) => { if app.current_tab == Tab::Layout { - app.layouts_list_state.scroll_up_by(10); + navigate_layout_up(app, SCROLL_PAGE); } } (KeyCode::PageDown, _) | (KeyCode::Char('v'), KeyModifiers::CONTROL) => { if app.current_tab == Tab::Layout { - app.layouts_list_state.scroll_down_by(10); + navigate_layout_down(app, SCROLL_PAGE); } } (KeyCode::Home, _) | (KeyCode::Char('<'), KeyModifiers::ALT) => { @@ -208,33 +208,27 @@ fn handle_search_mode(app: &mut AppState, event: Event) -> HandleResult { } (KeyCode::Enter, _) => { - // Change back to normal mode. - // We can eliminate the search filter when we do this - if app.current_tab == Tab::Layout && app.cursor.layout().nchildren() > 0 { - // Descend into the layout subtree for the selected child, do nothing if there's nothing to select. - if let Some(selected) = app.layouts_list_state.selected() { - app.cursor = match app.filter.as_ref() { - None => app.cursor.child(selected), - Some(filter) => { - let child_idx = filter - .iter() - .enumerate() - .filter_map(|(idx, show)| show.then_some(idx)) - .nth(selected) - .vortex_expect("There must be a selected item in the filter"); + if app.current_tab == Tab::Layout + && app.cursor.layout().nchildren() > 0 + && let Some(selected) = app.layouts_list_state.selected() + { + app.cursor = match app.filter.as_ref() { + None => app.cursor.child(selected), + Some(filter) => { + let child_idx = filter + .iter() + .enumerate() + .filter_map(|(idx, show)| show.then_some(idx)) + .nth(selected) + .vortex_expect("There must be a selected item in the filter"); - app.cursor.child(child_idx) - } - }; - - // Reset the list scroll state and tree scroll offset. - app.layouts_list_state = ListState::default().with_selected(Some(0)); - app.tree_scroll_offset = 0; + app.cursor.child(child_idx) + } + }; - app.clear_search(); - // Return to normal mode. - app.key_mode = KeyMode::Normal; - } + app.reset_layout_view_state(); + app.clear_search(); + app.key_mode = KeyMode::Normal; } } @@ -243,13 +237,10 @@ fn handle_search_mode(app: &mut AppState, event: Event) -> HandleResult { } (KeyCode::Char(c), _) => { - // reset selection state app.layouts_list_state.select_first(); - // append to our search string app.search_filter.push(c); } - // Most events unhandled. _ => {} } } @@ -260,8 +251,13 @@ fn handle_search_mode(app: &mut AppState, event: Event) -> HandleResult { // TODO: add tui_logger and have a logs tab so we can see the log output from // doing Vortex things. -pub async fn exec_tui(file: impl AsRef) -> VortexResult<()> { - let app = create_file_app(file).await?; +/// Launch the interactive TUI browser for a Vortex file. +/// +/// # Errors +/// +/// Returns an error if the file cannot be opened or if there's a terminal I/O error. +pub async fn exec_tui(session: &VortexSession, file: impl AsRef) -> VortexResult<()> { + let app = AppState::new(session, file).await?; let mut terminal = ratatui::init(); terminal.clear()?; diff --git a/vortex-tui/src/browse/ui/layouts.rs b/vortex-tui/src/browse/ui/layouts.rs index 110d3eb70ac..1e51eb7a804 100644 --- a/vortex-tui/src/browse/ui/layouts.rs +++ b/vortex-tui/src/browse/ui/layouts.rs @@ -37,7 +37,6 @@ use vortex::expr::root; use vortex::layout::layouts::flat::FlatVTable; use vortex::layout::layouts::zoned::ZonedVTable; -use crate::SESSION; use crate::browse::app::AppState; use crate::browse::app::LayoutCursor; @@ -114,13 +113,13 @@ fn render_layout_header(cursor: &LayoutCursor, area: Rect, buf: &mut Buffer) { Widget::render(List::new(rows), inner_area, buf); } -/// Render the inner Array for a FlatLayout +/// Render the inner Array for a FlatLayout. fn render_array(app: &AppState<'_>, area: Rect, buf: &mut Buffer, is_stats_table: bool) { let row_count = app.cursor.layout().row_count(); let reader = app .cursor .layout() - .new_reader("".into(), app.vxf.segment_source(), &SESSION) + .new_reader("".into(), app.vxf.segment_source(), app.session) .vortex_expect("Failed to create reader"); // FIXME(ngates): our TUI app should never perform I/O in the render loop... @@ -241,7 +240,7 @@ fn render_array(app: &AppState<'_>, area: Rect, buf: &mut Buffer, is_stats_table fn render_children_list(app: &mut AppState, area: Rect, buf: &mut Buffer) { // TODO: add selection state. - let search_filter = app.search_filter.clone(); + let search_filter = &app.search_filter; let layout = app.cursor.layout(); if layout.nchildren() > 0 { @@ -264,7 +263,7 @@ fn render_children_list(app: &mut AppState, area: Rect, buf: &mut Buffer) { .enumerate() .filter_map(|(idx, name)| { matcher - .fuzzy_match(&name, &search_filter) + .fuzzy_match(&name, search_filter) .map(|_| (idx, name.to_string())) }) .collect_vec(); diff --git a/vortex-tui/src/browse/ui/mod.rs b/vortex-tui/src/browse/ui/mod.rs index cbe30878ec6..e639a756b66 100644 --- a/vortex-tui/src/browse/ui/mod.rs +++ b/vortex-tui/src/browse/ui/mod.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +//! UI rendering components for the TUI browser. + mod layouts; mod segments; @@ -17,6 +19,12 @@ use super::app::KeyMode; use super::app::Tab; use crate::browse::ui::segments::segments_ui; +/// Render the complete TUI application to the given frame. +/// +/// This is the main entry point for rendering. It draws: +/// - The outer border with title and help text +/// - The tab bar showing available views +/// - The content area for the currently selected tab pub fn render_app(app: &mut AppState<'_>, frame: &mut Frame<'_>) { // Render the outer tab view, then render the inner frame view. let bottom_text = if app.key_mode == KeyMode::Search { diff --git a/vortex-tui/src/browse/ui/segments.rs b/vortex-tui/src/browse/ui/segments.rs index abed64388b5..5b5b3bec802 100644 --- a/vortex-tui/src/browse/ui/segments.rs +++ b/vortex-tui/src/browse/ui/segments.rs @@ -40,24 +40,44 @@ use vortex::utils::aliases::hash_map::HashMap; use crate::browse::app::AppState; +/// State for the segment grid visualization. +/// +/// This struct manages the layout tree and scroll state for displaying segments in a grid view. +/// The segment tree is lazily computed on first render and cached for subsequent frames. #[derive(Debug, Clone, Default)] pub struct SegmentGridState<'a> { - /// state for the segment grid layout + /// The computed layout tree for the segment grid, or `None` if not yet computed. + /// + /// Contains the taffy layout tree, root node ID, and a map of node contents. pub segment_tree: Option<(TaffyTree<()>, NodeId, HashMap>)>, + + /// State for the horizontal scrollbar widget. pub horizontal_scroll_state: ScrollbarState, + + /// State for the vertical scrollbar widget. pub vertical_scroll_state: ScrollbarState, + + /// Current vertical scroll position in pixels. pub vertical_scroll: usize, + + /// Current horizontal scroll position in pixels. pub horizontal_scroll: usize, + + /// Maximum horizontal scroll position. pub max_horizontal_scroll: usize, + + /// Maximum vertical scroll position. pub max_vertical_scroll: usize, } impl SegmentGridState<'_> { + /// Scroll the viewport up by the given amount. pub fn scroll_up(&mut self, amount: usize) { self.vertical_scroll = self.vertical_scroll.saturating_sub(amount); self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll); } + /// Scroll the viewport down by the given amount. pub fn scroll_down(&mut self, amount: usize) { self.vertical_scroll = self .vertical_scroll @@ -66,6 +86,7 @@ impl SegmentGridState<'_> { self.vertical_scroll_state = self.vertical_scroll_state.position(self.vertical_scroll); } + /// Scroll the viewport left by the given amount. pub fn scroll_left(&mut self, amount: usize) { self.horizontal_scroll = self.horizontal_scroll.saturating_sub(amount); self.horizontal_scroll_state = self @@ -73,6 +94,7 @@ impl SegmentGridState<'_> { .position(self.horizontal_scroll); } + /// Scroll the viewport right by the given amount. pub fn scroll_right(&mut self, amount: usize) { self.horizontal_scroll = self .horizontal_scroll @@ -291,55 +313,56 @@ fn to_display_segment_tree<'a>( .get_mut(&name) .vortex_expect("Must have segment for name"); chunks.sort_by(|a, b| a.spec.offset.cmp(&b.spec.offset)); - let leaves = chunks - .iter() - .scan(0u64, |current_offset, segment| { - let node_id = tree - .new_leaf(Style { - min_size: Size { - width: Dimension::percent(1.0), - height: Dimension::length(7.0), - }, - size: Size { - width: Dimension::percent(1.0), - height: Dimension::length(15.0), - }, - ..Default::default() - }) - .expect("Fail to create leaf node"); - node_contents.insert( - node_id, - NodeContents { - title: segment.name.clone(), - contents: vec![ - Line::raw(format!( - "Rows: {}..{} ({})", - segment.row_offset, - segment.row_offset + segment.row_count, - segment.row_count - )), - Line::raw(format!( - "Bytes: {}..{} ({})", - segment.spec.offset, - segment.spec.offset + segment.spec.length as u64, - humansize::format_size(segment.spec.length, DECIMAL), - )), - Line::raw(format!("Align: {}", segment.spec.alignment)), - Line::raw(format!( - "Byte gap: {}", - if *current_offset == 0 { - 0 - } else { - segment.spec.offset - *current_offset - } - )), - ], - }, - ); - *current_offset = segment.spec.length as u64 + segment.spec.offset; - Some(node_id) - }) - .collect::>(); + + // Build leaf nodes for each segment chunk. + let mut leaves = Vec::with_capacity(chunks.len()); + let mut current_offset = 0u64; + for segment in chunks.iter() { + let node_id = tree.new_leaf(Style { + min_size: Size { + width: Dimension::percent(1.0), + height: Dimension::length(7.0), + }, + size: Size { + width: Dimension::percent(1.0), + height: Dimension::length(15.0), + }, + ..Default::default() + })?; + + node_contents.insert( + node_id, + NodeContents { + title: segment.name.clone(), + contents: vec![ + Line::raw(format!( + "Rows: {}..{} ({})", + segment.row_offset, + segment.row_offset + segment.row_count, + segment.row_count + )), + Line::raw(format!( + "Bytes: {}..{} ({})", + segment.spec.offset, + segment.spec.offset + segment.spec.length as u64, + humansize::format_size(segment.spec.length, DECIMAL), + )), + Line::raw(format!("Align: {}", segment.spec.alignment)), + Line::raw(format!( + "Byte gap: {}", + if current_offset == 0 { + 0 + } else { + segment.spec.offset - current_offset + } + )), + ], + }, + ); + + current_offset = segment.spec.length as u64 + segment.spec.offset; + leaves.push(node_id); + } let node_id = tree.new_with_children( Style { diff --git a/vortex-tui/src/convert.rs b/vortex-tui/src/convert.rs index 4095ea13b1d..baf8441977c 100644 --- a/vortex-tui/src/convert.rs +++ b/vortex-tui/src/convert.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +//! Convert Parquet files to Vortex format. + use std::path::PathBuf; use clap::Parser; @@ -20,32 +22,42 @@ use vortex::error::VortexError; use vortex::error::VortexExpect; use vortex::file::WriteOptionsSessionExt; use vortex::file::WriteStrategyBuilder; +use vortex::session::VortexSession; -use crate::SESSION; - -#[derive(Clone, Copy, Debug, ValueEnum)] -enum Strategy { +/// Compression strategy to use when converting Parquet files to Vortex format. +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +pub enum Strategy { + /// Use the BtrBlocks compressor strategy (default) + #[default] Btrblocks, + /// Use the Compact compression strategy for more aggressive compression. Compact, } + +/// Command-line flags for the convert command. #[derive(Debug, Clone, Parser)] -pub struct Flags { - /// Path to the Parquet file on disk to convert to Vortex +pub struct ConvertArgs { + /// Path to the Parquet file on disk to convert to Vortex. pub file: PathBuf, - /// Execute quietly. No output will be printed. - #[arg(short, long)] - quiet: bool, - /// Compression strategy. #[arg(short, long, default_value = "btrblocks")] - strategy: Strategy, + pub strategy: Strategy, + + /// Execute quietly. No output will be printed. + #[arg(short, long)] + pub quiet: bool, } -const BATCH_SIZE: usize = 8192; +/// The batch size of the record batches. +pub const BATCH_SIZE: usize = 8192; /// Convert Parquet files to Vortex. -pub async fn exec_convert(flags: Flags) -> anyhow::Result<()> { +/// +/// # Errors +/// +/// Returns an error if the input file cannot be read or the output file cannot be written. +pub async fn exec_convert(session: &VortexSession, flags: ConvertArgs) -> anyhow::Result<()> { let input_path = flags.file.clone(); if !flags.quiet { eprintln!("Converting input Parquet file: {}", input_path.display()); @@ -87,7 +99,7 @@ pub async fn exec_convert(flags: Flags) -> anyhow::Result<()> { }; let mut file = File::create(output_path).await?; - SESSION + session .write_options() .with_strategy(strategy.build()) .write(&mut file, ArrayStreamAdapter::new(dtype, vortex_stream)) diff --git a/vortex-tui/src/inspect.rs b/vortex-tui/src/inspect.rs index 2f4f1e0432a..fc8cedfec34 100644 --- a/vortex-tui/src/inspect.rs +++ b/vortex-tui/src/inspect.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +//! Inspect Vortex file metadata and structure. + use std::collections::VecDeque; use std::fs::File; use std::io::Read; @@ -25,22 +27,23 @@ use vortex::file::OpenOptionsSessionExt; use vortex::file::VERSION; use vortex::flatbuffers::footer as fb; use vortex::layout::LayoutRef; +use vortex::session::VortexSession; -use crate::SESSION; - +/// Command-line arguments for the inspect command. #[derive(Debug, clap::Parser)] pub struct InspectArgs { - /// What to inspect + /// What to inspect. #[clap(subcommand)] pub mode: Option, - /// Path to the Vortex file to inspect + /// Path to the Vortex file to inspect. pub file: PathBuf, } +/// What component of the Vortex file to inspect. #[derive(Debug, clap::Subcommand)] pub enum InspectMode { - /// Read and display the EOF marker (8 bytes at end of file) + /// Read and display the EOF marker (8 bytes at end of file). Eof, /// Read and display the postscript @@ -50,8 +53,13 @@ pub enum InspectMode { Footer, } -pub async fn exec_inspect(args: InspectArgs) -> anyhow::Result<()> { - let mut inspector = VortexInspector::new(args.file.clone())?; +/// Inspect Vortex file footer and metadata. +/// +/// # Errors +/// +/// Returns an error if the file cannot be opened or its metadata cannot be read. +pub async fn exec_inspect(session: &VortexSession, args: InspectArgs) -> anyhow::Result<()> { + let mut inspector = VortexInspector::new(session, args.file.clone())?; println!("File: {}", args.file.display()); println!("Size: {} bytes", inspector.file_size); @@ -114,14 +122,15 @@ pub async fn exec_inspect(args: InspectArgs) -> anyhow::Result<()> { Ok(()) } -struct VortexInspector { +struct VortexInspector<'a> { + session: &'a VortexSession, path: PathBuf, file: File, file_size: u64, } -impl VortexInspector { - fn new(path: PathBuf) -> VortexResult { +impl<'a> VortexInspector<'a> { + fn new(session: &'a VortexSession, path: PathBuf) -> VortexResult { let mut file = File::open(&path).map_err(|e| vortex_err!("Failed to open file {:?}: {}", path, e))?; @@ -130,6 +139,7 @@ impl VortexInspector { .map_err(|e| vortex_err!("Failed to get file size: {}", e))?; Ok(Self { + session, path, file, file_size, @@ -219,7 +229,8 @@ impl VortexInspector { } async fn read_footer(&mut self) -> VortexResult