Open orhun opened 3 months ago
I have the same issue with my tree widget and a lot of data. Especially scrolling was really sluggish. I delayed that problem with debounced input events. Only render every 20 ms or so and not on every event. But that does not solve the issue of a single render being slow.
My current leading thought is an abstraction of text and height. In order to know which items are shown with the given offset every height of every item is needed which results in doing text().height() for many items. mqttui
uses only items with height 1, but it's still doing the height() call as its generic. In the ideal case only the text of the items to be rendered needs to be used.
// oversimplified
trait TreeItem {
fn to_text(&self) -> Text<'_>;
fn height(&self) -> usize {
self.to_text().height()
}
}
With this trait the implementation could use the default height method which uses the text or override it for performance benefits.
As an alternative idea the text / height could be cached once and only the cache be used then.
I have nothing of that benchmarked. I am just throwing ideas around.
Some findings from Discord:
Generating the rows once makes it slightly faster:
diff --git a/examples/table.rs b/examples/table.rs
index 744e142..a372ac4 100644
--- a/examples/table.rs
+++ b/examples/table.rs
@@ -88,18 +88,36 @@ impl Data {
}
}
-struct App {
+struct App<'a> {
state: TableState,
items: Vec<Data>,
longest_item_lens: (u16, u16, u16), // order is (name, address, email)
scroll_state: ScrollbarState,
colors: TableColors,
color_index: usize,
+ rows: Vec<Row<'a>>,
}
-impl App {
+impl<'a> App<'a> {
fn new() -> Self {
let data_vec = generate_fake_names();
+ let colors = TableColors::new(&PALETTES[0]);
+ let rows = data_vec
+ .iter()
+ .enumerate()
+ .map(|(i, data)| {
+ let color = match i % 2 {
+ 0 => colors.normal_row_color,
+ _ => colors.alt_row_color,
+ };
+ let item = data.ref_array();
+ item.into_iter()
+ .map(|content| Cell::from(Text::from(format!("\n{content}\n"))))
+ .collect::<Row>()
+ .style(Style::new().fg(colors.row_fg).bg(color))
+ .height(4)
+ })
+ .collect();
Self {
state: TableState::default().with_selected(0),
longest_item_lens: constraint_len_calculator(&data_vec),
@@ -107,6 +125,7 @@ impl App {
colors: TableColors::new(&PALETTES[0]),
color_index: 0,
items: data_vec,
+ rows,
}
}
pub fn next(&mut self) {
@@ -156,7 +175,7 @@ impl App {
fn generate_fake_names() -> Vec<Data> {
use fakeit::{address, contact, name};
- (0..20)
+ (0..15000)
.map(|_| {
let name = name::full();
let address = format!(
@@ -252,21 +271,9 @@ fn render_table(f: &mut Frame, app: &mut App, area: Rect) {
.collect::<Row>()
.style(header_style)
.height(1);
- let rows = app.items.iter().enumerate().map(|(i, data)| {
- let color = match i % 2 {
- 0 => app.colors.normal_row_color,
- _ => app.colors.alt_row_color,
- };
- let item = data.ref_array();
- item.into_iter()
- .map(|content| Cell::from(Text::from(format!("\n{content}\n"))))
- .collect::<Row>()
- .style(Style::new().fg(app.colors.row_fg).bg(color))
- .height(4)
- });
let bar = " █ ";
let t = Table::new(
- rows,
+ app.rows.clone(),
[
// + 1 is for padding.
Constraint::Length(app.longest_item_lens.0 + 1),
Or we can simply store Table
in App
:
diff --git a/examples/table.rs b/examples/table.rs
index 744e142..aba0e31 100644
--- a/examples/table.rs
+++ b/examples/table.rs
@@ -88,25 +88,74 @@ impl Data {
}
}
-struct App {
+struct App<'a> {
state: TableState,
items: Vec<Data>,
- longest_item_lens: (u16, u16, u16), // order is (name, address, email)
scroll_state: ScrollbarState,
colors: TableColors,
color_index: usize,
+ table: Table<'a>,
}
-impl App {
+impl<'a> App<'a> {
fn new() -> Self {
let data_vec = generate_fake_names();
+ let colors = TableColors::new(&PALETTES[0]);
+ let rows: Vec<Row> = data_vec
+ .iter()
+ .enumerate()
+ .map(|(i, data)| {
+ let color = match i % 2 {
+ 0 => colors.normal_row_color,
+ _ => colors.alt_row_color,
+ };
+ let item = data.ref_array();
+ item.into_iter()
+ .map(|content| Cell::from(Text::from(format!("\n{content}\n"))))
+ .collect::<Row>()
+ .style(Style::new().fg(colors.row_fg).bg(color))
+ .height(4)
+ })
+ .collect();
+ let header_style = Style::default().fg(colors.header_fg).bg(colors.header_bg);
+ let selected_style = Style::default()
+ .add_modifier(Modifier::REVERSED)
+ .fg(colors.selected_style_fg);
+
+ let header = ["Name", "Address", "Email"]
+ .into_iter()
+ .map(Cell::from)
+ .collect::<Row>()
+ .style(header_style)
+ .height(1);
+ let bar = " █ ";
+ let longest_item_lens = constraint_len_calculator(&data_vec);
+ let table = Table::new(
+ rows.clone(),
+ [
+ // + 1 is for padding.
+ Constraint::Length(longest_item_lens.0 + 1),
+ Constraint::Min(longest_item_lens.1 + 1),
+ Constraint::Min(longest_item_lens.2),
+ ],
+ )
+ .header(header)
+ .highlight_style(selected_style)
+ .highlight_symbol(Text::from(vec![
+ "".into(),
+ bar.into(),
+ bar.into(),
+ "".into(),
+ ]))
+ .bg(colors.buffer_bg)
+ .highlight_spacing(HighlightSpacing::Always);
Self {
state: TableState::default().with_selected(0),
- longest_item_lens: constraint_len_calculator(&data_vec),
scroll_state: ScrollbarState::new((data_vec.len() - 1) * ITEM_HEIGHT),
colors: TableColors::new(&PALETTES[0]),
color_index: 0,
items: data_vec,
+ table,
}
}
pub fn next(&mut self) {
@@ -156,7 +205,7 @@ impl App {
fn generate_fake_names() -> Vec<Data> {
use fakeit::{address, contact, name};
- (0..20)
+ (0..15000)
.map(|_| {
let name = name::full();
let address = format!(
@@ -239,52 +288,7 @@ fn ui(f: &mut Frame, app: &mut App) {
}
fn render_table(f: &mut Frame, app: &mut App, area: Rect) {
- let header_style = Style::default()
- .fg(app.colors.header_fg)
- .bg(app.colors.header_bg);
- let selected_style = Style::default()
- .add_modifier(Modifier::REVERSED)
- .fg(app.colors.selected_style_fg);
-
- let header = ["Name", "Address", "Email"]
- .into_iter()
- .map(Cell::from)
- .collect::<Row>()
- .style(header_style)
- .height(1);
- let rows = app.items.iter().enumerate().map(|(i, data)| {
- let color = match i % 2 {
- 0 => app.colors.normal_row_color,
- _ => app.colors.alt_row_color,
- };
- let item = data.ref_array();
- item.into_iter()
- .map(|content| Cell::from(Text::from(format!("\n{content}\n"))))
- .collect::<Row>()
- .style(Style::new().fg(app.colors.row_fg).bg(color))
- .height(4)
- });
- let bar = " █ ";
- let t = Table::new(
- rows,
- [
- // + 1 is for padding.
- Constraint::Length(app.longest_item_lens.0 + 1),
- Constraint::Min(app.longest_item_lens.1 + 1),
- Constraint::Min(app.longest_item_lens.2),
- ],
- )
- .header(header)
- .highlight_style(selected_style)
- .highlight_symbol(Text::from(vec![
- "".into(),
- bar.into(),
- bar.into(),
- "".into(),
- ]))
- .bg(app.colors.buffer_bg)
- .highlight_spacing(HighlightSpacing::Always);
- f.render_stateful_widget(t, area, &mut app.state);
+ f.render_stateful_widget(&app.table, area, &mut app.state);
}
fn constraint_len_calculator(items: &[Data]) -> (u16, u16, u16) {
But still, I don't think these solutions aren't optimal. I will look into the height issue that @EdJoPaTo pointed out to see if it has any effect on the performance.
In case you're interested, in my library I've solved this problem by adding a trait that provides the widget size pre-render. When implementing this trait and Widget on the reference instead of the value of the list element, it allows for lazy evaluation and I can basically render any number of elements, here is an example https://github.com/preiter93/tui-widget-list/blob/v0.9/examples/long.rs
One possible approach for improving the table performance in cases where there are a lot of rows is to get only the rows that need to be rendered on demand. I tried to write a PoC that demonstrates that where the main struggle was to not break the start..end
calculation. I'm sure this can be written much better as this is only an example: https://github.com/boaz-chen/ratatui/commit/dbb2d385e02596e6725f939e51a59695e745bead
As a pretty immediate workaround, you can kinda do this outside of the table pretty easily also:
This is an easy brute force approach to fixing this (i.e. we just throw away a bunch of unnecessary data without worrying too much about getting the amount exactly right as the order of magnitude amount discarded is the thing that matters, not the exact amount).
That makes sense. I already implemented a brute force approach here: https://github.com/orhun/binsider/commit/e86b10c72da39cc08ba0e6664fc4b12c859aadf6
Basically I do some skipping/limiting of items to only render a certain part of the table.
My TreeItem
trait idea from earlier turns out as a huge improvement. When rendering the cargo metadata
JSON as a Tree this ends up in a 5 time performance improvement. height
and Text
are decoupled and height is always 1 which results in way better performance on checking which items are in view. Then only the relevant items are actually rendered. And only on render the actual item style logic is done. Before every item, even out of view, created all these Spans, Lines, and Styles which are thrown away again without being shown anyway.
Interestingly this approach also has improved benchmarks for small data which mostly fits into the buffer. An empty tree takes 5% less time while a relatively small unstyled &'static str
example is also generated 3 times faster (while rendering ~6% faster).
https://github.com/EdJoPaTo/tui-rs-tree-widget/pull/33
This is likely to be especially effective as large sections of tree data is closed by default. But other widgets like tables should benefit from this approach too as there are not that many items that can be rendered into the view anyway.
Description
In one of my TUI projects I have 15k items in a
Table
and the render is pretty slow and laggy. I press the key to scroll the table but it takes around 1-2 seconds.To Reproduce
To confirm it, I tried this with the table example as well.
Just apply this patch:
Expected behavior
Normal table performance.
Screenshots
Provided above.
Environment
Additional context
--release
make things a bit faster but it is still laggy.