1#[derive(Debug, Clone)]
2pub struct Viewport {
3 terminal_width: u16,
4 terminal_height: u16,
5 output_area: LayoutArea,
6 input_area: LayoutArea,
7 content_height: usize,
8 window_height: usize,
9 scroll_offset: usize,
10 auto_scroll_enabled: bool,
11 min_terminal_height: u16,
12 min_terminal_width: u16,
13}
14
15#[derive(Debug, Clone, Copy)]
16pub struct LayoutArea {
17 pub x: u16,
18 pub y: u16,
19 pub width: u16,
20 pub height: u16,
21}
22
23#[derive(Debug, Clone)]
24pub enum ViewportEvent {
25 TerminalResized {
26 width: u16,
27 height: u16,
28 },
29 ContentChanged {
30 new_height: usize,
31 },
32 ScrollRequest {
33 direction: ScrollDirection,
34 amount: usize,
35 },
36 ForceAutoScroll,
37}
38
39#[derive(Debug, Clone)]
40pub enum ScrollDirection {
41 Up,
42 Down,
43 ToTop,
44 ToBottom,
45 PageUp,
46 PageDown,
47}
48
49impl LayoutArea {
50 pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
51 Self {
52 x,
53 y,
54 width,
55 height,
56 }
57 }
58
59 pub fn is_valid(&self) -> bool {
60 self.width > 0 && self.height > 0
61 }
62
63 pub fn as_rect(&self) -> ratatui::layout::Rect {
64 ratatui::layout::Rect {
65 x: self.x,
66 y: self.y,
67 width: self.width,
68 height: self.height,
69 }
70 }
71}
72
73impl Viewport {
74 pub fn new(terminal_width: u16, terminal_height: u16) -> Self {
75 let mut viewport = Self {
76 terminal_width: terminal_width.max(40),
77 terminal_height: terminal_height.max(10),
78 output_area: LayoutArea::new(0, 0, 0, 0),
79 input_area: LayoutArea::new(0, 0, 0, 0),
80 content_height: 0,
81 window_height: 0,
82 scroll_offset: 0,
83 auto_scroll_enabled: true,
84 min_terminal_height: 10,
85 min_terminal_width: 40,
86 };
87 viewport.calculate_layout();
88 viewport
89 }
90
91 pub fn update_terminal_size(&mut self, width: u16, height: u16) -> bool {
92 let new_width = width.max(self.min_terminal_width);
93 let new_height = height.max(self.min_terminal_height);
94 let changed = self.terminal_width != new_width || self.terminal_height != new_height;
95
96 if changed {
97 self.terminal_width = new_width;
98 self.terminal_height = new_height;
99 self.calculate_layout();
100 self.adjust_scroll_after_resize();
101 }
102 changed
103 }
104
105 fn calculate_layout(&mut self) {
106 if self.terminal_width < 10 || self.terminal_height < 5 {
108 self.terminal_width = self.terminal_width.max(10);
109 self.terminal_height = self.terminal_height.max(5);
110 }
111
112 let margin = 1u16;
113 let available_height = self.terminal_height.saturating_sub(margin * 2);
114
115 let input_height = match available_height {
117 h if h >= 5 => 3,
118 h if h >= 3 => 2,
119 _ => 2,
120 }
121 .min(available_height.saturating_sub(1));
122
123 let output_height = available_height.saturating_sub(input_height).max(1);
124
125 if input_height < 2 || output_height < 1 {
127 self.create_emergency_layout(margin);
128 } else {
129 self.create_normal_layout(margin, output_height, input_height);
130 }
131
132 self.window_height = output_height.max(1) as usize;
133 self.validate_layout();
134 }
135
136 fn create_emergency_layout(&mut self, margin: u16) {
137 let width = self.terminal_width.saturating_sub(margin * 2).max(1);
138 self.output_area = LayoutArea::new(
139 margin,
140 margin,
141 width,
142 self.terminal_height.saturating_sub(3).max(1),
143 );
144 self.input_area = LayoutArea::new(margin, self.output_area.height + margin, width, 2);
145 }
146
147 fn create_normal_layout(&mut self, margin: u16, output_height: u16, input_height: u16) {
148 let width = self.terminal_width.saturating_sub(margin * 2).max(1);
149 self.output_area = LayoutArea::new(margin, margin, width, output_height);
150 self.input_area = LayoutArea::new(margin, margin + output_height, width, input_height);
151 }
152
153 fn validate_layout(&mut self) {
154 if !self.output_area.is_valid() || !self.input_area.is_valid() {
155 self.output_area = LayoutArea::new(
156 0,
157 0,
158 self.terminal_width.max(1),
159 self.terminal_height.saturating_sub(2).max(1),
160 );
161 self.input_area =
162 LayoutArea::new(0, self.output_area.height, self.terminal_width.max(1), 2);
163 self.window_height = self.output_area.height.max(1) as usize;
164 }
165 }
166
167 pub fn scroll_up(&mut self, lines: usize) {
169 if lines > 0 {
170 self.disable_auto_scroll();
171 }
172 self.scroll_offset = self.scroll_offset.saturating_sub(lines.max(1));
173 }
174
175 pub fn scroll_down(&mut self, lines: usize) {
176 self.scroll_offset = self.scroll_offset.saturating_add(lines.max(1));
177 self.clamp_scroll_offset();
178 if self.is_at_bottom() {
179 self.enable_auto_scroll();
180 }
181 }
182
183 pub fn scroll_to_top(&mut self) {
184 self.disable_auto_scroll();
185 self.scroll_offset = 0;
186 }
187
188 pub fn scroll_to_bottom(&mut self) {
189 self.scroll_offset = self.max_scroll_offset();
190 self.auto_scroll_enabled = true;
191 }
192
193 pub fn page_up(&mut self) {
194 self.scroll_up(self.window_height.saturating_sub(1).max(1));
195 }
196
197 pub fn page_down(&mut self) {
198 self.scroll_down(self.window_height.saturating_sub(1).max(1));
199 }
200
201 pub fn update_content_height(&mut self, new_content_height: usize) {
203 self.content_height = new_content_height;
204 self.clamp_scroll_offset();
205 }
206
207 pub fn update_content_height_silent(&mut self, new_content_height: usize) {
208 self.content_height = new_content_height;
209 self.clamp_scroll_offset();
210 }
211
212 pub fn set_scroll_offset_direct_silent(&mut self, offset: usize) {
213 self.scroll_offset = offset.min(self.max_scroll_offset());
214 }
215
216 pub fn enable_auto_scroll_silent(&mut self) {
217 self.auto_scroll_enabled = true;
218 }
219
220 pub fn force_auto_scroll(&mut self) {
221 self.enable_auto_scroll_silent();
222 self.scroll_to_bottom();
223 }
224
225 pub fn set_scroll_offset_direct(&mut self, offset: usize) {
226 self.scroll_offset = offset;
227 self.clamp_scroll_offset();
228 }
229
230 pub fn enable_auto_scroll(&mut self) {
231 self.auto_scroll_enabled = true;
232 }
233
234 pub fn disable_auto_scroll(&mut self) {
235 self.auto_scroll_enabled = false;
236 }
237
238 pub fn get_visible_range(&self) -> (usize, usize) {
240 if self.content_height == 0 || self.window_height == 0 {
241 return (0, 0);
242 }
243 let start = self.scroll_offset;
244 let end = (start + self.window_height).min(self.content_height);
245 (start, end)
246 }
247
248 pub fn output_area(&self) -> LayoutArea {
250 self.output_area
251 }
252 pub fn input_area(&self) -> LayoutArea {
253 self.input_area
254 }
255 pub fn window_height(&self) -> usize {
256 self.window_height
257 }
258 pub fn content_height(&self) -> usize {
259 self.content_height
260 }
261 pub fn scroll_offset(&self) -> usize {
262 self.scroll_offset
263 }
264 pub fn is_auto_scroll_enabled(&self) -> bool {
265 self.auto_scroll_enabled
266 }
267 pub fn terminal_size(&self) -> (u16, u16) {
268 (self.terminal_width, self.terminal_height)
269 }
270
271 pub fn is_usable(&self) -> bool {
272 self.terminal_width >= self.min_terminal_width
273 && self.terminal_height >= self.min_terminal_height
274 && self.output_area.is_valid()
275 && self.input_area.is_valid()
276 }
277
278 pub fn debug_info(&self) -> String {
279 format!("Viewport: {}x{}, output: {}x{}+{}+{}, input: {}x{}+{}+{}, content: {}, window: {}, offset: {}, auto: {}, at_bottom: {}, max_offset: {}",
280 self.terminal_width, self.terminal_height,
281 self.output_area.width, self.output_area.height, self.output_area.x, self.output_area.y,
282 self.input_area.width, self.input_area.height, self.input_area.x, self.input_area.y,
283 self.content_height, self.window_height, self.scroll_offset, self.auto_scroll_enabled,
284 self.is_at_bottom(), self.max_scroll_offset())
285 }
286
287 pub fn short_debug(&self) -> String {
288 format!(
289 "{}x{}, content: {}, offset: {}",
290 self.terminal_width, self.terminal_height, self.content_height, self.scroll_offset
291 )
292 }
293
294 pub fn handle_event(&mut self, event: ViewportEvent) -> bool {
296 match event {
297 ViewportEvent::TerminalResized { width, height } => {
298 self.update_terminal_size(width, height)
299 }
300 ViewportEvent::ContentChanged { new_height } => {
301 self.update_content_height(new_height);
302 true
303 }
304 ViewportEvent::ScrollRequest { direction, amount } => {
305 match direction {
306 ScrollDirection::Up => self.scroll_up(amount),
307 ScrollDirection::Down => self.scroll_down(amount),
308 ScrollDirection::ToTop => self.scroll_to_top(),
309 ScrollDirection::ToBottom => self.scroll_to_bottom(),
310 ScrollDirection::PageUp => self.page_up(),
311 ScrollDirection::PageDown => self.page_down(),
312 }
313 true
314 }
315 ViewportEvent::ForceAutoScroll => {
316 self.force_auto_scroll();
317 true
318 }
319 }
320 }
321
322 fn max_scroll_offset(&self) -> usize {
324 self.content_height.saturating_sub(self.window_height)
325 }
326
327 fn is_at_bottom(&self) -> bool {
328 let max_offset = self.max_scroll_offset();
329 self.scroll_offset >= max_offset || max_offset == 0
330 }
331
332 fn clamp_scroll_offset(&mut self) {
333 self.scroll_offset = self.scroll_offset.min(self.max_scroll_offset());
334 }
335
336 fn adjust_scroll_after_resize(&mut self) {
337 if self.auto_scroll_enabled {
338 self.scroll_to_bottom();
339 } else {
340 self.clamp_scroll_offset();
341 }
342 }
343}