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 pub fn short_debug(&self) -> String {
517 format!(
518 "{}x{}, content: {}, offset: {}",
519 self.terminal_width, self.terminal_height, self.content_height, self.scroll_offset
520 )
521 }
522
523 fn max_scroll_offset(&self) -> usize {
526 if self.content_height > self.window_height {
527 self.content_height - self.window_height
528 } else {
529 0
530 }
531 }
532
533 fn is_at_bottom(&self) -> bool {
535 let max_offset = self.max_scroll_offset();
536 self.scroll_offset >= max_offset || max_offset == 0
538 }
539
540 fn clamp_scroll_offset(&mut self) {
541 let max_offset = self.max_scroll_offset();
542 if self.scroll_offset > max_offset {
543 self.scroll_offset = max_offset;
544 }
545 }
546
547 fn adjust_scroll_after_resize(&mut self) {
548 if self.auto_scroll_enabled {
550 self.scroll_to_bottom();
551 } else {
552 self.clamp_scroll_offset();
553 }
554 }
555}
556
557#[derive(Debug, Clone)]
559pub enum ViewportEvent {
560 TerminalResized {
561 width: u16,
562 height: u16,
563 },
564 ContentChanged {
565 new_height: usize,
566 },
567 ScrollRequest {
568 direction: ScrollDirection,
569 amount: usize,
570 },
571 ForceAutoScroll,
572}
573
574#[derive(Debug, Clone)]
575pub enum ScrollDirection {
576 Up,
577 Down,
578 ToTop,
579 ToBottom,
580 PageUp,
581 PageDown,
582}
583
584impl Viewport {
585 pub fn handle_event(&mut self, event: ViewportEvent) -> bool {
587 match event {
588 ViewportEvent::TerminalResized { width, height } => {
589 self.update_terminal_size(width, height)
590 }
591 ViewportEvent::ContentChanged { new_height } => {
592 self.update_content_height(new_height);
593 true
594 }
595 ViewportEvent::ScrollRequest { direction, amount } => {
596 log::trace!(
597 "📜 Processing scroll request: {:?} by {}",
598 direction,
599 amount
600 );
601
602 match direction {
603 ScrollDirection::Up => self.scroll_up(amount),
604 ScrollDirection::Down => self.scroll_down(amount),
605 ScrollDirection::ToTop => self.scroll_to_top(),
606 ScrollDirection::ToBottom => self.scroll_to_bottom(),
607 ScrollDirection::PageUp => self.page_up(),
608 ScrollDirection::PageDown => self.page_down(),
609 }
610 true
611 }
612 ViewportEvent::ForceAutoScroll => {
613 self.force_auto_scroll();
614 true
615 }
616 }
617 }
618}