Skip to content

Commit 06b51be

Browse files
authored
Merge pull request #93 from ynqa/feat/csv
feat(widgets): add performant CSV table widget and viewer
2 parents 579451a + fc548f4 commit 06b51be

11 files changed

Lines changed: 49091 additions & 2 deletions

File tree

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ anyhow = "1.0.102"
1414
async-trait = "0.1.89"
1515
crossbeam-skiplist = "0.1.3"
1616
crossterm = { version = "0.29.0", features = ["use-dev-tty", "event-stream", "serde"] }
17+
csv = "1.3.1"
1718
futures = "0.3.32"
1819
radix_trie = "0.3.0"
1920
rayon = "1.11.0"

‎examples/csv/Cargo.toml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "csv-viewer"
3+
version = "0.1.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[dependencies]
8+
anyhow = { workspace = true }
9+
clap = { version = "4.5.60", features = ["derive"] }
10+
promkit = { path = "../../promkit", features = ["table"] }
11+
tokio = { workspace = true }
12+
13+
[[bin]]
14+
name = "csv"
15+
path = "src/csv.rs"

‎examples/csv/src/csv.rs‎

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
use std::{fs::File, io, path::PathBuf};
2+
3+
use clap::Parser;
4+
use promkit::{
5+
core::{
6+
crossterm::{
7+
event::{
8+
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
9+
MouseEvent, MouseEventKind,
10+
},
11+
execute, terminal,
12+
},
13+
render::{Renderer, SharedRenderer},
14+
Widget,
15+
},
16+
widgets::table::{CsvOptions, Document, State},
17+
Prompt, Signal,
18+
};
19+
20+
/// Interactive CSV viewer powered by promkit.
21+
#[derive(Debug, Parser)]
22+
#[command(name = "csv", version)]
23+
struct Args {
24+
/// Optional path to a CSV file. Reads from stdin when omitted or when "-" is specified.
25+
input: Option<PathBuf>,
26+
}
27+
28+
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
29+
enum Index {
30+
Table,
31+
}
32+
33+
const KEY_HORIZONTAL_SCROLL_CELLS: usize = 1;
34+
const MOUSE_HORIZONTAL_SCROLL_CELLS: usize = 3;
35+
36+
struct CsvViewer {
37+
renderer: Option<SharedRenderer<Index>>,
38+
table: State,
39+
}
40+
41+
impl CsvViewer {
42+
fn new(document: Document) -> Self {
43+
Self {
44+
renderer: None,
45+
table: State::new(document),
46+
}
47+
}
48+
49+
fn handle_event(&mut self, event: &Event) -> anyhow::Result<Signal> {
50+
match event {
51+
Event::Key(KeyEvent {
52+
code: KeyCode::Enter,
53+
modifiers: KeyModifiers::NONE,
54+
kind: KeyEventKind::Press,
55+
state: KeyEventState::NONE,
56+
}) => return Ok(Signal::Quit),
57+
Event::Key(KeyEvent {
58+
code: KeyCode::Char('c'),
59+
modifiers: KeyModifiers::CONTROL,
60+
kind: KeyEventKind::Press,
61+
state: KeyEventState::NONE,
62+
}) => return Err(anyhow::anyhow!("ctrl+c")),
63+
Event::Key(KeyEvent {
64+
code: KeyCode::Up,
65+
modifiers: KeyModifiers::NONE,
66+
kind: KeyEventKind::Press,
67+
state: KeyEventState::NONE,
68+
})
69+
| Event::Mouse(MouseEvent {
70+
kind: MouseEventKind::ScrollUp,
71+
modifiers: KeyModifiers::NONE,
72+
..
73+
}) => {
74+
self.table.document.up();
75+
}
76+
Event::Key(KeyEvent {
77+
code: KeyCode::Down,
78+
modifiers: KeyModifiers::NONE,
79+
kind: KeyEventKind::Press,
80+
state: KeyEventState::NONE,
81+
})
82+
| Event::Mouse(MouseEvent {
83+
kind: MouseEventKind::ScrollDown,
84+
modifiers: KeyModifiers::NONE,
85+
..
86+
}) => {
87+
self.table.document.down();
88+
}
89+
Event::Key(KeyEvent {
90+
code: KeyCode::Left,
91+
modifiers: KeyModifiers::NONE,
92+
kind: KeyEventKind::Press,
93+
state: KeyEventState::NONE,
94+
}) => {
95+
self.table
96+
.document
97+
.scroll_left_by(KEY_HORIZONTAL_SCROLL_CELLS);
98+
}
99+
Event::Mouse(MouseEvent {
100+
kind: MouseEventKind::ScrollLeft,
101+
modifiers: KeyModifiers::NONE,
102+
..
103+
})
104+
| Event::Mouse(MouseEvent {
105+
kind: MouseEventKind::ScrollUp,
106+
modifiers: KeyModifiers::SHIFT,
107+
..
108+
}) => {
109+
self.table
110+
.document
111+
.scroll_right_by(MOUSE_HORIZONTAL_SCROLL_CELLS);
112+
}
113+
Event::Key(KeyEvent {
114+
code: KeyCode::Right,
115+
modifiers: KeyModifiers::NONE,
116+
kind: KeyEventKind::Press,
117+
state: KeyEventState::NONE,
118+
}) => {
119+
self.table
120+
.document
121+
.scroll_right_by(KEY_HORIZONTAL_SCROLL_CELLS);
122+
}
123+
Event::Mouse(MouseEvent {
124+
kind: MouseEventKind::ScrollRight,
125+
modifiers: KeyModifiers::NONE,
126+
..
127+
})
128+
| Event::Mouse(MouseEvent {
129+
kind: MouseEventKind::ScrollDown,
130+
modifiers: KeyModifiers::SHIFT,
131+
..
132+
}) => {
133+
self.table
134+
.document
135+
.scroll_left_by(MOUSE_HORIZONTAL_SCROLL_CELLS);
136+
}
137+
_ => {}
138+
}
139+
Ok(Signal::Continue)
140+
}
141+
142+
async fn render(&mut self) -> anyhow::Result<()> {
143+
let renderer = self
144+
.renderer
145+
.as_ref()
146+
.ok_or_else(|| anyhow::anyhow!("Renderer not initialized"))?;
147+
let (width, height) = terminal::size()?;
148+
renderer
149+
.update([(
150+
Index::Table,
151+
self.table.create_graphemes_in_viewport(width, height),
152+
)])
153+
.render()
154+
.await
155+
}
156+
}
157+
158+
#[promkit::async_trait::async_trait]
159+
impl Prompt for CsvViewer {
160+
async fn initialize(&mut self) -> anyhow::Result<()> {
161+
let (width, height) = terminal::size()?;
162+
self.renderer = Some(SharedRenderer::new(
163+
Renderer::try_new_with_graphemes(
164+
[(
165+
Index::Table,
166+
self.table.create_graphemes_in_viewport(width, height),
167+
)],
168+
true,
169+
)
170+
.await?,
171+
));
172+
Ok(())
173+
}
174+
175+
async fn evaluate(&mut self, event: &Event) -> anyhow::Result<Signal> {
176+
let signal = self.handle_event(event)?;
177+
if signal == Signal::Continue {
178+
self.render().await?;
179+
}
180+
Ok(signal)
181+
}
182+
183+
type Return = ();
184+
185+
fn finalize(&mut self) -> anyhow::Result<Self::Return> {
186+
Ok(())
187+
}
188+
}
189+
190+
fn parse_document(args: &Args) -> anyhow::Result<Document> {
191+
let options = CsvOptions::default();
192+
match &args.input {
193+
None => {
194+
let stdin = io::stdin();
195+
Document::from_csv(stdin.lock(), options).map_err(anyhow::Error::from)
196+
}
197+
Some(path) if path == &PathBuf::from("-") => {
198+
let stdin = io::stdin();
199+
Document::from_csv(stdin.lock(), options).map_err(anyhow::Error::from)
200+
}
201+
Some(path) => Document::from_csv(File::open(path)?, options).map_err(anyhow::Error::from),
202+
}
203+
}
204+
205+
/// Ensure the terminal is restored to its original state when dropped.
206+
struct TerminalGuard;
207+
208+
impl Drop for TerminalGuard {
209+
fn drop(&mut self) {
210+
let _ = execute!(
211+
io::stdout(),
212+
terminal::LeaveAlternateScreen,
213+
event::DisableMouseCapture
214+
);
215+
}
216+
}
217+
218+
#[tokio::main]
219+
async fn main() -> anyhow::Result<()> {
220+
let args = Args::parse();
221+
let document = parse_document(&args)?;
222+
223+
execute!(
224+
io::stdout(),
225+
terminal::EnterAlternateScreen,
226+
event::EnableMouseCapture
227+
)?;
228+
let _terminal_guard = TerminalGuard;
229+
230+
CsvViewer::new(document).run().await
231+
}
232+
233+
#[cfg(test)]
234+
mod tests {
235+
use super::*;
236+
237+
fn viewer() -> CsvViewer {
238+
CsvViewer::new(
239+
Document::from_csv(
240+
"a,b,c\none,two,three\nfour,five,six\n".as_bytes(),
241+
CsvOptions::default(),
242+
)
243+
.unwrap(),
244+
)
245+
}
246+
247+
#[test]
248+
fn arrow_keys_move_on_both_axes() {
249+
let mut viewer = viewer();
250+
viewer.table.create_graphemes_in_viewport(4, 2);
251+
252+
viewer
253+
.handle_event(&Event::Key(KeyEvent::new(
254+
KeyCode::Down,
255+
KeyModifiers::NONE,
256+
)))
257+
.unwrap();
258+
assert_eq!(viewer.table.document.position(), 1);
259+
260+
viewer
261+
.handle_event(&Event::Key(KeyEvent::new(
262+
KeyCode::Right,
263+
KeyModifiers::NONE,
264+
)))
265+
.unwrap();
266+
assert_eq!(
267+
viewer.table.document.horizontal_offset(),
268+
KEY_HORIZONTAL_SCROLL_CELLS
269+
);
270+
}
271+
272+
#[test]
273+
fn macos_horizontal_scroll_left_reveals_content_on_the_right() {
274+
let mut viewer = viewer();
275+
viewer.table.create_graphemes_in_viewport(4, 2);
276+
viewer
277+
.handle_event(&Event::Mouse(MouseEvent {
278+
kind: MouseEventKind::ScrollLeft,
279+
column: 0,
280+
row: 0,
281+
modifiers: KeyModifiers::NONE,
282+
}))
283+
.unwrap();
284+
285+
assert_eq!(
286+
viewer.table.document.horizontal_offset(),
287+
MOUSE_HORIZONTAL_SCROLL_CELLS
288+
);
289+
290+
viewer
291+
.handle_event(&Event::Mouse(MouseEvent {
292+
kind: MouseEventKind::ScrollRight,
293+
column: 0,
294+
row: 0,
295+
modifiers: KeyModifiers::NONE,
296+
}))
297+
.unwrap();
298+
assert_eq!(viewer.table.document.horizontal_offset(), 0);
299+
}
300+
301+
#[test]
302+
fn loads_the_large_csv_fixture_from_a_file() {
303+
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
304+
.join("../../promkit-widgets/benches/table.csv");
305+
let document = parse_document(&Args { input: Some(path) }).unwrap();
306+
307+
assert_eq!(document.row_count(), 47_852);
308+
assert_eq!(document.column_count(), 12);
309+
}
310+
}

