1use std::ops::Range;
2
3use regex::Regex;
4
5use crate::filter::{CompiledFilter, FilterMatch};
6use crate::line_index::LineIndex;
7use crate::render::{count_rows, render_line, Cell, RenderOpts};
8use crate::source::Source;
9
10fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
16 let mut text = String::new();
17 let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
18 for (col, cell) in row.iter().enumerate() {
19 match cell {
20 Cell::Char { ch, .. } => {
21 starts.push(col);
22 text.push(*ch);
23 }
24 Cell::Empty => {
25 starts.push(col);
26 text.push(' ');
27 }
28 Cell::Continuation => {}
29 }
30 }
31 starts.push(row.len());
32 (text, starts)
33}
34
35fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
40 if row.is_empty() {
41 return Vec::new();
42 }
43 let last_content_col = row
44 .iter()
45 .enumerate()
46 .rev()
47 .find_map(|(c, cell)| match cell {
48 Cell::Char { width, .. } => Some(c + *width as usize),
49 Cell::Continuation => Some(c + 1),
50 Cell::Empty => None,
51 })
52 .unwrap_or(0);
53 if last_content_col == 0 {
54 return Vec::new();
55 }
56 let (text, starts) = row_text_and_starts(row);
57 let mut out = Vec::new();
58 for m in regex.find_iter(&text) {
59 if m.start() == m.end() {
60 continue;
61 }
62 let char_start = text[..m.start()].chars().count();
63 let char_end = text[..m.end()].chars().count();
64 if char_start >= starts.len() - 1 || char_end <= char_start {
65 continue;
66 }
67 let col_start = starts[char_start];
68 let col_end = starts[char_end].min(last_content_col);
69 if col_end > col_start {
70 out.push(col_start..col_end);
71 }
72 }
73 out
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum RowStyle {
78 Normal,
79 Dim,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum SearchDirection {
86 Forward,
87 Backward,
88}
89
90#[derive(Debug, Clone)]
91pub struct SearchState {
92 pub raw: String,
93 pub regex: Regex,
94 pub direction: SearchDirection,
95}
96
97#[derive(Debug, Clone)]
98pub struct Frame {
99 pub body: Vec<Vec<Cell>>, pub row_styles: Vec<RowStyle>, pub highlights: Vec<Vec<std::ops::Range<usize>>>,
106 pub status: String,
107}
108
109pub struct Viewport {
110 top_line: usize,
111 top_row: usize,
112 cols: u16,
113 rows: u16,
114 pub opts: RenderOpts,
115 pub show_line_numbers: bool,
116 pub source_label: String,
117 follow_mode: bool,
118 live_mode: bool,
119 prettify_label: Option<String>,
120 filter: Option<CompiledFilter>,
121 dim_mode: bool,
122 visible_lines: Vec<usize>,
125 visible_scanned: usize,
128 search: Option<SearchState>,
129 display: Option<crate::format::DisplayRenderer>,
133}
134
135impl Viewport {
136 pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
137 let mut opts = RenderOpts::default();
138 opts.cols = cols;
139 Self {
140 top_line: 0,
141 top_row: 0,
142 cols,
143 rows,
144 opts,
145 show_line_numbers: false,
146 source_label,
147 follow_mode: false,
148 live_mode: false,
149 prettify_label: None,
150 filter: None,
151 dim_mode: false,
152 visible_lines: Vec::new(),
153 visible_scanned: 0,
154 search: None,
155 display: None,
156 }
157 }
158
159 pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
160 self.display = renderer;
161 }
162
163 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
168 let range = idx.line_range(line_n, src);
169 let raw = src.bytes(range);
170 if let Some(r) = self.display.as_ref() {
171 if let Some(rendered) = r.render_line(&raw) {
172 return std::borrow::Cow::Owned(rendered.into_bytes());
173 }
174 }
175 raw
176 }
177
178 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
182 let regex = Regex::new(&raw).map_err(|e| e.to_string())?;
183 self.search = Some(SearchState { raw, regex, direction });
184 Ok(())
185 }
186
187 pub fn clear_search(&mut self) { self.search = None; }
188
189 pub fn search_active(&self) -> bool { self.search.is_some() }
190
191 pub fn search_direction(&self) -> SearchDirection {
192 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
193 }
194
195 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
199 let Some(s) = self.search.as_ref() else { return false; };
200 let forward = match (s.direction, reverse) {
201 (SearchDirection::Forward, false) | (SearchDirection::Backward, true) => true,
202 _ => false,
203 };
204 idx.extend_to_end(src);
205 let pattern = s.regex.clone();
206 if self.hide_mode() {
207 self.extend_visible_lines(idx, src);
208 self.search_step_in_visible(&pattern, src, idx, forward)
209 } else {
210 self.search_step_in_logical(&pattern, src, idx, forward)
211 }
212 }
213
214 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
215 let bytes = self.line_display_bytes(src, idx, line_n);
219 match std::str::from_utf8(&bytes) {
220 Ok(s) => pattern.is_match(s),
221 Err(_) => false,
222 }
223 }
224
225 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
226 let total = idx.line_count();
227 if total == 0 { return false; }
228 let start = self.top_line;
229 for offset in 1..=total {
232 let line_n = if forward {
233 (start + offset) % total
234 } else {
235 (start + total - offset) % total
236 };
237 if self.line_matches(pattern, src, idx, line_n) {
238 self.top_line = line_n;
239 self.top_row = 0;
240 return true;
241 }
242 }
243 false
244 }
245
246 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
247 let total = self.visible_lines.len();
248 if total == 0 { return false; }
249 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
251 for offset in 1..=total {
252 let visible_idx = if forward {
253 (cur + offset) % total
254 } else {
255 (cur + total - offset) % total
256 };
257 let line_n = self.visible_lines[visible_idx];
258 if self.line_matches(pattern, src, idx, line_n) {
259 self.top_line = line_n;
260 self.top_row = 0;
261 return true;
262 }
263 }
264 false
265 }
266
267 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
268 self.filter = filter;
269 self.visible_lines.clear();
270 self.visible_scanned = 0;
271 self.top_line = 0;
273 self.top_row = 0;
274 }
275
276 pub fn set_dim_mode(&mut self, on: bool) {
277 self.dim_mode = on;
278 self.visible_lines.clear();
282 self.visible_scanned = 0;
283 }
284
285 pub fn filter_active(&self) -> bool { self.filter.is_some() }
286
287 pub fn dim_mode(&self) -> bool { self.dim_mode }
288
289 fn hide_mode(&self) -> bool { self.filter.is_some() && !self.dim_mode }
290
291 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
295 if !self.hide_mode() {
296 return;
297 }
298 let Some(filter) = self.filter.as_ref() else { return };
299 let total = idx.line_count();
300 while self.visible_scanned < total {
301 let line_n = self.visible_scanned;
302 let range = idx.line_range(line_n, src);
303 let bytes = src.bytes(range);
304 if matches!(filter.evaluate(&bytes), FilterMatch::Matched) {
305 self.visible_lines.push(line_n);
306 }
307 self.visible_scanned += 1;
308 }
309 }
310
311 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
312
313 pub fn follow_mode(&self) -> bool { self.follow_mode }
314
315 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
316
317 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
318
319 pub fn live_mode(&self) -> bool { self.live_mode }
320
321 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
322
323 pub fn set_prettify_label(&mut self, label: Option<String>) {
326 self.prettify_label = label;
327 }
328
329 pub fn invalidate_filter_cache(&mut self) {
334 self.visible_lines.clear();
335 self.visible_scanned = 0;
336 }
337
338 pub fn clamp_top_line(&mut self, line_count: usize) {
341 if line_count == 0 {
342 self.top_line = 0;
343 self.top_row = 0;
344 } else if self.top_line >= line_count {
345 self.top_line = line_count - 1;
346 self.top_row = 0;
347 }
348 }
349
350 pub fn is_at_bottom(&self, idx: &LineIndex) -> bool {
354 let body = self.body_rows() as usize;
355 if self.hide_mode() {
356 let pos = self
358 .visible_lines
359 .iter()
360 .position(|&l| l >= self.top_line)
361 .unwrap_or(self.visible_lines.len());
362 pos + body >= self.visible_lines.len()
363 } else {
364 self.top_line + body >= idx.line_count()
365 }
366 }
367
368 fn gutter_width(&self, idx: &LineIndex) -> u16 {
370 if !self.show_line_numbers { return 0; }
371 let n = idx.line_count().max(1);
372 let digits = (n as f64).log10().floor() as u16 + 1;
373 digits + 1
374 }
375
376 fn render_opts(&self, gutter: u16) -> RenderOpts {
377 let mut o = self.opts.clone();
378 o.cols = self.cols.saturating_sub(gutter);
379 o
380 }
381
382 pub fn frame(&self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
383 let body_rows = self.body_rows() as usize;
384 idx.extend_to_line(self.top_line + body_rows + 1, src);
385
386 let gutter = self.gutter_width(idx);
387 let r_opts = self.render_opts(gutter);
388
389 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
390 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
391 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
392 let hide = self.hide_mode();
394 let total_lines = idx.line_count();
395
396 let mut hide_pos = if hide {
398 self.visible_lines
399 .iter()
400 .position(|&l| l >= self.top_line)
401 .unwrap_or(self.visible_lines.len())
402 } else {
403 0
404 };
405 let mut line_n = if hide {
406 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
407 } else {
408 self.top_line
409 };
410 let mut skip = if hide { 0 } else { self.top_row };
411
412 while body.len() < body_rows {
413 if line_n >= total_lines {
414 let mut row = Vec::with_capacity(self.cols as usize);
415 if gutter > 0 {
416 for _ in 0..gutter { row.push(Cell::Empty); }
417 }
418 while row.len() < self.cols as usize { row.push(Cell::Empty); }
419 body.push(row);
420 row_styles.push(RowStyle::Normal);
421 highlights.push(Vec::new());
422 line_n += 1;
423 continue;
424 }
425 let raw = src.bytes(idx.line_range(line_n, src));
428 let display_bytes = if let Some(r) = self.display.as_ref() {
429 match r.render_line(&raw) {
430 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
431 None => raw.clone(),
432 }
433 } else {
434 raw.clone()
435 };
436 let rows = render_line(&display_bytes, &r_opts);
437 let style = if let Some(f) = self.filter.as_ref() {
438 if self.dim_mode {
439 match f.evaluate(&raw) {
440 FilterMatch::Matched => RowStyle::Normal,
441 _ => RowStyle::Dim,
442 }
443 } else {
444 RowStyle::Normal
446 }
447 } else {
448 RowStyle::Normal
449 };
450
451 for (i, mut content_row) in rows.into_iter().enumerate() {
452 if i < skip { continue; }
453 if body.len() >= body_rows { break; }
454 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
455 if gutter > 0 {
456 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
457 for c in label.chars() {
458 full.push(Cell::Char { ch: c, width: 1 });
459 }
460 }
461 full.append(&mut content_row);
462 let row_highlights = if let Some(s) = self.search.as_ref() {
466 find_row_highlights(&full, &s.regex)
467 } else {
468 Vec::new()
469 };
470 body.push(full);
471 row_styles.push(style);
472 highlights.push(row_highlights);
473 }
474 skip = 0;
475 if hide {
477 hide_pos += 1;
478 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
479 } else {
480 line_n += 1;
481 }
482 }
483
484 let status = self.format_status(idx, src);
485 Frame { body, row_styles, highlights, status }
486 }
487
488 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
489 let body_rows = self.body_rows() as usize;
490 let total = idx.line_count();
491 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
494 let visible_total = self.visible_lines.len();
495 let cur = self
497 .visible_lines
498 .iter()
499 .position(|&l| l >= self.top_line)
500 .unwrap_or(visible_total);
501 let top = cur + 1;
502 let bottom = (cur + body_rows).min(visible_total.max(1));
503 let total_str = if src.is_complete() {
504 format!("{visible_total}/{total}")
505 } else {
506 format!("{visible_total}/{total}+")
507 };
508 (top, bottom, visible_total, total_str)
509 } else {
510 let top = self.top_line + 1;
511 let bottom = (self.top_line + body_rows).min(total.max(1));
512 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
513 (top, bottom, total, total_str)
514 };
515 let pct = if total_for_pct == 0 { 0 } else { (bottom * 100) / total_for_pct };
516 let mut s = format!("{} {}-{}/{} {}%", self.source_label, top, bottom, total_str, pct);
517 if !self.hide_mode() && self.top_row > 0 {
522 let line_rows = if total > 0 {
523 let bytes = self.line_display_bytes(src, idx, self.top_line);
524 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)))
525 } else { 1 };
526 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
527 }
528 if let Some(f) = self.filter.as_ref() {
529 s.push_str(&format!(" [{}]", f.format_name));
530 s.push_str(if self.dim_mode { " [dim]" } else { " [filter]" });
531 }
532 if let Some(sr) = self.search.as_ref() {
533 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
534 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
535 }
536 if let Some(label) = self.prettify_label.as_ref() {
537 s.push_str(&format!(" [pretty:{label}]"));
538 }
539 if self.live_mode { s.push_str(" (L)"); }
540 if self.follow_mode { s.push_str(" (F)"); }
541 s
542 }
543
544 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
549 if delta == 0 { return; }
550 if self.hide_mode() {
551 self.scroll_lines(delta, src, idx);
552 return;
553 }
554 if delta > 0 {
555 idx.extend_to_line(self.top_line + delta as usize + 1, src);
556 let total = idx.line_count();
557 if total == 0 { return; }
558 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
559 self.top_line = target;
560 self.top_row = 0;
561 } else {
562 let back = (-delta) as usize;
563 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
568 let extra_back = back.saturating_sub(consumed_for_snap);
569 self.top_line = self.top_line.saturating_sub(extra_back);
570 self.top_row = 0;
571 }
572 }
573
574 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
575 if delta == 0 { return; }
576 if self.hide_mode() {
577 self.extend_visible_lines(idx, src);
581 let total = self.visible_lines.len();
582 if total == 0 {
583 self.top_line = 0;
584 self.top_row = 0;
585 return;
586 }
587 let cur = self
588 .visible_lines
589 .iter()
590 .position(|&l| l >= self.top_line)
591 .unwrap_or(total);
592 let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
593 self.top_line = self.visible_lines[new];
594 self.top_row = 0;
595 return;
596 }
597 if delta > 0 {
598 let mut remaining = delta as usize;
599 while remaining > 0 {
600 idx.extend_to_line(self.top_line + 1, src);
601 let total = idx.line_count();
602 if total == 0 { break; }
603 let bytes = self.line_display_bytes(src, idx, self.top_line);
604 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)));
605 if self.top_row + 1 < line_rows {
606 self.top_row += 1;
607 } else if self.top_line + 1 < total {
608 self.top_row = 0;
609 self.top_line += 1;
610 } else {
611 break;
612 }
613 remaining -= 1;
614 }
615 } else {
616 let mut remaining = (-delta) as usize;
617 while remaining > 0 {
618 if self.top_row > 0 {
619 self.top_row -= 1;
620 } else if self.top_line > 0 {
621 self.top_line -= 1;
622 let bytes = self.line_display_bytes(src, idx, self.top_line);
623 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)));
624 self.top_row = line_rows.saturating_sub(1);
625 } else {
626 break;
627 }
628 remaining -= 1;
629 }
630 }
631 }
632
633 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
634 let n = self.body_rows() as i64;
635 self.scroll_lines(n, src, idx);
636 }
637
638 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
639 let n = self.body_rows() as i64;
640 self.scroll_lines(-n, src, idx);
641 }
642
643 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
644 let n = (self.body_rows() / 2).max(1) as i64;
645 self.scroll_lines(n, src, idx);
646 }
647
648 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
649 let n = (self.body_rows() / 2).max(1) as i64;
650 self.scroll_lines(-n, src, idx);
651 }
652
653 pub fn goto_top(&mut self) {
654 self.top_line = 0;
655 self.top_row = 0;
656 }
657
658 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
659 idx.extend_to_end(src);
660 let body = self.body_rows() as usize;
661 if self.hide_mode() {
662 self.extend_visible_lines(idx, src);
663 let total = self.visible_lines.len();
664 let target_visible = total.saturating_sub(body);
665 self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
666 self.top_row = 0;
667 } else {
668 let total = idx.line_count();
669 self.top_line = total.saturating_sub(body);
670 self.top_row = 0;
671 }
672 }
673
674 pub fn resize(&mut self, cols: u16, rows: u16) {
675 self.cols = cols.max(1);
676 self.rows = rows.max(2);
677 self.opts.cols = self.cols;
678 }
679
680 pub fn toggle_line_numbers(&mut self) {
681 self.show_line_numbers = !self.show_line_numbers;
682 }
683
684 pub fn toggle_chop(&mut self) {
685 self.opts.wrap = !self.opts.wrap;
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use crate::source::MockSource;
693
694 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
695 let m = MockSource::new();
696 m.append(content);
697 m.finish();
698 let idx = LineIndex::new();
699 (m, idx)
700 }
701
702 #[test]
703 fn frame_renders_body_height_rows() {
704 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
705 let v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
707 assert_eq!(frame.body.len(), 4);
708 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1 });
709 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1 });
710 }
711
712 #[test]
713 fn scroll_down_advances_top_line() {
714 let (m, mut idx) = setup(b"a\nb\nc\nd\n");
715 let mut v = Viewport::new(10, 5, "test".into());
716 v.scroll_lines(2, &m, &mut idx);
717 assert_eq!(v.top_line, 2);
718 assert_eq!(v.top_row, 0);
719 }
720
721 #[test]
722 fn scroll_up_clamps_at_zero() {
723 let (m, mut idx) = setup(b"a\nb\nc\n");
724 let mut v = Viewport::new(10, 5, "test".into());
725 v.scroll_lines(-5, &m, &mut idx);
726 assert_eq!(v.top_line, 0);
727 assert_eq!(v.top_row, 0);
728 }
729
730 #[test]
731 fn scroll_down_clamps_at_last_line() {
732 let (m, mut idx) = setup(b"a\nb\nc\n");
733 let mut v = Viewport::new(10, 5, "test".into());
734 v.scroll_lines(50, &m, &mut idx);
735 assert_eq!(v.top_line, 2);
736 }
737
738 #[test]
739 fn scroll_logical_lines_skips_wrap_rows() {
740 let mut content = vec![b'X'; 500];
742 content.push(b'\n');
743 content.extend_from_slice(b"second\n");
744 content.extend_from_slice(b"third\n");
745 let (m, mut idx) = setup(&content);
746 let mut v = Viewport::new(10, 8, "f".into());
747 v.scroll_logical_lines(1, &m, &mut idx);
748 assert_eq!((v.top_line, v.top_row), (1, 0));
749 v.scroll_logical_lines(1, &m, &mut idx);
750 assert_eq!((v.top_line, v.top_row), (2, 0));
751 }
752
753 #[test]
754 fn scroll_logical_lines_back_snaps_to_line_start() {
755 let mut content = vec![b'A'; 50];
757 content.push(b'\n');
758 content.extend_from_slice(&vec![b'B'; 50]);
759 content.push(b'\n');
760 let (m, mut idx) = setup(&content);
761 let mut v = Viewport::new(10, 8, "f".into());
762 v.scroll_lines(7, &m, &mut idx);
763 assert_eq!(v.top_line, 1, "should be on line 1");
764 assert!(v.top_row > 0, "should be inside line 1's wraps");
765 v.scroll_logical_lines(-1, &m, &mut idx);
766 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
767 v.scroll_logical_lines(-1, &m, &mut idx);
768 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
769 }
770
771 #[test]
772 fn scroll_down_walks_wraps_of_last_line() {
773 let mut content = b"first\n".to_vec();
775 content.extend_from_slice(&vec![b'X'; 30]);
776 content.push(b'\n');
777 let (m, mut idx) = setup(&content);
778 let mut v = Viewport::new(10, 5, "f".into());
779 v.scroll_lines(1, &m, &mut idx);
780 assert_eq!((v.top_line, v.top_row), (1, 0));
781 v.scroll_lines(1, &m, &mut idx);
782 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
783 v.scroll_lines(1, &m, &mut idx);
784 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
785 }
786
787 #[test]
788 fn scroll_down_walks_wrap_rows_within_long_line() {
789 let mut content = vec![b'X'; 30];
791 content.push(b'\n');
792 content.extend_from_slice(b"second\n");
793 let (m, mut idx) = setup(&content);
794 let mut v = Viewport::new(10, 5, "f".into());
795 v.scroll_lines(1, &m, &mut idx);
796 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
797 v.scroll_lines(1, &m, &mut idx);
798 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
799 v.scroll_lines(1, &m, &mut idx);
800 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
801 }
802
803 #[test]
804 fn status_line_shows_range_and_pct() {
805 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
806 let v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
808 assert!(frame.status.starts_with("f 1-4/10"));
809 }
810
811 #[test]
812 fn page_down_advances_by_body_rows() {
813 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
814 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
816 assert_eq!(v.top_line, 4);
817 }
818
819 #[test]
820 fn page_up_then_page_down_returns_to_start_when_no_resize() {
821 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
822 let mut v = Viewport::new(10, 5, "f".into());
823 v.page_down(&m, &mut idx);
824 v.page_up(&m, &mut idx);
825 assert_eq!(v.top_line, 0);
826 assert_eq!(v.top_row, 0);
827 }
828
829 #[test]
830 fn half_page_down_advances_by_half_body() {
831 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
832 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
834 assert_eq!(v.top_line, 3);
835 }
836
837 #[test]
838 fn goto_top_resets_position() {
839 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
840 let mut v = Viewport::new(10, 5, "f".into());
841 v.scroll_lines(2, &m, &mut idx);
842 v.goto_top();
843 assert_eq!(v.top_line, 0);
844 assert_eq!(v.top_row, 0);
845 }
846
847 #[test]
848 fn goto_bottom_scrolls_to_last_page() {
849 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
850 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
852 assert_eq!(v.top_line, 6);
854 }
855
856 #[test]
857 fn resize_updates_dimensions_and_render_opts() {
858 let (m, mut idx) = setup(b"1\n2\n");
859 let mut v = Viewport::new(10, 5, "f".into());
860 v.resize(40, 12);
861 assert_eq!(v.cols, 40);
862 assert_eq!(v.rows, 12);
863 assert_eq!(v.opts.cols, 40);
864 let _ = v.frame(&m, &mut idx);
865 }
866
867 #[test]
868 fn toggle_line_numbers_changes_gutter() {
869 let (m, mut idx) = setup(b"a\nb\nc\n");
870 let mut v = Viewport::new(10, 5, "f".into());
871 let frame_off = v.frame(&m, &mut idx);
872 v.toggle_line_numbers();
873 let frame_on = v.frame(&m, &mut idx);
874 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1 });
876 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1 });
877 }
878
879 #[test]
880 fn toggle_chop_changes_wrap_mode() {
881 let (m, mut idx) = setup(b"abcdefghij\n");
882 let mut v = Viewport::new(4, 5, "f".into());
883 v.toggle_chop();
884 let frame = v.frame(&m, &mut idx);
885 assert_eq!(frame.body[0][..4],
888 [Cell::Char { ch: 'a', width: 1 }, Cell::Char { ch: 'b', width: 1 },
889 Cell::Char { ch: 'c', width: 1 }, Cell::Char { ch: 'd', width: 1 }]);
890 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
892 }
893
894 #[test]
897 fn is_at_bottom_initially_only_when_source_fits() {
898 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
901 assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
902 }
903
904 #[test]
905 fn is_at_bottom_false_when_top_and_more_lines_below() {
906 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
909 assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
910 }
911
912 #[test]
913 fn is_at_bottom_true_after_goto_bottom() {
914 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
915 let mut v = Viewport::new(10, 5, "f".into());
916 v.goto_bottom(&m, &mut idx);
917 assert!(v.is_at_bottom(&idx));
918 }
919
920 #[test]
921 fn status_shows_F_suffix_when_follow_mode_on() {
922 let (m, mut idx) = setup(b"a\nb\n");
923 let mut v = Viewport::new(20, 5, "f".into());
924 let frame_off = v.frame(&m, &mut idx);
925 assert!(!frame_off.status.contains("(F)"));
926 v.set_follow_mode(true);
927 let frame_on = v.frame(&m, &mut idx);
928 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
929 }
930
931 #[test]
932 fn toggle_follow_flips_state() {
933 let mut v = Viewport::new(10, 5, "f".into());
934 assert!(!v.follow_mode());
935 v.toggle_follow();
936 assert!(v.follow_mode());
937 v.toggle_follow();
938 assert!(!v.follow_mode());
939 }
940
941 #[test]
942 fn status_shows_prettify_label_when_set() {
943 let (m, mut idx) = setup(b"a\n");
944 let mut v = Viewport::new(40, 5, "f".into());
945 let frame_off = v.frame(&m, &mut idx);
946 assert!(!frame_off.status.contains("[pretty"));
947 v.set_prettify_label(Some("json".into()));
948 let frame_on = v.frame(&m, &mut idx);
949 assert!(frame_on.status.contains("[pretty:json]"),
950 "expected [pretty:json] in status, got: {}", frame_on.status);
951 v.set_prettify_label(Some("json:err".into()));
952 let frame_err = v.frame(&m, &mut idx);
953 assert!(frame_err.status.contains("[pretty:json:err]"),
954 "expected [pretty:json:err] in status, got: {}", frame_err.status);
955 }
956
957 #[test]
958 fn status_shows_l_suffix_when_live_mode_on() {
959 let (m, mut idx) = setup(b"a\nb\n");
960 let mut v = Viewport::new(20, 5, "f".into());
961 let frame_off = v.frame(&m, &mut idx);
962 assert!(!frame_off.status.contains("(L)"));
963 v.set_live_mode(true);
964 let frame_on = v.frame(&m, &mut idx);
965 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
966 }
967
968 #[test]
969 fn clamp_top_line_pulls_back_when_total_shrinks() {
970 let mut v = Viewport::new(20, 5, "f".into());
971 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
980 let (m, mut idx) = setup(b"only\n");
982 let _ = v.frame(&m, &mut idx);
983 }
984
985 fn simulate_growth_tick(
988 v: &mut Viewport,
989 src: &MockSource,
990 idx: &mut LineIndex,
991 ) {
992 if !v.follow_mode() { return; }
993 let was_at_bottom = v.is_at_bottom(idx);
994 let lines_before = idx.line_count();
995 idx.notice_new_bytes(src);
996 if idx.line_count() != lines_before && was_at_bottom {
997 v.goto_bottom(src, idx);
998 }
999 }
1000
1001 #[test]
1002 fn auto_scroll_engages_when_at_bottom() {
1003 let m = MockSource::new();
1004 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
1006 let mut v = Viewport::new(10, 5, "f".into());
1007 v.set_follow_mode(true);
1008 idx.extend_to_end(&m);
1009 assert!(v.is_at_bottom(&idx));
1010 let top_before = {
1011 let f = v.frame(&m, &mut idx);
1012 f.status.clone() };
1014 let _ = top_before;
1015 m.append(b"5\n6\n7\n8\n");
1017 simulate_growth_tick(&mut v, &m, &mut idx);
1018 assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
1020 let frame = v.frame(&m, &mut idx);
1021 let last_row = &frame.body[frame.body.len() - 1];
1024 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1 });
1025 }
1026
1027 #[test]
1028 fn auto_scroll_suppressed_when_scrolled_up() {
1029 let m = MockSource::new();
1030 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
1032 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
1034 idx.extend_to_end(&m);
1035 v.goto_bottom(&m, &mut idx);
1036 v.scroll_lines(-2, &m, &mut idx);
1038 assert!(!v.is_at_bottom(&idx));
1039 let frame_before = v.frame(&m, &mut idx);
1040 let top_first_cell_before = frame_before.body[0][0].clone();
1041 m.append(b"9\n10\n");
1043 simulate_growth_tick(&mut v, &m, &mut idx);
1044 let frame_after = v.frame(&m, &mut idx);
1046 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
1047 }
1048
1049 #[test]
1052 fn set_search_compiles_regex() {
1053 let mut v = Viewport::new(10, 5, "f".into());
1054 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
1055 assert!(v.search_active());
1056 }
1057
1058 #[test]
1059 fn set_search_rejects_bad_regex() {
1060 let mut v = Viewport::new(10, 5, "f".into());
1061 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
1062 assert!(!err.is_empty());
1063 assert!(!v.search_active(), "no search should be set on error");
1064 }
1065
1066 #[test]
1067 fn search_step_forward_finds_match_after_top() {
1068 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1069 let mut v = Viewport::new(20, 5, "f".into());
1070 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1071 let found = v.search_repeat(&m, &mut idx, false);
1072 assert!(found);
1073 assert_eq!(v.top_line, 2);
1075 }
1076
1077 #[test]
1078 fn search_step_backward_finds_match_before_top() {
1079 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1080 let mut v = Viewport::new(20, 5, "f".into());
1081 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
1083 let found = v.search_repeat(&m, &mut idx, false);
1084 assert!(found);
1085 assert_eq!(v.top_line, 0);
1086 }
1087
1088 #[test]
1089 fn search_wraps_at_end() {
1090 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1091 let mut v = Viewport::new(20, 5, "f".into());
1092 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
1094 let found = v.search_repeat(&m, &mut idx, false);
1095 assert!(found, "search should wrap forward past EOF");
1096 assert_eq!(v.top_line, 0);
1097 }
1098
1099 #[test]
1100 fn search_no_match_returns_false_and_does_not_move() {
1101 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1102 let mut v = Viewport::new(20, 5, "f".into());
1103 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
1104 let found = v.search_repeat(&m, &mut idx, false);
1105 assert!(!found);
1106 assert_eq!(v.top_line, 0);
1107 }
1108
1109 #[test]
1110 fn frame_records_highlight_ranges_for_matches() {
1111 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
1112 let mut v = Viewport::new(20, 5, "f".into());
1113 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1114 let frame = v.frame(&m, &mut idx);
1115 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1117 assert!(frame.highlights[0].is_empty());
1118 assert!(frame.highlights[1].is_empty());
1119 assert_eq!(frame.highlights[2], vec![0..5]);
1120 assert!(frame.highlights[3].is_empty());
1121 }
1122
1123 #[test]
1124 fn frame_highlights_substring_inside_a_row() {
1125 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
1126 let mut v = Viewport::new(40, 5, "f".into());
1127 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1128 let frame = v.frame(&m, &mut idx);
1129 assert_eq!(frame.highlights[0], vec![18..22]);
1131 assert!(frame.highlights[1].is_empty());
1132 }
1133
1134 #[test]
1135 fn search_highlight_with_filter_dim_keeps_row_dim() {
1136 let (m, mut idx) = setup(b"alpha\nbeta\n");
1139 let mut v = Viewport::new(20, 5, "f".into());
1140 let fmt = crate::format::LogFormat::compile(
1141 "simple",
1142 r"^(?P<line>.+)$",
1143 )
1144 .unwrap();
1145 let f = crate::filter::CompiledFilter::compile(
1146 &fmt,
1147 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
1148 )
1149 .unwrap();
1150 v.set_filter(Some(f));
1151 v.set_dim_mode(true);
1152 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1153 let frame = v.frame(&m, &mut idx);
1154 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1155 assert_eq!(frame.row_styles[1], RowStyle::Dim);
1156 assert_eq!(frame.highlights[1], vec![0..4]);
1157 }
1158
1159 #[test]
1160 fn search_status_shows_pattern() {
1161 let (m, mut idx) = setup(b"x\n");
1162 let mut v = Viewport::new(20, 5, "f".into());
1163 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1164 let frame = v.frame(&m, &mut idx);
1165 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
1166 }
1167
1168 #[test]
1169 fn repeat_search_after_first_match_advances() {
1170 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
1171 let mut v = Viewport::new(40, 5, "f".into());
1172 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1173 assert!(v.search_repeat(&m, &mut idx, false));
1174 assert_eq!(v.top_line, 1, "first foo");
1175 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1176 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
1177 assert_eq!(v.top_line, 3, "should advance to next foo");
1178 }
1179
1180 #[test]
1181 fn auto_scroll_paused_when_follow_off() {
1182 let m = MockSource::new();
1183 m.append(b"1\n2\n3\n4\n");
1184 let mut idx = LineIndex::new();
1185 let mut v = Viewport::new(10, 5, "f".into());
1186 idx.extend_to_end(&m);
1188 let frame_before = v.frame(&m, &mut idx);
1189 let top_first_cell = frame_before.body[0][0].clone();
1190 m.append(b"5\n6\n7\n8\n");
1191 simulate_growth_tick(&mut v, &m, &mut idx);
1192 let frame_after = v.frame(&m, &mut idx);
1193 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
1194 }
1195}