Skip to main content

ftui_text/
view.rs

1#![forbid(unsafe_code)]
2
3//! Text view utilities for scrollable, wrapped display.
4//!
5//! The view precomputes "virtual lines" produced by wrapping so callers can
6//! perform deterministic viewport math (scroll by line/page, map source lines
7//! to wrapped lines, and compute visible ranges) without duplicating logic.
8
9use crate::rope::Rope;
10use crate::wrap::{WrapMode, WrapOptions, display_width, wrap_with_options};
11use std::ops::Range;
12
13/// Viewport size in terminal cells.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub struct Viewport {
16    /// Width in terminal columns.
17    pub width: usize,
18    /// Height in terminal rows.
19    pub height: usize,
20}
21
22impl Viewport {
23    /// Create a new viewport size.
24    #[must_use]
25    pub const fn new(width: usize, height: usize) -> Self {
26        Self { width, height }
27    }
28}
29
30/// A single wrapped (virtual) line in the view.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ViewLine {
33    /// The rendered text for this virtual line.
34    pub text: String,
35    /// The source (logical) line index from the original text.
36    pub source_line: usize,
37    /// True if this is a wrapped continuation of the source line.
38    pub is_wrap: bool,
39    /// Display width in terminal cells.
40    pub width: usize,
41}
42
43/// A scrollable, wrapped view over a text buffer.
44#[derive(Debug, Clone)]
45pub struct TextView {
46    text: Rope,
47    wrap: WrapMode,
48    width: usize,
49    lines: Vec<ViewLine>,
50    max_width: usize,
51    source_line_count: usize,
52}
53
54impl TextView {
55    /// Build a view from raw text, wrap mode, and viewport width.
56    #[must_use]
57    pub fn new(text: impl Into<Rope>, width: usize, wrap: WrapMode) -> Self {
58        let mut view = Self {
59            text: text.into(),
60            wrap,
61            width,
62            lines: Vec::new(),
63            max_width: 0,
64            source_line_count: 0,
65        };
66        view.rebuild();
67        view
68    }
69
70    /// Replace the text and recompute layout.
71    pub fn set_text(&mut self, text: impl Into<Rope>) {
72        self.text = text.into();
73        self.rebuild();
74    }
75
76    /// Update wrap mode and recompute layout.
77    pub fn set_wrap(&mut self, wrap: WrapMode) {
78        if self.wrap != wrap {
79            self.wrap = wrap;
80            self.rebuild();
81        }
82    }
83
84    /// Update viewport width and recompute layout.
85    pub fn set_width(&mut self, width: usize) {
86        if self.width != width {
87            self.width = width;
88            self.rebuild();
89        }
90    }
91
92    /// Current wrap mode.
93    #[inline]
94    #[must_use]
95    pub const fn wrap_mode(&self) -> WrapMode {
96        self.wrap
97    }
98
99    /// Current viewport width used for wrapping.
100    #[inline]
101    #[must_use]
102    pub const fn width(&self) -> usize {
103        self.width
104    }
105
106    /// Number of logical (source) lines in the text.
107    #[inline]
108    #[must_use]
109    pub const fn source_line_count(&self) -> usize {
110        self.source_line_count
111    }
112
113    /// Number of virtual (wrapped) lines.
114    #[inline]
115    #[must_use]
116    pub fn virtual_line_count(&self) -> usize {
117        self.lines.len()
118    }
119
120    /// Maximum display width across all virtual lines.
121    #[inline]
122    #[must_use]
123    pub const fn max_width(&self) -> usize {
124        self.max_width
125    }
126
127    /// Access all virtual lines.
128    #[inline]
129    #[must_use]
130    pub fn lines(&self) -> &[ViewLine] {
131        &self.lines
132    }
133
134    /// Map a source line index to its first virtual line index.
135    #[must_use]
136    pub fn source_to_virtual(&self, source_line: usize) -> Option<usize> {
137        self.lines
138            .iter()
139            .position(|line| line.source_line == source_line)
140    }
141
142    /// Map a virtual line index to its source line index.
143    #[must_use]
144    pub fn virtual_to_source(&self, virtual_line: usize) -> Option<usize> {
145        self.lines.get(virtual_line).map(|line| line.source_line)
146    }
147
148    /// Clamp scroll position to a valid range for the given viewport height.
149    #[must_use]
150    pub fn clamp_scroll(&self, scroll_y: usize, viewport_height: usize) -> usize {
151        let total = self.lines.len();
152        if total == 0 {
153            return 0;
154        }
155        if viewport_height == 0 {
156            return scroll_y.min(total);
157        }
158        let max_scroll = total.saturating_sub(viewport_height);
159        scroll_y.min(max_scroll)
160    }
161
162    /// Maximum scroll offset for the given viewport height.
163    #[must_use]
164    pub fn max_scroll(&self, viewport_height: usize) -> usize {
165        let total = self.lines.len();
166        if total == 0 {
167            return 0;
168        }
169        if viewport_height == 0 {
170            return total;
171        }
172        total.saturating_sub(viewport_height)
173    }
174
175    /// Compute the visible virtual line range for a scroll offset + viewport height.
176    #[must_use]
177    pub fn visible_range(&self, scroll_y: usize, viewport_height: usize) -> Range<usize> {
178        let total = self.lines.len();
179        if total == 0 || viewport_height == 0 {
180            return 0..0;
181        }
182        let scroll = self.clamp_scroll(scroll_y, viewport_height);
183        let end = (scroll + viewport_height).min(total);
184        scroll..end
185    }
186
187    /// Get the visible virtual lines for a scroll offset + viewport height.
188    #[must_use]
189    pub fn visible_lines(&self, scroll_y: usize, viewport_height: usize) -> &[ViewLine] {
190        let range = self.visible_range(scroll_y, viewport_height);
191        &self.lines[range]
192    }
193
194    /// Scroll so the given source line is at the top of the viewport.
195    /// Returns `None` if the source line doesn't exist.
196    #[must_use]
197    pub fn scroll_to_line(&self, source_line: usize, viewport_height: usize) -> Option<usize> {
198        let virtual_line = self.source_to_virtual(source_line)?;
199        Some(self.clamp_scroll(virtual_line, viewport_height))
200    }
201
202    /// Scroll to the top of the view.
203    #[must_use]
204    pub fn scroll_to_top(&self) -> usize {
205        0
206    }
207
208    /// Scroll to the bottom of the view.
209    #[must_use]
210    pub fn scroll_to_bottom(&self, viewport_height: usize) -> usize {
211        self.max_scroll(viewport_height)
212    }
213
214    /// Scroll by a line delta (positive or negative).
215    #[must_use]
216    pub fn scroll_by_lines(&self, scroll_y: usize, delta: isize, viewport_height: usize) -> usize {
217        let next = (scroll_y as i64) + (delta as i64);
218        let next = if next < 0 { 0 } else { next as usize };
219        self.clamp_scroll(next, viewport_height)
220    }
221
222    /// Scroll by a page delta (positive or negative).
223    #[must_use]
224    pub fn scroll_by_pages(&self, scroll_y: usize, pages: isize, viewport_height: usize) -> usize {
225        if viewport_height == 0 {
226            return self.clamp_scroll(scroll_y, viewport_height);
227        }
228        let delta = (viewport_height as i64) * (pages as i64);
229        let next = (scroll_y as i64) + delta;
230        let next = if next < 0 { 0 } else { next as usize };
231        self.clamp_scroll(next, viewport_height)
232    }
233
234    fn rebuild(&mut self) {
235        self.lines.clear();
236        self.max_width = 0;
237
238        let preserve_indent = self.wrap == WrapMode::Char;
239        let options = WrapOptions::new(self.width)
240            .mode(self.wrap)
241            .preserve_indent(preserve_indent);
242
243        let mut source_lines = 0;
244
245        for (source_line, line) in self.text.lines().enumerate() {
246            source_lines += 1;
247            let mut line_text = line.to_string();
248            if line_text.ends_with('\n') {
249                line_text.pop();
250            }
251            if line_text.ends_with('\r') {
252                line_text.pop();
253            }
254
255            let wrapped = wrap_with_options(&line_text, &options);
256            if wrapped.is_empty() {
257                let width = 0;
258                self.lines.push(ViewLine {
259                    text: String::new(),
260                    source_line,
261                    is_wrap: false,
262                    width,
263                });
264                self.max_width = self.max_width.max(width);
265                continue;
266            }
267
268            for (idx, part) in wrapped.into_iter().enumerate() {
269                let width = display_width(&part);
270                self.max_width = self.max_width.max(width);
271                self.lines.push(ViewLine {
272                    text: part,
273                    source_line,
274                    is_wrap: idx > 0,
275                    width,
276                });
277            }
278        }
279
280        self.source_line_count = source_lines;
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::{TextView, Viewport};
287    use crate::wrap::WrapMode;
288
289    #[test]
290    fn view_basic_counts() {
291        let view = TextView::new("a\nbb", 10, WrapMode::None);
292        assert_eq!(view.source_line_count(), 2);
293        assert_eq!(view.virtual_line_count(), 2);
294        assert_eq!(view.max_width(), 2);
295    }
296
297    #[test]
298    fn view_wraps_word() {
299        let view = TextView::new("hello world", 5, WrapMode::Word);
300        let lines: Vec<&str> = view.lines().iter().map(|l| l.text.as_str()).collect();
301        assert_eq!(lines, vec!["hello", "world"]);
302    }
303
304    #[test]
305    fn view_wraps_cjk_by_cells() {
306        let view = TextView::new("你好世界", 4, WrapMode::Char);
307        let lines: Vec<&str> = view.lines().iter().map(|l| l.text.as_str()).collect();
308        assert_eq!(lines, vec!["你好", "世界"]);
309    }
310
311    #[test]
312    fn view_strips_crlf() {
313        let view = TextView::new("a\r\nb", 10, WrapMode::None);
314        let lines: Vec<&str> = view.lines().iter().map(|l| l.text.as_str()).collect();
315        assert_eq!(lines, vec!["a", "b"]);
316    }
317
318    #[test]
319    fn visible_range_clamps_scroll() {
320        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
321        let range = view.visible_range(5, 2);
322        assert_eq!(range, 1..3);
323    }
324
325    #[test]
326    fn scroll_to_line_clamps() {
327        let view = TextView::new("a\nb\nc\nd", 10, WrapMode::None);
328        let scroll = view.scroll_to_line(3, 2).expect("line 3 exists");
329        assert_eq!(scroll, 2);
330    }
331
332    #[test]
333    fn scroll_by_pages_moves_in_viewport_steps() {
334        let view = TextView::new("1\n2\n3\n4\n5", 10, WrapMode::None);
335        let scroll = view.scroll_by_pages(0, 1, 2);
336        assert_eq!(scroll, 2);
337        let back = view.scroll_by_pages(scroll, -1, 2);
338        assert_eq!(back, 0);
339    }
340
341    #[test]
342    fn scroll_to_bottom_respects_viewport() {
343        let view = TextView::new("a\nb\nc\nd", 10, WrapMode::None);
344        let bottom = view.scroll_to_bottom(2);
345        assert_eq!(bottom, 2);
346        let top = view.scroll_to_top();
347        assert_eq!(top, 0);
348    }
349
350    #[test]
351    fn visible_lines_returns_slice() {
352        let view = TextView::new("a\nb\nc\nd", 10, WrapMode::None);
353        let visible = view.visible_lines(1, 2);
354        let texts: Vec<&str> = visible.iter().map(|l| l.text.as_str()).collect();
355        assert_eq!(texts, vec!["b", "c"]);
356    }
357
358    #[test]
359    fn viewport_struct_is_copyable() {
360        let viewport = Viewport::new(80, 24);
361        let copy = viewport;
362        assert_eq!(copy.width, 80);
363        assert_eq!(copy.height, 24);
364    }
365
366    // ====== Empty text ======
367
368    #[test]
369    fn empty_text_view() {
370        let view = TextView::new("", 10, WrapMode::None);
371        assert_eq!(view.source_line_count(), 1); // empty text is still 1 source line
372        assert_eq!(view.virtual_line_count(), 1);
373        assert_eq!(view.max_width(), 0);
374    }
375
376    #[test]
377    fn empty_text_scroll() {
378        let view = TextView::new("", 10, WrapMode::None);
379        assert_eq!(view.max_scroll(5), 0);
380        assert_eq!(view.clamp_scroll(100, 5), 0);
381        assert_eq!(view.visible_range(0, 5), 0..1);
382    }
383
384    // ====== Source/virtual line mapping ======
385
386    #[test]
387    fn source_to_virtual_no_wrap() {
388        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
389        assert_eq!(view.source_to_virtual(0), Some(0));
390        assert_eq!(view.source_to_virtual(1), Some(1));
391        assert_eq!(view.source_to_virtual(2), Some(2));
392        assert_eq!(view.source_to_virtual(3), None);
393    }
394
395    #[test]
396    fn virtual_to_source_no_wrap() {
397        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
398        assert_eq!(view.virtual_to_source(0), Some(0));
399        assert_eq!(view.virtual_to_source(1), Some(1));
400        assert_eq!(view.virtual_to_source(2), Some(2));
401        assert_eq!(view.virtual_to_source(3), None);
402    }
403
404    #[test]
405    fn source_to_virtual_with_wrap() {
406        // "abcde" with width 3 wraps into 2 virtual lines
407        let view = TextView::new("abcde\nxy", 3, WrapMode::Char);
408        assert_eq!(view.source_to_virtual(0), Some(0)); // first virtual line of "abcde"
409        assert_eq!(view.source_to_virtual(1), Some(2)); // "xy" starts at virtual line 2
410    }
411
412    #[test]
413    fn virtual_to_source_with_wrap() {
414        let view = TextView::new("abcde\nxy", 3, WrapMode::Char);
415        assert_eq!(view.virtual_to_source(0), Some(0)); // first wrap of "abcde"
416        assert_eq!(view.virtual_to_source(1), Some(0)); // second wrap of "abcde"
417        assert_eq!(view.virtual_to_source(2), Some(1)); // "xy"
418    }
419
420    // ====== Wrap flag ======
421
422    #[test]
423    fn is_wrap_flag_set_correctly() {
424        let view = TextView::new("abcde", 3, WrapMode::Char);
425        let lines = view.lines();
426        assert!(!lines[0].is_wrap); // first segment is NOT a wrap
427        assert!(lines[1].is_wrap); // second segment IS a wrap continuation
428    }
429
430    // ====== set_text / set_wrap / set_width ======
431
432    #[test]
433    fn set_text_recomputes() {
434        let mut view = TextView::new("abc", 10, WrapMode::None);
435        assert_eq!(view.source_line_count(), 1);
436        view.set_text("a\nb\nc");
437        assert_eq!(view.source_line_count(), 3);
438        assert_eq!(view.virtual_line_count(), 3);
439    }
440
441    #[test]
442    fn set_wrap_recomputes() {
443        let mut view = TextView::new("hello world", 5, WrapMode::None);
444        let before = view.virtual_line_count();
445        view.set_wrap(WrapMode::Word);
446        let after = view.virtual_line_count();
447        // word wrap at width 5 should produce more lines than no wrap
448        assert!(after >= before);
449    }
450
451    #[test]
452    fn set_wrap_same_mode_is_noop() {
453        let mut view = TextView::new("hello", 10, WrapMode::None);
454        let count1 = view.virtual_line_count();
455        view.set_wrap(WrapMode::None); // same mode
456        assert_eq!(view.virtual_line_count(), count1);
457    }
458
459    #[test]
460    fn set_width_recomputes() {
461        let mut view = TextView::new("abcdef", 3, WrapMode::Char);
462        let count_narrow = view.virtual_line_count();
463        view.set_width(100);
464        let count_wide = view.virtual_line_count();
465        assert!(count_narrow > count_wide);
466    }
467
468    #[test]
469    fn set_width_same_is_noop() {
470        let mut view = TextView::new("abc", 10, WrapMode::None);
471        let count = view.virtual_line_count();
472        view.set_width(10);
473        assert_eq!(view.virtual_line_count(), count);
474    }
475
476    // ====== Accessors ======
477
478    #[test]
479    fn wrap_mode_accessor() {
480        let view = TextView::new("abc", 10, WrapMode::Word);
481        assert_eq!(view.wrap_mode(), WrapMode::Word);
482    }
483
484    #[test]
485    fn width_accessor() {
486        let view = TextView::new("abc", 42, WrapMode::None);
487        assert_eq!(view.width(), 42);
488    }
489
490    // ====== max_width ======
491
492    #[test]
493    fn max_width_across_lines() {
494        let view = TextView::new("ab\nabcde\nxy", 100, WrapMode::None);
495        assert_eq!(view.max_width(), 5); // "abcde" is the widest
496    }
497
498    #[test]
499    fn max_width_with_wide_chars() {
500        let view = TextView::new("\u{4E16}\u{754C}", 100, WrapMode::None); // "世界" = 4 cells
501        assert_eq!(view.max_width(), 4);
502    }
503
504    // ====== Scroll clamping ======
505
506    #[test]
507    fn clamp_scroll_within_bounds() {
508        let view = TextView::new("a\nb\nc\nd", 10, WrapMode::None); // 4 lines
509        assert_eq!(view.clamp_scroll(0, 2), 0);
510        assert_eq!(view.clamp_scroll(1, 2), 1);
511        assert_eq!(view.clamp_scroll(2, 2), 2); // max scroll for 4 lines, viewport 2
512        assert_eq!(view.clamp_scroll(3, 2), 2); // clamped
513        assert_eq!(view.clamp_scroll(100, 2), 2); // clamped
514    }
515
516    #[test]
517    fn clamp_scroll_viewport_larger_than_content() {
518        let view = TextView::new("a\nb", 10, WrapMode::None); // 2 lines
519        // viewport 10 > 2 lines, max_scroll = 0
520        assert_eq!(view.clamp_scroll(0, 10), 0);
521        assert_eq!(view.clamp_scroll(5, 10), 0);
522    }
523
524    #[test]
525    fn clamp_scroll_zero_viewport() {
526        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
527        // viewport 0: clamp to total (min of scroll_y and total)
528        let result = view.clamp_scroll(1, 0);
529        assert_eq!(result, 1);
530    }
531
532    // ====== max_scroll ======
533
534    #[test]
535    fn max_scroll_basic() {
536        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None); // 5 lines
537        assert_eq!(view.max_scroll(3), 2); // 5 - 3 = 2
538        assert_eq!(view.max_scroll(5), 0); // exact fit
539        assert_eq!(view.max_scroll(10), 0); // viewport bigger
540    }
541
542    #[test]
543    fn max_scroll_zero_viewport() {
544        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
545        assert_eq!(view.max_scroll(0), 3); // total lines
546    }
547
548    // ====== visible_range / visible_lines ======
549
550    #[test]
551    fn visible_range_basic() {
552        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None);
553        assert_eq!(view.visible_range(0, 3), 0..3);
554        assert_eq!(view.visible_range(1, 3), 1..4);
555        assert_eq!(view.visible_range(2, 3), 2..5);
556    }
557
558    #[test]
559    fn visible_range_zero_viewport() {
560        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
561        assert_eq!(view.visible_range(0, 0), 0..0);
562    }
563
564    #[test]
565    fn visible_lines_content() {
566        let view = TextView::new("alpha\nbeta\ngamma\ndelta", 10, WrapMode::None);
567        let visible = view.visible_lines(1, 2);
568        assert_eq!(visible.len(), 2);
569        assert_eq!(visible[0].text, "beta");
570        assert_eq!(visible[1].text, "gamma");
571    }
572
573    // ====== scroll_to_line ======
574
575    #[test]
576    fn scroll_to_line_basic() {
577        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None);
578        assert_eq!(view.scroll_to_line(0, 3), Some(0));
579        assert_eq!(view.scroll_to_line(2, 3), Some(2));
580        assert_eq!(view.scroll_to_line(4, 3), Some(2)); // clamped to max_scroll
581    }
582
583    #[test]
584    fn scroll_to_line_nonexistent() {
585        let view = TextView::new("a\nb", 10, WrapMode::None);
586        assert_eq!(view.scroll_to_line(5, 2), None);
587    }
588
589    // ====== scroll_by_lines ======
590
591    #[test]
592    fn scroll_by_lines_positive() {
593        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None);
594        assert_eq!(view.scroll_by_lines(0, 2, 3), 2);
595        assert_eq!(view.scroll_by_lines(0, 100, 3), 2); // clamped
596    }
597
598    #[test]
599    fn scroll_by_lines_negative() {
600        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None);
601        assert_eq!(view.scroll_by_lines(2, -1, 3), 1);
602        assert_eq!(view.scroll_by_lines(2, -100, 3), 0); // clamped to 0
603    }
604
605    // ====== scroll_by_pages ======
606
607    #[test]
608    fn scroll_by_pages_forward() {
609        // 10 lines, viewport 3: pages move by 3
610        let text = (0..10)
611            .map(|i| format!("line{i}"))
612            .collect::<Vec<_>>()
613            .join("\n");
614        let view = TextView::new(text.as_str(), 100, WrapMode::None);
615        assert_eq!(view.scroll_by_pages(0, 1, 3), 3);
616        assert_eq!(view.scroll_by_pages(0, 2, 3), 6);
617    }
618
619    #[test]
620    fn scroll_by_pages_backward() {
621        let text = (0..10)
622            .map(|i| format!("line{i}"))
623            .collect::<Vec<_>>()
624            .join("\n");
625        let view = TextView::new(text.as_str(), 100, WrapMode::None);
626        assert_eq!(view.scroll_by_pages(6, -1, 3), 3);
627        assert_eq!(view.scroll_by_pages(6, -3, 3), 0); // clamps to 0
628    }
629
630    #[test]
631    fn scroll_by_pages_zero_viewport() {
632        let view = TextView::new("a\nb\nc", 10, WrapMode::None);
633        // zero viewport: should not panic, return clamped
634        let result = view.scroll_by_pages(0, 1, 0);
635        assert_eq!(result, 0);
636    }
637
638    // ====== scroll_to_top / scroll_to_bottom ======
639
640    #[test]
641    fn scroll_to_top_and_bottom() {
642        let view = TextView::new("a\nb\nc\nd\ne", 10, WrapMode::None);
643        assert_eq!(view.scroll_to_top(), 0);
644        assert_eq!(view.scroll_to_bottom(3), 2);
645        assert_eq!(view.scroll_to_bottom(5), 0);
646        assert_eq!(view.scroll_to_bottom(1), 4);
647    }
648
649    // ====== Trailing newline ======
650
651    #[test]
652    fn trailing_newline_text() {
653        let view = TextView::new("a\nb\n", 10, WrapMode::None);
654        assert_eq!(view.source_line_count(), 3);
655        // Last source line is empty
656        let lines = view.lines();
657        assert_eq!(lines.last().unwrap().text, "");
658    }
659
660    // ====== Only newlines ======
661
662    #[test]
663    fn only_newlines() {
664        let view = TextView::new("\n\n\n", 10, WrapMode::None);
665        assert_eq!(view.source_line_count(), 4); // 3 newlines = 4 lines
666        assert_eq!(view.virtual_line_count(), 4);
667        for line in view.lines() {
668            assert_eq!(line.text, "");
669        }
670    }
671
672    // ====== ViewLine fields ======
673
674    #[test]
675    fn view_line_source_line_tracking() {
676        let view = TextView::new("ab\ncd\nef", 10, WrapMode::None);
677        for (i, line) in view.lines().iter().enumerate() {
678            assert_eq!(line.source_line, i);
679            assert!(!line.is_wrap);
680        }
681    }
682
683    #[test]
684    fn view_line_width_tracking() {
685        let view = TextView::new("ab\nabcde\n\u{4E16}", 10, WrapMode::None);
686        assert_eq!(view.lines()[0].width, 2);
687        assert_eq!(view.lines()[1].width, 5);
688        assert_eq!(view.lines()[2].width, 2); // CJK = 2 cells
689    }
690
691    // ====== Viewport struct ======
692
693    #[test]
694    fn viewport_default() {
695        let v = Viewport::default();
696        assert_eq!(v.width, 0);
697        assert_eq!(v.height, 0);
698    }
699
700    #[test]
701    fn viewport_equality() {
702        assert_eq!(Viewport::new(80, 24), Viewport::new(80, 24));
703        assert_ne!(Viewport::new(80, 24), Viewport::new(120, 24));
704    }
705}