1use std::ops::Range;
2
3use regex::Regex;
4
5use crate::filter::{CompiledFilter, FilterMatch};
6use crate::grep::GrepPredicate;
7use crate::line_index::LineIndex;
8use crate::render::{count_rows, render_line, Cell, RenderOpts};
9use crate::source::Source;
10
11fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
17 let mut text = String::new();
18 let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
19 for (col, cell) in row.iter().enumerate() {
20 match cell {
21 Cell::Char { ch, .. } => {
22 starts.push(col);
23 text.push(*ch);
24 }
25 Cell::Empty => {
26 starts.push(col);
27 text.push(' ');
28 }
29 Cell::Continuation => {}
30 }
31 }
32 starts.push(row.len());
33 (text, starts)
34}
35
36fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
41 if row.is_empty() {
42 return Vec::new();
43 }
44 let last_content_col = row
45 .iter()
46 .enumerate()
47 .rev()
48 .find_map(|(c, cell)| match cell {
49 Cell::Char { width, .. } => Some(c + *width as usize),
50 Cell::Continuation => Some(c + 1),
51 Cell::Empty => None,
52 })
53 .unwrap_or(0);
54 if last_content_col == 0 {
55 return Vec::new();
56 }
57 let (text, starts) = row_text_and_starts(row);
58 let mut out = Vec::new();
59 for m in regex.find_iter(&text) {
60 if m.start() == m.end() {
61 continue;
62 }
63 let char_start = text[..m.start()].chars().count();
64 let char_end = text[..m.end()].chars().count();
65 if char_start >= starts.len() - 1 || char_end <= char_start {
66 continue;
67 }
68 let col_start = starts[char_start];
69 let col_end = starts[char_end].min(last_content_col);
70 if col_end > col_start {
71 out.push(col_start..col_end);
72 }
73 }
74 out
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum RowStyle {
79 Normal,
80 Dim,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum SearchDirection {
87 Forward,
88 Backward,
89}
90
91#[derive(Debug, Clone)]
92pub struct SearchState {
93 pub raw: String,
94 pub regex: Regex,
95 pub direction: SearchDirection,
96}
97
98#[derive(Debug, Clone)]
99pub struct Frame {
100 pub body: Vec<Vec<Cell>>, pub row_styles: Vec<RowStyle>, pub highlights: Vec<Vec<std::ops::Range<usize>>>,
107 pub status: String,
108}
109
110pub struct Viewport {
111 top_line: usize,
112 top_row: usize,
113 cols: u16,
114 rows: u16,
115 pub opts: RenderOpts,
116 pub show_line_numbers: bool,
117 pub source_label: String,
118 follow_mode: bool,
119 live_mode: bool,
120 prettify_label: Option<String>,
121 filter: Option<CompiledFilter>,
122 grep: Option<GrepPredicate>,
123 dim_mode: bool,
124 visible_lines: Vec<usize>,
127 visible_scanned: usize,
130 search: Option<SearchState>,
131 display: Option<crate::format::DisplayRenderer>,
135}
136
137impl Viewport {
138 pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
139 let opts = RenderOpts { cols, ..RenderOpts::default() };
140 Self {
141 top_line: 0,
142 top_row: 0,
143 cols,
144 rows,
145 opts,
146 show_line_numbers: false,
147 source_label,
148 follow_mode: false,
149 live_mode: false,
150 prettify_label: None,
151 filter: None,
152 grep: None,
153 dim_mode: false,
154 visible_lines: Vec::new(),
155 visible_scanned: 0,
156 search: None,
157 display: None,
158 }
159 }
160
161 pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
162 self.display = renderer;
163 }
164
165 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
170 let range = idx.line_range(line_n, src);
171 let raw = src.bytes(range);
172 if let Some(r) = self.display.as_ref() {
173 if let Some(rendered) = r.render_line(&raw) {
174 return std::borrow::Cow::Owned(rendered.into_bytes());
175 }
176 }
177 raw
178 }
179
180 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
184 let regex = Regex::new(&raw).map_err(|e| e.to_string())?;
185 self.search = Some(SearchState { raw, regex, direction });
186 Ok(())
187 }
188
189 pub fn clear_search(&mut self) { self.search = None; }
190
191 pub fn search_active(&self) -> bool { self.search.is_some() }
192
193 pub fn search_direction(&self) -> SearchDirection {
194 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
195 }
196
197 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
201 if idx.records_mode() {
202 self.search_repeat_records(src, idx, reverse)
203 } else {
204 self.search_repeat_lines(src, idx, reverse)
205 }
206 }
207
208 fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
210 let Some(s) = self.search.as_ref() else { return false; };
211 let forward = matches!(
212 (s.direction, reverse),
213 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
214 );
215 idx.extend_to_end(src);
216 let pattern = s.regex.clone();
217 if self.hide_mode() {
218 self.extend_visible_lines(idx, src);
219 self.search_step_in_visible(&pattern, src, idx, forward)
220 } else {
221 self.search_step_in_logical(&pattern, src, idx, forward)
222 }
223 }
224
225 fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
229 let Some(s) = self.search.as_ref() else { return false; };
230 let forward = matches!(
231 (s.direction, reverse),
232 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
233 );
234 let pattern = s.regex.clone();
235 idx.extend_to_end(src);
236
237 let total = idx.record_count();
238 if total == 0 { return false; }
239
240 let cur_record = idx.line_to_record(self.top_line);
241
242 let range: Box<dyn Iterator<Item = usize>> = if forward {
243 Box::new(((cur_record + 1)..total).chain(0..=cur_record))
244 } else {
245 let earlier: Vec<usize> = (0..cur_record).rev().collect();
246 let later: Vec<usize> = (cur_record..total).rev().collect();
247 Box::new(earlier.into_iter().chain(later))
248 };
249
250 for r in range {
251 let bytes_cow = idx.record_bytes(r, src);
252 let text = String::from_utf8_lossy(&bytes_cow);
253 if pattern.is_match(&text) {
254 let line_range = idx.record_line_range(r);
255 self.top_line = line_range.start;
256 self.top_row = 0;
257 return true;
258 }
259 }
260 false
261 }
262
263 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
264 let bytes = self.line_display_bytes(src, idx, line_n);
268 match std::str::from_utf8(&bytes) {
269 Ok(s) => pattern.is_match(s),
270 Err(_) => false,
271 }
272 }
273
274 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
275 let total = idx.line_count();
276 if total == 0 { return false; }
277 let start = self.top_line;
278 for offset in 1..=total {
281 let line_n = if forward {
282 (start + offset) % total
283 } else {
284 (start + total - offset) % total
285 };
286 if self.line_matches(pattern, src, idx, line_n) {
287 self.top_line = line_n;
288 self.top_row = 0;
289 return true;
290 }
291 }
292 false
293 }
294
295 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
296 let total = self.visible_lines.len();
297 if total == 0 { return false; }
298 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
300 for offset in 1..=total {
301 let visible_idx = if forward {
302 (cur + offset) % total
303 } else {
304 (cur + total - offset) % total
305 };
306 let line_n = self.visible_lines[visible_idx];
307 if self.line_matches(pattern, src, idx, line_n) {
308 self.top_line = line_n;
309 self.top_row = 0;
310 return true;
311 }
312 }
313 false
314 }
315
316 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
317 self.filter = filter;
318 self.visible_lines.clear();
319 self.visible_scanned = 0;
320 self.top_line = 0;
322 self.top_row = 0;
323 }
324
325 pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
326 self.grep = grep;
327 self.visible_lines.clear();
328 self.visible_scanned = 0;
329 self.top_line = 0;
330 self.top_row = 0;
331 }
332
333 pub fn grep_active(&self) -> bool { self.grep.is_some() }
334
335 pub fn set_dim_mode(&mut self, on: bool) {
336 self.dim_mode = on;
337 self.visible_lines.clear();
341 self.visible_scanned = 0;
342 }
343
344 pub fn filter_active(&self) -> bool { self.filter.is_some() }
345
346 pub fn dim_mode(&self) -> bool { self.dim_mode }
347
348 fn hide_mode(&self) -> bool {
349 (self.filter.is_some() || self.grep.is_some()) && !self.dim_mode
350 }
351
352 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
357 if !self.hide_mode() {
358 return;
359 }
360 if idx.records_mode() {
361 self.extend_visible_lines_records(idx, src);
362 } else {
363 self.extend_visible_lines_per_line(idx, src);
364 }
365 }
366
367 fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
369 let total = idx.line_count();
370 while self.visible_scanned < total {
371 let line_n = self.visible_scanned;
372 let range = idx.line_range(line_n, src);
373 let bytes = src.bytes(range);
374 if self.line_passes(&bytes) {
375 self.visible_lines.push(line_n);
376 }
377 self.visible_scanned += 1;
378 }
379 }
380
381 fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
388 self.visible_lines.clear();
389 self.visible_scanned = 0; let total_records = idx.record_count();
391 for r in 0..total_records {
392 let bytes_cow = idx.record_bytes(r, src);
393 let bytes: &[u8] = &bytes_cow;
394 if self.line_passes(bytes) {
395 for line_n in idx.record_line_range(r) {
396 self.visible_lines.push(line_n);
397 }
398 }
399 }
400 }
401
402 fn line_passes(&self, line: &[u8]) -> bool {
408 let filter_ok = match self.filter.as_ref() {
409 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
410 None => true,
411 };
412 let grep_ok = match self.grep.as_ref() {
413 Some(g) => g.matches(line),
414 None => true,
415 };
416 filter_ok && grep_ok
417 }
418
419 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
423 if !self.dim_mode {
424 return false;
425 }
426 if idx.records_mode() {
427 let r = idx.line_to_record(line_n);
428 let bytes_cow = idx.record_bytes(r, src);
429 let bytes: &[u8] = &bytes_cow;
430 !self.line_passes(bytes)
431 } else {
432 let range = idx.line_range(line_n, src);
433 let bytes = src.bytes(range);
434 !self.line_passes(&bytes)
435 }
436 }
437
438 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
439
440 pub fn follow_mode(&self) -> bool { self.follow_mode }
441
442 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
443
444 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
445
446 pub fn live_mode(&self) -> bool { self.live_mode }
447
448 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
449
450 pub fn set_prettify_label(&mut self, label: Option<String>) {
453 self.prettify_label = label;
454 }
455
456 pub fn invalidate_filter_cache(&mut self) {
461 self.visible_lines.clear();
462 self.visible_scanned = 0;
463 }
464
465 pub fn clamp_top_line(&mut self, line_count: usize) {
468 if line_count == 0 {
469 self.top_line = 0;
470 self.top_row = 0;
471 } else if self.top_line >= line_count {
472 self.top_line = line_count - 1;
473 self.top_row = 0;
474 }
475 }
476
477 pub fn is_at_bottom(&self, idx: &LineIndex) -> bool {
481 let body = self.body_rows() as usize;
482 if self.hide_mode() {
483 let pos = self
485 .visible_lines
486 .iter()
487 .position(|&l| l >= self.top_line)
488 .unwrap_or(self.visible_lines.len());
489 pos + body >= self.visible_lines.len()
490 } else {
491 self.top_line + body >= idx.line_count()
492 }
493 }
494
495 fn gutter_width(&self, idx: &LineIndex) -> u16 {
497 if !self.show_line_numbers { return 0; }
498 let n = idx.line_count().max(1);
499 let digits = (n as f64).log10().floor() as u16 + 1;
500 digits + 1
501 }
502
503 fn render_opts(&self, gutter: u16) -> RenderOpts {
504 let mut o = self.opts.clone();
505 o.cols = self.cols.saturating_sub(gutter);
506 o
507 }
508
509 pub fn frame(&self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
510 let body_rows = self.body_rows() as usize;
511 idx.extend_to_line(self.top_line + body_rows + 1, src);
512
513 let gutter = self.gutter_width(idx);
514 let r_opts = self.render_opts(gutter);
515
516 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
517 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
518 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
519 let hide = self.hide_mode();
521 let total_lines = idx.line_count();
522
523 let mut hide_pos = if hide {
525 self.visible_lines
526 .iter()
527 .position(|&l| l >= self.top_line)
528 .unwrap_or(self.visible_lines.len())
529 } else {
530 0
531 };
532 let mut line_n = if hide {
533 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
534 } else {
535 self.top_line
536 };
537 let mut skip = if hide { 0 } else { self.top_row };
538
539 while body.len() < body_rows {
540 if line_n >= total_lines {
541 let mut row = Vec::with_capacity(self.cols as usize);
542 if gutter > 0 {
543 for _ in 0..gutter { row.push(Cell::Empty); }
544 }
545 while row.len() < self.cols as usize { row.push(Cell::Empty); }
546 body.push(row);
547 row_styles.push(RowStyle::Normal);
548 highlights.push(Vec::new());
549 line_n += 1;
550 continue;
551 }
552 let raw = src.bytes(idx.line_range(line_n, src));
555 let display_bytes = if let Some(r) = self.display.as_ref() {
556 match r.render_line(&raw) {
557 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
558 None => raw.clone(),
559 }
560 } else {
561 raw.clone()
562 };
563 let rows = render_line(&display_bytes, &r_opts);
564 let style = if self.filter.is_some() || self.grep.is_some() {
565 if self.dim_mode {
566 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
567 } else {
568 RowStyle::Normal
570 }
571 } else {
572 RowStyle::Normal
573 };
574
575 for (i, mut content_row) in rows.into_iter().enumerate() {
576 if i < skip { continue; }
577 if body.len() >= body_rows { break; }
578 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
579 if gutter > 0 {
580 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
581 for c in label.chars() {
582 full.push(Cell::Char { ch: c, width: 1 });
583 }
584 }
585 full.append(&mut content_row);
586 let row_highlights = if let Some(s) = self.search.as_ref() {
590 find_row_highlights(&full, &s.regex)
591 } else {
592 Vec::new()
593 };
594 body.push(full);
595 row_styles.push(style);
596 highlights.push(row_highlights);
597 }
598 skip = 0;
599 if hide {
601 hide_pos += 1;
602 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
603 } else {
604 line_n += 1;
605 }
606 }
607
608 let status = self.format_status(idx, src);
609 Frame { body, row_styles, highlights, status }
610 }
611
612 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
613 let body_rows = self.body_rows() as usize;
614 let total = idx.line_count();
615 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
618 let visible_total = self.visible_lines.len();
619 let cur = self
621 .visible_lines
622 .iter()
623 .position(|&l| l >= self.top_line)
624 .unwrap_or(visible_total);
625 let top = cur + 1;
626 let bottom = (cur + body_rows).min(visible_total.max(1));
627 let total_str = if src.is_complete() {
628 format!("{visible_total}/{total}")
629 } else {
630 format!("{visible_total}/{total}+")
631 };
632 (top, bottom, visible_total, total_str)
633 } else {
634 let top = self.top_line + 1;
635 let bottom = (self.top_line + body_rows).min(total.max(1));
636 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
637 (top, bottom, total, total_str)
638 };
639 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
640 let (line_prefix, records_block) = if idx.records_mode() {
642 let line_total = idx.line_count();
643 let rec_total = idx.record_count();
644 let rec_block = if line_total == 0 || rec_total == 0 {
645 format!("R0-0/{}", rec_total)
646 } else {
647 let rec_top = idx.line_to_record(self.top_line) + 1;
648 let rec_bottom = idx.line_to_record(bottom.saturating_sub(1)) + 1;
649 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
650 };
651 ("L", Some(rec_block))
652 } else {
653 ("", None)
654 };
655 let middle = match records_block {
656 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
657 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
658 };
659 let mut s = format!("{} {}", self.source_label, middle);
660 if !self.hide_mode() && self.top_row > 0 {
665 let line_rows = if total > 0 {
666 let bytes = self.line_display_bytes(src, idx, self.top_line);
667 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)))
668 } else { 1 };
669 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
670 }
671 if let Some(f) = self.filter.as_ref() {
672 s.push_str(&format!(" [{}]", f.format_name));
673 }
674 if self.grep.is_some() {
675 s.push_str(" [grep]");
676 }
677 if self.filter.is_some() || self.grep.is_some() {
678 s.push_str(if self.dim_mode { " [dim]" } else { " [filter]" });
679 }
680 if let Some(sr) = self.search.as_ref() {
681 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
682 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
683 }
684 if let Some(label) = self.prettify_label.as_ref() {
685 s.push_str(&format!(" [pretty:{label}]"));
686 }
687 if self.live_mode { s.push_str(" (L)"); }
688 if self.follow_mode { s.push_str(" (F)"); }
689 s
690 }
691
692 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
697 if delta == 0 { return; }
698 if self.hide_mode() {
699 self.scroll_lines(delta, src, idx);
700 return;
701 }
702 if delta > 0 {
703 idx.extend_to_line(self.top_line + delta as usize + 1, src);
704 let total = idx.line_count();
705 if total == 0 { return; }
706 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
707 self.top_line = target;
708 self.top_row = 0;
709 } else {
710 let back = (-delta) as usize;
711 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
716 let extra_back = back.saturating_sub(consumed_for_snap);
717 self.top_line = self.top_line.saturating_sub(extra_back);
718 self.top_row = 0;
719 }
720 }
721
722 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
723 if delta == 0 { return; }
724 if self.hide_mode() {
725 self.extend_visible_lines(idx, src);
729 let total = self.visible_lines.len();
730 if total == 0 {
731 self.top_line = 0;
732 self.top_row = 0;
733 return;
734 }
735 let cur = self
736 .visible_lines
737 .iter()
738 .position(|&l| l >= self.top_line)
739 .unwrap_or(total);
740 let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
741 self.top_line = self.visible_lines[new];
742 self.top_row = 0;
743 return;
744 }
745 if delta > 0 {
746 let mut remaining = delta as usize;
747 while remaining > 0 {
748 idx.extend_to_line(self.top_line + 1, src);
749 let total = idx.line_count();
750 if total == 0 { break; }
751 let bytes = self.line_display_bytes(src, idx, self.top_line);
752 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)));
753 if self.top_row + 1 < line_rows {
754 self.top_row += 1;
755 } else if self.top_line + 1 < total {
756 self.top_row = 0;
757 self.top_line += 1;
758 } else {
759 break;
760 }
761 remaining -= 1;
762 }
763 } else {
764 let mut remaining = (-delta) as usize;
765 while remaining > 0 {
766 if self.top_row > 0 {
767 self.top_row -= 1;
768 } else if self.top_line > 0 {
769 self.top_line -= 1;
770 let bytes = self.line_display_bytes(src, idx, self.top_line);
771 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)));
772 self.top_row = line_rows.saturating_sub(1);
773 } else {
774 break;
775 }
776 remaining -= 1;
777 }
778 }
779 }
780
781 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
782 let n = self.body_rows() as i64;
783 self.scroll_lines(n, src, idx);
784 }
785
786 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
787 let n = self.body_rows() as i64;
788 self.scroll_lines(-n, src, idx);
789 }
790
791 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
792 let n = (self.body_rows() / 2).max(1) as i64;
793 self.scroll_lines(n, src, idx);
794 }
795
796 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
797 let n = (self.body_rows() / 2).max(1) as i64;
798 self.scroll_lines(-n, src, idx);
799 }
800
801 pub fn goto_top(&mut self) {
802 self.top_line = 0;
803 self.top_row = 0;
804 }
805
806 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
807 idx.extend_to_end(src);
808 let body = self.body_rows() as usize;
809 if self.hide_mode() {
810 self.extend_visible_lines(idx, src);
811 let total = self.visible_lines.len();
812 let target_visible = total.saturating_sub(body);
813 self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
814 self.top_row = 0;
815 } else {
816 let total = idx.line_count();
817 self.top_line = total.saturating_sub(body);
818 self.top_row = 0;
819 }
820 }
821
822 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
824 idx.extend_to_line(n, src);
825 let target = n.min(idx.line_count().saturating_sub(1));
826 self.top_line = target;
827 self.top_row = 0;
828 }
829
830 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
832 while idx.record_count() <= n && idx.scanned_through() < src.len() {
836 idx.extend_to_end(src);
837 }
838 if idx.record_count() == 0 {
839 return;
840 }
841 let target = n.min(idx.record_count().saturating_sub(1));
842 let line_range = idx.record_line_range(target);
843 self.top_line = line_range.start;
844 self.top_row = 0;
845 }
846
847 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
850 let p = p.min(100) as usize;
851 let target_byte = src.len().saturating_mul(p) / 100;
852 idx.extend_to_byte_for_query(src, target_byte);
853 let line_n = idx.line_at_byte(target_byte)
854 .or_else(|| {
855 let lc = idx.line_count();
857 if lc > 0 { Some(lc - 1) } else { None }
858 })
859 .unwrap_or(0);
860 self.top_line = line_n;
861 self.top_row = 0;
862 }
863
864 pub fn top_line(&self) -> usize {
866 self.top_line
867 }
868
869 pub fn resize(&mut self, cols: u16, rows: u16) {
870 self.cols = cols.max(1);
871 self.rows = rows.max(2);
872 self.opts.cols = self.cols;
873 }
874
875 pub fn toggle_line_numbers(&mut self) {
876 self.show_line_numbers = !self.show_line_numbers;
877 }
878
879 pub fn toggle_chop(&mut self) {
880 self.opts.wrap = !self.opts.wrap;
881 }
882
883 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892 use crate::source::MockSource;
893
894 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
895 let m = MockSource::new();
896 m.append(content);
897 m.finish();
898 let idx = LineIndex::new();
899 (m, idx)
900 }
901
902 #[test]
903 fn frame_renders_body_height_rows() {
904 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
905 let v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
907 assert_eq!(frame.body.len(), 4);
908 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1 });
909 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1 });
910 }
911
912 #[test]
913 fn scroll_down_advances_top_line() {
914 let (m, mut idx) = setup(b"a\nb\nc\nd\n");
915 let mut v = Viewport::new(10, 5, "test".into());
916 v.scroll_lines(2, &m, &mut idx);
917 assert_eq!(v.top_line, 2);
918 assert_eq!(v.top_row, 0);
919 }
920
921 #[test]
922 fn scroll_up_clamps_at_zero() {
923 let (m, mut idx) = setup(b"a\nb\nc\n");
924 let mut v = Viewport::new(10, 5, "test".into());
925 v.scroll_lines(-5, &m, &mut idx);
926 assert_eq!(v.top_line, 0);
927 assert_eq!(v.top_row, 0);
928 }
929
930 #[test]
931 fn scroll_down_clamps_at_last_line() {
932 let (m, mut idx) = setup(b"a\nb\nc\n");
933 let mut v = Viewport::new(10, 5, "test".into());
934 v.scroll_lines(50, &m, &mut idx);
935 assert_eq!(v.top_line, 2);
936 }
937
938 #[test]
939 fn scroll_logical_lines_skips_wrap_rows() {
940 let mut content = vec![b'X'; 500];
942 content.push(b'\n');
943 content.extend_from_slice(b"second\n");
944 content.extend_from_slice(b"third\n");
945 let (m, mut idx) = setup(&content);
946 let mut v = Viewport::new(10, 8, "f".into());
947 v.scroll_logical_lines(1, &m, &mut idx);
948 assert_eq!((v.top_line, v.top_row), (1, 0));
949 v.scroll_logical_lines(1, &m, &mut idx);
950 assert_eq!((v.top_line, v.top_row), (2, 0));
951 }
952
953 #[test]
954 fn scroll_logical_lines_back_snaps_to_line_start() {
955 let mut content = vec![b'A'; 50];
957 content.push(b'\n');
958 content.extend_from_slice(&[b'B'; 50]);
959 content.push(b'\n');
960 let (m, mut idx) = setup(&content);
961 let mut v = Viewport::new(10, 8, "f".into());
962 v.scroll_lines(7, &m, &mut idx);
963 assert_eq!(v.top_line, 1, "should be on line 1");
964 assert!(v.top_row > 0, "should be inside line 1's wraps");
965 v.scroll_logical_lines(-1, &m, &mut idx);
966 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
967 v.scroll_logical_lines(-1, &m, &mut idx);
968 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
969 }
970
971 #[test]
972 fn scroll_down_walks_wraps_of_last_line() {
973 let mut content = b"first\n".to_vec();
975 content.extend_from_slice(&[b'X'; 30]);
976 content.push(b'\n');
977 let (m, mut idx) = setup(&content);
978 let mut v = Viewport::new(10, 5, "f".into());
979 v.scroll_lines(1, &m, &mut idx);
980 assert_eq!((v.top_line, v.top_row), (1, 0));
981 v.scroll_lines(1, &m, &mut idx);
982 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
983 v.scroll_lines(1, &m, &mut idx);
984 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
985 }
986
987 #[test]
988 fn scroll_down_walks_wrap_rows_within_long_line() {
989 let mut content = vec![b'X'; 30];
991 content.push(b'\n');
992 content.extend_from_slice(b"second\n");
993 let (m, mut idx) = setup(&content);
994 let mut v = Viewport::new(10, 5, "f".into());
995 v.scroll_lines(1, &m, &mut idx);
996 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
997 v.scroll_lines(1, &m, &mut idx);
998 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
999 v.scroll_lines(1, &m, &mut idx);
1000 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1001 }
1002
1003 #[test]
1004 fn status_line_shows_range_and_pct() {
1005 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1006 let v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
1008 assert!(frame.status.starts_with("f 1-4/10"));
1009 }
1010
1011 #[test]
1012 fn page_down_advances_by_body_rows() {
1013 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1014 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
1016 assert_eq!(v.top_line, 4);
1017 }
1018
1019 #[test]
1020 fn page_up_then_page_down_returns_to_start_when_no_resize() {
1021 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1022 let mut v = Viewport::new(10, 5, "f".into());
1023 v.page_down(&m, &mut idx);
1024 v.page_up(&m, &mut idx);
1025 assert_eq!(v.top_line, 0);
1026 assert_eq!(v.top_row, 0);
1027 }
1028
1029 #[test]
1030 fn half_page_down_advances_by_half_body() {
1031 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1032 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
1034 assert_eq!(v.top_line, 3);
1035 }
1036
1037 #[test]
1038 fn goto_top_resets_position() {
1039 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1040 let mut v = Viewport::new(10, 5, "f".into());
1041 v.scroll_lines(2, &m, &mut idx);
1042 v.goto_top();
1043 assert_eq!(v.top_line, 0);
1044 assert_eq!(v.top_row, 0);
1045 }
1046
1047 #[test]
1048 fn goto_bottom_scrolls_to_last_page() {
1049 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1050 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
1052 assert_eq!(v.top_line, 6);
1054 }
1055
1056 #[test]
1057 fn goto_line_positions_top_line() {
1058 let m = MockSource::new();
1059 m.append(b"a\nb\nc\nd\ne\n");
1060 let mut idx = LineIndex::new();
1061 idx.extend_to_end(&m);
1062 let mut v = Viewport::new(20, 5, "f".into());
1063 v.goto_line(3, &m, &mut idx);
1064 assert_eq!(v.top_line(), 3);
1065 }
1066
1067 #[test]
1068 fn goto_line_clamps_to_last_line() {
1069 let m = MockSource::new();
1070 m.append(b"a\nb\n");
1071 let mut idx = LineIndex::new();
1072 idx.extend_to_end(&m);
1073 let mut v = Viewport::new(20, 5, "f".into());
1074 v.goto_line(999, &m, &mut idx);
1075 assert_eq!(v.top_line(), 1);
1076 }
1077
1078 #[test]
1079 fn goto_record_positions_at_record_start_line() {
1080 let m = MockSource::new();
1081 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
1082 let mut idx = LineIndex::new();
1083 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1084 idx.extend_to_end(&m);
1085 let mut v = Viewport::new(20, 5, "f".into());
1086 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
1088 }
1089
1090 #[test]
1091 fn goto_record_in_line_per_record_mode_equals_goto_line() {
1092 let m = MockSource::new();
1093 m.append(b"a\nb\nc\n");
1094 let mut idx = LineIndex::new();
1095 idx.extend_to_end(&m);
1096 let mut v = Viewport::new(20, 5, "f".into());
1097 v.goto_record(2, &m, &mut idx);
1098 assert_eq!(v.top_line(), 2);
1099 }
1100
1101 #[test]
1102 fn goto_percent_50_lands_in_middle() {
1103 let m = MockSource::new();
1104 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
1106 idx.extend_to_end(&m);
1107 let mut v = Viewport::new(20, 5, "f".into());
1108 v.goto_percent(50, &m, &mut idx);
1109 assert_eq!(v.top_line(), 2); }
1111
1112 #[test]
1113 fn goto_percent_100_lands_at_last_line() {
1114 let m = MockSource::new();
1115 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
1117 idx.extend_to_end(&m);
1118 let mut v = Viewport::new(20, 5, "f".into());
1119 v.goto_percent(100, &m, &mut idx);
1120 assert_eq!(v.top_line(), 2);
1121 }
1122
1123 #[test]
1124 fn goto_percent_0_lands_at_first_line() {
1125 let m = MockSource::new();
1126 m.append(b"a\nb\nc\n");
1127 let mut idx = LineIndex::new();
1128 idx.extend_to_end(&m);
1129 let mut v = Viewport::new(20, 5, "f".into());
1130 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
1132 v.goto_percent(0, &m, &mut idx);
1133 assert_eq!(v.top_line(), 0);
1134 }
1135
1136 #[test]
1137 fn resize_updates_dimensions_and_render_opts() {
1138 let (m, mut idx) = setup(b"1\n2\n");
1139 let mut v = Viewport::new(10, 5, "f".into());
1140 v.resize(40, 12);
1141 assert_eq!(v.cols, 40);
1142 assert_eq!(v.rows, 12);
1143 assert_eq!(v.opts.cols, 40);
1144 let _ = v.frame(&m, &mut idx);
1145 }
1146
1147 #[test]
1148 fn toggle_line_numbers_changes_gutter() {
1149 let (m, mut idx) = setup(b"a\nb\nc\n");
1150 let mut v = Viewport::new(10, 5, "f".into());
1151 let frame_off = v.frame(&m, &mut idx);
1152 v.toggle_line_numbers();
1153 let frame_on = v.frame(&m, &mut idx);
1154 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1 });
1156 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1 });
1157 }
1158
1159 #[test]
1160 fn toggle_chop_changes_wrap_mode() {
1161 let (m, mut idx) = setup(b"abcdefghij\n");
1162 let mut v = Viewport::new(4, 5, "f".into());
1163 v.toggle_chop();
1164 let frame = v.frame(&m, &mut idx);
1165 assert_eq!(frame.body[0][..4],
1168 [Cell::Char { ch: 'a', width: 1 }, Cell::Char { ch: 'b', width: 1 },
1169 Cell::Char { ch: 'c', width: 1 }, Cell::Char { ch: 'd', width: 1 }]);
1170 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1172 }
1173
1174 #[test]
1177 fn is_at_bottom_initially_only_when_source_fits() {
1178 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
1181 assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
1182 }
1183
1184 #[test]
1185 fn is_at_bottom_false_when_top_and_more_lines_below() {
1186 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);
1189 assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
1190 }
1191
1192 #[test]
1193 fn is_at_bottom_true_after_goto_bottom() {
1194 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1195 let mut v = Viewport::new(10, 5, "f".into());
1196 v.goto_bottom(&m, &mut idx);
1197 assert!(v.is_at_bottom(&idx));
1198 }
1199
1200 #[test]
1201 fn status_shows_follow_suffix_when_follow_mode_on() {
1202 let (m, mut idx) = setup(b"a\nb\n");
1203 let mut v = Viewport::new(20, 5, "f".into());
1204 let frame_off = v.frame(&m, &mut idx);
1205 assert!(!frame_off.status.contains("(F)"));
1206 v.set_follow_mode(true);
1207 let frame_on = v.frame(&m, &mut idx);
1208 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
1209 }
1210
1211 #[test]
1212 fn toggle_follow_flips_state() {
1213 let mut v = Viewport::new(10, 5, "f".into());
1214 assert!(!v.follow_mode());
1215 v.toggle_follow();
1216 assert!(v.follow_mode());
1217 v.toggle_follow();
1218 assert!(!v.follow_mode());
1219 }
1220
1221 #[test]
1222 fn status_shows_prettify_label_when_set() {
1223 let (m, mut idx) = setup(b"a\n");
1224 let mut v = Viewport::new(40, 5, "f".into());
1225 let frame_off = v.frame(&m, &mut idx);
1226 assert!(!frame_off.status.contains("[pretty"));
1227 v.set_prettify_label(Some("json".into()));
1228 let frame_on = v.frame(&m, &mut idx);
1229 assert!(frame_on.status.contains("[pretty:json]"),
1230 "expected [pretty:json] in status, got: {}", frame_on.status);
1231 v.set_prettify_label(Some("json:err".into()));
1232 let frame_err = v.frame(&m, &mut idx);
1233 assert!(frame_err.status.contains("[pretty:json:err]"),
1234 "expected [pretty:json:err] in status, got: {}", frame_err.status);
1235 }
1236
1237 #[test]
1238 fn status_shows_l_suffix_when_live_mode_on() {
1239 let (m, mut idx) = setup(b"a\nb\n");
1240 let mut v = Viewport::new(20, 5, "f".into());
1241 let frame_off = v.frame(&m, &mut idx);
1242 assert!(!frame_off.status.contains("(L)"));
1243 v.set_live_mode(true);
1244 let frame_on = v.frame(&m, &mut idx);
1245 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
1246 }
1247
1248 #[test]
1249 fn clamp_top_line_pulls_back_when_total_shrinks() {
1250 let mut v = Viewport::new(20, 5, "f".into());
1251 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
1260 let (m, mut idx) = setup(b"only\n");
1262 let _ = v.frame(&m, &mut idx);
1263 }
1264
1265 fn simulate_growth_tick(
1268 v: &mut Viewport,
1269 src: &MockSource,
1270 idx: &mut LineIndex,
1271 ) {
1272 if !v.follow_mode() { return; }
1273 let was_at_bottom = v.is_at_bottom(idx);
1274 let lines_before = idx.line_count();
1275 idx.notice_new_bytes(src);
1276 if idx.line_count() != lines_before && was_at_bottom {
1277 v.goto_bottom(src, idx);
1278 }
1279 }
1280
1281 #[test]
1282 fn auto_scroll_engages_when_at_bottom() {
1283 let m = MockSource::new();
1284 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
1286 let mut v = Viewport::new(10, 5, "f".into());
1287 v.set_follow_mode(true);
1288 idx.extend_to_end(&m);
1289 assert!(v.is_at_bottom(&idx));
1290 let top_before = {
1291 let f = v.frame(&m, &mut idx);
1292 f.status.clone() };
1294 let _ = top_before;
1295 m.append(b"5\n6\n7\n8\n");
1297 simulate_growth_tick(&mut v, &m, &mut idx);
1298 assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
1300 let frame = v.frame(&m, &mut idx);
1301 let last_row = &frame.body[frame.body.len() - 1];
1304 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1 });
1305 }
1306
1307 #[test]
1308 fn auto_scroll_suppressed_when_scrolled_up() {
1309 let m = MockSource::new();
1310 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
1312 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
1314 idx.extend_to_end(&m);
1315 v.goto_bottom(&m, &mut idx);
1316 v.scroll_lines(-2, &m, &mut idx);
1318 assert!(!v.is_at_bottom(&idx));
1319 let frame_before = v.frame(&m, &mut idx);
1320 let top_first_cell_before = frame_before.body[0][0].clone();
1321 m.append(b"9\n10\n");
1323 simulate_growth_tick(&mut v, &m, &mut idx);
1324 let frame_after = v.frame(&m, &mut idx);
1326 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
1327 }
1328
1329 #[test]
1332 fn set_search_compiles_regex() {
1333 let mut v = Viewport::new(10, 5, "f".into());
1334 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
1335 assert!(v.search_active());
1336 }
1337
1338 #[test]
1339 fn set_search_rejects_bad_regex() {
1340 let mut v = Viewport::new(10, 5, "f".into());
1341 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
1342 assert!(!err.is_empty());
1343 assert!(!v.search_active(), "no search should be set on error");
1344 }
1345
1346 #[test]
1347 fn search_step_forward_finds_match_after_top() {
1348 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1349 let mut v = Viewport::new(20, 5, "f".into());
1350 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1351 let found = v.search_repeat(&m, &mut idx, false);
1352 assert!(found);
1353 assert_eq!(v.top_line, 2);
1355 }
1356
1357 #[test]
1358 fn search_step_backward_finds_match_before_top() {
1359 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1360 let mut v = Viewport::new(20, 5, "f".into());
1361 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
1363 let found = v.search_repeat(&m, &mut idx, false);
1364 assert!(found);
1365 assert_eq!(v.top_line, 0);
1366 }
1367
1368 #[test]
1369 fn search_wraps_at_end() {
1370 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1371 let mut v = Viewport::new(20, 5, "f".into());
1372 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
1374 let found = v.search_repeat(&m, &mut idx, false);
1375 assert!(found, "search should wrap forward past EOF");
1376 assert_eq!(v.top_line, 0);
1377 }
1378
1379 #[test]
1380 fn search_no_match_returns_false_and_does_not_move() {
1381 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1382 let mut v = Viewport::new(20, 5, "f".into());
1383 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
1384 let found = v.search_repeat(&m, &mut idx, false);
1385 assert!(!found);
1386 assert_eq!(v.top_line, 0);
1387 }
1388
1389 #[test]
1390 fn frame_records_highlight_ranges_for_matches() {
1391 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
1392 let mut v = Viewport::new(20, 5, "f".into());
1393 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1394 let frame = v.frame(&m, &mut idx);
1395 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1397 assert!(frame.highlights[0].is_empty());
1398 assert!(frame.highlights[1].is_empty());
1399 assert_eq!(frame.highlights[2], vec![0..5]);
1400 assert!(frame.highlights[3].is_empty());
1401 }
1402
1403 #[test]
1404 fn frame_highlights_substring_inside_a_row() {
1405 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
1406 let mut v = Viewport::new(40, 5, "f".into());
1407 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1408 let frame = v.frame(&m, &mut idx);
1409 assert_eq!(frame.highlights[0], vec![18..22]);
1411 assert!(frame.highlights[1].is_empty());
1412 }
1413
1414 #[test]
1415 fn search_highlight_with_filter_dim_keeps_row_dim() {
1416 let (m, mut idx) = setup(b"alpha\nbeta\n");
1419 let mut v = Viewport::new(20, 5, "f".into());
1420 let fmt = crate::format::LogFormat::compile(
1421 "simple",
1422 r"^(?P<line>.+)$",
1423 )
1424 .unwrap();
1425 let f = crate::filter::CompiledFilter::compile(
1426 &fmt,
1427 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
1428 )
1429 .unwrap();
1430 v.set_filter(Some(f));
1431 v.set_dim_mode(true);
1432 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1433 let frame = v.frame(&m, &mut idx);
1434 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1435 assert_eq!(frame.row_styles[1], RowStyle::Dim);
1436 assert_eq!(frame.highlights[1], vec![0..4]);
1437 }
1438
1439 #[test]
1440 fn grep_only_hides_non_matching_lines() {
1441 use crate::grep::GrepPredicate;
1442 let src = crate::source::MockSource::new();
1443 src.append(b"keep this error\n");
1444 src.append(b"drop this one\n");
1445 src.append(b"another error line\n");
1446 src.finish();
1447 let mut idx = crate::line_index::LineIndex::new();
1448 idx.extend_to_end(&src);
1449
1450 let mut v = Viewport::new(40, 5, "test".into());
1451 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()]).unwrap()));
1452 v.extend_visible_lines(&idx, &src);
1453
1454 let frame = v.frame(&src, &mut idx);
1456 let body_text: Vec<String> = frame.body.iter()
1457 .map(|row| row.iter().filter_map(|c| match c {
1458 crate::render::Cell::Char { ch, .. } => Some(*ch),
1459 _ => None,
1460 }).collect())
1461 .collect();
1462 assert!(body_text[0].contains("keep this error"));
1463 assert!(body_text[1].contains("another error line"));
1464 assert!(frame.status.contains("[grep]"));
1465 }
1466
1467 #[test]
1468 fn filter_and_grep_combine_with_and() {
1469 use crate::grep::GrepPredicate;
1470 let fmt = crate::format::LogFormat::compile(
1471 "simple",
1472 r"^(?P<level>\w+) (?P<msg>.+)$",
1473 ).unwrap();
1474 let f = crate::filter::CompiledFilter::compile(
1475 &fmt,
1476 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
1477 ).unwrap();
1478 let g = GrepPredicate::compile(&["timeout".to_string()]).unwrap();
1479
1480 let src = crate::source::MockSource::new();
1481 src.append(b"ERROR timeout connecting\n"); src.append(b"ERROR file not found\n"); src.append(b"WARN timeout retrying\n"); src.append(b"INFO all good\n"); src.finish();
1486 let mut idx = crate::line_index::LineIndex::new();
1487 idx.extend_to_end(&src);
1488
1489 let mut v = Viewport::new(80, 5, "test".into());
1490 v.set_filter(Some(f));
1491 v.set_grep(Some(g));
1492 v.extend_visible_lines(&idx, &src);
1493 assert_eq!(v.visible_lines(), &[0usize]);
1494 }
1495
1496 #[test]
1497 fn search_status_shows_pattern() {
1498 let (m, mut idx) = setup(b"x\n");
1499 let mut v = Viewport::new(20, 5, "f".into());
1500 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1501 let frame = v.frame(&m, &mut idx);
1502 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
1503 }
1504
1505 #[test]
1506 fn repeat_search_after_first_match_advances() {
1507 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
1508 let mut v = Viewport::new(40, 5, "f".into());
1509 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1510 assert!(v.search_repeat(&m, &mut idx, false));
1511 assert_eq!(v.top_line, 1, "first foo");
1512 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1513 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
1514 assert_eq!(v.top_line, 3, "should advance to next foo");
1515 }
1516
1517 #[test]
1518 fn auto_scroll_paused_when_follow_off() {
1519 let m = MockSource::new();
1520 m.append(b"1\n2\n3\n4\n");
1521 let mut idx = LineIndex::new();
1522 let mut v = Viewport::new(10, 5, "f".into());
1523 idx.extend_to_end(&m);
1525 let frame_before = v.frame(&m, &mut idx);
1526 let top_first_cell = frame_before.body[0][0].clone();
1527 m.append(b"5\n6\n7\n8\n");
1528 simulate_growth_tick(&mut v, &m, &mut idx);
1529 let frame_after = v.frame(&m, &mut idx);
1530 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
1531 }
1532
1533 #[test]
1536 fn search_jumps_to_next_matching_record() {
1537 let m = MockSource::new();
1538 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
1539 let mut idx = LineIndex::new();
1540 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1541 idx.extend_to_end(&m);
1542 let mut v = Viewport::new(40, 10, "f".into());
1543 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
1544 let hit = v.search_repeat(&m, &mut idx, false);
1545 assert!(hit, "should find 'charlie' in record 2");
1546 assert_eq!(v.top_line(), 3); }
1548
1549 #[test]
1550 fn search_finds_cross_line_match_in_record_with_s_flag() {
1551 let m = MockSource::new();
1552 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
1553 let mut idx = LineIndex::new();
1554 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1555 idx.extend_to_end(&m);
1556 let mut v = Viewport::new(40, 10, "f".into());
1557 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
1558 let hit = v.search_repeat(&m, &mut idx, false);
1559 assert!(hit, "should match across \\n inside record 0 with (?s)");
1560 assert_eq!(v.top_line(), 0);
1561 }
1562
1563 #[test]
1564 fn search_repeat_with_no_match_returns_false() {
1565 let m = MockSource::new();
1566 m.append(b"[1] alpha\n[2] bravo\n");
1567 let mut idx = LineIndex::new();
1568 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1569 idx.extend_to_end(&m);
1570 let mut v = Viewport::new(40, 10, "f".into());
1571 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
1572 let hit = v.search_repeat(&m, &mut idx, false);
1573 assert!(!hit);
1574 }
1575
1576 #[test]
1579 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
1580 let m = MockSource::new();
1583 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
1584 let mut idx = LineIndex::new();
1585 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1586 idx.extend_to_end(&m);
1587 let grep = GrepPredicate::compile(&["cont a".to_string()]).unwrap();
1588 let mut v = Viewport::new(40, 10, "f".into());
1589 v.set_grep(Some(grep));
1590 v.extend_visible_lines(&idx, &m);
1591 assert_eq!(v.visible_lines(), &[0usize, 1]);
1594 }
1595
1596 #[test]
1597 fn grep_matches_across_record_newlines_in_records_mode() {
1598 let m = MockSource::new();
1600 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
1601 let mut idx = LineIndex::new();
1602 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1603 idx.extend_to_end(&m);
1604 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()]).unwrap();
1605 let mut v = Viewport::new(40, 10, "f".into());
1606 v.set_grep(Some(grep));
1607 v.extend_visible_lines(&idx, &m);
1608 assert_eq!(v.visible_lines(), &[0usize, 1]);
1610 }
1611
1612 #[test]
1613 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
1614 let m = MockSource::new();
1617 m.append(b"[1] head\n cont\n[2] other\n cont\n");
1618 let mut idx = LineIndex::new();
1619 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1620 idx.extend_to_end(&m);
1621 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()]).unwrap();
1622 let mut v = Viewport::new(40, 10, "f".into());
1623 v.set_grep(Some(grep));
1624 v.set_dim_mode(true);
1625 v.extend_visible_lines(&idx, &m);
1626 assert_eq!(v.visible_lines(), &[] as &[usize]);
1628 assert!(!v.should_dim_line(0, &idx, &m));
1630 assert!(!v.should_dim_line(1, &idx, &m));
1631 assert!(v.should_dim_line(2, &idx, &m));
1633 assert!(v.should_dim_line(3, &idx, &m));
1634 }
1635
1636 #[test]
1637 fn status_unchanged_when_records_inactive() {
1638 let (m, mut idx) = setup(b"a\nb\nc\n");
1639 let v = Viewport::new(20, 5, "f".into());
1640 let frame = v.frame(&m, &mut idx);
1641 let status = &frame.status;
1642 assert!(status.contains("1-3/3"), "got: {status}");
1644 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
1645 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
1646 }
1647
1648 #[test]
1649 fn status_dual_readout_when_records_active() {
1650 let m = MockSource::new();
1651 m.append(b"[1] a\n cont\n[2] b\n");
1652 m.finish();
1653 let mut idx = LineIndex::new();
1654 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1655 idx.extend_to_end(&m);
1656 let v = Viewport::new(20, 5, "f".into());
1657 let frame = v.frame(&m, &mut idx);
1658 let status = &frame.status;
1659 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
1660 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
1661 }
1662}