///|
/// ScrollableList - Scrollable list component with auto-scroll support
///|
/// Scroll state for managing scrollable content
pub(all) struct ScrollState {
/// Current scroll offset (0 = top)
offset : @signals.Signal[Int]
/// Auto-scroll enabled (follows new content when at bottom)
auto_scroll : @signals.Signal[Bool]
}
///|
/// Create a new scroll state
pub fn ScrollState::new() -> ScrollState {
{ offset: @signals.signal(0), auto_scroll: @signals.signal(true) }
}
///|
/// Create scroll state with initial values
pub fn ScrollState::with_values(
offset : Int,
auto_scroll : Bool,
) -> ScrollState {
{ offset: @signals.signal(offset), auto_scroll: @signals.signal(auto_scroll) }
}
///|
/// Calculate max scroll offset
/// total_lines: total number of content lines
/// visible_height: number of visible lines (excluding scroll indicator)
/// reserve_indicator: whether to reserve 1 line for scroll indicator
pub fn calc_max_scroll(
total_lines : Int,
visible_height : Int,
reserve_indicator? : Bool = true,
) -> Int {
let content_height = if reserve_indicator && total_lines > visible_height {
visible_height - 1
} else {
visible_height
}
if total_lines > content_height {
total_lines - content_height
} else {
0
}
}
///|
/// Check if scroll is at bottom
pub fn ScrollState::is_at_bottom(
self : ScrollState,
total_lines : Int,
visible_height : Int,
) -> Bool {
let max = calc_max_scroll(total_lines, visible_height)
self.offset.get() >= max
}
///|
/// Scroll up by given amount
/// Returns true if scroll position changed
pub fn ScrollState::scroll_up(self : ScrollState, amount : Int) -> Bool {
let current = self.offset.get()
if current > 0 {
let new_offset = if current >= amount { current - amount } else { 0 }
self.offset.set(new_offset)
self.auto_scroll.set(false) // Disable auto-scroll when user scrolls up
true
} else {
false
}
}
///|
/// Scroll down by given amount
/// Returns true if scroll position changed
pub fn ScrollState::scroll_down(
self : ScrollState,
amount : Int,
total_lines : Int,
visible_height : Int,
) -> Bool {
let max = calc_max_scroll(total_lines, visible_height)
let current = self.offset.get()
if current < max {
let new_offset = if current + amount > max { max } else { current + amount }
self.offset.set(new_offset)
// Re-enable auto-scroll if at bottom
if new_offset >= max {
self.auto_scroll.set(true)
}
true
} else {
false
}
}
///|
/// Scroll to bottom
pub fn ScrollState::scroll_to_bottom(
self : ScrollState,
total_lines : Int,
visible_height : Int,
) -> Unit {
let max = calc_max_scroll(total_lines, visible_height)
self.offset.set(max)
self.auto_scroll.set(true)
}
///|
/// Auto-scroll to bottom if enabled
/// Call this when new content is added
pub fn ScrollState::auto_scroll_to_bottom(
self : ScrollState,
total_lines : Int,
visible_height : Int,
) -> Unit {
if self.auto_scroll.get() {
let max = calc_max_scroll(total_lines, visible_height)
self.offset.set(max)
}
}
///|
/// Reset scroll state (scroll to top, enable auto-scroll)
pub fn ScrollState::reset(self : ScrollState) -> Unit {
self.offset.set(0)
self.auto_scroll.set(true)
}
///|
/// Scroll indicator type
pub(all) enum ScrollIndicator {
None
Up
Down
Both
} derive(Show)
///|
/// Get scroll indicator based on current position
pub fn ScrollState::get_indicator(
self : ScrollState,
total_lines : Int,
visible_height : Int,
) -> ScrollIndicator {
let max = calc_max_scroll(total_lines, visible_height)
if max == 0 {
ScrollIndicator::None
} else {
let offset = self.offset.get()
if offset > 0 && offset < max {
ScrollIndicator::Both
} else if offset > 0 {
ScrollIndicator::Up
} else {
ScrollIndicator::Down
}
}
}
///|
/// Get scroll indicator text
pub fn scroll_indicator_text(indicator : ScrollIndicator) -> String {
match indicator {
ScrollIndicator::None => ""
ScrollIndicator::Up => "↑ scroll"
ScrollIndicator::Down => "↓ scroll"
ScrollIndicator::Both => "↑↓ scroll"
}
}
///|
/// Scroll indicator component
pub fn scroll_indicator(
indicator : ScrollIndicator,
fg? : @core.Color = @core.Color::rgb(80, 80, 80),
) -> @core.Component {
text(scroll_indicator_text(indicator), fg~)
}
///|
/// Apply scroll offset to an array of items
/// Returns the visible slice based on scroll offset and visible height
pub fn[T] apply_scroll(
items : Array[T],
offset : Int,
visible_height : Int,
reserve_indicator? : Bool = true,
) -> Array[T] {
let total = items.length()
let has_scroll = total > visible_height
let content_height = if reserve_indicator && has_scroll {
visible_height - 1
} else {
visible_height
}
let result : Array[T] = []
let mut added = 0
for i, item in items {
if i >= offset && added < content_height {
result.push(item)
added = added + 1
}
}
result
}
///|
/// Scrollable list component
/// items: array of components to display
/// state: scroll state
/// visible_height: number of visible lines
/// show_indicator: whether to show scroll indicator
pub fn scrollable_list(
items : Array[@core.Component],
state : ScrollState,
visible_height : Int,
show_indicator? : Bool = true,
indicator_fg? : @core.Color = @core.Color::rgb(80, 80, 80),
) -> Array[@core.Component] {
let total = items.length()
let offset = state.offset.get()
let visible = apply_scroll(items, offset, visible_height)
if show_indicator {
let indicator = state.get_indicator(total, visible_height)
match indicator {
ScrollIndicator::None => visible
_ => {
visible.push(scroll_indicator(indicator, fg=indicator_fg))
visible
}
}
} else {
visible
}
}
///|
/// Build a vertical scrollbar string for the given height
/// Returns an array of single-character strings, one per line
pub fn build_scrollbar(
offset : Int,
total_items : Int,
visible_count : Int,
height : Int,
track_char? : String = "│",
thumb_char? : String = "┃",
) -> Array[String] {
let result : Array[String] = []
if total_items <= visible_count || height <= 0 {
// No scrolling needed - return empty track
for _i = 0; _i < height; _i = _i + 1 {
result.push(track_char)
}
return result
}
// Calculate thumb size (minimum 1 line)
let thumb_height = {
let h = height * visible_count / total_items
if h < 1 {
1
} else {
h
}
}
// Calculate thumb position
let max_offset = total_items - visible_count
let scrollable_range = height - thumb_height
let thumb_pos = if max_offset > 0 {
offset * scrollable_range / max_offset
} else {
0
}
// Build the scrollbar
for i = 0; i < height; i = i + 1 {
if i >= thumb_pos && i < thumb_pos + thumb_height {
result.push(thumb_char)
} else {
result.push(track_char)
}
}
result
}
///|
/// Scrollbar component - renders a vertical scrollbar as a text_box
/// Note: track_color is kept for API compatibility but text_box uses single color
pub fn scrollbar(
offset : Int,
total_items : Int,
visible_count : Int,
height : Int,
track_color? : @core.Color = @core.Color::rgb(60, 60, 60),
thumb_color? : @core.Color = @core.Color::rgb(120, 120, 120),
track_char? : String = "│",
thumb_char? : String = "┃",
) -> @core.Component {
let _ = track_color // Reserved for future per-line coloring
let lines = build_scrollbar(
offset,
total_items,
visible_count,
height,
track_char~,
thumb_char~,
)
// Join lines with newlines to create a multi-line string
let content = lines
.iter()
.fold(init="", fn(acc, s) {
if acc.length() == 0 {
s
} else {
acc + "\n" + s
}
})
// Use text_box for proper height/width handling
// Color the whole scrollbar with thumb_color for visibility
@core.text_box(
content,
fg=thumb_color,
width=@types.Dimension::Length(1.0),
height=@types.Dimension::Length(height.to_double()),
)
}