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 format_label: Option<String>,
122 filter: Option<CompiledFilter>,
123 grep: Option<GrepPredicate>,
124 dim_mode: bool,
125 visible_lines: Vec<usize>,
128 visible_scanned: usize,
131 search: Option<SearchState>,
132 display: Option<crate::format::DisplayRenderer>,
136 hex_mode: bool,
137 prompt: Option<crate::prompt::ParsedPrompt>,
140 preprocess_failure: Option<String>,
143 file_index: Option<(usize, usize)>,
145}
146
147impl Viewport {
148 pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
149 let opts = RenderOpts { cols, ..RenderOpts::default() };
150 Self {
151 top_line: 0,
152 top_row: 0,
153 cols,
154 rows,
155 opts,
156 show_line_numbers: false,
157 source_label,
158 follow_mode: false,
159 live_mode: false,
160 prettify_label: None,
161 format_label: None,
162 filter: None,
163 grep: None,
164 dim_mode: false,
165 visible_lines: Vec::new(),
166 visible_scanned: 0,
167 search: None,
168 display: None,
169 hex_mode: false,
170 prompt: None,
171 preprocess_failure: None,
172 file_index: None,
173 }
174 }
175
176 pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
177 self.display = renderer;
178 }
179
180 pub fn set_hex_mode(&mut self, on: bool) {
181 self.hex_mode = on;
182 }
183
184 pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
185 self.prompt = prompt;
186 }
187
188 pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
189 self.preprocess_failure = msg;
190 }
191
192 pub fn set_file_index(&mut self, current: usize, total: usize) {
193 self.file_index = if total > 1 {
194 Some((current, total))
195 } else {
196 None
197 };
198 }
199
200 pub fn set_source_label(&mut self, label: String) {
201 self.source_label = label;
202 }
203
204 pub fn source_label_clone(&self) -> String {
205 self.source_label.clone()
206 }
207
208 fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
213 let range = idx.line_range(line_n, src);
214 let raw = src.bytes(range);
215 if let Some(r) = self.display.as_ref() {
216 if let Some(rendered) = r.render_line(&raw) {
217 return std::borrow::Cow::Owned(rendered.into_bytes());
218 }
219 }
220 raw
221 }
222
223 pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
227 let regex = Regex::new(&raw).map_err(|e| e.to_string())?;
228 self.search = Some(SearchState { raw, regex, direction });
229 Ok(())
230 }
231
232 pub fn clear_search(&mut self) { self.search = None; }
233
234 pub fn search_active(&self) -> bool { self.search.is_some() }
235
236 pub fn search_direction(&self) -> SearchDirection {
237 self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
238 }
239
240 pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
244 if idx.records_mode() {
245 self.search_repeat_records(src, idx, reverse)
246 } else {
247 self.search_repeat_lines(src, idx, reverse)
248 }
249 }
250
251 fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
253 let Some(s) = self.search.as_ref() else { return false; };
254 let forward = matches!(
255 (s.direction, reverse),
256 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
257 );
258 idx.extend_to_end(src);
259 let pattern = s.regex.clone();
260 if self.hide_mode() {
261 self.extend_visible_lines(idx, src);
262 self.search_step_in_visible(&pattern, src, idx, forward)
263 } else {
264 self.search_step_in_logical(&pattern, src, idx, forward)
265 }
266 }
267
268 fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
272 let Some(s) = self.search.as_ref() else { return false; };
273 let forward = matches!(
274 (s.direction, reverse),
275 (SearchDirection::Forward, false) | (SearchDirection::Backward, true)
276 );
277 let pattern = s.regex.clone();
278 idx.extend_to_end(src);
279
280 let total = idx.record_count();
281 if total == 0 { return false; }
282
283 let cur_record = idx.line_to_record(self.top_line);
284
285 let range: Box<dyn Iterator<Item = usize>> = if forward {
286 Box::new(((cur_record + 1)..total).chain(0..=cur_record))
287 } else {
288 let earlier: Vec<usize> = (0..cur_record).rev().collect();
289 let later: Vec<usize> = (cur_record..total).rev().collect();
290 Box::new(earlier.into_iter().chain(later))
291 };
292
293 for r in range {
294 let bytes_cow = idx.record_bytes(r, src);
295 let text = String::from_utf8_lossy(&bytes_cow);
296 if pattern.is_match(&text) {
297 let line_range = idx.record_line_range(r);
298 self.top_line = line_range.start;
299 self.top_row = 0;
300 return true;
301 }
302 }
303 false
304 }
305
306 fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
307 let bytes = self.line_display_bytes(src, idx, line_n);
311 match std::str::from_utf8(&bytes) {
312 Ok(s) => pattern.is_match(s),
313 Err(_) => false,
314 }
315 }
316
317 fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
318 let total = idx.line_count();
319 if total == 0 { return false; }
320 let start = self.top_line;
321 for offset in 1..=total {
324 let line_n = if forward {
325 (start + offset) % total
326 } else {
327 (start + total - offset) % total
328 };
329 if self.line_matches(pattern, src, idx, line_n) {
330 self.top_line = line_n;
331 self.top_row = 0;
332 return true;
333 }
334 }
335 false
336 }
337
338 fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
339 let total = self.visible_lines.len();
340 if total == 0 { return false; }
341 let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
343 for offset in 1..=total {
344 let visible_idx = if forward {
345 (cur + offset) % total
346 } else {
347 (cur + total - offset) % total
348 };
349 let line_n = self.visible_lines[visible_idx];
350 if self.line_matches(pattern, src, idx, line_n) {
351 self.top_line = line_n;
352 self.top_row = 0;
353 return true;
354 }
355 }
356 false
357 }
358
359 pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
360 self.filter = filter;
361 self.visible_lines.clear();
362 self.visible_scanned = 0;
363 self.top_line = 0;
365 self.top_row = 0;
366 }
367
368 pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
369 self.grep = grep;
370 self.visible_lines.clear();
371 self.visible_scanned = 0;
372 self.top_line = 0;
373 self.top_row = 0;
374 }
375
376 pub fn grep_active(&self) -> bool { self.grep.is_some() }
377
378 pub fn set_dim_mode(&mut self, on: bool) {
379 self.dim_mode = on;
380 self.visible_lines.clear();
384 self.visible_scanned = 0;
385 }
386
387 pub fn filter_active(&self) -> bool { self.filter.is_some() }
388
389 pub fn dim_mode(&self) -> bool { self.dim_mode }
390
391 fn hide_mode(&self) -> bool {
392 (self.filter.is_some() || self.grep.is_some()) && !self.dim_mode
393 }
394
395 pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
400 if !self.hide_mode() {
401 return;
402 }
403 if idx.records_mode() {
404 self.extend_visible_lines_records(idx, src);
405 } else {
406 self.extend_visible_lines_per_line(idx, src);
407 }
408 }
409
410 fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
412 let total = idx.line_count();
413 while self.visible_scanned < total {
414 let line_n = self.visible_scanned;
415 let range = idx.line_range(line_n, src);
416 let bytes = src.bytes(range);
417 if self.line_passes(&bytes) {
418 self.visible_lines.push(line_n);
419 }
420 self.visible_scanned += 1;
421 }
422 }
423
424 fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
431 self.visible_lines.clear();
432 self.visible_scanned = 0; let total_records = idx.record_count();
434 for r in 0..total_records {
435 let bytes_cow = idx.record_bytes(r, src);
436 let bytes: &[u8] = &bytes_cow;
437 if self.line_passes(bytes) {
438 for line_n in idx.record_line_range(r) {
439 self.visible_lines.push(line_n);
440 }
441 }
442 }
443 }
444
445 fn line_passes(&self, line: &[u8]) -> bool {
451 let filter_ok = match self.filter.as_ref() {
452 Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
453 None => true,
454 };
455 let grep_ok = match self.grep.as_ref() {
456 Some(g) => g.matches(line),
457 None => true,
458 };
459 filter_ok && grep_ok
460 }
461
462 fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
466 if !self.dim_mode {
467 return false;
468 }
469 if idx.records_mode() {
470 let r = idx.line_to_record(line_n);
471 let bytes_cow = idx.record_bytes(r, src);
472 let bytes: &[u8] = &bytes_cow;
473 !self.line_passes(bytes)
474 } else {
475 let range = idx.line_range(line_n, src);
476 let bytes = src.bytes(range);
477 !self.line_passes(&bytes)
478 }
479 }
480
481 pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
482
483 pub fn follow_mode(&self) -> bool { self.follow_mode }
484
485 pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
486
487 pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
488
489 pub fn live_mode(&self) -> bool { self.live_mode }
490
491 pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
492
493 pub fn set_prettify_label(&mut self, label: Option<String>) {
496 self.prettify_label = label;
497 }
498
499 pub fn set_format_label(&mut self, label: Option<String>) {
502 self.format_label = label;
503 }
504
505 pub fn invalidate_filter_cache(&mut self) {
510 self.visible_lines.clear();
511 self.visible_scanned = 0;
512 }
513
514 pub fn clamp_top_line(&mut self, line_count: usize) {
517 if line_count == 0 {
518 self.top_line = 0;
519 self.top_row = 0;
520 } else if self.top_line >= line_count {
521 self.top_line = line_count - 1;
522 self.top_row = 0;
523 }
524 }
525
526 pub fn is_at_bottom(&self, idx: &LineIndex) -> bool {
530 let body = self.body_rows() as usize;
531 if self.hide_mode() {
532 let pos = self
534 .visible_lines
535 .iter()
536 .position(|&l| l >= self.top_line)
537 .unwrap_or(self.visible_lines.len());
538 pos + body >= self.visible_lines.len()
539 } else {
540 self.top_line + body >= idx.line_count()
541 }
542 }
543
544 fn gutter_width(&self, idx: &LineIndex) -> u16 {
546 if !self.show_line_numbers { return 0; }
547 let n = idx.line_count().max(1);
548 let digits = (n as f64).log10().floor() as u16 + 1;
549 digits + 1
550 }
551
552 fn render_opts(&self, gutter: u16) -> RenderOpts {
553 let mut o = self.opts.clone();
554 o.cols = self.cols.saturating_sub(gutter);
555 o
556 }
557
558 pub fn frame(&self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
559 if self.hex_mode {
560 return self.frame_hex(src);
561 }
562 let body_rows = self.body_rows() as usize;
563 idx.extend_to_line(self.top_line + body_rows + 1, src);
564
565 let gutter = self.gutter_width(idx);
566 let r_opts = self.render_opts(gutter);
567
568 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
569 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
570 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
571 let hide = self.hide_mode();
573 let total_lines = idx.line_count();
574
575 let mut hide_pos = if hide {
577 self.visible_lines
578 .iter()
579 .position(|&l| l >= self.top_line)
580 .unwrap_or(self.visible_lines.len())
581 } else {
582 0
583 };
584 let mut line_n = if hide {
585 self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
586 } else {
587 self.top_line
588 };
589 let mut skip = if hide { 0 } else { self.top_row };
590
591 while body.len() < body_rows {
592 if line_n >= total_lines {
593 let mut row = Vec::with_capacity(self.cols as usize);
594 if gutter > 0 {
595 for _ in 0..gutter { row.push(Cell::Empty); }
596 }
597 while row.len() < self.cols as usize { row.push(Cell::Empty); }
598 body.push(row);
599 row_styles.push(RowStyle::Normal);
600 highlights.push(Vec::new());
601 line_n += 1;
602 continue;
603 }
604 let raw = src.bytes(idx.line_range(line_n, src));
607 let display_bytes = if let Some(r) = self.display.as_ref() {
608 match r.render_line(&raw) {
609 Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
610 None => raw.clone(),
611 }
612 } else {
613 raw.clone()
614 };
615 let rows = render_line(&display_bytes, &r_opts);
616 let style = if self.filter.is_some() || self.grep.is_some() {
617 if self.dim_mode {
618 if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
619 } else {
620 RowStyle::Normal
622 }
623 } else {
624 RowStyle::Normal
625 };
626
627 for (i, mut content_row) in rows.into_iter().enumerate() {
628 if i < skip { continue; }
629 if body.len() >= body_rows { break; }
630 let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
631 if gutter > 0 {
632 let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
633 for c in label.chars() {
634 full.push(Cell::Char { ch: c, width: 1 });
635 }
636 }
637 full.append(&mut content_row);
638 let row_highlights = if let Some(s) = self.search.as_ref() {
642 find_row_highlights(&full, &s.regex)
643 } else {
644 Vec::new()
645 };
646 body.push(full);
647 row_styles.push(style);
648 highlights.push(row_highlights);
649 }
650 skip = 0;
651 if hide {
653 hide_pos += 1;
654 line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
655 } else {
656 line_n += 1;
657 }
658 }
659
660 let status = self.format_status(idx, src);
661 Frame { body, row_styles, highlights, status }
662 }
663
664 fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
665 if let Some(p) = self.prompt.as_ref() {
666 let ctx = self.build_prompt_context(idx, src);
667 return p.render(&ctx);
668 }
669 let body_rows = self.body_rows() as usize;
670 let total = idx.line_count();
671 let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
674 let visible_total = self.visible_lines.len();
675 let cur = self
677 .visible_lines
678 .iter()
679 .position(|&l| l >= self.top_line)
680 .unwrap_or(visible_total);
681 let top = cur + 1;
682 let bottom = (cur + body_rows).min(visible_total.max(1));
683 let total_str = if src.is_complete() {
684 format!("{visible_total}/{total}")
685 } else {
686 format!("{visible_total}/{total}+")
687 };
688 (top, bottom, visible_total, total_str)
689 } else {
690 let top = self.top_line + 1;
691 let bottom = (self.top_line + body_rows).min(total.max(1));
692 let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
693 (top, bottom, total, total_str)
694 };
695 let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
696 let (line_prefix, records_block) = if idx.records_mode() {
698 let line_total = idx.line_count();
699 let rec_total = idx.record_count();
700 let rec_block = if line_total == 0 || rec_total == 0 {
701 format!("R0-0/{}", rec_total)
702 } else {
703 let rec_top = idx.line_to_record(self.top_line) + 1;
704 let rec_bottom = idx.line_to_record(bottom.saturating_sub(1)) + 1;
705 format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
706 };
707 ("L", Some(rec_block))
708 } else {
709 ("", None)
710 };
711 let middle = match records_block {
712 Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
713 None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
714 };
715 let label_with_index = match self.file_index {
716 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
717 None => self.source_label.clone(),
718 };
719 let mut s = format!("{} {}", label_with_index, middle);
720 if !self.hide_mode() && self.top_row > 0 {
725 let line_rows = if total > 0 {
726 let bytes = self.line_display_bytes(src, idx, self.top_line);
727 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)))
728 } else { 1 };
729 s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
730 }
731 if let Some(f) = self.filter.as_ref() {
732 s.push_str(&format!(" [{}]", f.format_name));
733 }
734 if self.grep.is_some() {
735 s.push_str(" [grep]");
736 }
737 if self.filter.is_some() || self.grep.is_some() {
738 s.push_str(if self.dim_mode { " [dim]" } else { " [hide]" });
739 }
740 if let Some(sr) = self.search.as_ref() {
741 let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
742 s.push_str(&format!(" [{}{}]", prefix, sr.raw));
743 }
744 if let Some(label) = self.prettify_label.as_ref() {
745 s.push_str(&format!(" [pretty:{label}]"));
746 }
747 if self.live_mode { s.push_str(" (L)"); }
748 if self.follow_mode { s.push_str(" (F)"); }
749 if let Some(msg) = self.preprocess_failure.as_ref() {
750 let first_line = msg.lines().next().unwrap_or("");
751 s.push_str(&format!(" [preprocess-failed: {}]", first_line));
752 }
753 s
754 }
755
756 fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
757 use crate::prompt::PromptContext;
758
759 let body_rows = self.body_rows() as usize;
760 let total = idx.line_count();
761 let top = self.top_line + 1;
762 let bottom = (self.top_line + body_rows).min(total.max(1));
763 let pct = (bottom * 100).checked_div(total).unwrap_or(0);
764
765 let records_mode = idx.records_mode();
766 let (rec_top, rec_bottom, rec_total) = if records_mode {
767 let rt = idx.line_to_record(self.top_line) + 1;
768 let rb = idx.line_to_record(bottom.saturating_sub(1)) + 1;
769 (rt, rb, idx.record_count())
770 } else {
771 (0, 0, 0)
772 };
773
774 let wrap_offset = if !self.hide_mode() && self.top_row > 0 {
775 let line_rows = if total > 0 {
776 let bytes = self.line_display_bytes(src, idx, self.top_line);
777 count_rows(&bytes, &self.render_opts(self.gutter_width(idx)))
778 } else { 1 };
779 format!("+{}/{}", self.top_row, line_rows)
780 } else {
781 String::new()
782 };
783
784 let format_tag = self.format_label.as_ref()
785 .map(|n| format!(" [{}]", n))
786 .unwrap_or_default();
787 let filter_tag = self.filter.as_ref()
788 .map(|f| format!(" [{}]", f.format_name))
789 .unwrap_or_default();
790 let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
791 let hide_tag = if self.filter.is_some() || self.grep.is_some() {
792 if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
793 } else {
794 String::new()
795 };
796 let search_tag = self.search.as_ref()
797 .map(|s| {
798 let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
799 format!(" [{}{}]", p, s.raw)
800 })
801 .unwrap_or_default();
802 let pretty_tag = self.prettify_label.as_ref()
803 .map(|l| format!(" [pretty:{l}]"))
804 .unwrap_or_default();
805 let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
806 let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
807 let preprocess_failed_tag = self.preprocess_failure.as_ref()
808 .map(|msg| {
809 let first_line = msg.lines().next().unwrap_or("");
810 format!(" [preprocess-failed: {}]", first_line)
811 })
812 .unwrap_or_default();
813
814 let file_index_tag = match self.file_index {
815 Some((current, total)) => format!(" [{}/{}]", current + 1, total),
816 None => String::new(),
817 };
818
819 PromptContext {
820 label: self.source_label.clone(),
821 top,
822 bottom,
823 total,
824 pct: pct.min(100) as u8,
825 rec_top,
826 rec_bottom,
827 rec_total,
828 records_mode,
829 wrap_offset,
830 format_tag,
831 filter_tag,
832 grep_tag,
833 hide_tag,
834 search_tag,
835 pretty_tag,
836 live_tag,
837 follow_tag,
838 preprocess_failed_tag,
839 file_index_tag,
840 }
841 }
842
843 fn frame_hex(&self, src: &dyn Source) -> Frame {
844 use crate::hex::format_hex_row;
845 use crate::render::{render_line, Cell, RenderOpts};
846
847 let body_rows = self.rows.saturating_sub(1) as usize;
848 let total_bytes = src.len();
849 let total_hex_rows = total_bytes.div_ceil(16);
850
851 let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
852 let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
853 let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
854
855 let opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1 };
856
857 for row_idx in 0..body_rows {
858 let hex_row = self.top_line + row_idx;
859 if hex_row >= total_hex_rows {
860 body.push(vec![Cell::Empty; self.cols as usize]);
861 } else {
862 let offset = hex_row * 16;
863 let end = (offset + 16).min(total_bytes);
864 let bytes_cow = src.bytes(offset..end);
865 let text = format_hex_row(offset, &bytes_cow);
866 let rows = render_line(text.as_bytes(), &opts);
867 body.push(rows.into_iter().next().unwrap_or_else(|| {
868 vec![Cell::Empty; self.cols as usize]
869 }));
870 }
871 row_styles.push(RowStyle::Normal);
872 highlights.push(Vec::new());
873 }
874
875 let status = self.format_status_hex(src);
876 Frame { body, row_styles, highlights, status }
877 }
878
879 fn format_status_hex(&self, src: &dyn Source) -> String {
880 let total_bytes = src.len();
881 let body_rows = self.rows.saturating_sub(1) as usize;
882 let top_byte = self.top_line * 16;
884 let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
887 let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
888 let label_with_index = match self.file_index {
889 Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
890 None => self.source_label.clone(),
891 };
892 format!(
893 "{} off {}-{}/{} {}% [hex]",
894 label_with_index, top_byte, bottom_byte, total_bytes, pct
895 )
896 }
897
898 pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
903 if delta == 0 { return; }
904 if self.hide_mode() {
905 self.scroll_lines(delta, src, idx);
906 return;
907 }
908 if delta > 0 {
909 idx.extend_to_line(self.top_line + delta as usize + 1, src);
910 let total = idx.line_count();
911 if total == 0 { return; }
912 let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
913 self.top_line = target;
914 self.top_row = 0;
915 } else {
916 let back = (-delta) as usize;
917 let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
922 let extra_back = back.saturating_sub(consumed_for_snap);
923 self.top_line = self.top_line.saturating_sub(extra_back);
924 self.top_row = 0;
925 }
926 }
927
928 pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
929 if delta == 0 { return; }
930 if self.hide_mode() {
931 self.extend_visible_lines(idx, src);
935 let total = self.visible_lines.len();
936 if total == 0 {
937 self.top_line = 0;
938 self.top_row = 0;
939 return;
940 }
941 let cur = self
942 .visible_lines
943 .iter()
944 .position(|&l| l >= self.top_line)
945 .unwrap_or(total);
946 let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
947 self.top_line = self.visible_lines[new];
948 self.top_row = 0;
949 return;
950 }
951 if delta > 0 {
952 let mut remaining = delta as usize;
953 while remaining > 0 {
954 idx.extend_to_line(self.top_line + 1, src);
955 let total = idx.line_count();
956 if total == 0 { break; }
957 let bytes = self.line_display_bytes(src, idx, self.top_line);
958 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)));
959 if self.top_row + 1 < line_rows {
960 self.top_row += 1;
961 } else if self.top_line + 1 < total {
962 self.top_row = 0;
963 self.top_line += 1;
964 } else {
965 break;
966 }
967 remaining -= 1;
968 }
969 } else {
970 let mut remaining = (-delta) as usize;
971 while remaining > 0 {
972 if self.top_row > 0 {
973 self.top_row -= 1;
974 } else if self.top_line > 0 {
975 self.top_line -= 1;
976 let bytes = self.line_display_bytes(src, idx, self.top_line);
977 let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)));
978 self.top_row = line_rows.saturating_sub(1);
979 } else {
980 break;
981 }
982 remaining -= 1;
983 }
984 }
985 }
986
987 pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
988 let n = self.body_rows() as i64;
989 self.scroll_lines(n, src, idx);
990 }
991
992 pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
993 let n = self.body_rows() as i64;
994 self.scroll_lines(-n, src, idx);
995 }
996
997 pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
998 let n = (self.body_rows() / 2).max(1) as i64;
999 self.scroll_lines(n, src, idx);
1000 }
1001
1002 pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1003 let n = (self.body_rows() / 2).max(1) as i64;
1004 self.scroll_lines(-n, src, idx);
1005 }
1006
1007 pub fn goto_top(&mut self) {
1008 self.top_line = 0;
1009 self.top_row = 0;
1010 }
1011
1012 pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
1013 idx.extend_to_end(src);
1014 let body = self.body_rows() as usize;
1015 if self.hide_mode() {
1016 self.extend_visible_lines(idx, src);
1017 let total = self.visible_lines.len();
1018 let target_visible = total.saturating_sub(body);
1019 self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
1020 self.top_row = 0;
1021 } else {
1022 let total = idx.line_count();
1023 self.top_line = total.saturating_sub(body);
1024 self.top_row = 0;
1025 }
1026 }
1027
1028 pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1030 idx.extend_to_line(n, src);
1031 let target = n.min(idx.line_count().saturating_sub(1));
1032 self.top_line = target;
1033 self.top_row = 0;
1034 }
1035
1036 pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
1038 while idx.record_count() <= n && idx.scanned_through() < src.len() {
1042 idx.extend_to_end(src);
1043 }
1044 if idx.record_count() == 0 {
1045 return;
1046 }
1047 let target = n.min(idx.record_count().saturating_sub(1));
1048 let line_range = idx.record_line_range(target);
1049 self.top_line = line_range.start;
1050 self.top_row = 0;
1051 }
1052
1053 pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
1056 let p = p.min(100) as usize;
1057 let target_byte = src.len().saturating_mul(p) / 100;
1058 idx.extend_to_byte_for_query(src, target_byte);
1059 let line_n = idx.line_at_byte(target_byte)
1060 .or_else(|| {
1061 let lc = idx.line_count();
1063 if lc > 0 { Some(lc - 1) } else { None }
1064 })
1065 .unwrap_or(0);
1066 self.top_line = line_n;
1067 self.top_row = 0;
1068 }
1069
1070 pub fn top_line(&self) -> usize {
1072 self.top_line
1073 }
1074
1075 pub fn resize(&mut self, cols: u16, rows: u16) {
1076 self.cols = cols.max(1);
1077 self.rows = rows.max(2);
1078 self.opts.cols = self.cols;
1079 }
1080
1081 pub fn toggle_line_numbers(&mut self) {
1082 self.show_line_numbers = !self.show_line_numbers;
1083 }
1084
1085 pub fn toggle_chop(&mut self) {
1086 self.opts.wrap = !self.opts.wrap;
1087 }
1088
1089 pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use super::*;
1098 use crate::source::MockSource;
1099
1100 fn setup(content: &[u8]) -> (MockSource, LineIndex) {
1101 let m = MockSource::new();
1102 m.append(content);
1103 m.finish();
1104 let idx = LineIndex::new();
1105 (m, idx)
1106 }
1107
1108 #[test]
1109 fn frame_renders_body_height_rows() {
1110 let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
1111 let v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
1113 assert_eq!(frame.body.len(), 4);
1114 assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1 });
1115 assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1 });
1116 }
1117
1118 #[test]
1119 fn scroll_down_advances_top_line() {
1120 let (m, mut idx) = setup(b"a\nb\nc\nd\n");
1121 let mut v = Viewport::new(10, 5, "test".into());
1122 v.scroll_lines(2, &m, &mut idx);
1123 assert_eq!(v.top_line, 2);
1124 assert_eq!(v.top_row, 0);
1125 }
1126
1127 #[test]
1128 fn scroll_up_clamps_at_zero() {
1129 let (m, mut idx) = setup(b"a\nb\nc\n");
1130 let mut v = Viewport::new(10, 5, "test".into());
1131 v.scroll_lines(-5, &m, &mut idx);
1132 assert_eq!(v.top_line, 0);
1133 assert_eq!(v.top_row, 0);
1134 }
1135
1136 #[test]
1137 fn scroll_down_clamps_at_last_line() {
1138 let (m, mut idx) = setup(b"a\nb\nc\n");
1139 let mut v = Viewport::new(10, 5, "test".into());
1140 v.scroll_lines(50, &m, &mut idx);
1141 assert_eq!(v.top_line, 2);
1142 }
1143
1144 #[test]
1145 fn scroll_logical_lines_skips_wrap_rows() {
1146 let mut content = vec![b'X'; 500];
1148 content.push(b'\n');
1149 content.extend_from_slice(b"second\n");
1150 content.extend_from_slice(b"third\n");
1151 let (m, mut idx) = setup(&content);
1152 let mut v = Viewport::new(10, 8, "f".into());
1153 v.scroll_logical_lines(1, &m, &mut idx);
1154 assert_eq!((v.top_line, v.top_row), (1, 0));
1155 v.scroll_logical_lines(1, &m, &mut idx);
1156 assert_eq!((v.top_line, v.top_row), (2, 0));
1157 }
1158
1159 #[test]
1160 fn scroll_logical_lines_back_snaps_to_line_start() {
1161 let mut content = vec![b'A'; 50];
1163 content.push(b'\n');
1164 content.extend_from_slice(&[b'B'; 50]);
1165 content.push(b'\n');
1166 let (m, mut idx) = setup(&content);
1167 let mut v = Viewport::new(10, 8, "f".into());
1168 v.scroll_lines(7, &m, &mut idx);
1169 assert_eq!(v.top_line, 1, "should be on line 1");
1170 assert!(v.top_row > 0, "should be inside line 1's wraps");
1171 v.scroll_logical_lines(-1, &m, &mut idx);
1172 assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
1173 v.scroll_logical_lines(-1, &m, &mut idx);
1174 assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
1175 }
1176
1177 #[test]
1178 fn scroll_down_walks_wraps_of_last_line() {
1179 let mut content = b"first\n".to_vec();
1181 content.extend_from_slice(&[b'X'; 30]);
1182 content.push(b'\n');
1183 let (m, mut idx) = setup(&content);
1184 let mut v = Viewport::new(10, 5, "f".into());
1185 v.scroll_lines(1, &m, &mut idx);
1186 assert_eq!((v.top_line, v.top_row), (1, 0));
1187 v.scroll_lines(1, &m, &mut idx);
1188 assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
1189 v.scroll_lines(1, &m, &mut idx);
1190 assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
1191 }
1192
1193 #[test]
1194 fn scroll_down_walks_wrap_rows_within_long_line() {
1195 let mut content = vec![b'X'; 30];
1197 content.push(b'\n');
1198 content.extend_from_slice(b"second\n");
1199 let (m, mut idx) = setup(&content);
1200 let mut v = Viewport::new(10, 5, "f".into());
1201 v.scroll_lines(1, &m, &mut idx);
1202 assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
1203 v.scroll_lines(1, &m, &mut idx);
1204 assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
1205 v.scroll_lines(1, &m, &mut idx);
1206 assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
1207 }
1208
1209 #[test]
1210 fn status_line_shows_range_and_pct() {
1211 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1212 let v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
1214 assert!(frame.status.starts_with("f 1-4/10"));
1215 }
1216
1217 #[test]
1218 fn page_down_advances_by_body_rows() {
1219 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1220 let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
1222 assert_eq!(v.top_line, 4);
1223 }
1224
1225 #[test]
1226 fn page_up_then_page_down_returns_to_start_when_no_resize() {
1227 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1228 let mut v = Viewport::new(10, 5, "f".into());
1229 v.page_down(&m, &mut idx);
1230 v.page_up(&m, &mut idx);
1231 assert_eq!(v.top_line, 0);
1232 assert_eq!(v.top_row, 0);
1233 }
1234
1235 #[test]
1236 fn half_page_down_advances_by_half_body() {
1237 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1238 let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
1240 assert_eq!(v.top_line, 3);
1241 }
1242
1243 #[test]
1244 fn goto_top_resets_position() {
1245 let (m, mut idx) = setup(b"1\n2\n3\n4\n");
1246 let mut v = Viewport::new(10, 5, "f".into());
1247 v.scroll_lines(2, &m, &mut idx);
1248 v.goto_top();
1249 assert_eq!(v.top_line, 0);
1250 assert_eq!(v.top_row, 0);
1251 }
1252
1253 #[test]
1254 fn goto_bottom_scrolls_to_last_page() {
1255 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
1256 let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
1258 assert_eq!(v.top_line, 6);
1260 }
1261
1262 #[test]
1263 fn goto_line_positions_top_line() {
1264 let m = MockSource::new();
1265 m.append(b"a\nb\nc\nd\ne\n");
1266 let mut idx = LineIndex::new();
1267 idx.extend_to_end(&m);
1268 let mut v = Viewport::new(20, 5, "f".into());
1269 v.goto_line(3, &m, &mut idx);
1270 assert_eq!(v.top_line(), 3);
1271 }
1272
1273 #[test]
1274 fn goto_line_clamps_to_last_line() {
1275 let m = MockSource::new();
1276 m.append(b"a\nb\n");
1277 let mut idx = LineIndex::new();
1278 idx.extend_to_end(&m);
1279 let mut v = Viewport::new(20, 5, "f".into());
1280 v.goto_line(999, &m, &mut idx);
1281 assert_eq!(v.top_line(), 1);
1282 }
1283
1284 #[test]
1285 fn goto_record_positions_at_record_start_line() {
1286 let m = MockSource::new();
1287 m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
1288 let mut idx = LineIndex::new();
1289 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1290 idx.extend_to_end(&m);
1291 let mut v = Viewport::new(20, 5, "f".into());
1292 v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
1294 }
1295
1296 #[test]
1297 fn goto_record_in_line_per_record_mode_equals_goto_line() {
1298 let m = MockSource::new();
1299 m.append(b"a\nb\nc\n");
1300 let mut idx = LineIndex::new();
1301 idx.extend_to_end(&m);
1302 let mut v = Viewport::new(20, 5, "f".into());
1303 v.goto_record(2, &m, &mut idx);
1304 assert_eq!(v.top_line(), 2);
1305 }
1306
1307 #[test]
1308 fn goto_percent_50_lands_in_middle() {
1309 let m = MockSource::new();
1310 m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
1312 idx.extend_to_end(&m);
1313 let mut v = Viewport::new(20, 5, "f".into());
1314 v.goto_percent(50, &m, &mut idx);
1315 assert_eq!(v.top_line(), 2); }
1317
1318 #[test]
1319 fn goto_percent_100_lands_at_last_line() {
1320 let m = MockSource::new();
1321 m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
1323 idx.extend_to_end(&m);
1324 let mut v = Viewport::new(20, 5, "f".into());
1325 v.goto_percent(100, &m, &mut idx);
1326 assert_eq!(v.top_line(), 2);
1327 }
1328
1329 #[test]
1330 fn goto_percent_0_lands_at_first_line() {
1331 let m = MockSource::new();
1332 m.append(b"a\nb\nc\n");
1333 let mut idx = LineIndex::new();
1334 idx.extend_to_end(&m);
1335 let mut v = Viewport::new(20, 5, "f".into());
1336 v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
1338 v.goto_percent(0, &m, &mut idx);
1339 assert_eq!(v.top_line(), 0);
1340 }
1341
1342 #[test]
1343 fn resize_updates_dimensions_and_render_opts() {
1344 let (m, mut idx) = setup(b"1\n2\n");
1345 let mut v = Viewport::new(10, 5, "f".into());
1346 v.resize(40, 12);
1347 assert_eq!(v.cols, 40);
1348 assert_eq!(v.rows, 12);
1349 assert_eq!(v.opts.cols, 40);
1350 let _ = v.frame(&m, &mut idx);
1351 }
1352
1353 #[test]
1354 fn toggle_line_numbers_changes_gutter() {
1355 let (m, mut idx) = setup(b"a\nb\nc\n");
1356 let mut v = Viewport::new(10, 5, "f".into());
1357 let frame_off = v.frame(&m, &mut idx);
1358 v.toggle_line_numbers();
1359 let frame_on = v.frame(&m, &mut idx);
1360 assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1 });
1362 assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1 });
1363 }
1364
1365 #[test]
1366 fn toggle_chop_changes_wrap_mode() {
1367 let (m, mut idx) = setup(b"abcdefghij\n");
1368 let mut v = Viewport::new(4, 5, "f".into());
1369 v.toggle_chop();
1370 let frame = v.frame(&m, &mut idx);
1371 assert_eq!(frame.body[0][..4],
1374 [Cell::Char { ch: 'a', width: 1 }, Cell::Char { ch: 'b', width: 1 },
1375 Cell::Char { ch: 'c', width: 1 }, Cell::Char { ch: 'd', width: 1 }]);
1376 assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
1378 }
1379
1380 #[test]
1383 fn is_at_bottom_initially_only_when_source_fits() {
1384 let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
1387 assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
1388 }
1389
1390 #[test]
1391 fn is_at_bottom_false_when_top_and_more_lines_below() {
1392 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);
1395 assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
1396 }
1397
1398 #[test]
1399 fn is_at_bottom_true_after_goto_bottom() {
1400 let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
1401 let mut v = Viewport::new(10, 5, "f".into());
1402 v.goto_bottom(&m, &mut idx);
1403 assert!(v.is_at_bottom(&idx));
1404 }
1405
1406 #[test]
1407 fn status_shows_follow_suffix_when_follow_mode_on() {
1408 let (m, mut idx) = setup(b"a\nb\n");
1409 let mut v = Viewport::new(20, 5, "f".into());
1410 let frame_off = v.frame(&m, &mut idx);
1411 assert!(!frame_off.status.contains("(F)"));
1412 v.set_follow_mode(true);
1413 let frame_on = v.frame(&m, &mut idx);
1414 assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
1415 }
1416
1417 #[test]
1418 fn toggle_follow_flips_state() {
1419 let mut v = Viewport::new(10, 5, "f".into());
1420 assert!(!v.follow_mode());
1421 v.toggle_follow();
1422 assert!(v.follow_mode());
1423 v.toggle_follow();
1424 assert!(!v.follow_mode());
1425 }
1426
1427 #[test]
1428 fn status_shows_prettify_label_when_set() {
1429 let (m, mut idx) = setup(b"a\n");
1430 let mut v = Viewport::new(40, 5, "f".into());
1431 let frame_off = v.frame(&m, &mut idx);
1432 assert!(!frame_off.status.contains("[pretty"));
1433 v.set_prettify_label(Some("json".into()));
1434 let frame_on = v.frame(&m, &mut idx);
1435 assert!(frame_on.status.contains("[pretty:json]"),
1436 "expected [pretty:json] in status, got: {}", frame_on.status);
1437 v.set_prettify_label(Some("json:err".into()));
1438 let frame_err = v.frame(&m, &mut idx);
1439 assert!(frame_err.status.contains("[pretty:json:err]"),
1440 "expected [pretty:json:err] in status, got: {}", frame_err.status);
1441 }
1442
1443 #[test]
1444 fn status_shows_l_suffix_when_live_mode_on() {
1445 let (m, mut idx) = setup(b"a\nb\n");
1446 let mut v = Viewport::new(20, 5, "f".into());
1447 let frame_off = v.frame(&m, &mut idx);
1448 assert!(!frame_off.status.contains("(L)"));
1449 v.set_live_mode(true);
1450 let frame_on = v.frame(&m, &mut idx);
1451 assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
1452 }
1453
1454 #[test]
1455 fn clamp_top_line_pulls_back_when_total_shrinks() {
1456 let mut v = Viewport::new(20, 5, "f".into());
1457 v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
1466 let (m, mut idx) = setup(b"only\n");
1468 let _ = v.frame(&m, &mut idx);
1469 }
1470
1471 fn simulate_growth_tick(
1474 v: &mut Viewport,
1475 src: &MockSource,
1476 idx: &mut LineIndex,
1477 ) {
1478 if !v.follow_mode() { return; }
1479 let was_at_bottom = v.is_at_bottom(idx);
1480 let lines_before = idx.line_count();
1481 idx.notice_new_bytes(src);
1482 if idx.line_count() != lines_before && was_at_bottom {
1483 v.goto_bottom(src, idx);
1484 }
1485 }
1486
1487 #[test]
1488 fn auto_scroll_engages_when_at_bottom() {
1489 let m = MockSource::new();
1490 m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
1492 let mut v = Viewport::new(10, 5, "f".into());
1493 v.set_follow_mode(true);
1494 idx.extend_to_end(&m);
1495 assert!(v.is_at_bottom(&idx));
1496 let top_before = {
1497 let f = v.frame(&m, &mut idx);
1498 f.status.clone() };
1500 let _ = top_before;
1501 m.append(b"5\n6\n7\n8\n");
1503 simulate_growth_tick(&mut v, &m, &mut idx);
1504 assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
1506 let frame = v.frame(&m, &mut idx);
1507 let last_row = &frame.body[frame.body.len() - 1];
1510 assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1 });
1511 }
1512
1513 #[test]
1514 fn auto_scroll_suppressed_when_scrolled_up() {
1515 let m = MockSource::new();
1516 m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
1518 let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
1520 idx.extend_to_end(&m);
1521 v.goto_bottom(&m, &mut idx);
1522 v.scroll_lines(-2, &m, &mut idx);
1524 assert!(!v.is_at_bottom(&idx));
1525 let frame_before = v.frame(&m, &mut idx);
1526 let top_first_cell_before = frame_before.body[0][0].clone();
1527 m.append(b"9\n10\n");
1529 simulate_growth_tick(&mut v, &m, &mut idx);
1530 let frame_after = v.frame(&m, &mut idx);
1532 assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
1533 }
1534
1535 #[test]
1538 fn set_search_compiles_regex() {
1539 let mut v = Viewport::new(10, 5, "f".into());
1540 assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
1541 assert!(v.search_active());
1542 }
1543
1544 #[test]
1545 fn set_search_rejects_bad_regex() {
1546 let mut v = Viewport::new(10, 5, "f".into());
1547 let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
1548 assert!(!err.is_empty());
1549 assert!(!v.search_active(), "no search should be set on error");
1550 }
1551
1552 #[test]
1553 fn search_step_forward_finds_match_after_top() {
1554 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1555 let mut v = Viewport::new(20, 5, "f".into());
1556 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1557 let found = v.search_repeat(&m, &mut idx, false);
1558 assert!(found);
1559 assert_eq!(v.top_line, 2);
1561 }
1562
1563 #[test]
1564 fn search_step_backward_finds_match_before_top() {
1565 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
1566 let mut v = Viewport::new(20, 5, "f".into());
1567 v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
1569 let found = v.search_repeat(&m, &mut idx, false);
1570 assert!(found);
1571 assert_eq!(v.top_line, 0);
1572 }
1573
1574 #[test]
1575 fn search_wraps_at_end() {
1576 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1577 let mut v = Viewport::new(20, 5, "f".into());
1578 v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
1580 let found = v.search_repeat(&m, &mut idx, false);
1581 assert!(found, "search should wrap forward past EOF");
1582 assert_eq!(v.top_line, 0);
1583 }
1584
1585 #[test]
1586 fn search_no_match_returns_false_and_does_not_move() {
1587 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
1588 let mut v = Viewport::new(20, 5, "f".into());
1589 v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
1590 let found = v.search_repeat(&m, &mut idx, false);
1591 assert!(!found);
1592 assert_eq!(v.top_line, 0);
1593 }
1594
1595 #[test]
1596 fn frame_records_highlight_ranges_for_matches() {
1597 let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
1598 let mut v = Viewport::new(20, 5, "f".into());
1599 v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
1600 let frame = v.frame(&m, &mut idx);
1601 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1603 assert!(frame.highlights[0].is_empty());
1604 assert!(frame.highlights[1].is_empty());
1605 assert_eq!(frame.highlights[2], vec![0..5]);
1606 assert!(frame.highlights[3].is_empty());
1607 }
1608
1609 #[test]
1610 fn frame_highlights_substring_inside_a_row() {
1611 let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
1612 let mut v = Viewport::new(40, 5, "f".into());
1613 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1614 let frame = v.frame(&m, &mut idx);
1615 assert_eq!(frame.highlights[0], vec![18..22]);
1617 assert!(frame.highlights[1].is_empty());
1618 }
1619
1620 #[test]
1621 fn search_highlight_with_filter_dim_keeps_row_dim() {
1622 let (m, mut idx) = setup(b"alpha\nbeta\n");
1625 let mut v = Viewport::new(20, 5, "f".into());
1626 let fmt = crate::format::LogFormat::compile(
1627 "simple",
1628 r"^(?P<line>.+)$",
1629 )
1630 .unwrap();
1631 let f = crate::filter::CompiledFilter::compile(
1632 &fmt,
1633 vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
1634 )
1635 .unwrap();
1636 v.set_filter(Some(f));
1637 v.set_dim_mode(true);
1638 v.set_search("beta".into(), SearchDirection::Forward).unwrap();
1639 let frame = v.frame(&m, &mut idx);
1640 assert_eq!(frame.row_styles[0], RowStyle::Normal);
1641 assert_eq!(frame.row_styles[1], RowStyle::Dim);
1642 assert_eq!(frame.highlights[1], vec![0..4]);
1643 }
1644
1645 #[test]
1646 fn grep_only_hides_non_matching_lines() {
1647 use crate::grep::GrepPredicate;
1648 let src = crate::source::MockSource::new();
1649 src.append(b"keep this error\n");
1650 src.append(b"drop this one\n");
1651 src.append(b"another error line\n");
1652 src.finish();
1653 let mut idx = crate::line_index::LineIndex::new();
1654 idx.extend_to_end(&src);
1655
1656 let mut v = Viewport::new(40, 5, "test".into());
1657 v.set_grep(Some(GrepPredicate::compile(&["error".to_string()]).unwrap()));
1658 v.extend_visible_lines(&idx, &src);
1659
1660 let frame = v.frame(&src, &mut idx);
1662 let body_text: Vec<String> = frame.body.iter()
1663 .map(|row| row.iter().filter_map(|c| match c {
1664 crate::render::Cell::Char { ch, .. } => Some(*ch),
1665 _ => None,
1666 }).collect())
1667 .collect();
1668 assert!(body_text[0].contains("keep this error"));
1669 assert!(body_text[1].contains("another error line"));
1670 assert!(frame.status.contains("[grep]"));
1671 }
1672
1673 #[test]
1674 fn filter_and_grep_combine_with_and() {
1675 use crate::grep::GrepPredicate;
1676 let fmt = crate::format::LogFormat::compile(
1677 "simple",
1678 r"^(?P<level>\w+) (?P<msg>.+)$",
1679 ).unwrap();
1680 let f = crate::filter::CompiledFilter::compile(
1681 &fmt,
1682 vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
1683 ).unwrap();
1684 let g = GrepPredicate::compile(&["timeout".to_string()]).unwrap();
1685
1686 let src = crate::source::MockSource::new();
1687 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();
1692 let mut idx = crate::line_index::LineIndex::new();
1693 idx.extend_to_end(&src);
1694
1695 let mut v = Viewport::new(80, 5, "test".into());
1696 v.set_filter(Some(f));
1697 v.set_grep(Some(g));
1698 v.extend_visible_lines(&idx, &src);
1699 assert_eq!(v.visible_lines(), &[0usize]);
1700 }
1701
1702 #[test]
1703 fn search_status_shows_pattern() {
1704 let (m, mut idx) = setup(b"x\n");
1705 let mut v = Viewport::new(20, 5, "f".into());
1706 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1707 let frame = v.frame(&m, &mut idx);
1708 assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
1709 }
1710
1711 #[test]
1712 fn repeat_search_after_first_match_advances() {
1713 let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
1714 let mut v = Viewport::new(40, 5, "f".into());
1715 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1716 assert!(v.search_repeat(&m, &mut idx, false));
1717 assert_eq!(v.top_line, 1, "first foo");
1718 v.set_search("foo".into(), SearchDirection::Forward).unwrap();
1719 assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
1720 assert_eq!(v.top_line, 3, "should advance to next foo");
1721 }
1722
1723 #[test]
1724 fn auto_scroll_paused_when_follow_off() {
1725 let m = MockSource::new();
1726 m.append(b"1\n2\n3\n4\n");
1727 let mut idx = LineIndex::new();
1728 let mut v = Viewport::new(10, 5, "f".into());
1729 idx.extend_to_end(&m);
1731 let frame_before = v.frame(&m, &mut idx);
1732 let top_first_cell = frame_before.body[0][0].clone();
1733 m.append(b"5\n6\n7\n8\n");
1734 simulate_growth_tick(&mut v, &m, &mut idx);
1735 let frame_after = v.frame(&m, &mut idx);
1736 assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
1737 }
1738
1739 #[test]
1742 fn search_jumps_to_next_matching_record() {
1743 let m = MockSource::new();
1744 m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
1745 let mut idx = LineIndex::new();
1746 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1747 idx.extend_to_end(&m);
1748 let mut v = Viewport::new(40, 10, "f".into());
1749 v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
1750 let hit = v.search_repeat(&m, &mut idx, false);
1751 assert!(hit, "should find 'charlie' in record 2");
1752 assert_eq!(v.top_line(), 3); }
1754
1755 #[test]
1756 fn search_finds_cross_line_match_in_record_with_s_flag() {
1757 let m = MockSource::new();
1758 m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
1759 let mut idx = LineIndex::new();
1760 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1761 idx.extend_to_end(&m);
1762 let mut v = Viewport::new(40, 10, "f".into());
1763 v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
1764 let hit = v.search_repeat(&m, &mut idx, false);
1765 assert!(hit, "should match across \\n inside record 0 with (?s)");
1766 assert_eq!(v.top_line(), 0);
1767 }
1768
1769 #[test]
1770 fn search_repeat_with_no_match_returns_false() {
1771 let m = MockSource::new();
1772 m.append(b"[1] alpha\n[2] bravo\n");
1773 let mut idx = LineIndex::new();
1774 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1775 idx.extend_to_end(&m);
1776 let mut v = Viewport::new(40, 10, "f".into());
1777 v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
1778 let hit = v.search_repeat(&m, &mut idx, false);
1779 assert!(!hit);
1780 }
1781
1782 #[test]
1785 fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
1786 let m = MockSource::new();
1789 m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
1790 let mut idx = LineIndex::new();
1791 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1792 idx.extend_to_end(&m);
1793 let grep = GrepPredicate::compile(&["cont a".to_string()]).unwrap();
1794 let mut v = Viewport::new(40, 10, "f".into());
1795 v.set_grep(Some(grep));
1796 v.extend_visible_lines(&idx, &m);
1797 assert_eq!(v.visible_lines(), &[0usize, 1]);
1800 }
1801
1802 #[test]
1803 fn grep_matches_across_record_newlines_in_records_mode() {
1804 let m = MockSource::new();
1806 m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
1807 let mut idx = LineIndex::new();
1808 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1809 idx.extend_to_end(&m);
1810 let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()]).unwrap();
1811 let mut v = Viewport::new(40, 10, "f".into());
1812 v.set_grep(Some(grep));
1813 v.extend_visible_lines(&idx, &m);
1814 assert_eq!(v.visible_lines(), &[0usize, 1]);
1816 }
1817
1818 #[test]
1819 fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
1820 let m = MockSource::new();
1823 m.append(b"[1] head\n cont\n[2] other\n cont\n");
1824 let mut idx = LineIndex::new();
1825 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1826 idx.extend_to_end(&m);
1827 let grep = GrepPredicate::compile(&[r"\[1\]".to_string()]).unwrap();
1828 let mut v = Viewport::new(40, 10, "f".into());
1829 v.set_grep(Some(grep));
1830 v.set_dim_mode(true);
1831 v.extend_visible_lines(&idx, &m);
1832 assert_eq!(v.visible_lines(), &[] as &[usize]);
1834 assert!(!v.should_dim_line(0, &idx, &m));
1836 assert!(!v.should_dim_line(1, &idx, &m));
1837 assert!(v.should_dim_line(2, &idx, &m));
1839 assert!(v.should_dim_line(3, &idx, &m));
1840 }
1841
1842 #[test]
1843 fn status_unchanged_when_records_inactive() {
1844 let (m, mut idx) = setup(b"a\nb\nc\n");
1845 let v = Viewport::new(20, 5, "f".into());
1846 let frame = v.frame(&m, &mut idx);
1847 let status = &frame.status;
1848 assert!(status.contains("1-3/3"), "got: {status}");
1850 assert!(!status.contains("L1"), "no L block in line-mode: {status}");
1851 assert!(!status.contains("R1"), "no R block in line-mode: {status}");
1852 }
1853
1854 #[test]
1855 fn status_dual_readout_when_records_active() {
1856 let m = MockSource::new();
1857 m.append(b"[1] a\n cont\n[2] b\n");
1858 m.finish();
1859 let mut idx = LineIndex::new();
1860 idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
1861 idx.extend_to_end(&m);
1862 let v = Viewport::new(20, 5, "f".into());
1863 let frame = v.frame(&m, &mut idx);
1864 let status = &frame.status;
1865 assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
1866 assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
1867 }
1868
1869 #[test]
1870 fn format_status_uses_custom_template_when_set() {
1871 let m = MockSource::new();
1872 m.append(b"a\nb\nc\n");
1873 m.finish();
1874 let mut idx = LineIndex::new();
1875 idx.extend_to_end(&m);
1876 let mut v = Viewport::new(20, 5, "f".into());
1877 let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
1878 v.set_prompt(Some(prompt));
1879 let frame = v.frame(&m, &mut idx);
1880 assert_eq!(frame.status, "f 100%");
1881 }
1882
1883 #[test]
1884 fn status_shows_preprocess_failed_tag_when_set() {
1885 let m = MockSource::new();
1886 m.append(b"a\n");
1887 let mut idx = LineIndex::new();
1888 idx.extend_to_end(&m);
1889 let mut v = Viewport::new(40, 5, "f".into());
1890 v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
1891 let frame = v.frame(&m, &mut idx);
1892 assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
1893 "got: {}", frame.status);
1894 }
1895
1896 #[test]
1897 fn status_shows_file_index_when_multifile() {
1898 let m = MockSource::new();
1899 m.append(b"a\n");
1900 let mut idx = LineIndex::new();
1901 idx.extend_to_end(&m);
1902 let mut v = Viewport::new(60, 5, "f.log".into());
1903 v.set_file_index(0, 3);
1904 let frame = v.frame(&m, &mut idx);
1905 assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
1906 }
1907
1908 #[test]
1909 fn status_omits_file_index_when_single_file() {
1910 let m = MockSource::new();
1911 m.append(b"a\n");
1912 let mut idx = LineIndex::new();
1913 idx.extend_to_end(&m);
1914 let mut v = Viewport::new(60, 5, "f.log".into());
1915 v.set_file_index(0, 1);
1916 let frame = v.frame(&m, &mut idx);
1917 assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
1918 }
1919}