@@ -5,6 +5,7 @@ use crate::{
5
5
context:: UpdateContext ,
6
6
draw:: { Draw , DrawMetadata } ,
7
7
event:: { Event , EventHandler , Update } ,
8
+ state:: { Identified , StateCell } ,
8
9
} ,
9
10
} ;
10
11
use ratatui:: {
@@ -19,6 +20,7 @@ use ratatui::{
19
20
use slumber_config:: Action ;
20
21
use std:: { cell:: Cell , cmp} ;
21
22
use unicode_width:: UnicodeWidthStr ;
23
+ use uuid:: Uuid ;
22
24
23
25
/// A scrollable (but not editable) block of text. Internal state will be
24
26
/// updated on each render, to adjust to the text's width/height. Generally the
@@ -27,14 +29,13 @@ use unicode_width::UnicodeWidthStr;
27
29
/// (especially if it includes syntax highlighting).
28
30
#[ derive( derive_more:: Debug , Default ) ]
29
31
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 > ,
30
35
/// Horizontal scroll
31
36
offset_x : Cell < usize > ,
32
37
/// Vertical scroll
33
38
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 > ,
38
39
/// How wide is the visible text area, excluding gutter/scrollbars?
39
40
window_width : Cell < usize > ,
40
41
/// How tall is the visible text area, exluding gutter/scrollbars?
@@ -45,7 +46,7 @@ pub struct TextWindow {
45
46
pub struct TextWindowProps < ' a > {
46
47
/// Text to render. We take a reference because this component tends to
47
48
/// 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 > > ,
49
50
/// Extra text to render below the text window
50
51
pub footer : Option < Text < ' a > > ,
51
52
pub margins : ScrollbarMargins ,
@@ -70,21 +71,35 @@ impl Default for ScrollbarMargins {
70
71
}
71
72
}
72
73
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
+
73
82
impl TextWindow {
74
83
/// Get the final line that we can't scroll past. This will be the first
75
84
/// line of the last page of text
76
85
fn max_scroll_line ( & self ) -> usize {
77
- self . text_height
86
+ let text_height = self
87
+ . text_size
78
88
. 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 ( ) )
80
92
}
81
93
82
94
/// Get the final column that we can't scroll (horizontally) past. This will
83
95
/// be the left edge of the rightmost "page" of text
84
96
fn max_scroll_column ( & self ) -> usize {
85
- self . text_width
97
+ let text_width = self
98
+ . text_size
86
99
. 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 ( ) )
88
103
}
89
104
90
105
fn scroll_up ( & mut self , lines : usize ) {
@@ -137,6 +152,11 @@ impl TextWindow {
137
152
. take ( self . window_height . get ( ) )
138
153
. enumerate ( ) ;
139
154
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)
140
160
let graphemes = line
141
161
. styled_graphemes ( Style :: default ( ) )
142
162
. skip ( self . offset_x . get ( ) )
@@ -185,31 +205,39 @@ impl<'a> Draw<TextWindowProps<'a>> for TextWindow {
185
205
) {
186
206
let styles = & TuiContext :: get ( ) . styles ;
187
207
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
+ } ) ;
199
227
200
228
let [ gutter_area, _, text_area] = Layout :: horizontal ( [
201
229
// 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
+ ) ,
203
233
Constraint :: Length ( 1 ) , // Spacer
204
234
Constraint :: Min ( 0 ) ,
205
235
] )
206
236
. 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 ;
209
239
210
240
// 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) ;
213
241
self . window_width . set ( text_area. width as usize ) ;
214
242
self . window_height . set ( text_area. height as usize ) ;
215
243
@@ -218,8 +246,10 @@ impl<'a> Draw<TextWindowProps<'a>> for TextWindow {
218
246
219
247
// Draw line numbers in the gutter
220
248
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
+ ) ;
223
253
frame. render_widget (
224
254
Paragraph :: new (
225
255
( first_line..=last_line)
@@ -244,7 +274,7 @@ impl<'a> Draw<TextWindowProps<'a>> for TextWindow {
244
274
x : text_area. x ,
245
275
y : text_area. y
246
276
+ ( cmp:: min (
247
- self . text_height . get ( ) ,
277
+ text_state . height ,
248
278
self . window_height . get ( ) ,
249
279
) ) as u16 ,
250
280
width : text_area. width ,
@@ -257,7 +287,7 @@ impl<'a> Draw<TextWindowProps<'a>> for TextWindow {
257
287
if has_vertical_scroll {
258
288
frame. render_widget (
259
289
Scrollbar {
260
- content_length : self . text_height . get ( ) ,
290
+ content_length : text_state . height ,
261
291
offset : self . offset_y . get ( ) ,
262
292
// We substracted the margin from the text area before, so
263
293
// we have to add that back now
@@ -270,7 +300,7 @@ impl<'a> Draw<TextWindowProps<'a>> for TextWindow {
270
300
if has_horizontal_scroll {
271
301
frame. render_widget (
272
302
Scrollbar {
273
- content_length : self . text_width . get ( ) ,
303
+ content_length : text_state . width ,
274
304
offset : self . offset_x . get ( ) ,
275
305
orientation : ScrollbarOrientation :: HorizontalBottom ,
276
306
// See note on other scrollbar for +1
@@ -299,7 +329,8 @@ mod tests {
299
329
harness : TestHarness ,
300
330
) {
301
331
let text =
302
- Text :: from ( "line 1\n line 2 is longer\n line 3\n line 4\n line 5" ) ;
332
+ Text :: from ( "line 1\n line 2 is longer\n line 3\n line 4\n line 5" )
333
+ . into ( ) ;
303
334
let mut component = TestComponent :: new (
304
335
& harness,
305
336
& terminal,
@@ -383,7 +414,8 @@ mod tests {
383
414
#[ with( 35 , 3 ) ] terminal : TestTerminal ,
384
415
harness : TestHarness ,
385
416
) {
386
- let text = Text :: from ( "intro\n 💚💙💜 this is a longer line\n outro" ) ;
417
+ let text =
418
+ Text :: from ( "intro\n 💚💙💜 this is a longer line\n outro" ) . into ( ) ;
387
419
TestComponent :: new (
388
420
& harness,
389
421
& terminal,
@@ -410,7 +442,7 @@ mod tests {
410
442
#[ with( 10 , 2 ) ] terminal : TestTerminal ,
411
443
harness : TestHarness ,
412
444
) {
413
- let text = Text :: from ( "💚💙💜💚💙💜" ) ;
445
+ let text = Text :: raw ( "💚💙💜💚💙💜" ) . into ( ) ;
414
446
TestComponent :: new (
415
447
& harness,
416
448
& terminal,
@@ -438,9 +470,9 @@ mod tests {
438
470
#[ with( 10 , 3 ) ] terminal : TestTerminal ,
439
471
harness : TestHarness ,
440
472
) {
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 ( ) ;
444
476
let mut component = TestComponent :: new (
445
477
& harness,
446
478
& terminal,
@@ -462,7 +494,7 @@ mod tests {
462
494
assert_eq ! ( component. data( ) . offset_x. get( ) , 10 ) ;
463
495
assert_eq ! ( component. data( ) . offset_y. get( ) , 2 ) ;
464
496
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 ( ) ;
466
498
component. set_props ( TextWindowProps {
467
499
text : & text,
468
500
margins : ScrollbarMargins {
@@ -481,9 +513,9 @@ mod tests {
481
513
/// automatically be clamped to match
482
514
#[ rstest]
483
515
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 ( ) ;
487
519
let mut component = TestComponent :: new (
488
520
& harness,
489
521
& terminal,
0 commit comments