Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Table is slow/laggy when there are a lot of items #1004

Open
orhun opened this issue Mar 29, 2024 · 7 comments
Open

Table is slow/laggy when there are a lot of items #1004

orhun opened this issue Mar 29, 2024 · 7 comments
Labels
bug Something isn't working

Comments

@orhun
Copy link
Sponsor Member

orhun commented Mar 29, 2024

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:

diff --git a/examples/table.rs b/examples/table.rs
index 744e142..94afc9b 100644
--- a/examples/table.rs
+++ b/examples/table.rs
@@ -156,7 +156,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!(

rec_20240328T224629

Expected behavior

Normal table performance.

Screenshots

Provided above.

Environment

  • OS: Arch Linux
  • Terminal Emulator: alacritty
  • Font: Ubuntu Mono
  • Crate version: 0.24.0
  • Backend: crossterm

Additional context

the issue is the conversion to vec in the construction of the table.
For the example, the right idea would be to do that just once rather than every time.
To fix the perf issue fully would require rewriting the code that chooses the rows to display to be more aware of lazy evaluating iterators

@orhun orhun added the bug Something isn't working label Mar 29, 2024
@EdJoPaTo
Copy link
Member

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.

@orhun
Copy link
Sponsor Member Author

orhun commented Mar 29, 2024

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.

@preiter93
Copy link

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

@boaz-chen
Copy link

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: boaz-chen@dbb2d38

@joshka
Copy link
Member

joshka commented May 6, 2024

As a pretty immediate workaround, you can kinda do this outside of the table pretty easily also:

  • grab the number of terminal lines
  • take twice the height of table rows before and after the selection / offset positions, and adjust all values by the delta before / after rending
  • E.g. 15000 rows, selection 2000, height 50. Choose rows 1900-2100, subtract 1900 from the selected index / offset and add it back after rendering. Let the list just worry about working with a small amount of rows.
  • This can be put into the list itself as well
  • (This breaks for if there are items with zero height however)

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).

@orhun
Copy link
Sponsor Member Author

orhun commented May 6, 2024

That makes sense. I already implemented a brute force approach here: orhun/binsider@e86b10c

Basically I do some skipping/limiting of items to only render a certain part of the table.

@EdJoPaTo
Copy link
Member

EdJoPaTo commented May 6, 2024

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).

EdJoPaTo/tui-rs-tree-widget#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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

5 participants