1use crate::key::{Binding, matches};
19use bubbletea::{Cmd, KeyMsg, Message, Model, MouseMsg};
20use lipgloss::Style;
21use unicode_width::UnicodeWidthChar;
22
23#[derive(Debug, Clone)]
25pub struct KeyMap {
26 pub page_down: Binding,
28 pub page_up: Binding,
30 pub half_page_up: Binding,
32 pub half_page_down: Binding,
34 pub down: Binding,
36 pub up: Binding,
38 pub left: Binding,
40 pub right: Binding,
42}
43
44impl Default for KeyMap {
45 fn default() -> Self {
46 Self {
47 page_down: Binding::new()
48 .keys(&["pgdown", " ", "f"])
49 .help("f/pgdn", "page down"),
50 page_up: Binding::new()
51 .keys(&["pgup", "b"])
52 .help("b/pgup", "page up"),
53 half_page_up: Binding::new().keys(&["u", "ctrl+u"]).help("u", "½ page up"),
54 half_page_down: Binding::new()
55 .keys(&["d", "ctrl+d"])
56 .help("d", "½ page down"),
57 up: Binding::new().keys(&["up", "k"]).help("↑/k", "up"),
58 down: Binding::new().keys(&["down", "j"]).help("↓/j", "down"),
59 left: Binding::new().keys(&["left", "h"]).help("←/h", "move left"),
60 right: Binding::new()
61 .keys(&["right", "l"])
62 .help("→/l", "move right"),
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct Viewport {
70 pub width: usize,
72 pub height: usize,
74 pub key_map: KeyMap,
76 pub mouse_wheel_enabled: bool,
78 pub mouse_wheel_delta: usize,
80 y_offset: usize,
82 x_offset: usize,
84 horizontal_step: usize,
86 pub style: Style,
88 lines: Vec<String>,
90 longest_line_width: usize,
92}
93
94impl Viewport {
95 #[must_use]
97 pub fn new(width: usize, height: usize) -> Self {
98 Self {
99 width,
100 height,
101 key_map: KeyMap::default(),
102 mouse_wheel_enabled: true,
103 mouse_wheel_delta: 3,
104 y_offset: 0,
105 x_offset: 0,
106 horizontal_step: 0,
107 style: Style::new(),
108 lines: Vec::new(),
109 longest_line_width: 0,
110 }
111 }
112
113 pub fn set_content(&mut self, content: &str) {
115 let normalized = content.replace("\r\n", "\n");
116 self.lines = normalized.split('\n').map(String::from).collect();
117 self.longest_line_width = self
118 .lines
119 .iter()
120 .map(|l| visible_width(l))
121 .max()
122 .unwrap_or(0);
123
124 if self.y_offset > self.lines.len().saturating_sub(1) {
125 self.goto_bottom();
126 }
127 }
128
129 #[must_use]
131 pub fn y_offset(&self) -> usize {
132 self.y_offset
133 }
134
135 pub fn set_y_offset(&mut self, n: usize) {
137 self.y_offset = n.min(self.max_y_offset());
138 }
139
140 #[must_use]
142 pub fn x_offset(&self) -> usize {
143 self.x_offset
144 }
145
146 pub fn set_x_offset(&mut self, n: usize) {
148 self.x_offset = n.min(self.longest_line_width.saturating_sub(self.width));
149 }
150
151 pub fn set_horizontal_step(&mut self, n: usize) {
153 self.horizontal_step = n;
154 }
155
156 #[must_use]
158 pub fn at_top(&self) -> bool {
159 self.y_offset == 0
160 }
161
162 #[must_use]
164 pub fn at_bottom(&self) -> bool {
165 self.y_offset >= self.max_y_offset()
166 }
167
168 #[must_use]
170 pub fn past_bottom(&self) -> bool {
171 self.y_offset > self.max_y_offset()
172 }
173
174 #[must_use]
176 pub fn scroll_percent(&self) -> f64 {
177 if self.height >= self.lines.len() {
178 return 1.0;
179 }
180 let y = self.y_offset as f64;
181 let h = self.height as f64;
182 let t = self.lines.len() as f64;
183 let v = y / (t - h);
184 v.clamp(0.0, 1.0)
185 }
186
187 #[must_use]
189 pub fn horizontal_scroll_percent(&self) -> f64 {
190 if self.longest_line_width <= self.width {
191 return 1.0;
192 }
193 let x = self.x_offset as f64;
194 let scrollable = (self.longest_line_width - self.width) as f64;
195 let v = x / scrollable;
196 v.clamp(0.0, 1.0)
197 }
198
199 #[must_use]
201 pub fn total_line_count(&self) -> usize {
202 self.lines.len()
203 }
204
205 #[must_use]
207 pub fn visible_line_count(&self) -> usize {
208 self.visible_lines().len()
209 }
210
211 fn max_y_offset(&self) -> usize {
213 self.lines.len().saturating_sub(self.content_height())
214 }
215
216 fn visible_lines(&self) -> Vec<String> {
218 if self.lines.is_empty() {
219 return Vec::new();
220 }
221
222 let content_height = self.content_height();
223 if content_height == 0 {
224 return Vec::new();
225 }
226
227 let top = self.y_offset.min(self.lines.len());
228 let bottom = top.saturating_add(content_height).min(self.lines.len());
229
230 let visible = &self.lines[top..bottom];
231 let content_width = self.content_width();
232 if (self.x_offset == 0 && self.longest_line_width <= content_width) || content_width == 0 {
233 return visible.to_vec();
234 }
235
236 visible
237 .iter()
238 .map(|line| cut_line(line, self.x_offset, content_width))
239 .collect()
240 }
241
242 pub fn scroll_down(&mut self, n: usize) {
244 if self.at_bottom() || n == 0 || self.lines.is_empty() {
245 return;
246 }
247 self.set_y_offset(self.y_offset + n);
248 }
249
250 pub fn scroll_up(&mut self, n: usize) {
252 if self.at_top() || n == 0 || self.lines.is_empty() {
253 return;
254 }
255 self.set_y_offset(self.y_offset.saturating_sub(n));
256 }
257
258 pub fn scroll_left(&mut self, n: usize) {
260 self.set_x_offset(self.x_offset.saturating_sub(n));
261 }
262
263 pub fn scroll_right(&mut self, n: usize) {
265 self.set_x_offset(self.x_offset + n);
266 }
267
268 pub fn page_down(&mut self) {
270 if !self.at_bottom() {
271 self.scroll_down(self.height);
272 }
273 }
274
275 pub fn page_up(&mut self) {
277 if !self.at_top() {
278 self.scroll_up(self.height);
279 }
280 }
281
282 pub fn half_page_down(&mut self) {
284 if !self.at_bottom() {
285 self.scroll_down(self.height / 2);
286 }
287 }
288
289 pub fn half_page_up(&mut self) {
291 if !self.at_top() {
292 self.scroll_up(self.height / 2);
293 }
294 }
295
296 pub fn goto_top(&mut self) {
298 self.set_y_offset(0);
299 }
300
301 pub fn goto_bottom(&mut self) {
303 self.set_y_offset(self.max_y_offset());
304 }
305
306 pub fn update(&mut self, msg: &Message) {
308 if let Some(key) = msg.downcast_ref::<KeyMsg>() {
309 let key_str = key.to_string();
310
311 if matches(&key_str, &[&self.key_map.page_down]) {
312 self.page_down();
313 } else if matches(&key_str, &[&self.key_map.page_up]) {
314 self.page_up();
315 } else if matches(&key_str, &[&self.key_map.half_page_down]) {
316 self.half_page_down();
317 } else if matches(&key_str, &[&self.key_map.half_page_up]) {
318 self.half_page_up();
319 } else if matches(&key_str, &[&self.key_map.down]) {
320 self.scroll_down(1);
321 } else if matches(&key_str, &[&self.key_map.up]) {
322 self.scroll_up(1);
323 } else if matches(&key_str, &[&self.key_map.left]) {
324 self.scroll_left(self.horizontal_step);
325 } else if matches(&key_str, &[&self.key_map.right]) {
326 self.scroll_right(self.horizontal_step);
327 }
328 return;
329 }
330
331 if let Some(mouse) = msg.downcast_ref::<MouseMsg>() {
332 if !self.mouse_wheel_enabled || mouse.action != bubbletea::MouseAction::Press {
333 return;
334 }
335 match mouse.button {
336 bubbletea::MouseButton::WheelUp => {
337 if mouse.shift {
338 self.scroll_left(self.horizontal_step);
339 } else {
340 self.scroll_up(self.mouse_wheel_delta);
341 }
342 }
343 bubbletea::MouseButton::WheelDown => {
344 if mouse.shift {
345 self.scroll_right(self.horizontal_step);
346 } else {
347 self.scroll_down(self.mouse_wheel_delta);
348 }
349 }
350 bubbletea::MouseButton::WheelLeft => self.scroll_left(self.horizontal_step),
351 bubbletea::MouseButton::WheelRight => self.scroll_right(self.horizontal_step),
352 _ => {}
353 }
354 }
355 }
356
357 #[must_use]
359 pub fn view(&self) -> String {
360 let mut width = self.width;
361 if let Some(style_width) = self.style.get_width()
362 && style_width > 0
363 {
364 width = width.min(style_width as usize);
365 }
366
367 let mut height = self.height;
368 if let Some(style_height) = self.style.get_height()
369 && style_height > 0
370 {
371 height = height.min(style_height as usize);
372 }
373
374 let frame_width = self.style.get_horizontal_frame_size();
375 let frame_height = self.style.get_vertical_frame_size();
376 let content_width = width.saturating_sub(frame_width);
377 let content_height = height.saturating_sub(frame_height);
378 let lines = self.visible_lines();
379 let contents = if content_width == 0 || content_height == 0 {
380 String::new()
381 } else {
382 let content_style = Style::new()
383 .width(as_u16(content_width))
384 .height(as_u16(content_height))
385 .max_width(as_u16(content_width))
386 .max_height(as_u16(content_height));
387 content_style.render(&lines.join("\n"))
388 };
389
390 self.style.render(&contents)
391 }
392
393 fn content_width(&self) -> usize {
394 self.width
395 .saturating_sub(self.style.get_horizontal_frame_size())
396 }
397
398 fn content_height(&self) -> usize {
399 self.height
400 .saturating_sub(self.style.get_vertical_frame_size())
401 }
402}
403
404impl Model for Viewport {
406 fn init(&self) -> Option<Cmd> {
407 None
409 }
410
411 fn update(&mut self, msg: Message) -> Option<Cmd> {
412 Viewport::update(self, &msg);
414 None
415 }
416
417 fn view(&self) -> String {
418 Viewport::view(self)
419 }
420}
421
422fn as_u16(value: usize) -> u16 {
423 value.min(u16::MAX as usize) as u16
424}
425
426fn visible_width(s: &str) -> usize {
427 let mut width = 0;
428 let mut in_escape = false;
429 let mut in_csi = false;
430
431 for c in s.chars() {
432 if c == '\x1b' {
433 in_escape = true;
434 continue;
435 }
436 if in_escape {
437 in_escape = false;
438 if c == '[' {
439 in_csi = true;
441 }
442 continue;
444 }
445 if in_csi {
446 if ('@'..='~').contains(&c) {
448 in_csi = false;
449 }
450 continue;
451 }
452 width += UnicodeWidthChar::width(c).unwrap_or(0);
453 }
454
455 width
456}
457
458fn cut_line(line: &str, start: usize, width: usize) -> String {
459 if width == 0 {
460 return String::new();
461 }
462
463 let end = start.saturating_add(width);
464 let mut result = String::new();
465 let mut in_escape = false;
466 let mut in_csi = false;
467 let mut visible = 0;
468
469 for c in line.chars() {
470 if c == '\x1b' {
471 in_escape = true;
472 result.push(c);
473 continue;
474 }
475 if in_escape {
476 in_escape = false;
477 result.push(c);
478 if c == '[' {
479 in_csi = true;
481 }
482 continue;
484 }
485 if in_csi {
486 result.push(c);
487 if ('@'..='~').contains(&c) {
489 in_csi = false;
490 }
491 continue;
492 }
493
494 let cw = UnicodeWidthChar::width(c).unwrap_or(0);
495 if visible + cw <= start {
496 visible += cw;
497 continue;
498 }
499 if visible >= end {
500 break;
501 }
502 result.push(c);
503 visible += cw;
504 }
505
506 result
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 #[test]
514 fn test_viewport_new() {
515 let v = Viewport::new(80, 24);
516 assert_eq!(v.width, 80);
517 assert_eq!(v.height, 24);
518 assert!(v.mouse_wheel_enabled);
519 }
520
521 #[test]
522 fn test_viewport_set_content() {
523 let mut v = Viewport::new(80, 5);
524 v.set_content("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7");
525 assert_eq!(v.total_line_count(), 7);
526 }
527
528 #[test]
529 fn test_viewport_at_top_bottom() {
530 let mut v = Viewport::new(80, 3);
531 v.set_content("1\n2\n3\n4\n5");
532
533 assert!(v.at_top());
534 assert!(!v.at_bottom());
535
536 v.goto_bottom();
537 assert!(!v.at_top());
538 assert!(v.at_bottom());
539 }
540
541 #[test]
542 fn test_viewport_scroll() {
543 let mut v = Viewport::new(80, 3);
544 v.set_content("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
545
546 assert_eq!(v.y_offset(), 0);
547
548 v.scroll_down(2);
549 assert_eq!(v.y_offset(), 2);
550
551 v.scroll_up(1);
552 assert_eq!(v.y_offset(), 1);
553 }
554
555 #[test]
556 fn test_viewport_page_navigation() {
557 let mut v = Viewport::new(80, 3);
558 v.set_content("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
559
560 v.page_down();
561 assert_eq!(v.y_offset(), 3);
562
563 v.page_up();
564 assert_eq!(v.y_offset(), 0);
565 }
566
567 #[test]
568 fn test_viewport_scroll_percent() {
569 let mut v = Viewport::new(80, 5);
570 v.set_content("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
571
572 assert!((v.scroll_percent() - 0.0).abs() < 0.01);
573
574 v.goto_bottom();
575 assert!((v.scroll_percent() - 1.0).abs() < 0.01);
576 }
577
578 #[test]
579 fn test_viewport_view() {
580 let mut v = Viewport::new(80, 3);
581 v.set_content("Line 1\nLine 2\nLine 3\nLine 4");
582
583 let view = v.view();
584 assert!(view.contains("Line 1"));
585 assert!(view.contains("Line 2"));
586 assert!(view.contains("Line 3"));
587 assert!(!view.contains("Line 4"));
588 }
589
590 #[test]
591 fn test_viewport_view_pads_to_dimensions() {
592 let mut v = Viewport::new(4, 2);
593 v.set_content("a");
594 assert_eq!(v.view(), "a \n ");
595 }
596
597 #[test]
598 fn test_viewport_frame_affects_visible_height() {
599 let mut v = Viewport::new(10, 5);
600 v.style = Style::new().padding(1);
601 v.set_content("1\n2\n3\n4\n5\n6");
602 assert_eq!(v.visible_line_count(), 3);
603
604 v.goto_bottom();
605 assert_eq!(v.y_offset(), 3);
606 }
607
608 #[test]
609 fn test_viewport_horizontal_scroll() {
610 let mut v = Viewport::new(10, 5);
611 v.set_horizontal_step(5);
612 v.set_content("This is a very long line that exceeds the width");
613
614 assert_eq!(v.x_offset(), 0);
615
616 v.scroll_right(5);
617 assert_eq!(v.x_offset(), 5);
618
619 v.scroll_left(3);
620 assert_eq!(v.x_offset(), 2);
621 }
622
623 #[test]
624 fn test_viewport_horizontal_scroll_uses_display_width() {
625 let mut v = Viewport::new(4, 1);
626 v.set_content("日本語abc");
627 v.set_x_offset(2);
628 assert_eq!(v.view(), "本語");
629 }
630
631 #[test]
632 fn test_viewport_mouse_wheel_shift_scrolls_horizontal() {
633 let mut v = Viewport::new(10, 2);
634 v.set_content("This is a very long line that exceeds the width");
635 v.set_horizontal_step(2);
636
637 let down_shift = MouseMsg {
638 button: bubbletea::MouseButton::WheelDown,
639 shift: true,
640 ..MouseMsg::default()
641 };
642 v.update(&Message::new(down_shift));
643 assert_eq!(v.x_offset(), 2);
644
645 let up_shift = MouseMsg {
646 button: bubbletea::MouseButton::WheelUp,
647 shift: true,
648 ..MouseMsg::default()
649 };
650 v.update(&Message::new(up_shift));
651 assert_eq!(v.x_offset(), 0);
652 }
653
654 #[test]
655 fn test_viewport_mouse_wheel_ignores_release() {
656 let mut v = Viewport::new(10, 2);
657 v.set_content("1\n2\n3\n4");
658
659 let release = MouseMsg {
660 button: bubbletea::MouseButton::WheelDown,
661 action: bubbletea::MouseAction::Release,
662 ..MouseMsg::default()
663 };
664 v.update(&Message::new(release));
665 assert_eq!(v.y_offset(), 0);
666 }
667
668 #[test]
669 fn test_viewport_empty_content() {
670 let v = Viewport::new(80, 24);
671 assert_eq!(v.total_line_count(), 0);
672 assert!(v.at_top());
673 assert!(v.at_bottom());
674 }
675
676 #[test]
677 fn test_viewport_model_init_returns_none() {
678 let v = Viewport::new(80, 24);
679 assert!(Model::init(&v).is_none());
680 }
681
682 #[test]
683 fn test_viewport_model_update_scrolls() {
684 let mut v = Viewport::new(10, 2);
685 v.set_content("1\n2\n3\n4");
686 assert_eq!(v.y_offset(), 0);
687
688 let down_msg = Message::new(KeyMsg::from_char('j'));
689 let result = Model::update(&mut v, down_msg);
690 assert!(result.is_none());
691 assert_eq!(v.y_offset(), 1);
692 }
693
694 #[test]
695 fn test_viewport_model_view_matches_view() {
696 let mut v = Viewport::new(10, 2);
697 v.set_content("Line 1\nLine 2\nLine 3");
698 assert_eq!(Model::view(&v), v.view());
699 }
700
701 #[test]
702 fn test_visible_width_with_non_sgr_csi_sequences() {
703 assert_eq!(visible_width("\x1b[2JHello"), 5);
706 assert_eq!(visible_width("\x1b[HWorld"), 5);
708 assert_eq!(visible_width("\x1b[31m\x1b[2KRed"), 3);
710 assert_eq!(visible_width("Start\x1b[K"), 5);
712 }
713
714 #[test]
715 fn test_visible_width_with_simple_escapes() {
716 assert_eq!(visible_width("\x1b7Text\x1b8"), 4);
719 }
720
721 #[test]
722 fn test_cut_line_with_non_sgr_csi_sequences() {
723 let line = "\x1b[2JHello World";
725 assert_eq!(cut_line(line, 0, 5), "\x1b[2JHello");
727 assert_eq!(cut_line(line, 6, 5), "\x1b[2JWorld");
730 }
731}