Skip to content

Commit 3b3b1c0

Browse files
Optimize display of large text bodies
It turns out calculating the width of a text body is expensive for large bodies, because we have to count every grapheme. This change caches the text dimensions so we only have to calculate it when the text changes. Further progress on #356
1 parent 135fc12 commit 3b3b1c0

File tree

6 files changed

+155
-87
lines changed

6 files changed

+155
-87
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1313
### Fixed
1414

1515
- Fixed `ignore_certificate_hosts` and `large_body_size` fields not being loaded from config
16+
- Improve performance of large response bodies [#356](https://github.com/LucasPickering/slumber/issues/356)
17+
- This includes disabling prettyification and syntax highlighting on bodies over 1 MB (this size is configurable, via the `large_body_size` [config field](https://slumber.lucaspickering.me/book/api/configuration/index.html))
18+
- Loading a large response body should no longer cause the UI to freeze or low framerate
1619

1720
## [2.2.0] - 2024-10-22
1821

crates/tui/src/view/common/template_preview.rs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::{
22
context::TuiContext,
33
message::Message,
4-
view::{draw::Generate, util::highlight, ViewContext},
4+
view::{draw::Generate, state::Identified, util::highlight, ViewContext},
55
};
66
use ratatui::{
77
buffer::Buffer,
@@ -35,7 +35,7 @@ pub struct TemplatePreview {
3535
/// because it also gets an initial value. There should be effectively zero
3636
/// contention on the mutex because of the single write, and reads being
3737
/// single-threaded.
38-
text: Arc<Mutex<Text<'static>>>,
38+
text: Arc<Mutex<Identified<Text<'static>>>>,
3939
}
4040

4141
impl TemplatePreview {
@@ -46,15 +46,16 @@ impl TemplatePreview {
4646
/// unrendered and rendered content.
4747
pub fn new(template: Template, content_type: Option<ContentType>) -> Self {
4848
// Calculate raw text
49-
let text = highlight::highlight_if(
49+
let text: Identified<Text> = highlight::highlight_if(
5050
content_type,
5151
// We have to clone the template to detach the lifetime. We're
5252
// choosing to pay one upfront cost here so we don't have to
5353
// recompute the text on each render. Ideally we could hold onto
5454
// the template and have this text reference it, but that would be
5555
// self-referential
5656
template.display().into_owned().into(),
57-
);
57+
)
58+
.into();
5859
let text = Arc::new(Mutex::new(text));
5960

6061
// Trigger a task to render the preview and write the answer back into
@@ -78,17 +79,17 @@ impl TemplatePreview {
7879
/// mutex
7980
fn calculate_rendered_text(
8081
chunks: Vec<TemplateChunk>,
81-
destination: &Mutex<Text<'static>>,
82+
destination: &Mutex<Identified<Text<'static>>>,
8283
content_type: Option<ContentType>,
8384
) {
8485
let text = TextStitcher::stitch_chunks(&chunks);
8586
let text = highlight::highlight_if(content_type, text);
8687
*destination
8788
.lock()
88-
.expect("Template preview text lock is poisoned") = text;
89+
.expect("Template preview text lock is poisoned") = text.into();
8990
}
9091

91-
pub fn text(&self) -> impl '_ + Deref<Target = Text<'static>> {
92+
pub fn text(&self) -> impl '_ + Deref<Target = Identified<Text<'static>>> {
9293
self.text
9394
.lock()
9495
.expect("Template preview text lock is poisoned")
@@ -103,7 +104,8 @@ impl From<Template> for TemplatePreview {
103104

104105
/// Clone internal text. Only call this for small pieces of text
105106
impl Generate for &TemplatePreview {
106-
type Output<'this> = Text<'this>
107+
type Output<'this>
108+
= Text<'this>
107109
where
108110
Self: 'this;
109111

@@ -117,7 +119,7 @@ impl Generate for &TemplatePreview {
117119

118120
impl Widget for &TemplatePreview {
119121
fn render(self, area: Rect, buf: &mut Buffer) {
120-
self.text().deref().render(area, buf)
122+
(&**self.text()).render(area, buf)
121123
}
122124
}
123125

crates/tui/src/view/common/text_window.rs

Lines changed: 72 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::{
55
context::UpdateContext,
66
draw::{Draw, DrawMetadata},
77
event::{Event, EventHandler, Update},
8+
state::{Identified, StateCell},
89
},
910
};
1011
use ratatui::{
@@ -19,6 +20,7 @@ use ratatui::{
1920
use slumber_config::Action;
2021
use std::{cell::Cell, cmp};
2122
use unicode_width::UnicodeWidthStr;
23+
use uuid::Uuid;
2224

2325
/// A scrollable (but not editable) block of text. Internal state will be
2426
/// updated on each render, to adjust to the text's width/height. Generally the
@@ -27,14 +29,13 @@ use unicode_width::UnicodeWidthStr;
2729
/// (especially if it includes syntax highlighting).
2830
#[derive(derive_more::Debug, Default)]
2931
pub struct TextWindow {
32+
/// Cache the size of the text window, because it's expensive to calculate.
33+
/// Checking the width of a text requires counting all its graphemes.
34+
text_size: StateCell<Uuid, TextSize>,
3035
/// Horizontal scroll
3136
offset_x: Cell<usize>,
3237
/// Vertical scroll
3338
offset_y: Cell<usize>,
34-
/// How wide is the full text content?
35-
text_width: Cell<usize>,
36-
/// How tall is the full text content?
37-
text_height: Cell<usize>,
3839
/// How wide is the visible text area, excluding gutter/scrollbars?
3940
window_width: Cell<usize>,
4041
/// How tall is the visible text area, exluding gutter/scrollbars?
@@ -45,7 +46,7 @@ pub struct TextWindow {
4546
pub struct TextWindowProps<'a> {
4647
/// Text to render. We take a reference because this component tends to
4748
/// contain a lot of text, and we don't want to force a clone on render
48-
pub text: &'a Text<'a>,
49+
pub text: &'a Identified<Text<'a>>,
4950
/// Extra text to render below the text window
5051
pub footer: Option<Text<'a>>,
5152
pub margins: ScrollbarMargins,
@@ -70,21 +71,35 @@ impl Default for ScrollbarMargins {
7071
}
7172
}
7273

74+
#[derive(Debug, Default)]
75+
struct TextSize {
76+
/// Number of graphemes in the longest line in the text
77+
width: usize,
78+
/// Number of lines in the text
79+
height: usize,
80+
}
81+
7382
impl TextWindow {
7483
/// Get the final line that we can't scroll past. This will be the first
7584
/// line of the last page of text
7685
fn max_scroll_line(&self) -> usize {
77-
self.text_height
86+
let text_height = self
87+
.text_size
7888
.get()
79-
.saturating_sub(self.window_height.get())
89+
.map(|state| state.height)
90+
.unwrap_or_default();
91+
text_height.saturating_sub(self.window_height.get())
8092
}
8193

8294
/// Get the final column that we can't scroll (horizontally) past. This will
8395
/// be the left edge of the rightmost "page" of text
8496
fn max_scroll_column(&self) -> usize {
85-
self.text_width
97+
let text_width = self
98+
.text_size
8699
.get()
87-
.saturating_sub(self.window_width.get())
100+
.map(|state| state.width)
101+
.unwrap_or_default();
102+
text_width.saturating_sub(self.window_width.get())
88103
}
89104

90105
fn scroll_up(&mut self, lines: usize) {
@@ -137,6 +152,11 @@ impl TextWindow {
137152
.take(self.window_height.get())
138153
.enumerate();
139154
for (y, line) in lines {
155+
// This could be expensive if we're skipping a lot of graphemes,
156+
// i.e. scrolled far to the right in a wide body. Fortunately that's
157+
// a niche use case so not optimized for yet. To fix this we would
158+
// have to map grapheme number -> byte offset and cache that,
159+
// because skipping bytes is O(1) instead of O(n)
140160
let graphemes = line
141161
.styled_graphemes(Style::default())
142162
.skip(self.offset_x.get())
@@ -185,31 +205,39 @@ impl<'a> Draw<TextWindowProps<'a>> for TextWindow {
185205
) {
186206
let styles = &TuiContext::get().styles;
187207

188-
// Assume no line wrapping when calculating line count
189-
// Note: Paragraph has methods for this, but that requires an owned copy
190-
// of Text, which involves a lot of cloning
191-
let text_height = props.text.lines.len();
192-
let text_width = props
193-
.text
194-
.lines
195-
.iter()
196-
.map(Line::width)
197-
.max()
198-
.unwrap_or_default();
208+
let text_state = self.text_size.get_or_update(&props.text.id(), || {
209+
// Note: Paragraph has methods for this, but that requires an
210+
// owned copy of Text, which involves a lot of cloning
211+
212+
// This counts _graphemes_, not bytes, so it's O(n)
213+
let text_width = props
214+
.text
215+
.lines
216+
.iter()
217+
.map(Line::width)
218+
.max()
219+
.unwrap_or_default();
220+
// Assume no line wrapping when calculating line count
221+
let text_height = props.text.lines.len();
222+
TextSize {
223+
width: text_width,
224+
height: text_height,
225+
}
226+
});
199227

200228
let [gutter_area, _, text_area] = Layout::horizontal([
201229
// Size gutter based on width of max line number
202-
Constraint::Length((text_height as f32).log10().floor() as u16 + 1),
230+
Constraint::Length(
231+
(text_state.height as f32).log10().floor() as u16 + 1,
232+
),
203233
Constraint::Length(1), // Spacer
204234
Constraint::Min(0),
205235
])
206236
.areas(metadata.area());
207-
let has_vertical_scroll = text_height > text_area.height as usize;
208-
let has_horizontal_scroll = text_width > text_area.width as usize;
237+
let has_vertical_scroll = text_state.height > text_area.height as usize;
238+
let has_horizontal_scroll = text_state.width > text_area.width as usize;
209239

210240
// Store text and window sizes for calculations in the update code
211-
self.text_width.set(text_width);
212-
self.text_height.set(text_height);
213241
self.window_width.set(text_area.width as usize);
214242
self.window_height.set(text_area.height as usize);
215243

@@ -218,8 +246,10 @@ impl<'a> Draw<TextWindowProps<'a>> for TextWindow {
218246

219247
// Draw line numbers in the gutter
220248
let first_line = self.offset_y.get() + 1;
221-
let last_line =
222-
cmp::min(first_line + self.window_height.get() - 1, text_height);
249+
let last_line = cmp::min(
250+
first_line + self.window_height.get() - 1,
251+
text_state.height,
252+
);
223253
frame.render_widget(
224254
Paragraph::new(
225255
(first_line..=last_line)
@@ -244,7 +274,7 @@ impl<'a> Draw<TextWindowProps<'a>> for TextWindow {
244274
x: text_area.x,
245275
y: text_area.y
246276
+ (cmp::min(
247-
self.text_height.get(),
277+
text_state.height,
248278
self.window_height.get(),
249279
)) as u16,
250280
width: text_area.width,
@@ -257,7 +287,7 @@ impl<'a> Draw<TextWindowProps<'a>> for TextWindow {
257287
if has_vertical_scroll {
258288
frame.render_widget(
259289
Scrollbar {
260-
content_length: self.text_height.get(),
290+
content_length: text_state.height,
261291
offset: self.offset_y.get(),
262292
// We substracted the margin from the text area before, so
263293
// we have to add that back now
@@ -270,7 +300,7 @@ impl<'a> Draw<TextWindowProps<'a>> for TextWindow {
270300
if has_horizontal_scroll {
271301
frame.render_widget(
272302
Scrollbar {
273-
content_length: self.text_width.get(),
303+
content_length: text_state.width,
274304
offset: self.offset_x.get(),
275305
orientation: ScrollbarOrientation::HorizontalBottom,
276306
// See note on other scrollbar for +1
@@ -299,7 +329,8 @@ mod tests {
299329
harness: TestHarness,
300330
) {
301331
let text =
302-
Text::from("line 1\nline 2 is longer\nline 3\nline 4\nline 5");
332+
Text::from("line 1\nline 2 is longer\nline 3\nline 4\nline 5")
333+
.into();
303334
let mut component = TestComponent::new(
304335
&harness,
305336
&terminal,
@@ -383,7 +414,8 @@ mod tests {
383414
#[with(35, 3)] terminal: TestTerminal,
384415
harness: TestHarness,
385416
) {
386-
let text = Text::from("intro\n💚💙💜 this is a longer line\noutro");
417+
let text =
418+
Text::from("intro\n💚💙💜 this is a longer line\noutro").into();
387419
TestComponent::new(
388420
&harness,
389421
&terminal,
@@ -410,7 +442,7 @@ mod tests {
410442
#[with(10, 2)] terminal: TestTerminal,
411443
harness: TestHarness,
412444
) {
413-
let text = Text::from("💚💙💜💚💙💜");
445+
let text = Text::raw("💚💙💜💚💙💜").into();
414446
TestComponent::new(
415447
&harness,
416448
&terminal,
@@ -438,9 +470,9 @@ mod tests {
438470
#[with(10, 3)] terminal: TestTerminal,
439471
harness: TestHarness,
440472
) {
441-
let text = ["1 this is a long line", "2", "3", "4", "5"]
442-
.join("\n")
443-
.into();
473+
let text =
474+
Text::from_iter(["1 this is a long line", "2", "3", "4", "5"])
475+
.into();
444476
let mut component = TestComponent::new(
445477
&harness,
446478
&terminal,
@@ -462,7 +494,7 @@ mod tests {
462494
assert_eq!(component.data().offset_x.get(), 10);
463495
assert_eq!(component.data().offset_y.get(), 2);
464496

465-
let text = ["1 less long line", "2", "3", "4"].join("\n").into();
497+
let text = Text::from_iter(["1 less long line", "2", "3", "4"]).into();
466498
component.set_props(TextWindowProps {
467499
text: &text,
468500
margins: ScrollbarMargins {
@@ -481,9 +513,9 @@ mod tests {
481513
/// automatically be clamped to match
482514
#[rstest]
483515
fn test_grow_window(terminal: TestTerminal, harness: TestHarness) {
484-
let text = ["1 this is a long line", "2", "3", "4", "5"]
485-
.join("\n")
486-
.into();
516+
let text =
517+
Text::from_iter(["1 this is a long line", "2", "3", "4", "5"])
518+
.into();
487519
let mut component = TestComponent::new(
488520
&harness,
489521
&terminal,

0 commit comments

Comments
 (0)