1use crate::i18n::get_translation;
8
9#[derive(Debug, Clone)]
10pub struct Viewport {
11 terminal_width: u16,
13 terminal_height: u16,
14
15 output_area: LayoutArea,
17 input_area: LayoutArea,
18
19 content_height: usize,
21 window_height: usize,
22
23 scroll_offset: usize,
25 auto_scroll_enabled: bool,
26
27 min_terminal_height: u16,
29 min_terminal_width: u16,
30}
31
32#[derive(Debug, Clone, Copy)]
33pub struct LayoutArea {
34 pub x: u16,
35 pub y: u16,
36 pub width: u16,
37 pub height: u16,
38}
39
40impl LayoutArea {
41 pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
42 Self {
43 x,
44 y,
45 width,
46 height,
47 }
48 }
49
50 pub fn is_valid(&self) -> bool {
51 self.width > 0 && self.height > 0
52 }
53
54 pub fn as_rect(&self) -> ratatui::layout::Rect {
55 ratatui::layout::Rect {
56 x: self.x,
57 y: self.y,
58 width: self.width,
59 height: self.height,
60 }
61 }
62}
63
64impl Viewport {
65 pub fn new(terminal_width: u16, terminal_height: u16) -> Self {
67 let mut viewport = Self {
68 terminal_width: terminal_width.max(40), terminal_height: terminal_height.max(10), output_area: LayoutArea::new(0, 0, 0, 0),
71 input_area: LayoutArea::new(0, 0, 0, 0),
72 content_height: 0,
73 window_height: 0,
74 scroll_offset: 0,
75 auto_scroll_enabled: true,
76 min_terminal_height: 10,
77 min_terminal_width: 40,
78 };
79
80 viewport.calculate_layout();
81 viewport
82 }
83
84 pub fn update_terminal_size(&mut self, width: u16, height: u16) -> bool {
86 let new_width = width.max(self.min_terminal_width);
87 let new_height = height.max(self.min_terminal_height);
88
89 let changed = self.terminal_width != new_width || self.terminal_height != new_height;
90
91 if changed {
92 self.terminal_width = new_width;
93 self.terminal_height = new_height;
94 self.calculate_layout();
95
96 self.adjust_scroll_after_resize();
98 }
99
100 changed
101 }
102
103 fn calculate_layout(&mut self) {
105 if self.terminal_width < 10 || self.terminal_height < 5 {
107 log::error!(
108 "{}",
109 get_translation(
110 "viewport.layout.too_small",
111 &[
112 &self.terminal_width.to_string(),
113 &self.terminal_height.to_string()
114 ]
115 )
116 );
117 self.terminal_width = self.terminal_width.max(10);
118 self.terminal_height = self.terminal_height.max(5);
119 }
120
121 let margin = 1_u16;
123 let available_height = self.terminal_height.saturating_sub(margin * 2);
124
125 let min_input_height = 2_u16;
127 let min_output_height = 1_u16;
128
129 let input_height = if available_height >= 5 {
131 3
132 } else if available_height >= 3 {
133 2
134 } else {
135 min_input_height
136 }
137 .min(available_height.saturating_sub(min_output_height));
138
139 let output_height = available_height
140 .saturating_sub(input_height)
141 .max(min_output_height);
142
143 if input_height < min_input_height || output_height < min_output_height {
145 log::error!(
146 "{}",
147 get_translation(
148 "viewport.layout.failed",
149 &[
150 &input_height.to_string(),
151 &output_height.to_string(),
152 &available_height.to_string()
153 ]
154 )
155 );
156
157 let emergency_input = min_input_height;
159 let emergency_output = available_height.saturating_sub(emergency_input);
160
161 self.output_area = LayoutArea::new(
162 margin,
163 margin,
164 self.terminal_width.saturating_sub(margin * 2).max(1),
165 emergency_output.max(1),
166 );
167 self.input_area = LayoutArea::new(
168 margin,
169 margin + emergency_output,
170 self.terminal_width.saturating_sub(margin * 2).max(1),
171 emergency_input,
172 );
173 } else {
174 self.output_area = LayoutArea::new(
176 margin,
177 margin,
178 self.terminal_width.saturating_sub(margin * 2).max(1),
179 output_height,
180 );
181
182 self.input_area = LayoutArea::new(
183 margin,
184 margin + output_height,
185 self.terminal_width.saturating_sub(margin * 2).max(1),
186 input_height,
187 );
188 }
189
190 self.window_height = output_height.max(1) as usize;
192
193 let total_used = self.output_area.height + self.input_area.height + margin * 2;
195 if total_used != self.terminal_height {
196 log::warn!(
197 "{}",
198 get_translation(
199 "viewport.layout.mismatch",
200 &[
201 &self.terminal_height.to_string(),
202 &total_used.to_string(),
203 &self.output_area.height.to_string(),
204 &self.input_area.height.to_string(),
205 &(margin * 2).to_string()
206 ]
207 )
208 );
209
210 if total_used > self.terminal_height + 2 {
212 log::error!("{}", get_translation("viewport.layout.broken", &[]));
214
215 self.output_area = LayoutArea::new(
217 0,
218 0,
219 self.terminal_width,
220 self.terminal_height.saturating_sub(3),
221 );
222 self.input_area = LayoutArea::new(
223 0,
224 self.terminal_height.saturating_sub(3),
225 self.terminal_width,
226 3,
227 );
228 self.window_height = self.output_area.height.max(1) as usize;
229 }
230 }
231
232 if !self.output_area.is_valid() || !self.input_area.is_valid() {
234 log::error!("{}", get_translation("viewport.layout.invalid", &[]));
235
236 self.output_area = LayoutArea::new(
237 0,
238 0,
239 self.terminal_width.max(1),
240 self.terminal_height.saturating_sub(2).max(1),
241 );
242 self.input_area =
243 LayoutArea::new(0, self.output_area.height, self.terminal_width.max(1), 2);
244 self.window_height = self.output_area.height.max(1) as usize;
245 }
246
247 log::trace!(
248 "{}",
249 get_translation(
250 "viewport.layout.calculated",
251 &[
252 &self.terminal_width.to_string(),
253 &self.terminal_height.to_string(),
254 &self.output_area.width.to_string(),
255 &self.output_area.height.to_string(),
256 &self.output_area.x.to_string(),
257 &self.output_area.y.to_string(),
258 &self.input_area.width.to_string(),
259 &self.input_area.height.to_string(),
260 &self.input_area.x.to_string(),
261 &self.input_area.y.to_string(),
262 &self.window_height.to_string()
263 ]
264 )
265 );
266 }
267
268 pub fn update_content_height(&mut self, new_content_height: usize) {
270 self.content_height = new_content_height;
271
272 let new_max_offset = self.max_scroll_offset();
273
274 self.clamp_scroll_offset();
276
277 let final_offset = self.scroll_offset;
278
279 if new_content_height > self.window_height && new_max_offset == 0 {
281 log::error!(
282 "🚨 Content height inconsistency! Content: {}, Window: {}, but max_offset is 0",
283 new_content_height,
284 self.window_height
285 );
286 }
287
288 if final_offset > new_max_offset {
289 log::error!(
290 "🚨 Scroll offset too high! Offset: {}, Max: {}",
291 final_offset,
292 new_max_offset
293 );
294 }
295 }
296
297 pub fn scroll_up(&mut self, lines: usize) {
299 if lines > 0 {
301 self.disable_auto_scroll();
302 }
303
304 let old_offset = self.scroll_offset;
305 let actual_lines = if lines == 0 { 1 } else { lines }; self.scroll_offset = self.scroll_offset.saturating_sub(actual_lines);
307
308 log::trace!(
309 "🔼 Scroll up: {} → {} (-{} lines)",
310 old_offset,
311 self.scroll_offset,
312 actual_lines
313 );
314 }
315
316 pub fn scroll_down(&mut self, lines: usize) {
318 let old_offset = self.scroll_offset;
319 let actual_lines = if lines == 0 { 1 } else { lines }; self.scroll_offset = self.scroll_offset.saturating_add(actual_lines);
321
322 self.clamp_scroll_offset();
324
325 if self.is_at_bottom() {
327 self.enable_auto_scroll();
328 log::trace!("✅ Auto-scroll re-enabled (reached bottom)");
329 }
330
331 log::trace!(
332 "🔽 Scroll down: {} → {} (+{} lines, auto_scroll: {})",
333 old_offset,
334 self.scroll_offset,
335 actual_lines,
336 self.auto_scroll_enabled
337 );
338 }
339
340 pub fn scroll_to_top(&mut self) {
341 self.disable_auto_scroll();
342 self.scroll_offset = 0;
343 log::trace!("🔝 Scroll to top");
344 }
345
346 pub fn scroll_to_bottom(&mut self) {
348 let old_offset = self.scroll_offset;
349 let max_offset = self.max_scroll_offset();
350
351 self.scroll_offset = max_offset;
352 self.auto_scroll_enabled = true;
353
354 if old_offset != self.scroll_offset {
355 log::info!(
356 "📍 Scrolled to bottom: {} → {} (max: {}, content: {}, window: {})",
357 old_offset,
358 self.scroll_offset,
359 max_offset,
360 self.content_height,
361 self.window_height
362 );
363 }
364 }
365
366 pub fn update_content_height_silent(&mut self, new_content_height: usize) {
368 self.content_height = new_content_height;
369 self.clamp_scroll_offset();
370 }
371
372 pub fn set_scroll_offset_direct_silent(&mut self, offset: usize) {
374 self.scroll_offset = offset.min(self.max_scroll_offset());
375 }
376
377 pub fn enable_auto_scroll_silent(&mut self) {
379 self.auto_scroll_enabled = true;
380 }
381
382 pub fn force_auto_scroll(&mut self) {
384 self.enable_auto_scroll_silent();
385 self.scroll_to_bottom();
386 }
387
388 pub fn page_up(&mut self) {
390 let page_size = self.window_height.saturating_sub(1).max(1);
391 log::trace!("📄 Page up: {} lines", page_size);
392 self.scroll_up(page_size);
393 }
394
395 pub fn page_down(&mut self) {
396 let page_size = self.window_height.saturating_sub(1).max(1);
397 log::trace!("📄 Page down: {} lines", page_size);
398 self.scroll_down(page_size);
399 }
400
401 pub fn set_scroll_offset_direct(&mut self, offset: usize) {
403 let old_offset = self.scroll_offset;
404 self.scroll_offset = offset;
405 self.clamp_scroll_offset();
406
407 log::trace!(
408 "📍 Direct scroll offset set: {} → {} (clamped to {})",
409 old_offset,
410 offset,
411 self.scroll_offset
412 );
413 }
414
415 pub fn enable_auto_scroll(&mut self) {
417 self.auto_scroll_enabled = true;
418 log::trace!("✅ Auto-scroll enabled");
419 }
420
421 pub fn disable_auto_scroll(&mut self) {
423 self.auto_scroll_enabled = false;
424 log::trace!("❌ Auto-scroll disabled");
425 }
426
427 pub fn get_visible_range(&self) -> (usize, usize) {
429 if self.content_height == 0 || self.window_height == 0 {
430 return (0, 0);
431 }
432
433 let start = self.scroll_offset;
434 let end = (start + self.window_height).min(self.content_height);
435
436 log::trace!(
437 "👁️ Visible range: [{}, {}) of {} (window: {}, offset: {})",
438 start,
439 end,
440 self.content_height,
441 self.window_height,
442 self.scroll_offset
443 );
444
445 (start, end)
446 }
447
448 pub fn output_area(&self) -> LayoutArea {
450 self.output_area
451 }
452
453 pub fn input_area(&self) -> LayoutArea {
454 self.input_area
455 }
456
457 pub fn window_height(&self) -> usize {
458 self.window_height
459 }
460
461 pub fn content_height(&self) -> usize {
462 self.content_height
463 }
464
465 pub fn scroll_offset(&self) -> usize {
466 self.scroll_offset
467 }
468
469 pub fn is_auto_scroll_enabled(&self) -> bool {
470 self.auto_scroll_enabled
471 }
472
473 pub fn terminal_size(&self) -> (u16, u16) {
474 (self.terminal_width, self.terminal_height)
475 }
476
477 pub fn is_usable(&self) -> bool {
479 self.terminal_width >= self.min_terminal_width
480 && self.terminal_height >= self.min_terminal_height
481 && self.output_area.is_valid()
482 && self.input_area.is_valid()
483 }
484
485 pub fn debug_info(&self) -> String {
487 format!(
488 "Viewport: {}x{}, output: {}x{}+{}+{}, input: {}x{}+{}+{}, content: {}, window: {}, offset: {}, auto: {}, at_bottom: {}, max_offset: {}",
489 self.terminal_width, self.terminal_height,
490 self.output_area.width, self.output_area.height, self.output_area.x, self.output_area.y,
491 self.input_area.width, self.input_area.height, self.input_area.x, self.input_area.y,
492 self.content_height, self.window_height, self.scroll_offset, self.auto_scroll_enabled,
493 self.is_at_bottom(), self.max_scroll_offset()
494 )
495 }
496
497 pub fn short_debug(&self) -> String {
499 format!(
500 "{}x{}, content: {}, offset: {}",
501 self.terminal_width, self.terminal_height, self.content_height, self.scroll_offset
502 )
503 }
504
505 fn max_scroll_offset(&self) -> usize {
508 if self.content_height > self.window_height {
509 self.content_height - self.window_height
510 } else {
511 0
512 }
513 }
514
515 fn is_at_bottom(&self) -> bool {
517 let max_offset = self.max_scroll_offset();
518 self.scroll_offset >= max_offset || max_offset == 0
520 }
521
522 fn clamp_scroll_offset(&mut self) {
523 let max_offset = self.max_scroll_offset();
524 if self.scroll_offset > max_offset {
525 self.scroll_offset = max_offset;
526 }
527 }
528
529 fn adjust_scroll_after_resize(&mut self) {
530 if self.auto_scroll_enabled {
532 self.scroll_to_bottom();
533 } else {
534 self.clamp_scroll_offset();
535 }
536 }
537}
538
539#[derive(Debug, Clone)]
541pub enum ViewportEvent {
542 TerminalResized {
543 width: u16,
544 height: u16,
545 },
546 ContentChanged {
547 new_height: usize,
548 },
549 ScrollRequest {
550 direction: ScrollDirection,
551 amount: usize,
552 },
553 ForceAutoScroll,
554}
555
556#[derive(Debug, Clone)]
557pub enum ScrollDirection {
558 Up,
559 Down,
560 ToTop,
561 ToBottom,
562 PageUp,
563 PageDown,
564}
565
566impl Viewport {
567 pub fn handle_event(&mut self, event: ViewportEvent) -> bool {
569 match event {
570 ViewportEvent::TerminalResized { width, height } => {
571 self.update_terminal_size(width, height)
572 }
573 ViewportEvent::ContentChanged { new_height } => {
574 self.update_content_height(new_height);
575 true
576 }
577 ViewportEvent::ScrollRequest { direction, amount } => {
578 log::trace!(
579 "📜 Processing scroll request: {:?} by {}",
580 direction,
581 amount
582 );
583
584 match direction {
585 ScrollDirection::Up => self.scroll_up(amount),
586 ScrollDirection::Down => self.scroll_down(amount),
587 ScrollDirection::ToTop => self.scroll_to_top(),
588 ScrollDirection::ToBottom => self.scroll_to_bottom(),
589 ScrollDirection::PageUp => self.page_up(),
590 ScrollDirection::PageDown => self.page_down(),
591 }
592 true
593 }
594 ViewportEvent::ForceAutoScroll => {
595 self.force_auto_scroll();
596 true
597 }
598 }
599 }
600}