‎promkit-widgets/Cargo.toml‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ readme = "README.md"
1010

1111
[features]
1212
default = []
13-
all = ["checkbox", "json", "yaml", "listbox", "serde", "spinner", "status", "text", "texteditor", "tree"]
13+
all = ["checkbox", "json", "yaml", "listbox", "serde", "spinner", "status", "table", "text", "texteditor", "tree"]
1414
checkbox = ["listbox"]
1515
json = ["dep:serde_json", "dep:rayon"]
1616
yaml = ["dep:serde_yaml", "dep:rayon"]
1717
listbox = []
1818
serde = ["dep:serde", "dep:termcfg"]
1919
spinner = ["dep:tokio"]
2020
status = ["text"]
21+
table = ["dep:csv", "dep:unicode-width"]
2122
text = []
2223
texteditor = []
2324
tree = []
@@ -27,20 +28,26 @@ anyhow = { workspace = true }
2728
promkit-core = { path = "../promkit-core", version = "=0.5.0" }
2829

2930
# Optional dependencies
31+
csv = { workspace = true, optional = true }
3032
rayon = { workspace = true, optional = true }
3133
serde = { workspace = true, optional = true }
3234
serde_json = { workspace = true, optional = true }
3335
serde_yaml = { workspace = true, optional = true }
3436
termcfg = { workspace = true, optional = true }
3537
tokio = { workspace = true, optional = true }
38+
unicode-width = { workspace = true, optional = true }
3639

3740
[dev-dependencies]
3841
criterion = "0.8.2"
3942
# Enable `json` feature when `jsonz` testing
40-
promkit-widgets = { path = ".", features = ["json", "yaml"] }
43+
promkit-widgets = { path = ".", features = ["json", "table", "yaml"] }
4144
serde = { workspace = true }
4245
toml = "0.9.8"
4346

4447
[[bench]]
4548
name = "structured"
4649
harness = false
50+
51+
[[bench]]
52+
name = "table"
53+
harness = false

0 commit comments

Comments
 (0)