1use std::any::Any;
7use std::sync::{Arc, Mutex};
8
9use super::component::Component;
10use super::layout_node::{LayoutNode, LayoutNodeProvider, ScrollNode};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum FollowMode {
15 None,
17 #[default]
19 End,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum OverscrollMode {
25 #[default]
27 None,
28 Chain,
30 Contain,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum ScrollbarMode {
37 Hidden,
39 #[default]
41 Auto,
42 Always,
44}
45
46#[derive(Debug, Clone, Default)]
48pub struct ScrollViewOptions {
49 pub axis: Option<ScrollAxis>,
51 pub follow: FollowMode,
53 pub primary: bool,
55 pub overscroll: OverscrollMode,
57 pub scrollbar: ScrollbarMode,
59 pub scrollbar_hide_delay_ms: Option<u64>,
61}
62
63#[derive(Debug, Clone, Copy, Default)]
65pub enum ScrollAxis {
66 #[default]
67 Vertical,
68}
69
70#[derive(Debug, Clone)]
72struct ScrollViewState {
73 scroll_top: usize,
75 is_following_end: bool,
77 viewport_height: usize,
79 content_height: usize,
81 follow_suppressed_at_end: bool,
83 transient_scrollbar_visible: bool,
85}
86
87impl ScrollViewState {
88 fn new(follow: FollowMode) -> Self {
89 Self {
90 scroll_top: 0,
91 is_following_end: follow == FollowMode::End,
92 viewport_height: 0,
93 content_height: 0,
94 follow_suppressed_at_end: false,
95 transient_scrollbar_visible: false,
96 }
97 }
98}
99
100#[derive(Clone)]
101struct ScrollRenderCache {
102 content_width: usize,
103 lines: Arc<Vec<String>>,
104}
105
106#[derive(Clone)]
112pub struct ScrollView {
113 child: Arc<dyn Component>,
114 options: ScrollViewOptions,
115 state: Arc<Mutex<ScrollViewState>>,
116 render_cache: Arc<Mutex<Option<ScrollRenderCache>>>,
119 request_render_callback: Arc<Mutex<Option<Arc<dyn Fn() + Send + Sync>>>>,
121}
122
123impl ScrollView {
124 pub fn new(child: Arc<dyn Component>, options: ScrollViewOptions) -> Self {
126 let follow = options.follow;
127 Self {
128 child,
129 options,
130 state: Arc::new(Mutex::new(ScrollViewState::new(follow))),
131 render_cache: Arc::new(Mutex::new(None)),
132 request_render_callback: Arc::new(Mutex::new(None)),
133 }
134 }
135
136 pub fn simple(child: Arc<dyn Component>) -> Self {
138 Self::new(child, ScrollViewOptions::default())
139 }
140
141 pub fn primary(child: Arc<dyn Component>) -> Self {
143 Self::new(
144 child,
145 ScrollViewOptions {
146 follow: FollowMode::End,
147 primary: true,
148 ..Default::default()
149 },
150 )
151 }
152
153 pub fn child(&self) -> &Arc<dyn Component> {
155 &self.child
156 }
157
158 pub fn scroll_top(&self) -> usize {
160 self.state.lock().map(|s| s.scroll_top).unwrap_or(0)
161 }
162
163 pub fn is_following_end(&self) -> bool {
165 self.state
166 .lock()
167 .map(|s| s.is_following_end)
168 .unwrap_or(false)
169 }
170
171 pub fn follows_end(&self) -> bool {
173 self.options.follow == FollowMode::End
174 }
175
176 pub fn viewport_height(&self) -> usize {
178 self.state.lock().map(|s| s.viewport_height).unwrap_or(0)
179 }
180
181 pub fn content_height(&self) -> usize {
183 self.state.lock().map(|s| s.content_height).unwrap_or(0)
184 }
185
186 pub fn is_primary(&self) -> bool {
188 self.options.primary
189 }
190
191 pub fn is_scrollbar_visible(&self) -> bool {
193 match self.options.scrollbar {
194 ScrollbarMode::Always => self.viewport_height() > 0,
195 ScrollbarMode::Auto => {
196 self.content_height() > self.viewport_height()
197 && self
198 .state
199 .lock()
200 .map(|s| s.transient_scrollbar_visible)
201 .unwrap_or(false)
202 }
203 ScrollbarMode::Hidden => false,
204 }
205 }
206
207 pub fn get_content_width(&self, width: usize) -> usize {
209 if self.options.scrollbar == ScrollbarMode::Always && width > 1 {
210 width - 1
211 } else {
212 width
213 }
214 }
215
216 pub fn scroll_by(&self, delta: i32) -> i32 {
221 if delta == 0 {
222 return 0;
223 }
224
225 if let Ok(mut state) = self.state.lock() {
226 let max_scroll = state.content_height.saturating_sub(state.viewport_height);
227
228 let start = if state.is_following_end {
230 max_scroll
231 } else {
232 state.scroll_top
233 };
234
235 let new_top = if delta > 0 {
236 start.saturating_add(delta as usize)
237 } else {
238 start.saturating_sub((-delta) as usize)
239 };
240
241 let clamped = new_top.min(max_scroll);
242 let moved = (clamped as i32) - (start as i32);
243
244 state.scroll_top = clamped;
245 state.is_following_end =
246 self.options.follow == FollowMode::End && clamped >= max_scroll;
247 state.follow_suppressed_at_end = false;
248
249 if moved != 0 {
251 state.transient_scrollbar_visible = true;
252 }
253
254 delta - moved
255 } else {
256 delta
257 }
258 }
259
260 pub fn scroll_to(&self, position: usize) {
262 if let Ok(mut state) = self.state.lock() {
263 let max_scroll = state.content_height.saturating_sub(state.viewport_height);
264 state.scroll_top = position.min(max_scroll);
265
266 if self.options.follow == FollowMode::End {
267 state.is_following_end = state.scroll_top >= max_scroll;
268 }
269 state.follow_suppressed_at_end = false;
270 state.transient_scrollbar_visible = true;
271 }
272 self.request_render();
273 }
274
275 pub fn scroll_to_start(&self) {
277 if let Ok(mut state) = self.state.lock() {
278 let content_fits = state.content_height <= state.viewport_height;
279 let following_end = self.options.follow == FollowMode::End && content_fits;
280 let changed = state.scroll_top != 0 || state.is_following_end != following_end;
281 state.scroll_top = 0;
282 state.is_following_end = following_end;
283 state.follow_suppressed_at_end = false;
284
285 if changed {
286 state.transient_scrollbar_visible = true;
287 }
288 }
289 self.request_render();
290 }
291
292 pub fn scroll_to_end(&self) {
294 if let Ok(mut state) = self.state.lock() {
295 let max_scroll = state.content_height.saturating_sub(state.viewport_height);
296 let changed = state.scroll_top != max_scroll || !state.is_following_end;
297 state.scroll_top = max_scroll;
298 state.is_following_end = self.options.follow == FollowMode::End;
299 state.follow_suppressed_at_end = false;
300
301 if changed {
302 state.transient_scrollbar_visible = true;
303 }
304 }
305 self.request_render();
306 }
307
308 pub fn update_layout(
310 &self,
311 content_height: usize,
312 viewport_height: usize,
313 request_render: Option<Arc<dyn Fn() + Send + Sync>>,
314 ) {
315 if let Ok(mut state) = self.state.lock() {
316 state.content_height = content_height;
317 state.viewport_height = viewport_height;
318
319 if let Some(cb) = request_render {
321 *self.request_render_callback.lock().unwrap() = Some(cb);
322 }
323
324 if self.options.follow == FollowMode::End && state.is_following_end {
326 let max_scroll = content_height.saturating_sub(viewport_height);
327 state.scroll_top = max_scroll;
328 }
329
330 let max_scroll = content_height.saturating_sub(viewport_height);
332 state.scroll_top = state.scroll_top.min(max_scroll);
333
334 if state.scroll_top < max_scroll {
336 state.follow_suppressed_at_end = false;
337 }
338 if self.options.follow == FollowMode::End
339 && state.scroll_top == max_scroll
340 && !state.follow_suppressed_at_end
341 {
342 state.is_following_end = true;
343 }
344
345 if content_height <= viewport_height {
347 state.transient_scrollbar_visible = false;
348 }
349 }
350 }
351
352 fn request_render(&self) {
354 if let Ok(cb) = self.request_render_callback.lock() {
355 if let Some(callback) = cb.as_ref() {
356 callback();
357 }
358 }
359 }
360
361 pub fn render_with_viewport(&self, width: usize, height: usize) -> Vec<String> {
367 self.render_with_viewport_impl(width, height, false)
368 }
369
370 pub fn render_with_cached_content(&self, width: usize, height: usize) -> Vec<String> {
373 self.render_with_viewport_impl(width, height, true)
374 }
375
376 fn render_with_viewport_impl(
377 &self,
378 width: usize,
379 height: usize,
380 reuse_cached_content: bool,
381 ) -> Vec<String> {
382 let content_width = self.get_content_width(width);
383 let cached = if reuse_cached_content {
384 self.render_cache
385 .lock()
386 .ok()
387 .and_then(|cache| cache.clone())
388 .filter(|cache| cache.content_width == content_width)
389 } else {
390 None
391 };
392 let all_lines = cached.map(|cache| cache.lines).unwrap_or_else(|| {
393 let lines = Arc::new(self.child.render(content_width));
394 if let Ok(mut cache) = self.render_cache.lock() {
395 *cache = Some(ScrollRenderCache {
396 content_width,
397 lines: lines.clone(),
398 });
399 }
400 lines
401 });
402 let content_height = all_lines.len();
403
404 self.update_layout(content_height, height, None);
406
407 let scroll_top = self.scroll_top();
408
409 let visible: Vec<String> = all_lines
411 .iter()
412 .skip(scroll_top)
413 .take(height)
414 .cloned()
415 .collect();
416
417 let mut visible = visible;
419 while visible.len() < height {
420 visible.push(String::new());
421 }
422
423 if self.is_scrollbar_visible() {
428 let total = content_height.max(1);
429 let viewport = height.max(1);
430 let thumb = if total <= viewport {
431 viewport
432 } else {
433 (viewport * viewport / total).max(1)
434 };
435 let thumb_top = if total > viewport {
436 scroll_top * (viewport.saturating_sub(thumb)) / (total - viewport)
437 } else {
438 0
439 };
440 for (i, line) in visible.iter_mut().enumerate() {
441 let on = i >= thumb_top && i < thumb_top + thumb;
442 if width > 0 && !line.contains("\x1b_G") && !line.contains("\x1b]1337;File=") {
443 let cell = if on {
444 self.scrollbar_style(" ")
445 } else {
446 " ".to_owned()
447 };
448 *line = crate::layout::composite_tui_line(line, &cell, width - 1, 1, width);
449 }
450 }
451 }
452
453 visible
454 }
455
456 pub fn scrollbar_style(&self, text: &str) -> String {
458 format!("\x1b[100m{}\x1b[49m", text)
459 }
460}
461
462impl Component for ScrollView {
463 fn render(&self, width: usize) -> Vec<String> {
464 let content_width = self.get_content_width(width);
466 self.child.render(content_width)
467 }
468
469 fn invalidate(&self) {
470 if let Ok(mut cache) = self.render_cache.lock() {
471 *cache = None;
472 }
473 self.child.invalidate();
474 }
475
476 fn as_any(&self) -> &dyn Any {
477 self
478 }
479}
480
481impl LayoutNodeProvider for ScrollView {
482 fn layout_node(&self) -> Option<LayoutNode> {
483 Some(LayoutNode::Scroll(ScrollNode::new(self.child.clone())))
484 }
485}
486
487pub fn render_scroll_view(scroll_view: &ScrollView, width: usize, height: usize) -> Vec<String> {
489 scroll_view.render_with_viewport(width, height)
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::Text;
496
497 #[test]
498 fn test_scroll_view_basic() {
499 let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3\nLine 4\nLine 5", 0, 0));
500 let scroll = ScrollView::simple(text);
501
502 assert_eq!(scroll.scroll_top(), 0);
503 assert!(scroll.is_following_end());
504 }
505
506 #[test]
507 fn test_scroll_by() {
508 let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3\nLine 4\nLine 5", 0, 0));
509 let scroll = ScrollView::new(
510 text,
511 ScrollViewOptions {
512 follow: FollowMode::None,
513 ..Default::default()
514 },
515 );
516
517 scroll.update_layout(5, 2, None);
519
520 let unused = scroll.scroll_by(1);
521 assert_eq!(unused, 0);
522 assert_eq!(scroll.scroll_top(), 1);
523 }
524
525 #[test]
526 fn test_scroll_to_end() {
527 let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3", 0, 0));
528 let scroll = ScrollView::new(
529 text,
530 ScrollViewOptions {
531 follow: FollowMode::End,
532 ..Default::default()
533 },
534 );
535
536 scroll.update_layout(3, 1, None);
537 scroll.scroll_to_end();
538
539 assert!(scroll.is_following_end());
540 assert_eq!(scroll.scroll_top(), 2);
541 }
542
543 #[test]
544 fn test_scroll_to_start() {
545 let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3", 0, 0));
546 let scroll = ScrollView::simple(text);
547
548 scroll.update_layout(3, 1, None);
549 scroll.scroll_by(2);
550 scroll.scroll_to_start();
551
552 assert_eq!(scroll.scroll_top(), 0);
553 assert!(!scroll.is_following_end());
554 }
555}
556#[cfg(test)]
557mod scroll_tests {
558 use super::*;
559 use crate::Container;
560 use crate::Text;
561
562 fn tall_scroll() -> (Arc<ScrollView>, Arc<Container>) {
563 let inner = Arc::new(Container::new());
564 for i in 0..50 {
565 inner.add_child(Arc::new(Text::new(format!("line {i:02}"), 0, 0)));
566 }
567 let sv = Arc::new(ScrollView::new(
568 inner.clone(),
569 ScrollViewOptions {
570 follow: FollowMode::End,
571 primary: true,
572 ..Default::default()
573 },
574 ));
575 (sv, inner)
576 }
577
578 #[test]
579 fn scroll_by_changes_visible_window() {
580 let (sv, _inner) = tall_scroll();
581 let before = sv.render_with_viewport(40, 10);
582 assert!(
583 before[0].contains("line 40"),
584 "following end shows tail: {:?}",
585 before[0]
586 );
587 let unused = sv.scroll_by(-10);
589 assert_eq!(unused, 0);
590 let after = sv.render_with_viewport(40, 10);
591 assert!(
592 after[0].contains("line 30"),
593 "scroll up should reveal older lines, got {:?}",
594 after[0]
595 );
596 sv.scroll_by(10);
598 let back = sv.render_with_viewport(40, 10);
599 assert!(back[0].contains("line 40"));
600 }
601
602 #[test]
603 fn manual_scroll_up_stays_put_until_user_returns_to_end() {
604 let (sv, inner) = tall_scroll();
605 sv.render_with_viewport(40, 10); assert!(sv.is_following_end());
607 sv.scroll_by(-3);
608 assert!(!sv.is_following_end(), "manual scroll leaves follow mode");
609
610 let position = sv.scroll_top();
611 inner.add_child(Arc::new(Text::new("new output", 0, 0)));
612 sv.render_with_viewport(40, 10);
613 assert_eq!(
614 sv.scroll_top(),
615 position,
616 "new output must not steal the viewport"
617 );
618 assert!(!sv.is_following_end());
619
620 sv.scroll_to_end();
621 assert!(sv.is_following_end());
622 let previous_end = sv.scroll_top();
623 inner.add_child(Arc::new(Text::new("more output", 0, 0)));
624 sv.render_with_viewport(40, 10);
625 assert!(
626 sv.scroll_top() > previous_end,
627 "tail should advance after follow is restored"
628 );
629 }
630
631 #[test]
632 fn scroll_to_start_keeps_follow_when_content_fits() {
633 let text = Arc::new(Text::new("short", 0, 0));
634 let sv = ScrollView::simple(text);
635 sv.update_layout(1, 10, None);
636
637 sv.scroll_to_start();
638
639 assert!(sv.is_following_end());
640 }
641}