Skip to main content

ai_agent/utils/
horizontal_scroll.rs

1//! Horizontal scroll window calculation utilities
2//!
3//! Calculate the visible window of items that fit within available width,
4//! ensuring the selected item is always visible. Uses edge-based scrolling.
5
6use serde::{Deserialize, Serialize};
7
8/// Horizontal scroll window result
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct HorizontalScrollWindow {
11    /// Start index of visible items
12    pub start_index: usize,
13    /// End index of visible items (exclusive)
14    pub end_index: usize,
15    /// Whether to show left arrow
16    pub show_left_arrow: bool,
17    /// Whether to show right arrow
18    pub show_right_arrow: bool,
19}
20
21/// Calculate the visible window of items that fit within available width,
22/// ensuring the selected item is always visible. Uses edge-based scrolling:
23/// the window only scrolls when the selected item would be outside the visible
24/// range, and positions the selected item at the edge (not centered).
25///
26/// # Arguments
27/// * `item_widths` - Array of item widths (each width should include separator if applicable)
28/// * `available_width` - Total available width for items
29/// * `arrow_width` - Width of scroll indicator arrow (including space)
30/// * `selected_idx` - Index of selected item (must stay visible)
31/// * `first_item_has_separator` - Whether first item's width includes a separator that should be ignored
32///
33/// # Returns
34/// Visible window bounds and whether to show scroll arrows
35pub fn calculate_horizontal_scroll_window(
36    item_widths: &[usize],
37    available_width: usize,
38    arrow_width: usize,
39    selected_idx: usize,
40    first_item_has_separator: bool,
41) -> HorizontalScrollWindow {
42    let total_items = item_widths.len();
43
44    if total_items == 0 {
45        return HorizontalScrollWindow {
46            start_index: 0,
47            end_index: 0,
48            show_left_arrow: false,
49            show_right_arrow: false,
50        };
51    }
52
53    // Clamp selected_idx to valid range
54    let clamped_selected = selected_idx.min(total_items - 1);
55
56    // If all items fit, show them all
57    let total_width: usize = item_widths.iter().sum();
58    if total_width <= available_width {
59        return HorizontalScrollWindow {
60            start_index: 0,
61            end_index: total_items,
62            show_left_arrow: false,
63            show_right_arrow: false,
64        };
65    }
66
67    // Calculate cumulative widths for efficient range calculations
68    let mut cumulative_widths: Vec<usize> = vec![0; total_items + 1];
69    for i in 0..total_items {
70        cumulative_widths[i + 1] = cumulative_widths[i] + item_widths[i];
71    }
72
73    // Helper to get width of range [start, end)
74    let range_width = |start: usize, end: usize| -> usize {
75        let base_width = cumulative_widths[end] - cumulative_widths[start];
76        // When starting after index 0 and first item has separator baked in,
77        // subtract 1 because we don't render leading separator on first visible item
78        if first_item_has_separator && start > 0 {
79            base_width.saturating_sub(1)
80        } else {
81            base_width
82        }
83    };
84
85    // Calculate effective available width based on whether we'll show arrows
86    let get_effective_width = |start: usize, end: usize| -> usize {
87        let mut width = available_width;
88        if start > 0 {
89            width -= arrow_width; // left arrow
90        }
91        if end < total_items {
92            width -= arrow_width; // right arrow
93        }
94        width
95    };
96
97    // Edge-based scrolling: Start from the beginning and only scroll when necessary
98    let mut start_index = 0;
99    let mut end_index = 1;
100
101    // Expand from start as much as possible
102    while end_index < total_items
103        && range_width(start_index, end_index + 1)
104            <= get_effective_width(start_index, end_index + 1)
105    {
106        end_index += 1;
107    }
108
109    // If selected is within visible range, we're done
110    if clamped_selected >= start_index && clamped_selected < end_index {
111        return HorizontalScrollWindow {
112            start_index,
113            end_index,
114            show_left_arrow: start_index > 0,
115            show_right_arrow: end_index < total_items,
116        };
117    }
118
119    // Selected is outside visible range - need to scroll
120    if clamped_selected >= end_index {
121        // Selected is to the right - scroll so selected is at the right edge
122        end_index = clamped_selected + 1;
123        start_index = clamped_selected;
124
125        // Expand left as much as possible (selected stays at right edge)
126        while start_index > 0
127            && range_width(start_index - 1, end_index)
128                <= get_effective_width(start_index - 1, end_index)
129        {
130            start_index -= 1;
131        }
132    } else {
133        // Selected is to the left - scroll so selected is at the left edge
134        start_index = clamped_selected;
135        end_index = clamped_selected + 1;
136
137        // Expand right as much as possible (selected stays at left edge)
138        while end_index < total_items
139            && range_width(start_index, end_index + 1)
140                <= get_effective_width(start_index, end_index + 1)
141        {
142            end_index += 1;
143        }
144    }
145
146    HorizontalScrollWindow {
147        start_index,
148        end_index,
149        show_left_arrow: start_index > 0,
150        show_right_arrow: end_index < total_items,
151    }
152}