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}