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 log::debug!(
93 "📐 Viewport resize: {}x{} → {}x{}",
94 self.terminal_width,
95 self.terminal_height,
96 new_width,
97 new_height
98 );
99
100 self.terminal_width = new_width;
101 self.terminal_height = new_height;
102 self.calculate_layout();
103
104 self.adjust_scroll_after_resize();
106 }
107
108 changed
109 }
110
111 fn calculate_layout(&mut self) {
113 if self.terminal_width < 10 || self.terminal_height < 5 {
115 log::error!(
116 "{}",
117 get_translation(
118 "viewport.layout.too_small",
119 &[
120 &self.terminal_width.to_string(),
121 &self.terminal_height.to_string()
122 ]
123 )
124 );
125 self.terminal_width = self.terminal_width.max(10);
126 self.terminal_height = self.terminal_height.max(5);
127 }
128
129 let margin = 1_u16;
131 let available_height = self.terminal_height.saturating_sub(margin * 2);
132
133 let min_input_height = 2_u16;
135 let min_output_height = 1_u16;
136
137 let input_height = if available_height >= 5 {
139 3
140 } else if available_height >= 3 {
141 2
142 } else {
143 min_input_height
144 }
145 .min(available_height.saturating_sub(min_output_height));
146
147 let output_height = available_height
148 .saturating_sub(input_height)
149 .max(min_output_height);
150
151 if input_height < min_input_height || output_height < min_output_height {
153 log::error!(
154 "{}",
155 get_translation(
156 "viewport.layout.failed",
157 &[
158 &input_height.to_string(),
159 &output_height.to_string(),
160 &available_height.to_string()
161 ]
162 )
163 );
164
165 let emergency_input = min_input_height;
167 let emergency_output = available_height.saturating_sub(emergency_input);
168
169 self.output_area = LayoutArea::new(
170 margin,
171 margin,
172 self.terminal_width.saturating_sub(margin * 2).max(1),
173 emergency_output.max(1),
174 );
175 self.input_area = LayoutArea::new(
176 margin,
177 margin + emergency_output,
178 self.terminal_width.saturating_sub(margin * 2).max(1),
179 emergency_input,
180 );
181 } else {
182 self.output_area = LayoutArea::new(
184 margin,
185 margin,
186 self.terminal_width.saturating_sub(margin * 2).max(1),
187 output_height,
188 );
189
190 self.input_area = LayoutArea::new(
191 margin,
192 margin + output_height,
193 self.terminal_width.saturating_sub(margin * 2).max(1),
194 input_height,
195 );
196 }
197
198 self.window_height = output_height.max(1) as usize;
200
201 let total_used = self.output_area.height + self.input_area.height + margin * 2;
203 if total_used != self.terminal_height {
204 log::warn!(
205 "{}",
206 get_translation(
207 "viewport.layout.mismatch",
208 &[
209 &self.terminal_height.to_string(),
210 &total_used.to_string(),
211 &self.output_area.height.to_string(),
212 &self.input_area.height.to_string(),
213 &(margin * 2).to_string()
214 ]
215 )
216 );
217
218 if total_used > self.terminal_height + 2 {
220 log::error!("{}", get_translation("viewport.layout.broken", &[]));
222
223 self.output_area = LayoutArea::new(
225 0,
226 0,
227 self.terminal_width,
228 self.terminal_height.saturating_sub(3),
229 );
230 self.input_area = LayoutArea::new(
231 0,
232 self.terminal_height.saturating_sub(3),
233 self.terminal_width,
234 3,
235 );
236 self.window_height = self.output_area.height.max(1) as usize;
237 }
238 }
239
240 if !self.output_area.is_valid() || !self.input_area.is_valid() {
242 log::error!("{}", get_translation("viewport.layout.invalid", &[]));
243
244 self.output_area = LayoutArea::new(
245 0,
246 0,
247 self.terminal_width.max(1),
248 self.terminal_height.saturating_sub(2).max(1),
249 );
250 self.input_area =
251 LayoutArea::new(0, self.output_area.height, self.terminal_width.max(1), 2);
252 self.window_height = self.output_area.height.max(1) as usize;
253 }
254
255 log::trace!(
256 "{}",
257 get_translation(
258 "viewport.layout.calculated",
259 &[
260 &self.terminal_width.to_string(),
261 &self.terminal_height.to_string(),
262 &self.output_area.width.to_string(),
263 &self.output_area.height.to_string(),
264 &self.output_area.x.to_string(),
265 &self.output_area.y.to_string(),
266 &self.input_area.width.to_string(),
267 &self.input_area.height.to_string(),
268 &self.input_area.x.to_string(),
269 &self.input_area.y.to_string(),
270 &self.window_height.to_string()
271 ]
272 )
273 );
274 }
275
276 pub fn update_content_height(&mut self, new_content_height: usize) {
278 let old_height = self.content_height;
279 let old_max_offset = self.max_scroll_offset();
280
281 self.content_height = new_content_height;
282
283 let new_max_offset = self.max_scroll_offset();
284
285 self.clamp_scroll_offset();
287
288 let final_offset = self.scroll_offset;
289
290 log::debug!(
291 "📊 Viewport content height updated: {} → {} (window: {}, max_offset: {} → {}, scroll_offset: {})",
292 old_height,
293 new_content_height,
294 self.window_height,
295 old_max_offset,
296 new_max_offset,
297 final_offset
298 );
299
300 if new_content_height > self.window_height && new_max_offset == 0 {
302 log::error!(
303 "🚨 Content height inconsistency! Content: {}, Window: {}, but max_offset is 0",
304 new_content_height,
305 self.window_height
306 );
307 }
308
309 if final_offset > new_max_offset {
310 log::error!(
311 "🚨 Scroll offset too high! Offset: {}, Max: {}",
312 final_offset,
313 new_max_offset
314 );
315 }
316 }
317
318 pub fn scroll_up(&mut self, lines: usize) {
320 if lines > 0 {
322 self.disable_auto_scroll();
323 }
324
325 let old_offset = self.scroll_offset;
326 let actual_lines = if lines == 0 { 1 } else { lines }; self.scroll_offset = self.scroll_offset.saturating_sub(actual_lines);
328
329 log::trace!(
330 "🔼 Scroll up: {} → {} (-{} lines)",
331 old_offset,
332 self.scroll_offset,
333 actual_lines
334 );
335 }
336
337 pub fn scroll_down(&mut self, lines: usize) {
339 let old_offset = self.scroll_offset;
340 let actual_lines = if lines == 0 { 1 } else { lines }; self.scroll_offset = self.scroll_offset.saturating_add(actual_lines);
342
343 self.clamp_scroll_offset();
345
346 if self.is_at_bottom() {
348 self.enable_auto_scroll();
349 log::trace!("✅ Auto-scroll re-enabled (reached bottom)");
350 }
351
352 log::trace!(
353 "🔽 Scroll down: {} → {} (+{} lines, auto_scroll: {})",
354 old_offset,
355 self.scroll_offset,
356 actual_lines,
357 self.auto_scroll_enabled
358 );
359 }
360
361 pub fn scroll_to_top(&mut self) {
362 self.disable_auto_scroll();
363 self.scroll_offset = 0;
364 log::trace!("🔝 Scroll to top");
365 }
366
367 pub fn scroll_to_bottom(&mut self) {
369 let old_offset = self.scroll_offset;
370 self.scroll_offset = self.max_scroll_offset();
371 self.enable_auto_scroll();
372
373 log::trace!(
374 "🔚 Scroll to bottom: {} → {} (max_offset: {}, content: {}, window: {})",
375 old_offset,
376 self.scroll_offset,
377 self.max_scroll_offset(),
378 self.content_height,
379 self.window_height
380 );
381 }
382
383 pub fn update_content_height_silent(&mut self, new_content_height: usize) {
385 self.content_height = new_content_height;
386 self.clamp_scroll_offset();
387 }
388
389 pub fn set_scroll_offset_direct_silent(&mut self, offset: usize) {
391 self.scroll_offset = offset;
392 self.clamp_scroll_offset();
393 }
394
395 pub fn enable_auto_scroll_silent(&mut self) {
397 self.auto_scroll_enabled = true;
398 }
399
400 pub fn force_auto_scroll(&mut self) {
402 self.enable_auto_scroll_silent();
403 self.scroll_to_bottom();
404 }
405
406 pub fn page_up(&mut self) {
408 let page_size = self.window_height.saturating_sub(1).max(1);
409 log::trace!("📄 Page up: {} lines", page_size);
410 self.scroll_up(page_size);
411 }
412
413 pub fn page_down(&mut self) {
414 let page_size = self.window_height.saturating_sub(1).max(1);
415 log::trace!("📄 Page down: {} lines", page_size);
416 self.scroll_down(page_size);
417 }
418
419 pub fn set_scroll_offset_direct(&mut self, offset: usize) {
421 let old_offset = self.scroll_offset;
422 self.scroll_offset = offset;
423 self.clamp_scroll_offset();
424
425 log::trace!(
426 "📍 Direct scroll offset set: {} → {} (clamped to {})",
427 old_offset,
428 offset,
429 self.scroll_offset
430 );
431 }
432
433 pub fn enable_auto_scroll(&mut self) {
435 self.auto_scroll_enabled = true;
436 log::trace!("✅ Auto-scroll enabled");
437 }
438
439 pub fn disable_auto_scroll(&mut self) {
441 self.auto_scroll_enabled = false;
442 log::trace!("❌ Auto-scroll disabled");
443 }
444
445 pub fn get_visible_range(&self) -> (usize, usize) {
447 if self.content_height == 0 || self.window_height == 0 {
448 return (0, 0);
449 }
450
451 let start = self.scroll_offset;
452 let end = (start + self.window_height).min(self.content_height);
453
454 log::trace!(
455 "👁️ Visible range: [{}, {}) of {} (window: {}, offset: {})",
456 start,
457 end,
458 self.content_height,
459 self.window_height,
460 self.scroll_offset
461 );
462
463 (start, end)
464 }
465
466 pub fn output_area(&self) -> LayoutArea {
468 self.output_area
469 }
470
471 pub fn input_area(&self) -> LayoutArea {
472 self.input_area
473 }
474
475 pub fn window_height(&self) -> usize {
476 self.window_height
477 }
478
479 pub fn content_height(&self) -> usize {
480 self.content_height
481 }
482
483 pub fn scroll_offset(&self) -> usize {
484 self.scroll_offset
485 }
486
487 pub fn is_auto_scroll_enabled(&self) -> bool {
488 self.auto_scroll_enabled
489 }
490
491 pub fn terminal_size(&self) -> (u16, u16) {
492 (self.terminal_width, self.terminal_height)
493 }
494
495 pub fn is_usable(&self) -> bool {
497 self.terminal_width >= self.min_terminal_width
498 && self.terminal_height >= self.min_terminal_height
499 && self.output_area.is_valid()
500 && self.input_area.is_valid()
501 }
502
503 pub fn debug_info(&self) -> String {
505 format!(
506 "Viewport: {}x{}, output: {}x{}+{}+{}, input: {}x{}+{}+{}, content: {}, window: {}, offset: {}, auto: {}, at_bottom: {}, max_offset: {}",
507 self.terminal_width, self.terminal_height,
508 self.output_area.width, self.output_area.height, self.output_area.x, self.output_area.y,
509 self.input_area.width, self.input_area.height, self.input_area.x, self.input_area.y,
510 self.content_height, self.window_height, self.scroll_offset, self.auto_scroll_enabled,
511 self.is_at_bottom(), self.max_scroll_offset()
512 )
513 }
514
515 fn max_scroll_offset(&self) -> usize {
518 if self.content_height > self.window_height {
519 self.content_height - self.window_height
520 } else {
521 0
522 }
523 }
524
525 fn is_at_bottom(&self) -> bool {
527 let max_offset = self.max_scroll_offset();
528 self.scroll_offset >= max_offset || max_offset == 0
530 }
531
532 fn clamp_scroll_offset(&mut self) {
533 let max_offset = self.max_scroll_offset();
534 if self.scroll_offset > max_offset {
535 self.scroll_offset = max_offset;
536 }
537 }
538
539 fn adjust_scroll_after_resize(&mut self) {
540 if self.auto_scroll_enabled {
542 self.scroll_to_bottom();
543 } else {
544 self.clamp_scroll_offset();
545 }
546 }
547}
548
549#[derive(Debug, Clone)]
551pub enum ViewportEvent {
552 TerminalResized {
553 width: u16,
554 height: u16,
555 },
556 ContentChanged {
557 new_height: usize,
558 },
559 ScrollRequest {
560 direction: ScrollDirection,
561 amount: usize,
562 },
563 ForceAutoScroll,
564}
565
566#[derive(Debug, Clone)]
567pub enum ScrollDirection {
568 Up,
569 Down,
570 ToTop,
571 ToBottom,
572 PageUp,
573 PageDown,
574}
575
576impl Viewport {
577 pub fn handle_event(&mut self, event: ViewportEvent) -> bool {
579 match event {
580 ViewportEvent::TerminalResized { width, height } => {
581 self.update_terminal_size(width, height)
582 }
583 ViewportEvent::ContentChanged { new_height } => {
584 self.update_content_height(new_height);
585 true
586 }
587 ViewportEvent::ScrollRequest { direction, amount } => {
588 log::trace!(
589 "📜 Processing scroll request: {:?} by {}",
590 direction,
591 amount
592 );
593
594 match direction {
595 ScrollDirection::Up => self.scroll_up(amount),
596 ScrollDirection::Down => self.scroll_down(amount),
597 ScrollDirection::ToTop => self.scroll_to_top(),
598 ScrollDirection::ToBottom => self.scroll_to_bottom(),
599 ScrollDirection::PageUp => self.page_up(),
600 ScrollDirection::PageDown => self.page_down(),
601 }
602 true
603 }
604 ViewportEvent::ForceAutoScroll => {
605 self.force_auto_scroll();
606 true
607 }
608 }
609 }
610}