[BUG] TextArea text entry slows down as associated sibling TextView content increases
On macOS 12.4, I've taken the TextView example, put the TextView into a LinearLayout, and then added a default TextArea as a second child. When the TextView has a small string, typing into the TextArea is fast. When the TextView's static content is big, however, typing is laggy.
Here's the code:
fn main() {
// Read some long text from a file.
// let content = include_str!("../marktwainworks.txt");
let content = include_str!("../norvig1mb.txt");
// let content = include_str!("../README.md");
let mut siv = cursive::default();
// We can quit by pressing q
siv.add_global_callback('q', |s| s.quit());
// The text is too long to fit on a line, so the view will wrap lines,
// and will adapt to the terminal size.
siv.add_fullscreen_layer(
Dialog::around(LinearLayout::vertical()
.child(Panel::new(
TextView::new(content)
.scrollable()
.wrap_with(OnEventView::new)
.on_pre_event_inner(Key::PageUp, |v, _| {
let scroller = v.get_scroller_mut();
if scroller.can_scroll_up() {
scroller.scroll_up(
scroller.last_outer_size().y.saturating_sub(1),
);
}
Some(EventResult::Consumed(None))
})
.on_pre_event_inner(Key::PageDown, |v, _| {
let scroller = v.get_scroller_mut();
if scroller.can_scroll_down() {
scroller.scroll_down(
scroller.last_outer_size().y.saturating_sub(1),
);
}
Some(EventResult::Consumed(None))
}),
))
.child(TextArea::new())
)
.title("Unicode and wide-character support")
// This is the alignment for the button
.h_align(HAlign::Center)
.button("Quit", |s| s.quit()),
);
// Show a popup on top of the view.
siv.add_layer(Dialog::info(
"Try resizing the terminal!\n(Press 'q' to \
quit when you're done.)",
));
siv.run();
}
The 1mb "Norvig" file referenced above is the first 1MB of Peter Norvig's big.txt. The collected works of Mark Twain come from Project Gutenberg and are ~15MB (https://www.gutenberg.org/cache/epub/3200/pg3200.txt). The "README.md" file, in contrast, is only 6KB.
With the README in the TextView, there's no noticeable lag for input into the TextArea. With the 1MB file there is. With the 15MB file, it's pretty bad.
Expected behavior I'd expect that typing into the TextArea would never be laggy with the code above, regardless of the content in the associated TextView, as the view is not changing while typing into the TextArea. It seems like the TextView is being repainted, possibly with some repeated calculation (to compute the rows?).
Environment
- Operating system used: macOS 12.4, Monterrey
- Backend used: ncurses (I think)
- Current locale (run
localein a terminal)
jon@Lucretius % locale
LANG="en_US.UTF-8"
LC_COLLATE="en_US.UTF-8"
LC_CTYPE="en_US.UTF-8"
LC_MESSAGES="en_US.UTF-8"
LC_MONETARY="en_US.UTF-8"
LC_NUMERIC="en_US.UTF-8"
LC_TIME="en_US.UTF-8"
LC_ALL=
- Cursive version (from crates.io, from git, ...): 0.18.0
Thank you!
Updated to Cursive version 0.19.0 and behavior is the same.
Hi, and thanks for the report!
It's most likely a combination of the LinearLayout and the TextView's cache leading to re-computation of the line wraps.
We could update TextView to include a few more size caches, so it can quickly answer the LinearLayout's progressive requests. Will see if I can prepare something for that.
I'm not sure what in LinearLayout would result in the TextView's cache being invalidated, but agree the primary bug here is in that occurring.
As separate issues, reading through TextView's code, I see a few things that could be optimized (I think; I'm new to Rust and to Cursive so could definitely be misreading things):
-
row_at()uses an O(n) loop, but as the vector is in sorted order, it could be switched to a much faster binary search (probably would speed up handling of large strings immensely):
/// Finds the row containing the grapheme at the given offset
fn row_at(&self, byte_offset: usize) -> usize {
debug!("Offset: {}", byte_offset);
assert!(!self.rows.is_empty());
assert!(byte_offset >= self.rows[0].start);
self.rows
.iter()
.enumerate()
.take_while(|&(_, row)| row.start <= byte_offset)
.map(|(i, _)| i)
.last()
.unwrap()
}
-
is_cache_valid()has a condition on the height (size.y) of the view area. A change in height needs to trigger repainting and figuring out which content needs to be displayed, but shouldn't affect the line wrapping boundaries.
fn is_cache_valid(&self, size: Vec2) -> bool {
match self.size_cache {
None => false,
Some(ref last) => last.x.accept(size.x) && last.y.accept(size.y),
}
}
-
soft_compute_rows()seemingly does double the amount of work. It callsmake_rows()first to determine how many rows there are. Then if the number of rows exceeds the height of the view area, it callsmake_rows()again. Changing the first loop simply to check whether there's more content than can fit in the given height, with an early exit from the loop when the the threshold is exceeded, could reduce the work done here.
fn soft_compute_rows(&mut self, size: Vec2) {
if self.is_cache_valid(size) {
debug!("Cache is still valid.");
return;
}
debug!("Computing! Oh yeah!");
let mut available = size.x;
self.rows = make_rows(&self.content, available);
self.fix_ghost_row();
if self.rows.len() > size.y {
available = available.saturating_sub(1);
// Apparently we'll need a scrollbar. Doh :(
self.rows = make_rows(&self.content, available);
self.fix_ghost_row();
}
if !self.rows.is_empty() {
self.size_cache = Some(SizeCache::build(size, size));
}
}
Are there unit tests in Cursive? I wouldn't mind working on these things if there are tests, but I'm likely to screw things up without tests given I'm a newb.
LinearLayout runs a few passes to compute the proper size for each child, and that process involves calling the children's required_size with different values. This effectively destroys TextView's cache, as it can currently only store one set of results. And re-computing this layout can be expensive for large text.
row_at() uses an O(n) loop, but as the vector is in sorted order, it could be switched to a much faster binary search (probably would speed up handling of large strings immensely):
That's for TextArea, right? Indeed, sounds like a good idea!
is_cache_valid() has a condition on the height (size.y) of the view area. A change in height needs to trigger repainting and figuring out which content needs to be displayed, but shouldn't affect the line wrapping boundaries.
Scrolling is where it matters. TextArea still handles scrolling itself, which means that if the text does not fit in the given height, it needs to include a scrollbar, which reduces the available width and invalidates the rows.
In theory it's possible to not start from scratch, but "patch" the existing rows, maybe re-using the break points previously computed. Not sure how much it would improve things, and it would add quite some complexity here.
soft_compute_rows() seemingly does double the amount of work. It calls make_rows() first to determine how many rows there are. Then if the number of rows exceeds the height of the view area, it calls make_rows() again. Changing the first loop simply to check whether there's more content than can fit in the given height, with an early exit from the loop when the the threshold is exceeded, could reduce the work done here.
Good idea! That would indeed bound the time spent in the first computation if the text is very large.
Note that TextArea is not currently meant to support very large text like a proper editor. For very large text several large changes could be considered, like changing the internal text representation (rope or similar?) or pre-computing more stuff (like the potential break-points for easy row "patching").
Are there unit tests in Cursive? I wouldn't mind working on these things if there are tests, but I'm likely to screw things up without tests given I'm a newb.
Eeeeh not enough, not enough. :( That being said, adding some tests can be a first step if you're motivated! :p Otherwise I can cook up some to help get started.