use std::any::Any;
use std::sync::{Arc, Mutex};
use super::component::{Component, RenderedDocument};
use super::layout_node::{LayoutNode, LayoutNodeProvider, ScrollNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FollowMode {
None,
#[default]
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OverscrollMode {
#[default]
None,
Chain,
Contain,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollbarMode {
Hidden,
#[default]
Auto,
Always,
}
#[derive(Debug, Clone, Default)]
pub struct ScrollViewOptions {
pub axis: Option<ScrollAxis>,
pub follow: FollowMode,
pub preserve_manual_position: bool,
pub primary: bool,
pub overscroll: OverscrollMode,
pub scrollbar: ScrollbarMode,
pub scrollbar_hide_delay_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum ScrollAxis {
#[default]
Vertical,
}
#[derive(Debug, Clone)]
struct ScrollViewState {
scroll_top: usize,
is_following_end: bool,
viewport_height: usize,
content_height: usize,
follow_suppressed_at_end: bool,
transient_scrollbar_visible: bool,
}
impl ScrollViewState {
fn new(follow: FollowMode) -> Self {
Self {
scroll_top: 0,
is_following_end: follow == FollowMode::End,
viewport_height: 0,
content_height: 0,
follow_suppressed_at_end: false,
transient_scrollbar_visible: false,
}
}
}
#[derive(Clone)]
struct ScrollRenderCache {
content_width: usize,
document: Arc<RenderedDocument>,
}
#[derive(Clone)]
pub struct ScrollView {
child: Arc<dyn Component>,
options: ScrollViewOptions,
state: Arc<Mutex<ScrollViewState>>,
render_cache: Arc<Mutex<Option<ScrollRenderCache>>>,
request_render_callback: Arc<Mutex<Option<Arc<dyn Fn() + Send + Sync>>>>,
}
impl ScrollView {
pub fn new(child: Arc<dyn Component>, options: ScrollViewOptions) -> Self {
let follow = options.follow;
Self {
child,
options,
state: Arc::new(Mutex::new(ScrollViewState::new(follow))),
render_cache: Arc::new(Mutex::new(None)),
request_render_callback: Arc::new(Mutex::new(None)),
}
}
pub fn simple(child: Arc<dyn Component>) -> Self {
Self::new(child, ScrollViewOptions::default())
}
pub fn primary(child: Arc<dyn Component>) -> Self {
Self::new(
child,
ScrollViewOptions {
follow: FollowMode::End,
primary: true,
..Default::default()
},
)
}
pub fn child(&self) -> &Arc<dyn Component> {
&self.child
}
pub fn scroll_top(&self) -> usize {
self.state.lock().map(|s| s.scroll_top).unwrap_or(0)
}
pub fn is_following_end(&self) -> bool {
self.state
.lock()
.map(|s| s.is_following_end)
.unwrap_or(false)
}
pub fn follows_end(&self) -> bool {
self.options.follow == FollowMode::End
}
pub fn viewport_height(&self) -> usize {
self.state.lock().map(|s| s.viewport_height).unwrap_or(0)
}
pub fn content_height(&self) -> usize {
self.state.lock().map(|s| s.content_height).unwrap_or(0)
}
pub fn is_primary(&self) -> bool {
self.options.primary
}
pub fn is_scrollbar_visible(&self) -> bool {
match self.options.scrollbar {
ScrollbarMode::Always => self.viewport_height() > 0,
ScrollbarMode::Auto => {
self.content_height() > self.viewport_height()
&& self
.state
.lock()
.map(|s| s.transient_scrollbar_visible)
.unwrap_or(false)
}
ScrollbarMode::Hidden => false,
}
}
pub fn get_content_width(&self, width: usize) -> usize {
if self.options.scrollbar == ScrollbarMode::Always && width > 1 {
width - 1
} else {
width
}
}
pub fn scroll_by(&self, delta: i32) -> i32 {
if delta == 0 {
return 0;
}
if let Ok(mut state) = self.state.lock() {
let max_scroll = state.content_height.saturating_sub(state.viewport_height);
let start = if state.is_following_end {
max_scroll
} else {
state.scroll_top
};
let new_top = if delta > 0 {
start.saturating_add(delta as usize)
} else {
start.saturating_sub((-delta) as usize)
};
let clamped = new_top.min(max_scroll);
let moved = (clamped as i32) - (start as i32);
state.scroll_top = clamped;
state.is_following_end =
self.options.follow == FollowMode::End && clamped >= max_scroll;
state.follow_suppressed_at_end = false;
if moved != 0 {
state.transient_scrollbar_visible = true;
}
delta - moved
} else {
delta
}
}
pub fn scroll_to(&self, position: usize) {
if let Ok(mut state) = self.state.lock() {
let max_scroll = state.content_height.saturating_sub(state.viewport_height);
state.scroll_top = position.min(max_scroll);
if self.options.follow == FollowMode::End {
state.is_following_end = state.scroll_top >= max_scroll;
}
state.follow_suppressed_at_end = false;
state.transient_scrollbar_visible = true;
}
self.request_render();
}
pub fn scroll_to_start(&self) {
if let Ok(mut state) = self.state.lock() {
let content_fits = state.content_height <= state.viewport_height;
let following_end = self.options.follow == FollowMode::End && content_fits;
let changed = state.scroll_top != 0 || state.is_following_end != following_end;
state.scroll_top = 0;
state.is_following_end = following_end;
state.follow_suppressed_at_end = false;
if changed {
state.transient_scrollbar_visible = true;
}
}
self.request_render();
}
pub fn scroll_to_end(&self) {
if let Ok(mut state) = self.state.lock() {
let max_scroll = state.content_height.saturating_sub(state.viewport_height);
let changed = state.scroll_top != max_scroll || !state.is_following_end;
state.scroll_top = max_scroll;
state.is_following_end = self.options.follow == FollowMode::End;
state.follow_suppressed_at_end = false;
if changed {
state.transient_scrollbar_visible = true;
}
}
self.request_render();
}
pub fn update_layout(
&self,
content_height: usize,
viewport_height: usize,
request_render: Option<Arc<dyn Fn() + Send + Sync>>,
) {
if let Ok(mut state) = self.state.lock() {
state.content_height = content_height;
state.viewport_height = viewport_height;
if let Some(cb) = request_render {
*self.request_render_callback.lock().unwrap() = Some(cb);
}
if self.options.follow == FollowMode::End && state.is_following_end {
let max_scroll = content_height.saturating_sub(viewport_height);
state.scroll_top = max_scroll;
}
let max_scroll = content_height.saturating_sub(viewport_height);
state.scroll_top = state.scroll_top.min(max_scroll);
if state.scroll_top < max_scroll {
state.follow_suppressed_at_end = false;
}
if self.options.follow == FollowMode::End
&& !self.options.preserve_manual_position
&& state.scroll_top == max_scroll
&& !state.follow_suppressed_at_end
{
state.is_following_end = true;
}
if content_height <= viewport_height {
state.transient_scrollbar_visible = false;
}
}
}
fn request_render(&self) {
if let Ok(cb) = self.request_render_callback.lock() {
if let Some(callback) = cb.as_ref() {
callback();
}
}
}
pub fn render_with_viewport(&self, width: usize, height: usize) -> Vec<String> {
self.render_with_viewport_impl(width, height, false)
}
pub fn render_with_cached_content(&self, width: usize, height: usize) -> Vec<String> {
self.render_with_viewport_impl(width, height, true)
}
fn render_with_viewport_impl(
&self,
width: usize,
height: usize,
reuse_cached_content: bool,
) -> Vec<String> {
let content_width = self.get_content_width(width);
let cached = if reuse_cached_content {
self.render_cache
.lock()
.ok()
.and_then(|cache| cache.clone())
.filter(|cache| cache.content_width == content_width)
} else {
None
};
let document = cached.map(|cache| cache.document).unwrap_or_else(|| {
let document = Arc::new(self.child.render_document(content_width));
if let Ok(mut cache) = self.render_cache.lock() {
*cache = Some(ScrollRenderCache {
content_width,
document: document.clone(),
});
}
document
});
let content_height = document.len();
self.update_layout(content_height, height, None);
let scroll_top = self.scroll_top();
let visible = document.lines(scroll_top, height);
let mut visible = visible;
while visible.len() < height {
visible.push(String::new());
}
if self.is_scrollbar_visible() {
let total = content_height.max(1);
let viewport = height.max(1);
let thumb = if total <= viewport {
viewport
} else {
(viewport * viewport / total).max(1)
};
let thumb_top = if total > viewport {
scroll_top * (viewport.saturating_sub(thumb)) / (total - viewport)
} else {
0
};
for (i, line) in visible.iter_mut().enumerate() {
let on = i >= thumb_top && i < thumb_top + thumb;
if width > 0 && !line.contains("\x1b_G") && !line.contains("\x1b]1337;File=") {
let cell = if on {
self.scrollbar_style(" ")
} else {
" ".to_owned()
};
*line = crate::layout::composite_tui_line(line, &cell, width - 1, 1, width);
}
}
}
visible
}
pub fn scrollbar_style(&self, text: &str) -> String {
format!("\x1b[100m{}\x1b[49m", text)
}
}
impl Component for ScrollView {
fn render(&self, width: usize) -> Vec<String> {
let content_width = self.get_content_width(width);
self.child.render(content_width)
}
fn invalidate(&self) {
if let Ok(mut cache) = self.render_cache.lock() {
*cache = None;
}
self.child.invalidate();
}
fn as_any(&self) -> &dyn Any {
self
}
}
impl LayoutNodeProvider for ScrollView {
fn layout_node(&self) -> Option<LayoutNode> {
Some(LayoutNode::Scroll(ScrollNode::new(self.child.clone())))
}
}
pub fn render_scroll_view(scroll_view: &ScrollView, width: usize, height: usize) -> Vec<String> {
scroll_view.render_with_viewport(width, height)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Text;
struct SharedHistory(Arc<Vec<String>>);
impl Component for SharedHistory {
fn render(&self, _: usize) -> Vec<String> {
panic!("fullscreen must not flatten historical rows");
}
fn render_document(&self, _: usize) -> RenderedDocument {
RenderedDocument::from_shared(self.0.clone())
}
fn invalidate(&self) {}
fn as_any(&self) -> &dyn Any {
self
}
}
struct MutableRow(Mutex<String>);
impl Component for MutableRow {
fn render(&self, _: usize) -> Vec<String> {
vec![self.0.lock().unwrap().clone()]
}
fn invalidate(&self) {}
fn as_any(&self) -> &dyn Any {
self
}
}
#[test]
fn fresh_frames_share_history_and_refresh_arbitrary_mutable_children() {
for size in [1, 1_000, 10_000] {
let rows = Arc::new(vec!["historical row".repeat(16); size]);
let transcript = Arc::new(crate::Container::new());
transcript.add_child(Arc::new(SharedHistory(rows.clone())));
let live = Arc::new(MutableRow(Mutex::new("first update".into())));
let live_component: Arc<dyn Component> = live.clone();
transcript.add_child(live_component.clone());
let wrapper = Arc::new(crate::Container::new());
wrapper.add_child(transcript.clone());
let scroll = ScrollView::new(
wrapper,
ScrollViewOptions {
scrollbar: ScrollbarMode::Hidden,
preserve_manual_position: true,
..Default::default()
},
);
assert_eq!(scroll.render_with_viewport(40, 1), ["first update"]);
assert_eq!(Arc::strong_count(&rows), 3);
*live.0.lock().unwrap() = "second update".into();
assert_eq!(scroll.render_with_viewport(40, 1), ["second update"]);
assert_eq!(Arc::strong_count(&rows), 3);
scroll.scroll_to_start();
let position = scroll.scroll_top();
*live.0.lock().unwrap() = "third update".into();
scroll.render_with_viewport(40, 1);
assert_eq!(scroll.scroll_top(), position);
assert!(!scroll.is_following_end());
scroll.scroll_to_end();
assert_eq!(scroll.render_with_cached_content(40, 1), ["third update"]);
transcript.remove_child(&live_component);
scroll.render_with_viewport(40, 1);
transcript.add_child(live_component);
assert_eq!(scroll.render_with_viewport(20, 1), ["third update"]);
scroll.invalidate();
*live.0.lock().unwrap() = "after invalidation".into();
assert_eq!(
scroll.render_with_cached_content(20, 1),
["after invalidation"]
);
assert_eq!(Arc::strong_count(&rows), 3);
}
}
#[test]
#[ignore = "manual render-cost comparison; run with --ignored --nocapture"]
fn compare_flat_and_shared_long_transcript_frames() {
use std::hint::black_box;
use std::time::Instant;
for count in [10, 200] {
let transcript = crate::Container::new();
for index in 0..count {
transcript.add_child(Arc::new(crate::UserMessageComponent::new(format!(
"prompt {index}\n{}",
"historical prompt text\n".repeat(10)
))));
let response = Arc::new(crate::AssistantMessageComponent::default());
response.update_text(&"historical **response** with `code`\n".repeat(20));
transcript.add_child(response);
}
let live = Arc::new(crate::AssistantMessageComponent::default());
transcript.add_child(live.clone());
live.update_text("initial live output");
let expected = transcript.render(80);
let document = transcript.render_document(80);
assert_eq!(document.lines(0, document.len()), expected);
let rows = expected.len();
let frames = 100;
let started = Instant::now();
for index in 0..frames {
live.update_text(&format!("live output {index}"));
let lines = transcript.render(80);
black_box(&lines[lines.len().saturating_sub(20)..]);
}
let flat = started.elapsed();
let started = Instant::now();
for index in 0..frames {
live.update_text(&format!("live output {index}"));
let document = transcript.render_document(80);
black_box(document.lines(document.len().saturating_sub(20), 20));
}
let shared = started.elapsed();
eprintln!("{count} pairs / {rows} rows / {frames} fresh frames: flat={flat:?}, shared={shared:?}");
}
}
#[test]
fn shared_message_updates_match_legacy_rendering() {
let transcript = Arc::new(crate::Container::new());
transcript.add_child(Arc::new(crate::UserMessageComponent::new("user prompt")));
let assistant = Arc::new(crate::AssistantMessageComponent::default());
assistant.update_text("first **response**");
assistant.set_trailing_spacing(1);
transcript.add_child(assistant.clone());
let bash = Arc::new(crate::BashExecutionComponent::new("echo result"));
transcript.add_child(bash.clone());
let scroll = ScrollView::new(
transcript.clone(),
ScrollViewOptions {
scrollbar: ScrollbarMode::Hidden,
..Default::default()
},
);
for width in [80, 30] {
assistant.update_text("latest **response**");
bash.append_output("latest tool output");
bash.set_complete(Some(0), false, crate::BashTruncation::default());
let expected = transcript.render(width);
let actual = scroll.render_with_viewport(width, expected.len());
assert_eq!(actual, expected);
assert!(crate::strip_ansi(&actual.join("\n")).contains("latest response"));
assert!(crate::strip_ansi(&actual.join("\n")).contains("latest tool output"));
}
}
#[test]
fn test_scroll_view_basic() {
let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3\nLine 4\nLine 5", 0, 0));
let scroll = ScrollView::simple(text);
assert_eq!(scroll.scroll_top(), 0);
assert!(scroll.is_following_end());
}
#[test]
fn test_scroll_by() {
let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3\nLine 4\nLine 5", 0, 0));
let scroll = ScrollView::new(
text,
ScrollViewOptions {
follow: FollowMode::None,
..Default::default()
},
);
scroll.update_layout(5, 2, None);
let unused = scroll.scroll_by(1);
assert_eq!(unused, 0);
assert_eq!(scroll.scroll_top(), 1);
}
#[test]
fn test_scroll_to_end() {
let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3", 0, 0));
let scroll = ScrollView::new(
text,
ScrollViewOptions {
follow: FollowMode::End,
..Default::default()
},
);
scroll.update_layout(3, 1, None);
scroll.scroll_to_end();
assert!(scroll.is_following_end());
assert_eq!(scroll.scroll_top(), 2);
}
#[test]
fn test_scroll_to_start() {
let text = Arc::new(Text::new("Line 1\nLine 2\nLine 3", 0, 0));
let scroll = ScrollView::simple(text);
scroll.update_layout(3, 1, None);
scroll.scroll_by(2);
scroll.scroll_to_start();
assert_eq!(scroll.scroll_top(), 0);
assert!(!scroll.is_following_end());
}
}
#[cfg(test)]
mod scroll_tests {
use super::*;
use crate::Container;
use crate::Text;
fn tall_scroll() -> (Arc<ScrollView>, Arc<Container>) {
let inner = Arc::new(Container::new());
for i in 0..50 {
inner.add_child(Arc::new(Text::new(format!("line {i:02}"), 0, 0)));
}
let sv = Arc::new(ScrollView::new(
inner.clone(),
ScrollViewOptions {
follow: FollowMode::End,
primary: true,
..Default::default()
},
));
(sv, inner)
}
#[test]
fn scroll_by_changes_visible_window() {
let (sv, _inner) = tall_scroll();
let before = sv.render_with_viewport(40, 10);
assert!(
before[0].contains("line 40"),
"following end shows tail: {:?}",
before[0]
);
let unused = sv.scroll_by(-10);
assert_eq!(unused, 0);
let after = sv.render_with_viewport(40, 10);
assert!(
after[0].contains("line 30"),
"scroll up should reveal older lines, got {:?}",
after[0]
);
sv.scroll_by(10);
let back = sv.render_with_viewport(40, 10);
assert!(back[0].contains("line 40"));
}
#[test]
fn manual_scroll_up_stays_put_until_user_returns_to_end() {
let (sv, inner) = tall_scroll();
sv.render_with_viewport(40, 10); assert!(sv.is_following_end());
sv.scroll_by(-3);
assert!(!sv.is_following_end(), "manual scroll leaves follow mode");
let position = sv.scroll_top();
inner.add_child(Arc::new(Text::new("new output", 0, 0)));
sv.render_with_viewport(40, 10);
assert_eq!(
sv.scroll_top(),
position,
"new output must not steal the viewport"
);
assert!(!sv.is_following_end());
sv.scroll_to_end();
assert!(sv.is_following_end());
let previous_end = sv.scroll_top();
inner.add_child(Arc::new(Text::new("more output", 0, 0)));
sv.render_with_viewport(40, 10);
assert!(
sv.scroll_top() > previous_end,
"tail should advance after follow is restored"
);
}
#[test]
fn detached_scroll_does_not_resume_follow_when_reflow_temporarily_fits() {
let text = Arc::new(Text::new("line\n".repeat(30), 0, 0));
let sv = ScrollView::new(
text.clone(),
ScrollViewOptions {
preserve_manual_position: true,
..Default::default()
},
);
sv.render_with_viewport(40, 10);
sv.scroll_by(-5);
assert!(!sv.is_following_end());
sv.render_with_viewport(40, 100);
assert!(!sv.is_following_end());
text.set_text("line\n".repeat(200));
sv.render_with_viewport(40, 10);
assert_eq!(sv.scroll_top(), 0);
assert!(!sv.is_following_end());
sv.scroll_to_end();
assert!(sv.is_following_end());
}
#[test]
fn scroll_to_start_keeps_follow_when_content_fits() {
let text = Arc::new(Text::new("short", 0, 0));
let sv = ScrollView::simple(text);
sv.update_layout(1, 10, None);
sv.scroll_to_start();
assert!(sv.is_following_end());
}
}