use std::ops::Range;
use regex::Regex;
use crate::filter::{CompiledFilter, FilterMatch};
use crate::grep::GrepPredicate;
use crate::line_index::LineIndex;
use crate::render::{count_rows, render_line, Cell, RenderOpts};
use crate::source::Source;
fn row_text_and_starts(row: &[Cell]) -> (String, Vec<usize>) {
let mut text = String::new();
let mut starts: Vec<usize> = Vec::with_capacity(row.len() + 1);
for (col, cell) in row.iter().enumerate() {
match cell {
Cell::Char { ch, .. } => {
starts.push(col);
text.push(*ch);
}
Cell::Empty => {
starts.push(col);
text.push(' ');
}
Cell::Continuation => {}
}
}
starts.push(row.len());
(text, starts)
}
fn find_row_highlights(row: &[Cell], regex: &Regex) -> Vec<Range<usize>> {
if row.is_empty() {
return Vec::new();
}
let last_content_col = row
.iter()
.enumerate()
.rev()
.find_map(|(c, cell)| match cell {
Cell::Char { width, .. } => Some(c + *width as usize),
Cell::Continuation => Some(c + 1),
Cell::Empty => None,
})
.unwrap_or(0);
if last_content_col == 0 {
return Vec::new();
}
let (text, starts) = row_text_and_starts(row);
let mut out = Vec::new();
for m in regex.find_iter(&text) {
if m.start() == m.end() {
continue;
}
let char_start = text[..m.start()].chars().count();
let char_end = text[..m.end()].chars().count();
if char_start >= starts.len() - 1 || char_end <= char_start {
continue;
}
let col_start = starts[char_start];
let col_end = starts[char_end].min(last_content_col);
if col_end > col_start {
out.push(col_start..col_end);
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RowStyle {
Normal,
Dim,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchDirection {
Forward,
Backward,
}
#[derive(Debug, Clone)]
pub struct SearchState {
pub raw: String,
pub regex: Regex,
pub direction: SearchDirection,
}
#[derive(Debug, Clone)]
pub struct Frame {
pub body: Vec<Vec<Cell>>, pub row_styles: Vec<RowStyle>, pub highlights: Vec<Vec<std::ops::Range<usize>>>,
pub status: String,
}
pub struct Viewport {
top_line: usize,
top_row: usize,
cols: u16,
rows: u16,
pub opts: RenderOpts,
pub show_line_numbers: bool,
pub source_label: String,
follow_mode: bool,
live_mode: bool,
prettify_label: Option<String>,
filter: Option<CompiledFilter>,
grep: Option<GrepPredicate>,
dim_mode: bool,
visible_lines: Vec<usize>,
visible_scanned: usize,
search: Option<SearchState>,
display: Option<crate::format::DisplayRenderer>,
}
impl Viewport {
pub fn new(cols: u16, rows: u16, source_label: String) -> Self {
let opts = RenderOpts { cols, ..RenderOpts::default() };
Self {
top_line: 0,
top_row: 0,
cols,
rows,
opts,
show_line_numbers: false,
source_label,
follow_mode: false,
live_mode: false,
prettify_label: None,
filter: None,
grep: None,
dim_mode: false,
visible_lines: Vec::new(),
visible_scanned: 0,
search: None,
display: None,
}
}
pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
self.display = renderer;
}
fn line_display_bytes<'a>(&self, src: &'a dyn Source, idx: &LineIndex, line_n: usize) -> std::borrow::Cow<'a, [u8]> {
let range = idx.line_range(line_n, src);
let raw = src.bytes(range);
if let Some(r) = self.display.as_ref() {
if let Some(rendered) = r.render_line(&raw) {
return std::borrow::Cow::Owned(rendered.into_bytes());
}
}
raw
}
pub fn set_search(&mut self, raw: String, direction: SearchDirection) -> Result<(), String> {
let regex = Regex::new(&raw).map_err(|e| e.to_string())?;
self.search = Some(SearchState { raw, regex, direction });
Ok(())
}
pub fn clear_search(&mut self) { self.search = None; }
pub fn search_active(&self) -> bool { self.search.is_some() }
pub fn search_direction(&self) -> SearchDirection {
self.search.as_ref().map(|s| s.direction).unwrap_or(SearchDirection::Forward)
}
pub fn search_repeat(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
if idx.records_mode() {
self.search_repeat_records(src, idx, reverse)
} else {
self.search_repeat_lines(src, idx, reverse)
}
}
fn search_repeat_lines(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
let Some(s) = self.search.as_ref() else { return false; };
let forward = matches!(
(s.direction, reverse),
(SearchDirection::Forward, false) | (SearchDirection::Backward, true)
);
idx.extend_to_end(src);
let pattern = s.regex.clone();
if self.hide_mode() {
self.extend_visible_lines(idx, src);
self.search_step_in_visible(&pattern, src, idx, forward)
} else {
self.search_step_in_logical(&pattern, src, idx, forward)
}
}
fn search_repeat_records(&mut self, src: &dyn Source, idx: &mut LineIndex, reverse: bool) -> bool {
let Some(s) = self.search.as_ref() else { return false; };
let forward = matches!(
(s.direction, reverse),
(SearchDirection::Forward, false) | (SearchDirection::Backward, true)
);
let pattern = s.regex.clone();
idx.extend_to_end(src);
let total = idx.record_count();
if total == 0 { return false; }
let cur_record = idx.line_to_record(self.top_line);
let range: Box<dyn Iterator<Item = usize>> = if forward {
Box::new(((cur_record + 1)..total).chain(0..=cur_record))
} else {
let earlier: Vec<usize> = (0..cur_record).rev().collect();
let later: Vec<usize> = (cur_record..total).rev().collect();
Box::new(earlier.into_iter().chain(later))
};
for r in range {
let bytes_cow = idx.record_bytes(r, src);
let text = String::from_utf8_lossy(&bytes_cow);
if pattern.is_match(&text) {
let line_range = idx.record_line_range(r);
self.top_line = line_range.start;
self.top_row = 0;
return true;
}
}
false
}
fn line_matches(&self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, line_n: usize) -> bool {
let bytes = self.line_display_bytes(src, idx, line_n);
match std::str::from_utf8(&bytes) {
Ok(s) => pattern.is_match(s),
Err(_) => false,
}
}
fn search_step_in_logical(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
let total = idx.line_count();
if total == 0 { return false; }
let start = self.top_line;
for offset in 1..=total {
let line_n = if forward {
(start + offset) % total
} else {
(start + total - offset) % total
};
if self.line_matches(pattern, src, idx, line_n) {
self.top_line = line_n;
self.top_row = 0;
return true;
}
}
false
}
fn search_step_in_visible(&mut self, pattern: &Regex, src: &dyn Source, idx: &LineIndex, forward: bool) -> bool {
let total = self.visible_lines.len();
if total == 0 { return false; }
let cur = self.visible_lines.iter().position(|&l| l >= self.top_line).unwrap_or(0);
for offset in 1..=total {
let visible_idx = if forward {
(cur + offset) % total
} else {
(cur + total - offset) % total
};
let line_n = self.visible_lines[visible_idx];
if self.line_matches(pattern, src, idx, line_n) {
self.top_line = line_n;
self.top_row = 0;
return true;
}
}
false
}
pub fn set_filter(&mut self, filter: Option<CompiledFilter>) {
self.filter = filter;
self.visible_lines.clear();
self.visible_scanned = 0;
self.top_line = 0;
self.top_row = 0;
}
pub fn set_grep(&mut self, grep: Option<GrepPredicate>) {
self.grep = grep;
self.visible_lines.clear();
self.visible_scanned = 0;
self.top_line = 0;
self.top_row = 0;
}
pub fn grep_active(&self) -> bool { self.grep.is_some() }
pub fn set_dim_mode(&mut self, on: bool) {
self.dim_mode = on;
self.visible_lines.clear();
self.visible_scanned = 0;
}
pub fn filter_active(&self) -> bool { self.filter.is_some() }
pub fn dim_mode(&self) -> bool { self.dim_mode }
fn hide_mode(&self) -> bool {
(self.filter.is_some() || self.grep.is_some()) && !self.dim_mode
}
pub fn extend_visible_lines(&mut self, idx: &LineIndex, src: &dyn Source) {
if !self.hide_mode() {
return;
}
if idx.records_mode() {
self.extend_visible_lines_records(idx, src);
} else {
self.extend_visible_lines_per_line(idx, src);
}
}
fn extend_visible_lines_per_line(&mut self, idx: &LineIndex, src: &dyn Source) {
let total = idx.line_count();
while self.visible_scanned < total {
let line_n = self.visible_scanned;
let range = idx.line_range(line_n, src);
let bytes = src.bytes(range);
if self.line_passes(&bytes) {
self.visible_lines.push(line_n);
}
self.visible_scanned += 1;
}
}
fn extend_visible_lines_records(&mut self, idx: &LineIndex, src: &dyn Source) {
self.visible_lines.clear();
self.visible_scanned = 0; let total_records = idx.record_count();
for r in 0..total_records {
let bytes_cow = idx.record_bytes(r, src);
let bytes: &[u8] = &bytes_cow;
if self.line_passes(bytes) {
for line_n in idx.record_line_range(r) {
self.visible_lines.push(line_n);
}
}
}
}
fn line_passes(&self, line: &[u8]) -> bool {
let filter_ok = match self.filter.as_ref() {
Some(f) => matches!(f.evaluate(line), FilterMatch::Matched),
None => true,
};
let grep_ok = match self.grep.as_ref() {
Some(g) => g.matches(line),
None => true,
};
filter_ok && grep_ok
}
fn should_dim_line(&self, line_n: usize, idx: &LineIndex, src: &dyn Source) -> bool {
if !self.dim_mode {
return false;
}
if idx.records_mode() {
let r = idx.line_to_record(line_n);
let bytes_cow = idx.record_bytes(r, src);
let bytes: &[u8] = &bytes_cow;
!self.line_passes(bytes)
} else {
let range = idx.line_range(line_n, src);
let bytes = src.bytes(range);
!self.line_passes(&bytes)
}
}
pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
pub fn follow_mode(&self) -> bool { self.follow_mode }
pub fn set_follow_mode(&mut self, on: bool) { self.follow_mode = on; }
pub fn toggle_follow(&mut self) { self.follow_mode = !self.follow_mode; }
pub fn live_mode(&self) -> bool { self.live_mode }
pub fn set_live_mode(&mut self, on: bool) { self.live_mode = on; }
pub fn set_prettify_label(&mut self, label: Option<String>) {
self.prettify_label = label;
}
pub fn invalidate_filter_cache(&mut self) {
self.visible_lines.clear();
self.visible_scanned = 0;
}
pub fn clamp_top_line(&mut self, line_count: usize) {
if line_count == 0 {
self.top_line = 0;
self.top_row = 0;
} else if self.top_line >= line_count {
self.top_line = line_count - 1;
self.top_row = 0;
}
}
pub fn is_at_bottom(&self, idx: &LineIndex) -> bool {
let body = self.body_rows() as usize;
if self.hide_mode() {
let pos = self
.visible_lines
.iter()
.position(|&l| l >= self.top_line)
.unwrap_or(self.visible_lines.len());
pos + body >= self.visible_lines.len()
} else {
self.top_line + body >= idx.line_count()
}
}
fn gutter_width(&self, idx: &LineIndex) -> u16 {
if !self.show_line_numbers { return 0; }
let n = idx.line_count().max(1);
let digits = (n as f64).log10().floor() as u16 + 1;
digits + 1
}
fn render_opts(&self, gutter: u16) -> RenderOpts {
let mut o = self.opts.clone();
o.cols = self.cols.saturating_sub(gutter);
o
}
pub fn frame(&self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
let body_rows = self.body_rows() as usize;
idx.extend_to_line(self.top_line + body_rows + 1, src);
let gutter = self.gutter_width(idx);
let r_opts = self.render_opts(gutter);
let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
let mut row_styles: Vec<RowStyle> = Vec::with_capacity(body_rows);
let mut highlights: Vec<Vec<std::ops::Range<usize>>> = Vec::with_capacity(body_rows);
let hide = self.hide_mode();
let total_lines = idx.line_count();
let mut hide_pos = if hide {
self.visible_lines
.iter()
.position(|&l| l >= self.top_line)
.unwrap_or(self.visible_lines.len())
} else {
0
};
let mut line_n = if hide {
self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines)
} else {
self.top_line
};
let mut skip = if hide { 0 } else { self.top_row };
while body.len() < body_rows {
if line_n >= total_lines {
let mut row = Vec::with_capacity(self.cols as usize);
if gutter > 0 {
for _ in 0..gutter { row.push(Cell::Empty); }
}
while row.len() < self.cols as usize { row.push(Cell::Empty); }
body.push(row);
row_styles.push(RowStyle::Normal);
highlights.push(Vec::new());
line_n += 1;
continue;
}
let raw = src.bytes(idx.line_range(line_n, src));
let display_bytes = if let Some(r) = self.display.as_ref() {
match r.render_line(&raw) {
Some(s) => std::borrow::Cow::Owned(s.into_bytes()),
None => raw.clone(),
}
} else {
raw.clone()
};
let rows = render_line(&display_bytes, &r_opts);
let style = if self.filter.is_some() || self.grep.is_some() {
if self.dim_mode {
if self.should_dim_line(line_n, idx, src) { RowStyle::Dim } else { RowStyle::Normal }
} else {
RowStyle::Normal
}
} else {
RowStyle::Normal
};
for (i, mut content_row) in rows.into_iter().enumerate() {
if i < skip { continue; }
if body.len() >= body_rows { break; }
let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
if gutter > 0 {
let label = if i == 0 { format!("{:>width$} ", line_n + 1, width = (gutter as usize - 1)) } else { " ".repeat(gutter as usize) };
for c in label.chars() {
full.push(Cell::Char { ch: c, width: 1 });
}
}
full.append(&mut content_row);
let row_highlights = if let Some(s) = self.search.as_ref() {
find_row_highlights(&full, &s.regex)
} else {
Vec::new()
};
body.push(full);
row_styles.push(style);
highlights.push(row_highlights);
}
skip = 0;
if hide {
hide_pos += 1;
line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
} else {
line_n += 1;
}
}
let status = self.format_status(idx, src);
Frame { body, row_styles, highlights, status }
}
fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
let body_rows = self.body_rows() as usize;
let total = idx.line_count();
let (top, bottom, total_for_pct, total_str): (usize, usize, usize, String) = if self.hide_mode() {
let visible_total = self.visible_lines.len();
let cur = self
.visible_lines
.iter()
.position(|&l| l >= self.top_line)
.unwrap_or(visible_total);
let top = cur + 1;
let bottom = (cur + body_rows).min(visible_total.max(1));
let total_str = if src.is_complete() {
format!("{visible_total}/{total}")
} else {
format!("{visible_total}/{total}+")
};
(top, bottom, visible_total, total_str)
} else {
let top = self.top_line + 1;
let bottom = (self.top_line + body_rows).min(total.max(1));
let total_str = if src.is_complete() { format!("{total}") } else { format!("{total}+") };
(top, bottom, total, total_str)
};
let pct = (bottom * 100).checked_div(total_for_pct).unwrap_or(0);
let (line_prefix, records_block) = if idx.records_mode() {
let line_total = idx.line_count();
let rec_total = idx.record_count();
let rec_block = if line_total == 0 || rec_total == 0 {
format!("R0-0/{}", rec_total)
} else {
let rec_top = idx.line_to_record(self.top_line) + 1;
let rec_bottom = idx.line_to_record(bottom.saturating_sub(1)) + 1;
format!("R{}-{}/{}", rec_top, rec_bottom, rec_total)
};
("L", Some(rec_block))
} else {
("", None)
};
let middle = match records_block {
Some(ref rb) => format!("{}{}-{}/{} {} {}%", line_prefix, top, bottom, total_str, rb, pct),
None => format!("{}-{}/{} {}%", top, bottom, total_str, pct),
};
let mut s = format!("{} {}", self.source_label, middle);
if !self.hide_mode() && self.top_row > 0 {
let line_rows = if total > 0 {
let bytes = self.line_display_bytes(src, idx, self.top_line);
count_rows(&bytes, &self.render_opts(self.gutter_width(idx)))
} else { 1 };
s.push_str(&format!(" +{}/{}", self.top_row, line_rows));
}
if let Some(f) = self.filter.as_ref() {
s.push_str(&format!(" [{}]", f.format_name));
}
if self.grep.is_some() {
s.push_str(" [grep]");
}
if self.filter.is_some() || self.grep.is_some() {
s.push_str(if self.dim_mode { " [dim]" } else { " [filter]" });
}
if let Some(sr) = self.search.as_ref() {
let prefix = if matches!(sr.direction, SearchDirection::Forward) { "/" } else { "?" };
s.push_str(&format!(" [{}{}]", prefix, sr.raw));
}
if let Some(label) = self.prettify_label.as_ref() {
s.push_str(&format!(" [pretty:{label}]"));
}
if self.live_mode { s.push_str(" (L)"); }
if self.follow_mode { s.push_str(" (F)"); }
s
}
pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
if delta == 0 { return; }
if self.hide_mode() {
self.scroll_lines(delta, src, idx);
return;
}
if delta > 0 {
idx.extend_to_line(self.top_line + delta as usize + 1, src);
let total = idx.line_count();
if total == 0 { return; }
let target = (self.top_line as i64 + delta).min(total as i64 - 1) as usize;
self.top_line = target;
self.top_row = 0;
} else {
let back = (-delta) as usize;
let consumed_for_snap = if self.top_row > 0 { 1 } else { 0 };
let extra_back = back.saturating_sub(consumed_for_snap);
self.top_line = self.top_line.saturating_sub(extra_back);
self.top_row = 0;
}
}
pub fn scroll_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
if delta == 0 { return; }
if self.hide_mode() {
self.extend_visible_lines(idx, src);
let total = self.visible_lines.len();
if total == 0 {
self.top_line = 0;
self.top_row = 0;
return;
}
let cur = self
.visible_lines
.iter()
.position(|&l| l >= self.top_line)
.unwrap_or(total);
let new = (cur as i64 + delta).clamp(0, total.saturating_sub(1) as i64) as usize;
self.top_line = self.visible_lines[new];
self.top_row = 0;
return;
}
if delta > 0 {
let mut remaining = delta as usize;
while remaining > 0 {
idx.extend_to_line(self.top_line + 1, src);
let total = idx.line_count();
if total == 0 { break; }
let bytes = self.line_display_bytes(src, idx, self.top_line);
let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)));
if self.top_row + 1 < line_rows {
self.top_row += 1;
} else if self.top_line + 1 < total {
self.top_row = 0;
self.top_line += 1;
} else {
break;
}
remaining -= 1;
}
} else {
let mut remaining = (-delta) as usize;
while remaining > 0 {
if self.top_row > 0 {
self.top_row -= 1;
} else if self.top_line > 0 {
self.top_line -= 1;
let bytes = self.line_display_bytes(src, idx, self.top_line);
let line_rows = count_rows(&bytes, &self.render_opts(self.gutter_width(idx)));
self.top_row = line_rows.saturating_sub(1);
} else {
break;
}
remaining -= 1;
}
}
}
pub fn page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
let n = self.body_rows() as i64;
self.scroll_lines(n, src, idx);
}
pub fn page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
let n = self.body_rows() as i64;
self.scroll_lines(-n, src, idx);
}
pub fn half_page_down(&mut self, src: &dyn Source, idx: &mut LineIndex) {
let n = (self.body_rows() / 2).max(1) as i64;
self.scroll_lines(n, src, idx);
}
pub fn half_page_up(&mut self, src: &dyn Source, idx: &mut LineIndex) {
let n = (self.body_rows() / 2).max(1) as i64;
self.scroll_lines(-n, src, idx);
}
pub fn goto_top(&mut self) {
self.top_line = 0;
self.top_row = 0;
}
pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
idx.extend_to_end(src);
let body = self.body_rows() as usize;
if self.hide_mode() {
self.extend_visible_lines(idx, src);
let total = self.visible_lines.len();
let target_visible = total.saturating_sub(body);
self.top_line = self.visible_lines.get(target_visible).copied().unwrap_or(0);
self.top_row = 0;
} else {
let total = idx.line_count();
self.top_line = total.saturating_sub(body);
self.top_row = 0;
}
}
pub fn goto_line(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
idx.extend_to_line(n, src);
let target = n.min(idx.line_count().saturating_sub(1));
self.top_line = target;
self.top_row = 0;
}
pub fn goto_record(&mut self, n: usize, src: &dyn Source, idx: &mut LineIndex) {
while idx.record_count() <= n && idx.scanned_through() < src.len() {
idx.extend_to_end(src);
}
if idx.record_count() == 0 {
return;
}
let target = n.min(idx.record_count().saturating_sub(1));
let line_range = idx.record_line_range(target);
self.top_line = line_range.start;
self.top_row = 0;
}
pub fn goto_percent(&mut self, p: u8, src: &dyn Source, idx: &mut LineIndex) {
let p = p.min(100) as usize;
let target_byte = src.len().saturating_mul(p) / 100;
idx.extend_to_byte_for_query(src, target_byte);
let line_n = idx.line_at_byte(target_byte)
.or_else(|| {
let lc = idx.line_count();
if lc > 0 { Some(lc - 1) } else { None }
})
.unwrap_or(0);
self.top_line = line_n;
self.top_row = 0;
}
pub fn top_line(&self) -> usize {
self.top_line
}
pub fn resize(&mut self, cols: u16, rows: u16) {
self.cols = cols.max(1);
self.rows = rows.max(2);
self.opts.cols = self.cols;
}
pub fn toggle_line_numbers(&mut self) {
self.show_line_numbers = !self.show_line_numbers;
}
pub fn toggle_chop(&mut self) {
self.opts.wrap = !self.opts.wrap;
}
pub fn visible_lines(&self) -> &[usize] { &self.visible_lines }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::MockSource;
fn setup(content: &[u8]) -> (MockSource, LineIndex) {
let m = MockSource::new();
m.append(content);
m.finish();
let idx = LineIndex::new();
(m, idx)
}
#[test]
fn frame_renders_body_height_rows() {
let (m, mut idx) = setup(b"a\nb\nc\nd\ne\n");
let v = Viewport::new(10, 5, "test".into()); let frame = v.frame(&m, &mut idx);
assert_eq!(frame.body.len(), 4);
assert_eq!(frame.body[0][0], Cell::Char { ch: 'a', width: 1 });
assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1 });
}
#[test]
fn scroll_down_advances_top_line() {
let (m, mut idx) = setup(b"a\nb\nc\nd\n");
let mut v = Viewport::new(10, 5, "test".into());
v.scroll_lines(2, &m, &mut idx);
assert_eq!(v.top_line, 2);
assert_eq!(v.top_row, 0);
}
#[test]
fn scroll_up_clamps_at_zero() {
let (m, mut idx) = setup(b"a\nb\nc\n");
let mut v = Viewport::new(10, 5, "test".into());
v.scroll_lines(-5, &m, &mut idx);
assert_eq!(v.top_line, 0);
assert_eq!(v.top_row, 0);
}
#[test]
fn scroll_down_clamps_at_last_line() {
let (m, mut idx) = setup(b"a\nb\nc\n");
let mut v = Viewport::new(10, 5, "test".into());
v.scroll_lines(50, &m, &mut idx);
assert_eq!(v.top_line, 2);
}
#[test]
fn scroll_logical_lines_skips_wrap_rows() {
let mut content = vec![b'X'; 500];
content.push(b'\n');
content.extend_from_slice(b"second\n");
content.extend_from_slice(b"third\n");
let (m, mut idx) = setup(&content);
let mut v = Viewport::new(10, 8, "f".into());
v.scroll_logical_lines(1, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (1, 0));
v.scroll_logical_lines(1, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (2, 0));
}
#[test]
fn scroll_logical_lines_back_snaps_to_line_start() {
let mut content = vec![b'A'; 50];
content.push(b'\n');
content.extend_from_slice(&[b'B'; 50]);
content.push(b'\n');
let (m, mut idx) = setup(&content);
let mut v = Viewport::new(10, 8, "f".into());
v.scroll_lines(7, &m, &mut idx);
assert_eq!(v.top_line, 1, "should be on line 1");
assert!(v.top_row > 0, "should be inside line 1's wraps");
v.scroll_logical_lines(-1, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (1, 0), "K snaps to start of current line");
v.scroll_logical_lines(-1, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (0, 0), "K then goes to previous line");
}
#[test]
fn scroll_down_walks_wraps_of_last_line() {
let mut content = b"first\n".to_vec();
content.extend_from_slice(&[b'X'; 30]);
content.push(b'\n');
let (m, mut idx) = setup(&content);
let mut v = Viewport::new(10, 5, "f".into());
v.scroll_lines(1, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (1, 0));
v.scroll_lines(1, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (1, 1), "should advance into wraps of last line");
v.scroll_lines(1, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (1, 2), "should reach last wrap row");
}
#[test]
fn scroll_down_walks_wrap_rows_within_long_line() {
let mut content = vec![b'X'; 30];
content.push(b'\n');
content.extend_from_slice(b"second\n");
let (m, mut idx) = setup(&content);
let mut v = Viewport::new(10, 5, "f".into());
v.scroll_lines(1, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (0, 1), "first j → wrap row 1");
v.scroll_lines(1, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (0, 2), "second j → wrap row 2");
v.scroll_lines(1, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (1, 0), "third j → next logical line");
}
#[test]
fn status_line_shows_range_and_pct() {
let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
let v = Viewport::new(20, 5, "f".into()); let frame = v.frame(&m, &mut idx);
assert!(frame.status.starts_with("f 1-4/10"));
}
#[test]
fn page_down_advances_by_body_rows() {
let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
let mut v = Viewport::new(10, 5, "f".into()); v.page_down(&m, &mut idx);
assert_eq!(v.top_line, 4);
}
#[test]
fn page_up_then_page_down_returns_to_start_when_no_resize() {
let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
let mut v = Viewport::new(10, 5, "f".into());
v.page_down(&m, &mut idx);
v.page_up(&m, &mut idx);
assert_eq!(v.top_line, 0);
assert_eq!(v.top_row, 0);
}
#[test]
fn half_page_down_advances_by_half_body() {
let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
let mut v = Viewport::new(10, 7, "f".into()); v.half_page_down(&m, &mut idx);
assert_eq!(v.top_line, 3);
}
#[test]
fn goto_top_resets_position() {
let (m, mut idx) = setup(b"1\n2\n3\n4\n");
let mut v = Viewport::new(10, 5, "f".into());
v.scroll_lines(2, &m, &mut idx);
v.goto_top();
assert_eq!(v.top_line, 0);
assert_eq!(v.top_row, 0);
}
#[test]
fn goto_bottom_scrolls_to_last_page() {
let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
let mut v = Viewport::new(10, 5, "f".into()); v.goto_bottom(&m, &mut idx);
assert_eq!(v.top_line, 6);
}
#[test]
fn goto_line_positions_top_line() {
let m = MockSource::new();
m.append(b"a\nb\nc\nd\ne\n");
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(20, 5, "f".into());
v.goto_line(3, &m, &mut idx);
assert_eq!(v.top_line(), 3);
}
#[test]
fn goto_line_clamps_to_last_line() {
let m = MockSource::new();
m.append(b"a\nb\n");
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(20, 5, "f".into());
v.goto_line(999, &m, &mut idx);
assert_eq!(v.top_line(), 1);
}
#[test]
fn goto_record_positions_at_record_start_line() {
let m = MockSource::new();
m.append(b"[1] a\n cont\n[2] b\n[3] c\n");
let mut idx = LineIndex::new();
idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
idx.extend_to_end(&m);
let mut v = Viewport::new(20, 5, "f".into());
v.goto_record(1, &m, &mut idx); assert_eq!(v.top_line(), 2);
}
#[test]
fn goto_record_in_line_per_record_mode_equals_goto_line() {
let m = MockSource::new();
m.append(b"a\nb\nc\n");
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(20, 5, "f".into());
v.goto_record(2, &m, &mut idx);
assert_eq!(v.top_line(), 2);
}
#[test]
fn goto_percent_50_lands_in_middle() {
let m = MockSource::new();
m.append(b"a\nb\nc\nd\ne\n"); let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(20, 5, "f".into());
v.goto_percent(50, &m, &mut idx);
assert_eq!(v.top_line(), 2); }
#[test]
fn goto_percent_100_lands_at_last_line() {
let m = MockSource::new();
m.append(b"a\nb\nc\n"); let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(20, 5, "f".into());
v.goto_percent(100, &m, &mut idx);
assert_eq!(v.top_line(), 2);
}
#[test]
fn goto_percent_0_lands_at_first_line() {
let m = MockSource::new();
m.append(b"a\nb\nc\n");
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(20, 5, "f".into());
v.goto_record(2, &m, &mut idx); assert_eq!(v.top_line(), 2);
v.goto_percent(0, &m, &mut idx);
assert_eq!(v.top_line(), 0);
}
#[test]
fn resize_updates_dimensions_and_render_opts() {
let (m, mut idx) = setup(b"1\n2\n");
let mut v = Viewport::new(10, 5, "f".into());
v.resize(40, 12);
assert_eq!(v.cols, 40);
assert_eq!(v.rows, 12);
assert_eq!(v.opts.cols, 40);
let _ = v.frame(&m, &mut idx);
}
#[test]
fn toggle_line_numbers_changes_gutter() {
let (m, mut idx) = setup(b"a\nb\nc\n");
let mut v = Viewport::new(10, 5, "f".into());
let frame_off = v.frame(&m, &mut idx);
v.toggle_line_numbers();
let frame_on = v.frame(&m, &mut idx);
assert_eq!(frame_off.body[0][0], Cell::Char { ch: 'a', width: 1 });
assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1 });
}
#[test]
fn toggle_chop_changes_wrap_mode() {
let (m, mut idx) = setup(b"abcdefghij\n");
let mut v = Viewport::new(4, 5, "f".into());
v.toggle_chop();
let frame = v.frame(&m, &mut idx);
assert_eq!(frame.body[0][..4],
[Cell::Char { ch: 'a', width: 1 }, Cell::Char { ch: 'b', width: 1 },
Cell::Char { ch: 'c', width: 1 }, Cell::Char { ch: 'd', width: 1 }]);
assert!(frame.body[1].iter().all(|c| matches!(c, Cell::Empty)));
}
#[test]
fn is_at_bottom_initially_only_when_source_fits() {
let (m, mut idx) = setup(b"a\nb\n"); let v = Viewport::new(10, 5, "f".into()); idx.extend_to_end(&m);
assert!(v.is_at_bottom(&idx), "small file fits in body, top is at bottom");
}
#[test]
fn is_at_bottom_false_when_top_and_more_lines_below() {
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);
assert!(!v.is_at_bottom(&idx), "top of 8-line file with body=4 is not at bottom");
}
#[test]
fn is_at_bottom_true_after_goto_bottom() {
let (m, mut idx) = setup(b"1\n2\n3\n4\n5\n6\n7\n8\n");
let mut v = Viewport::new(10, 5, "f".into());
v.goto_bottom(&m, &mut idx);
assert!(v.is_at_bottom(&idx));
}
#[test]
fn status_shows_follow_suffix_when_follow_mode_on() {
let (m, mut idx) = setup(b"a\nb\n");
let mut v = Viewport::new(20, 5, "f".into());
let frame_off = v.frame(&m, &mut idx);
assert!(!frame_off.status.contains("(F)"));
v.set_follow_mode(true);
let frame_on = v.frame(&m, &mut idx);
assert!(frame_on.status.contains("(F)"), "expected (F) in status, got: {}", frame_on.status);
}
#[test]
fn toggle_follow_flips_state() {
let mut v = Viewport::new(10, 5, "f".into());
assert!(!v.follow_mode());
v.toggle_follow();
assert!(v.follow_mode());
v.toggle_follow();
assert!(!v.follow_mode());
}
#[test]
fn status_shows_prettify_label_when_set() {
let (m, mut idx) = setup(b"a\n");
let mut v = Viewport::new(40, 5, "f".into());
let frame_off = v.frame(&m, &mut idx);
assert!(!frame_off.status.contains("[pretty"));
v.set_prettify_label(Some("json".into()));
let frame_on = v.frame(&m, &mut idx);
assert!(frame_on.status.contains("[pretty:json]"),
"expected [pretty:json] in status, got: {}", frame_on.status);
v.set_prettify_label(Some("json:err".into()));
let frame_err = v.frame(&m, &mut idx);
assert!(frame_err.status.contains("[pretty:json:err]"),
"expected [pretty:json:err] in status, got: {}", frame_err.status);
}
#[test]
fn status_shows_l_suffix_when_live_mode_on() {
let (m, mut idx) = setup(b"a\nb\n");
let mut v = Viewport::new(20, 5, "f".into());
let frame_off = v.frame(&m, &mut idx);
assert!(!frame_off.status.contains("(L)"));
v.set_live_mode(true);
let frame_on = v.frame(&m, &mut idx);
assert!(frame_on.status.contains("(L)"), "expected (L) in status, got: {}", frame_on.status);
}
#[test]
fn clamp_top_line_pulls_back_when_total_shrinks() {
let mut v = Viewport::new(20, 5, "f".into());
v.scroll_lines(0, &MockSource::new(), &mut LineIndex::new()); v.clamp_top_line(100); v.clamp_top_line(0); v.goto_top();
let (m, mut idx) = setup(b"only\n");
let _ = v.frame(&m, &mut idx);
}
fn simulate_growth_tick(
v: &mut Viewport,
src: &MockSource,
idx: &mut LineIndex,
) {
if !v.follow_mode() { return; }
let was_at_bottom = v.is_at_bottom(idx);
let lines_before = idx.line_count();
idx.notice_new_bytes(src);
if idx.line_count() != lines_before && was_at_bottom {
v.goto_bottom(src, idx);
}
}
#[test]
fn auto_scroll_engages_when_at_bottom() {
let m = MockSource::new();
m.append(b"1\n2\n3\n4\n"); let mut idx = LineIndex::new();
let mut v = Viewport::new(10, 5, "f".into());
v.set_follow_mode(true);
idx.extend_to_end(&m);
assert!(v.is_at_bottom(&idx));
let top_before = {
let f = v.frame(&m, &mut idx);
f.status.clone() };
let _ = top_before;
m.append(b"5\n6\n7\n8\n");
simulate_growth_tick(&mut v, &m, &mut idx);
assert!(v.is_at_bottom(&idx), "after auto-scroll, viewport should still be at bottom");
let frame = v.frame(&m, &mut idx);
let last_row = &frame.body[frame.body.len() - 1];
assert_eq!(last_row[0], Cell::Char { ch: '8', width: 1 });
}
#[test]
fn auto_scroll_suppressed_when_scrolled_up() {
let m = MockSource::new();
m.append(b"1\n2\n3\n4\n5\n6\n7\n8\n"); let mut idx = LineIndex::new();
let mut v = Viewport::new(10, 5, "f".into()); v.set_follow_mode(true);
idx.extend_to_end(&m);
v.goto_bottom(&m, &mut idx);
v.scroll_lines(-2, &m, &mut idx);
assert!(!v.is_at_bottom(&idx));
let frame_before = v.frame(&m, &mut idx);
let top_first_cell_before = frame_before.body[0][0].clone();
m.append(b"9\n10\n");
simulate_growth_tick(&mut v, &m, &mut idx);
let frame_after = v.frame(&m, &mut idx);
assert_eq!(frame_after.body[0][0], top_first_cell_before, "viewport moved despite being scrolled up");
}
#[test]
fn set_search_compiles_regex() {
let mut v = Viewport::new(10, 5, "f".into());
assert!(v.set_search("foo".into(), SearchDirection::Forward).is_ok());
assert!(v.search_active());
}
#[test]
fn set_search_rejects_bad_regex() {
let mut v = Viewport::new(10, 5, "f".into());
let err = v.set_search("[".into(), SearchDirection::Forward).unwrap_err();
assert!(!err.is_empty());
assert!(!v.search_active(), "no search should be set on error");
}
#[test]
fn search_step_forward_finds_match_after_top() {
let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
let mut v = Viewport::new(20, 5, "f".into());
v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
let found = v.search_repeat(&m, &mut idx, false);
assert!(found);
assert_eq!(v.top_line, 2);
}
#[test]
fn search_step_backward_finds_match_before_top() {
let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\nepsilon\n");
let mut v = Viewport::new(20, 5, "f".into());
v.scroll_lines(4, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Backward).unwrap();
let found = v.search_repeat(&m, &mut idx, false);
assert!(found);
assert_eq!(v.top_line, 0);
}
#[test]
fn search_wraps_at_end() {
let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
let mut v = Viewport::new(20, 5, "f".into());
v.scroll_lines(2, &m, &mut idx); v.set_search("alpha".into(), SearchDirection::Forward).unwrap();
let found = v.search_repeat(&m, &mut idx, false);
assert!(found, "search should wrap forward past EOF");
assert_eq!(v.top_line, 0);
}
#[test]
fn search_no_match_returns_false_and_does_not_move() {
let (m, mut idx) = setup(b"alpha\nbeta\ngamma\n");
let mut v = Viewport::new(20, 5, "f".into());
v.set_search("nowhere".into(), SearchDirection::Forward).unwrap();
let found = v.search_repeat(&m, &mut idx, false);
assert!(!found);
assert_eq!(v.top_line, 0);
}
#[test]
fn frame_records_highlight_ranges_for_matches() {
let (m, mut idx) = setup(b"alpha\nbeta\ngamma\ndelta\n");
let mut v = Viewport::new(20, 5, "f".into());
v.set_search("gamma".into(), SearchDirection::Forward).unwrap();
let frame = v.frame(&m, &mut idx);
assert_eq!(frame.row_styles[0], RowStyle::Normal);
assert!(frame.highlights[0].is_empty());
assert!(frame.highlights[1].is_empty());
assert_eq!(frame.highlights[2], vec![0..5]);
assert!(frame.highlights[3].is_empty());
}
#[test]
fn frame_highlights_substring_inside_a_row() {
let (m, mut idx) = setup(b"the alpha and the beta\nfoo\n");
let mut v = Viewport::new(40, 5, "f".into());
v.set_search("beta".into(), SearchDirection::Forward).unwrap();
let frame = v.frame(&m, &mut idx);
assert_eq!(frame.highlights[0], vec![18..22]);
assert!(frame.highlights[1].is_empty());
}
#[test]
fn search_highlight_with_filter_dim_keeps_row_dim() {
let (m, mut idx) = setup(b"alpha\nbeta\n");
let mut v = Viewport::new(20, 5, "f".into());
let fmt = crate::format::LogFormat::compile(
"simple",
r"^(?P<line>.+)$",
)
.unwrap();
let f = crate::filter::CompiledFilter::compile(
&fmt,
vec![crate::filter::FilterSpec::parse("line=alpha").unwrap()],
)
.unwrap();
v.set_filter(Some(f));
v.set_dim_mode(true);
v.set_search("beta".into(), SearchDirection::Forward).unwrap();
let frame = v.frame(&m, &mut idx);
assert_eq!(frame.row_styles[0], RowStyle::Normal);
assert_eq!(frame.row_styles[1], RowStyle::Dim);
assert_eq!(frame.highlights[1], vec![0..4]);
}
#[test]
fn grep_only_hides_non_matching_lines() {
use crate::grep::GrepPredicate;
let src = crate::source::MockSource::new();
src.append(b"keep this error\n");
src.append(b"drop this one\n");
src.append(b"another error line\n");
src.finish();
let mut idx = crate::line_index::LineIndex::new();
idx.extend_to_end(&src);
let mut v = Viewport::new(40, 5, "test".into());
v.set_grep(Some(GrepPredicate::compile(&["error".to_string()]).unwrap()));
v.extend_visible_lines(&idx, &src);
let frame = v.frame(&src, &mut idx);
let body_text: Vec<String> = frame.body.iter()
.map(|row| row.iter().filter_map(|c| match c {
crate::render::Cell::Char { ch, .. } => Some(*ch),
_ => None,
}).collect())
.collect();
assert!(body_text[0].contains("keep this error"));
assert!(body_text[1].contains("another error line"));
assert!(frame.status.contains("[grep]"));
}
#[test]
fn filter_and_grep_combine_with_and() {
use crate::grep::GrepPredicate;
let fmt = crate::format::LogFormat::compile(
"simple",
r"^(?P<level>\w+) (?P<msg>.+)$",
).unwrap();
let f = crate::filter::CompiledFilter::compile(
&fmt,
vec![crate::filter::FilterSpec::parse("level=ERROR").unwrap()],
).unwrap();
let g = GrepPredicate::compile(&["timeout".to_string()]).unwrap();
let src = crate::source::MockSource::new();
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();
let mut idx = crate::line_index::LineIndex::new();
idx.extend_to_end(&src);
let mut v = Viewport::new(80, 5, "test".into());
v.set_filter(Some(f));
v.set_grep(Some(g));
v.extend_visible_lines(&idx, &src);
assert_eq!(v.visible_lines(), &[0usize]);
}
#[test]
fn search_status_shows_pattern() {
let (m, mut idx) = setup(b"x\n");
let mut v = Viewport::new(20, 5, "f".into());
v.set_search("foo".into(), SearchDirection::Forward).unwrap();
let frame = v.frame(&m, &mut idx);
assert!(frame.status.contains("[/foo]"), "status: {}", frame.status);
}
#[test]
fn repeat_search_after_first_match_advances() {
let (m, mut idx) = setup(b"alpha\nfoo one\nbeta\nfoo two\ngamma\nfoo three\n");
let mut v = Viewport::new(40, 5, "f".into());
v.set_search("foo".into(), SearchDirection::Forward).unwrap();
assert!(v.search_repeat(&m, &mut idx, false));
assert_eq!(v.top_line, 1, "first foo");
v.set_search("foo".into(), SearchDirection::Forward).unwrap();
assert!(v.search_repeat(&m, &mut idx, false), "second search should still match");
assert_eq!(v.top_line, 3, "should advance to next foo");
}
#[test]
fn auto_scroll_paused_when_follow_off() {
let m = MockSource::new();
m.append(b"1\n2\n3\n4\n");
let mut idx = LineIndex::new();
let mut v = Viewport::new(10, 5, "f".into());
idx.extend_to_end(&m);
let frame_before = v.frame(&m, &mut idx);
let top_first_cell = frame_before.body[0][0].clone();
m.append(b"5\n6\n7\n8\n");
simulate_growth_tick(&mut v, &m, &mut idx);
let frame_after = v.frame(&m, &mut idx);
assert_eq!(frame_after.body[0][0], top_first_cell, "auto-scroll fired despite follow off");
}
#[test]
fn search_jumps_to_next_matching_record() {
let m = MockSource::new();
m.append(b"[1] alpha\n cont\n[2] bravo\n[3] charlie\n cont\n[4] delta\n");
let mut idx = LineIndex::new();
idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
idx.extend_to_end(&m);
let mut v = Viewport::new(40, 10, "f".into());
v.set_search("charlie".into(), SearchDirection::Forward).unwrap();
let hit = v.search_repeat(&m, &mut idx, false);
assert!(hit, "should find 'charlie' in record 2");
assert_eq!(v.top_line(), 3); }
#[test]
fn search_finds_cross_line_match_in_record_with_s_flag() {
let m = MockSource::new();
m.append(b"[1] head\n Renderer.php(214)\n[2] other line\n");
let mut idx = LineIndex::new();
idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
idx.extend_to_end(&m);
let mut v = Viewport::new(40, 10, "f".into());
v.set_search(r"(?s)head.*Renderer".into(), SearchDirection::Forward).unwrap();
let hit = v.search_repeat(&m, &mut idx, false);
assert!(hit, "should match across \\n inside record 0 with (?s)");
assert_eq!(v.top_line(), 0);
}
#[test]
fn search_repeat_with_no_match_returns_false() {
let m = MockSource::new();
m.append(b"[1] alpha\n[2] bravo\n");
let mut idx = LineIndex::new();
idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
idx.extend_to_end(&m);
let mut v = Viewport::new(40, 10, "f".into());
v.set_search("nonexistent".into(), SearchDirection::Forward).unwrap();
let hit = v.search_repeat(&m, &mut idx, false);
assert!(!hit);
}
#[test]
fn filter_hide_mode_drops_all_lines_of_nonmatching_record() {
let m = MockSource::new();
m.append(b"[1] head\n cont a\n[2] head\n cont b\n");
let mut idx = LineIndex::new();
idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
idx.extend_to_end(&m);
let grep = GrepPredicate::compile(&["cont a".to_string()]).unwrap();
let mut v = Viewport::new(40, 10, "f".into());
v.set_grep(Some(grep));
v.extend_visible_lines(&idx, &m);
assert_eq!(v.visible_lines(), &[0usize, 1]);
}
#[test]
fn grep_matches_across_record_newlines_in_records_mode() {
let m = MockSource::new();
m.append(b"[1] head\n Renderer.php\n[2] other\n body\n");
let mut idx = LineIndex::new();
idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
idx.extend_to_end(&m);
let grep = GrepPredicate::compile(&[r"(?s)head.*Renderer".to_string()]).unwrap();
let mut v = Viewport::new(40, 10, "f".into());
v.set_grep(Some(grep));
v.extend_visible_lines(&idx, &m);
assert_eq!(v.visible_lines(), &[0usize, 1]);
}
#[test]
fn dim_mode_keeps_all_lines_visible_dims_nonmatching_records() {
let m = MockSource::new();
m.append(b"[1] head\n cont\n[2] other\n cont\n");
let mut idx = LineIndex::new();
idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
idx.extend_to_end(&m);
let grep = GrepPredicate::compile(&[r"\[1\]".to_string()]).unwrap();
let mut v = Viewport::new(40, 10, "f".into());
v.set_grep(Some(grep));
v.set_dim_mode(true);
v.extend_visible_lines(&idx, &m);
assert_eq!(v.visible_lines(), &[] as &[usize]);
assert!(!v.should_dim_line(0, &idx, &m));
assert!(!v.should_dim_line(1, &idx, &m));
assert!(v.should_dim_line(2, &idx, &m));
assert!(v.should_dim_line(3, &idx, &m));
}
#[test]
fn status_unchanged_when_records_inactive() {
let (m, mut idx) = setup(b"a\nb\nc\n");
let v = Viewport::new(20, 5, "f".into());
let frame = v.frame(&m, &mut idx);
let status = &frame.status;
assert!(status.contains("1-3/3"), "got: {status}");
assert!(!status.contains("L1"), "no L block in line-mode: {status}");
assert!(!status.contains("R1"), "no R block in line-mode: {status}");
}
#[test]
fn status_dual_readout_when_records_active() {
let m = MockSource::new();
m.append(b"[1] a\n cont\n[2] b\n");
m.finish();
let mut idx = LineIndex::new();
idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
idx.extend_to_end(&m);
let v = Viewport::new(20, 5, "f".into());
let frame = v.frame(&m, &mut idx);
let status = &frame.status;
assert!(status.contains("L1-3/3"), "lines block missing or wrong: {status}");
assert!(status.contains("R1-2/2"), "records block missing or wrong: {status}");
}
}