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;
const MAX_RECONSTRUCT_LINES: usize = 256;
fn reconstruct_render_state(
src: &dyn Source,
idx: &crate::line_index::LineIndex,
target_line: usize,
) -> crate::render::RenderState {
let start = target_line.saturating_sub(MAX_RECONSTRUCT_LINES);
let mut state = crate::render::RenderState::default();
for line_no in start..target_line {
let range = idx.line_range(line_no, src);
let raw = src.bytes(range);
for &b in raw.as_ref() {
let _ = crate::ansi::step(
&mut state.parse,
&mut state.style,
&mut state.hyperlink,
b,
);
}
}
state
}
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 line_is_blank(bytes: &[u8]) -> bool {
bytes.iter().all(|&b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n')
}
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, Copy, PartialEq, Eq, Default)]
pub enum CaseMode {
#[default]
Sensitive,
Smart,
Insensitive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum QuitAtEof {
#[default]
Off,
Second,
First,
}
impl CaseMode {
pub fn apply_to_pattern(self, pattern: &str) -> String {
match self {
CaseMode::Sensitive => pattern.to_string(),
CaseMode::Insensitive => format!("(?i){pattern}"),
CaseMode::Smart => {
if pattern.chars().any(|c| c.is_uppercase()) {
pattern.to_string()
} else {
format!("(?i){pattern}")
}
}
}
}
}
#[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 status_style: crate::ansi::Style,
pub raw_rows: Vec<Option<Vec<u8>>>,
}
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>,
format_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>,
hex_mode: bool,
#[cfg(feature = "image")]
image: Option<image::RgbaImage>,
image_mode: bool,
image_no_color: bool,
#[cfg_attr(not(feature = "image"), allow(dead_code))]
image_format: String,
#[cfg(feature = "image")]
image_style: crate::image_render::AsciiStyle,
#[cfg_attr(not(feature = "image"), allow(dead_code))]
image_width: Option<usize>,
hex_group_size: usize,
prompt: Option<crate::prompt::ParsedPrompt>,
preprocess_failure: Option<String>,
file_index: Option<(usize, usize)>,
tag_active: Option<(String, usize, usize)>, ansi_mode: crate::render::AnsiMode,
status_style: crate::ansi::Style,
status_flash: Option<(String, u32)>,
ticks_since_growth: u32,
case_mode: CaseMode,
hilite_search: bool,
quit_at_eof: QuitAtEof,
eof_hits: u8,
squeeze_blanks: bool,
header_lines: usize,
header_cols: usize,
page_size: Option<u16>,
render_state: crate::render::RenderState,
render_state_for: usize,
}
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,
format_label: None,
filter: None,
grep: None,
dim_mode: false,
visible_lines: Vec::new(),
visible_scanned: 0,
search: None,
display: None,
hex_mode: false,
#[cfg(feature = "image")]
image: None,
image_mode: false,
image_no_color: false,
image_format: String::new(),
#[cfg(feature = "image")]
image_style: crate::image_render::AsciiStyle::Ramp,
image_width: None,
hex_group_size: 2,
prompt: None,
preprocess_failure: None,
file_index: None,
tag_active: None,
ansi_mode: crate::render::AnsiMode::Strict,
status_style: crate::ansi::Style { reverse: true, ..Default::default() },
status_flash: None,
ticks_since_growth: 0,
case_mode: CaseMode::default(),
hilite_search: true,
quit_at_eof: QuitAtEof::default(),
eof_hits: 0,
squeeze_blanks: false,
header_lines: 0,
header_cols: 0,
page_size: None,
render_state: crate::render::RenderState::default(),
render_state_for: usize::MAX,
}
}
pub fn case_mode(&self) -> CaseMode { self.case_mode }
pub fn hilite_search(&self) -> bool { self.hilite_search }
pub fn set_hilite_search(&mut self, on: bool) { self.hilite_search = on; }
pub fn set_quit_at_eof(&mut self, mode: QuitAtEof) {
self.quit_at_eof = mode;
self.eof_hits = 0;
}
pub fn set_squeeze_blanks(&mut self, on: bool) { self.squeeze_blanks = on; }
pub fn squeeze_blanks(&self) -> bool { self.squeeze_blanks }
pub fn set_header(&mut self, lines: usize, cols: usize) {
self.header_lines = lines;
self.header_cols = cols;
if self.top_line < self.header_lines {
self.top_line = self.header_lines;
}
}
pub fn header_lines(&self) -> usize { self.header_lines }
pub fn header_cols(&self) -> usize { self.header_cols }
pub fn set_page_size(&mut self, n: Option<u16>) { self.page_size = n; }
pub fn page_size(&self) -> Option<u16> { self.page_size }
pub fn note_motion_for_eof(&mut self, forward: bool, src: &dyn Source, idx: &LineIndex) -> bool {
match self.quit_at_eof {
QuitAtEof::Off => false,
QuitAtEof::First if forward && self.is_at_bottom(src, idx) => true,
QuitAtEof::Second if forward && self.is_at_bottom(src, idx) => {
self.eof_hits = self.eof_hits.saturating_add(1);
self.eof_hits >= 2
}
_ => {
if !forward { self.eof_hits = 0; }
false
}
}
}
pub fn set_case_mode(&mut self, mode: CaseMode) {
self.case_mode = mode;
if let Some(s) = self.search.clone() {
let _ = self.set_search(s.raw, s.direction);
}
}
pub fn set_status_style(&mut self, style: crate::ansi::Style) {
self.status_style = style;
}
pub fn status_style(&self) -> crate::ansi::Style {
self.status_style
}
pub fn flash(&mut self, msg: impl Into<String>, ticks: u32) {
self.status_flash = Some((msg.into(), ticks));
}
pub fn tick_flash(&mut self) {
if let Some((_, n)) = &mut self.status_flash {
*n = n.saturating_sub(1);
if *n == 0 {
self.status_flash = None;
}
}
}
pub fn note_growth(&mut self) {
self.ticks_since_growth = 0;
}
pub fn tick_idle(&mut self) {
self.ticks_since_growth = self.ticks_since_growth.saturating_add(1);
}
pub fn is_idle(&self) -> bool {
self.ticks_since_growth >= 20
}
pub fn set_display(&mut self, renderer: Option<crate::format::DisplayRenderer>) {
self.display = renderer;
}
pub fn set_hex_mode(&mut self, on: bool) {
self.hex_mode = on;
}
pub fn hex_mode(&self) -> bool {
self.hex_mode
}
#[cfg(feature = "image")]
pub fn set_image(&mut self, img: image::RgbaImage, format: &str, style: crate::image_render::AsciiStyle, width: Option<usize>) {
self.image = Some(img);
self.image_format = format.to_string();
self.image_style = style;
self.image_width = width;
self.image_mode = true;
self.top_line = 0;
self.top_row = 0;
}
pub fn set_image_no_color(&mut self, on: bool) { self.image_no_color = on; }
pub fn image_mode(&self) -> bool { self.image_mode }
#[cfg(feature = "image")]
fn image_cols(&self) -> u16 {
self.image_width.map(|w| w.clamp(1, u16::MAX as usize) as u16).unwrap_or(self.cols.max(1))
}
#[cfg(feature = "image")]
pub fn image_total_rows(&self) -> usize {
match &self.image {
Some(img) => {
let (w, h) = img.dimensions();
crate::image_render::output_rows(w, h, self.image_cols(), self.image_style)
}
None => 0,
}
}
#[cfg(feature = "image")]
pub fn is_at_bottom_image(&self) -> bool {
let body = self.body_rows() as usize;
self.top_line + body >= self.image_total_rows()
}
pub fn set_hex_group_size(&mut self, bytes_per_group: usize) {
if matches!(bytes_per_group, 1 | 2 | 4 | 8 | 16) {
self.hex_group_size = bytes_per_group;
}
}
pub fn hex_group_size(&self) -> usize {
self.hex_group_size
}
pub fn set_prompt(&mut self, prompt: Option<crate::prompt::ParsedPrompt>) {
self.prompt = prompt;
}
pub fn set_preprocess_failure(&mut self, msg: Option<String>) {
self.preprocess_failure = msg;
}
pub fn set_file_index(&mut self, current: usize, total: usize) {
self.file_index = if total > 1 {
Some((current, total))
} else {
None
};
}
pub fn set_tag_active(&mut self, info: Option<(String, usize, usize)>) {
self.tag_active = info;
}
pub fn set_ansi_mode(&mut self, mode: crate::render::AnsiMode) {
self.ansi_mode = mode;
}
pub fn ansi_mode(&self) -> crate::render::AnsiMode {
self.ansi_mode
}
pub fn set_source_label(&mut self, label: String) {
self.source_label = label;
}
pub fn source_label_clone(&self) -> String {
self.source_label.clone()
}
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 compiled = self.case_mode.apply_to_pattern(&raw);
let regex = Regex::new(&compiled).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 = idx.record_bytes_stripped(r, src);
let text = String::from_utf8_lossy(&bytes);
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 display = self.line_display_bytes(src, idx, line_n);
let bytes = crate::ansi::strip_sgr(&display);
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 bytes = idx.line_bytes_stripped(line_n, src);
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 {
if self.record_passes(idx, src, r) {
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 record_passes(&self, idx: &LineIndex, src: &dyn Source, r: usize) -> bool {
let bytes = if self.filter.is_some() || self.grep.is_some() {
Some(idx.record_bytes_stripped(r, src))
} else {
None
};
let filter_ok = match self.filter.as_ref() {
Some(f) => matches!(
f.evaluate_record(bytes.as_deref().unwrap()),
FilterMatch::Matched,
),
None => true,
};
let grep_ok = match self.grep.as_ref() {
Some(g) => g.matches(bytes.as_deref().unwrap()),
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);
!self.record_passes(idx, src, r)
} else {
let bytes = idx.line_bytes_stripped(line_n, src);
!self.line_passes(&bytes)
}
}
fn bottom_visible_line(&self, idx: &LineIndex) -> usize {
let body_rows = self.body_rows() as usize;
if self.hide_mode() && !self.visible_lines.is_empty() {
let cur = self
.visible_lines
.iter()
.position(|&l| l >= self.top_line)
.unwrap_or(self.visible_lines.len().saturating_sub(1));
let last_pos = (cur + body_rows.saturating_sub(1)).min(self.visible_lines.len() - 1);
return self.visible_lines[last_pos];
}
let total = idx.line_count();
if total == 0 {
return self.top_line;
}
(self.top_line + body_rows.saturating_sub(1)).min(total - 1)
}
pub fn body_rows(&self) -> u16 { self.rows.saturating_sub(1).max(1) }
pub fn follow_mode(&self) -> bool { self.follow_mode }
pub fn suspend_follow_if(&mut self, flag: bool) {
if flag {
self.follow_mode = false;
}
}
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 set_format_label(&mut self, label: Option<String>) {
self.format_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, src: &dyn Source, idx: &LineIndex) -> bool {
#[cfg(feature = "image")]
if self.image_mode {
return self.is_at_bottom_image();
}
if self.hide_mode() {
(self.top_line, self.top_row) >= self.hide_bottom_anchor(src, idx)
} else {
(self.top_line, self.top_row) >= self.bottom_anchor(src, idx)
}
}
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.mode = self.ansi_mode;
o
}
pub fn frame(&mut self, src: &dyn Source, idx: &mut LineIndex) -> Frame {
#[cfg(feature = "image")]
if self.image_mode {
return self.frame_image();
}
if self.hex_mode {
return self.frame_hex(src);
}
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 render_state = if self.ansi_mode == crate::render::AnsiMode::Interpret {
reconstruct_render_state(src, idx, self.top_line)
} else {
crate::render::RenderState::default()
};
self.render_state = render_state.clone();
self.render_state_for = self.top_line;
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 mut raw_rows: Vec<Option<Vec<u8>>> = Vec::with_capacity(body_rows);
let raw_passthrough = self.ansi_mode == crate::render::AnsiMode::Raw;
let hide = self.hide_mode();
let total_lines = idx.line_count();
let header_rows = if !hide && !raw_passthrough {
self.header_lines.min(body_rows).min(total_lines)
} else {
0
};
if header_rows > 0 {
for hl in 0..header_rows {
let raw = src.bytes(idx.line_range(hl, 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, None);
let mut content_row = rows.into_iter().next().unwrap_or_else(|| {
let mut v = Vec::with_capacity(self.cols as usize);
while v.len() < self.cols as usize { v.push(Cell::Empty); }
v
});
let mut full: Vec<Cell> = Vec::with_capacity(self.cols as usize);
if gutter > 0 {
let label = format!("{:>width$} ", hl + 1, width = (gutter as usize - 1));
for c in label.chars() {
full.push(Cell::Char {
ch: c,
width: 1,
style: crate::ansi::Style::default(),
hyperlink: None,
});
}
}
full.append(&mut content_row);
body.push(full);
row_styles.push(RowStyle::Normal);
highlights.push(Vec::new());
raw_rows.push(None);
}
}
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.max(self.header_lines)
};
let mut skip = if header_rows > 0 { 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());
raw_rows.push(None);
line_n += 1;
continue;
}
let raw = src.bytes(idx.line_range(line_n, src));
if self.squeeze_blanks && line_is_blank(&raw) {
let prev_blank = line_n.checked_sub(1).is_some_and(|p| {
let prev = src.bytes(idx.line_range(p, src));
line_is_blank(&prev)
});
if prev_blank {
line_n += 1;
continue;
}
}
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 state_arg = if self.ansi_mode == crate::render::AnsiMode::Interpret {
Some(&mut render_state)
} else {
None
};
let rows = render_line(&display_bytes, &r_opts, state_arg);
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
};
let mut first_emitted_for_this_line = true;
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, style: crate::ansi::Style::default(), hyperlink: None });
}
}
full.append(&mut content_row);
let row_highlights = if let (true, Some(s)) = (self.hilite_search, self.search.as_ref()) {
find_row_highlights(&full, &s.regex)
} else {
Vec::new()
};
body.push(full);
row_styles.push(style);
highlights.push(row_highlights);
if raw_passthrough {
if first_emitted_for_this_line {
raw_rows.push(Some(raw.to_vec()));
first_emitted_for_this_line = false;
} else {
raw_rows.push(Some(Vec::new()));
}
} else {
raw_rows.push(None);
}
}
skip = 0;
if hide {
hide_pos += 1;
line_n = self.visible_lines.get(hide_pos).copied().unwrap_or(total_lines);
} else {
line_n += 1;
}
}
self.render_state_for = usize::MAX;
let status = self.format_status(idx, src);
Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
}
fn format_status(&self, idx: &LineIndex, src: &dyn Source) -> String {
if let Some(p) = self.prompt.as_ref() {
let ctx = self.build_prompt_context(idx, src);
return p.render(&ctx);
}
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 bottom_line = self.bottom_visible_line(idx);
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_line) + 1;
let (rec_top, rec_bottom) = if rec_bottom < rec_top {
(rec_top, rec_top)
} else {
(rec_top, rec_bottom)
};
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 label_with_index = match self.file_index {
Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
None => self.source_label.clone(),
};
let mut s = format!("{} {}", label_with_index, 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)), None)
} 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 { " [hide]" });
}
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 {
if let Some((msg, _)) = self.status_flash.as_ref() {
s.push_str(" ");
s.push_str(msg);
} else if self.is_idle() {
s.push_str(" (F idle)");
} else {
s.push_str(" (F)");
}
}
if let Some(msg) = self.preprocess_failure.as_ref() {
let first_line = msg.lines().next().unwrap_or("");
s.push_str(&format!(" [preprocess-failed: {}]", first_line));
}
let tag_suffix = match &self.tag_active {
Some((name, cur, total)) if *total > 1 => {
format!(" [tag: {name} ({cur}/{total})]")
}
_ => String::new(),
};
s.push_str(&tag_suffix);
let used = s.chars().count();
let hint = ":help";
if (self.cols as usize) > used + 1 + hint.chars().count() {
let pad = self.cols as usize - used - hint.chars().count();
s.push_str(&" ".repeat(pad));
s.push_str(hint);
} else {
s.push(' ');
s.push_str(hint);
}
s
}
fn build_prompt_context(&self, idx: &LineIndex, src: &dyn Source) -> crate::prompt::PromptContext {
use crate::prompt::PromptContext;
let body_rows = self.body_rows() as usize;
let total = idx.line_count();
let top = self.top_line + 1;
let bottom = (self.top_line + body_rows).min(total.max(1));
let pct = (bottom * 100).checked_div(total).unwrap_or(0);
let bottom_line = self.bottom_visible_line(idx);
let records_mode = idx.records_mode();
let (rec_top, rec_bottom, rec_total) = if records_mode {
let rt = idx.line_to_record(self.top_line) + 1;
let rb_raw = idx.line_to_record(bottom_line) + 1;
let rb = if rb_raw < rt { rt } else { rb_raw };
(rt, rb, idx.record_count())
} else {
(0, 0, 0)
};
let wrap_offset = 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)), None)
} else { 1 };
format!("+{}/{}", self.top_row, line_rows)
} else {
String::new()
};
let format_tag = self.format_label.as_ref()
.map(|n| format!(" [{}]", n))
.unwrap_or_default();
let filter_tag = self.filter.as_ref()
.map(|f| format!(" [{}]", f.format_name))
.unwrap_or_default();
let grep_tag = if self.grep.is_some() { " [grep]".to_string() } else { String::new() };
let hide_tag = if self.filter.is_some() || self.grep.is_some() {
if self.dim_mode { " [dim]".to_string() } else { " [hide]".to_string() }
} else {
String::new()
};
let search_tag = self.search.as_ref()
.map(|s| {
let p = if matches!(s.direction, SearchDirection::Forward) { "/" } else { "?" };
format!(" [{}{}]", p, s.raw)
})
.unwrap_or_default();
let pretty_tag = self.prettify_label.as_ref()
.map(|l| format!(" [pretty:{l}]"))
.unwrap_or_default();
let live_tag = if self.live_mode { " (L)".to_string() } else { String::new() };
let follow_tag = if self.follow_mode { " (F)".to_string() } else { String::new() };
let preprocess_failed_tag = self.preprocess_failure.as_ref()
.map(|msg| {
let first_line = msg.lines().next().unwrap_or("");
format!(" [preprocess-failed: {}]", first_line)
})
.unwrap_or_default();
let file_index_tag = match self.file_index {
Some((current, total)) => format!(" [{}/{}]", current + 1, total),
None => String::new(),
};
let tag_tag = match &self.tag_active {
Some((name, cur, total)) if *total > 1 => {
format!(" [tag: {name} ({cur}/{total})]")
}
_ => String::new(),
};
PromptContext {
label: self.source_label.clone(),
top,
bottom,
total,
pct: pct.min(100) as u8,
rec_top,
rec_bottom,
rec_total,
records_mode,
wrap_offset,
format_tag,
filter_tag,
grep_tag,
hide_tag,
search_tag,
pretty_tag,
live_tag,
follow_tag,
preprocess_failed_tag,
file_index_tag,
tag_tag,
}
}
fn frame_hex(&self, src: &dyn Source) -> Frame {
use crate::hex::format_hex_row;
use crate::render::{render_line, Cell, RenderOpts};
let body_rows = self.rows.saturating_sub(1) as usize;
let total_bytes = src.len();
let total_hex_rows = total_bytes.div_ceil(16);
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 opts = RenderOpts { cols: self.cols, wrap: false, tab_width: 1, mode: crate::render::AnsiMode::Strict, rscroll_char: None, word_wrap: false };
for row_idx in 0..body_rows {
let hex_row = self.top_line + row_idx;
if hex_row >= total_hex_rows {
body.push(vec![Cell::Empty; self.cols as usize]);
} else {
let offset = hex_row * 16;
let end = (offset + 16).min(total_bytes);
let bytes_cow = src.bytes(offset..end);
let text = format_hex_row(offset, &bytes_cow, self.hex_group_size);
let rows = render_line(text.as_bytes(), &opts, None);
body.push(rows.into_iter().next().unwrap_or_else(|| {
vec![Cell::Empty; self.cols as usize]
}));
}
row_styles.push(RowStyle::Normal);
highlights.push(Vec::new());
}
let status = self.format_status_hex(src);
let raw_rows = vec![None; body.len()];
Frame { body, row_styles, highlights, status, status_style: self.status_style, raw_rows }
}
fn format_status_hex(&self, src: &dyn Source) -> String {
let total_bytes = src.len();
let body_rows = self.rows.saturating_sub(1) as usize;
let top_byte = self.top_line * 16;
let bottom_byte = ((self.top_line + body_rows) * 16).min(total_bytes);
let pct = (bottom_byte * 100).checked_div(total_bytes).unwrap_or(0);
let label_with_index = match self.file_index {
Some((current, total)) => format!("{} [{}/{}]", self.source_label, current + 1, total),
None => self.source_label.clone(),
};
let tag_suffix = match &self.tag_active {
Some((name, cur, total)) if *total > 1 => {
format!(" [tag: {name} ({cur}/{total})]")
}
_ => String::new(),
};
format!(
"{} off {}-{}/{} {}% [hex]{}",
label_with_index, top_byte, bottom_byte, total_bytes, pct, tag_suffix
)
}
#[cfg(feature = "image")]
fn frame_image(&self) -> Frame {
use crate::render::Cell;
let body_rows = self.body_rows() as usize;
let cols = self.cols as usize;
let img = match &self.image {
Some(i) => i,
None => {
let body = vec![vec![Cell::Empty; cols]; body_rows];
return Frame {
body,
row_styles: vec![RowStyle::Normal; body_rows],
highlights: vec![Vec::new(); body_rows],
status: self.image_format.clone(),
status_style: self.status_style,
raw_rows: vec![None; body_rows],
};
}
};
let color = !self.image_no_color;
let grid = crate::image_render::render_image(img, self.image_cols(), self.image_style, color);
let mut body: Vec<Vec<Cell>> = Vec::with_capacity(body_rows);
for r in 0..body_rows {
let gi = self.top_line + r;
if gi < grid.len() {
let mut row = grid[gi].clone();
row.truncate(cols);
while row.len() < cols { row.push(Cell::Empty); }
body.push(row);
} else {
body.push(vec![Cell::Empty; cols]);
}
}
let status = self.format_status_image(grid.len());
Frame {
body,
row_styles: vec![RowStyle::Normal; body_rows],
highlights: vec![Vec::new(); body_rows],
status,
status_style: self.status_style,
raw_rows: vec![None; body_rows],
}
}
#[cfg(feature = "image")]
fn format_status_image(&self, total_rows: usize) -> String {
let body = self.body_rows() as usize;
let top = self.top_line + 1;
let bottom = (self.top_line + body).min(total_rows.max(1));
let dims = self.image.as_ref().map(|i| { let (w, h) = i.dimensions(); format!("{w}×{h}") }).unwrap_or_default();
format!("{} {} {} rows {}-{}/{}", self.source_label, dims, self.image_format, top, bottom, total_rows)
}
pub fn scroll_logical_lines(&mut self, delta: i64, src: &dyn Source, idx: &mut LineIndex) {
if delta == 0 { return; }
#[cfg(feature = "image")]
if self.image_mode {
self.scroll_lines(delta, src, idx);
return;
}
if self.hide_mode() {
self.extend_visible_lines(idx, src);
let n = self.visible_lines.len();
if n == 0 {
self.top_line = 0;
self.top_row = 0;
return;
}
let vi = self
.visible_lines
.iter()
.position(|&l| l >= self.top_line)
.unwrap_or(n - 1);
if delta > 0 {
let target = (vi + delta as usize).min(n - 1);
self.top_line = self.visible_lines[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.visible_lines[vi.saturating_sub(extra_back)];
self.top_row = 0;
}
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; }
#[cfg(feature = "image")]
if self.image_mode {
let total = self.image_total_rows();
let body = self.body_rows() as usize;
let max_top = total.saturating_sub(body);
let next = (self.top_line as i64 + delta).clamp(0, max_top as i64);
self.top_line = next as usize;
self.top_row = 0;
return;
}
if self.hide_mode() {
self.extend_visible_lines(idx, src);
let n = self.visible_lines.len();
if n == 0 {
self.top_line = 0;
self.top_row = 0;
return;
}
let mut vi = self
.visible_lines
.iter()
.position(|&l| l >= self.top_line)
.unwrap_or(n - 1);
if self.visible_lines[vi] != self.top_line {
self.top_row = 0;
}
self.top_line = self.visible_lines[vi];
let r_opts = self.render_opts(self.gutter_width(idx));
if delta > 0 {
let mut remaining = delta as usize;
while remaining > 0 {
let line = self.visible_lines[vi];
let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
if self.top_row + 1 < rows {
self.top_row += 1;
} else if vi + 1 < n {
self.top_row = 0;
vi += 1;
self.top_line = self.visible_lines[vi];
} else {
break;
}
remaining -= 1;
}
let anchor = self.hide_bottom_anchor(src, idx);
if (self.top_line, self.top_row) > anchor {
self.top_line = anchor.0;
self.top_row = anchor.1;
}
} else {
let mut remaining = (-delta) as usize;
while remaining > 0 {
if self.top_row > 0 {
self.top_row -= 1;
} else if vi > 0 {
vi -= 1;
self.top_line = self.visible_lines[vi];
let line = self.visible_lines[vi];
let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
self.top_row = rows.saturating_sub(1);
} else {
break;
}
remaining -= 1;
}
}
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)), None);
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;
}
if idx.scanned_through() >= src.len() {
let anchor = self.bottom_anchor(src, idx);
if (self.top_line, self.top_row) > anchor {
self.top_line = anchor.0;
self.top_row = anchor.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)), None);
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.page_size
.map(|p| p as i64)
.unwrap_or_else(|| 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.page_size
.map(|p| p as i64)
.unwrap_or_else(|| 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;
}
fn bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
let body = self.body_rows() as usize;
let total = idx.line_count();
if total == 0 || body == 0 {
return (0, 0);
}
let r_opts = self.render_opts(self.gutter_width(idx));
let mut remaining = body;
let mut line = total - 1;
loop {
let bytes = self.line_display_bytes(src, idx, line);
let line_rows = count_rows(&bytes, &r_opts, None).max(1);
if line_rows >= remaining {
return (line, line_rows - remaining);
}
remaining -= line_rows;
if line == 0 {
return (0, 0);
}
line -= 1;
}
}
fn hide_bottom_anchor(&self, src: &dyn Source, idx: &LineIndex) -> (usize, usize) {
let body = self.body_rows() as usize;
let n = self.visible_lines.len();
if n == 0 || body == 0 {
return (0, 0);
}
let r_opts = self.render_opts(self.gutter_width(idx));
let mut remaining = body;
let mut vi = n - 1;
loop {
let line = self.visible_lines[vi];
let rows = count_rows(&self.line_display_bytes(src, idx, line), &r_opts, None).max(1);
if rows >= remaining {
return (line, rows - remaining);
}
remaining -= rows;
if vi == 0 {
return (self.visible_lines[0], 0);
}
vi -= 1;
}
}
pub fn goto_bottom(&mut self, src: &dyn Source, idx: &mut LineIndex) {
#[cfg(feature = "image")]
if self.image_mode {
let body = self.body_rows() as usize;
self.top_line = self.image_total_rows().saturating_sub(body);
self.top_row = 0;
return;
}
idx.extend_to_end(src);
if self.hide_mode() {
self.extend_visible_lines(idx, src);
let (line, row) = self.hide_bottom_anchor(src, idx);
self.top_line = line;
self.top_row = row;
} else {
let (line, row) = self.bottom_anchor(src, idx);
self.top_line = line;
self.top_row = row;
}
}
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 mut 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, style: crate::ansi::Style::default(), hyperlink: None });
assert_eq!(frame.body[3][0], Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
}
#[test]
fn scroll_down_advances_top_line() {
let (m, mut idx) = setup(b"a\nb\nc\nd\ne\nf\ng\nh\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\nd\ne\nf\ng\nh\n");
let mut v = Viewport::new(10, 5, "test".into());
v.scroll_lines(50, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (4, 0));
assert!(v.is_at_bottom(&m, &idx));
}
#[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');
content.extend_from_slice(&[b'C'; 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'; 60]);
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 the bottom anchor row");
v.scroll_lines(5, &m, &mut idx);
assert_eq!((v.top_line, v.top_row), (1, 2), "clamped at the bottom anchor");
}
#[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"a\nb\nc\nd\ne\nf\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 mut 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\n9\n10\n11\n12\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);
}
#[cfg(feature = "image")]
#[test]
fn image_mode_frame_renders_and_scrolls() {
use image::{Rgba, RgbaImage};
let img = RgbaImage::from_pixel(20, 200, Rgba([255, 255, 255, 255]));
let mut v = Viewport::new(20, 6, "cat.png".into()); v.set_image(img, "png", crate::image_render::AsciiStyle::Ramp, Some(20));
assert!(v.image_mode());
let total = v.image_total_rows();
assert!(total > 5, "tall image should exceed the body");
assert!(!v.is_at_bottom_image(), "starts at top");
let mut idx = LineIndex::new();
let m = MockSource::new();
let frame = v.frame(&m, &mut idx);
assert_eq!(frame.body.len(), 5);
v.goto_bottom(&m, &mut idx);
assert!(v.is_at_bottom_image());
}
#[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, style: crate::ansi::Style::default(), hyperlink: None });
assert_ne!(frame_on.body[0][0], Cell::Char { ch: 'a', width: 1, style: crate::ansi::Style::default(), hyperlink: None });
}
#[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, style: crate::ansi::Style::default(), hyperlink: None },
Cell::Char { ch: 'b', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
Cell::Char { ch: 'c', width: 1, style: crate::ansi::Style::default(), hyperlink: None },
Cell::Char { ch: 'd', width: 1, style: crate::ansi::Style::default(), hyperlink: None }]);
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(&m, &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(&m, &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(&m, &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 idle_indicator_kicks_in_at_threshold() {
let (m, mut idx) = setup(b"a\nb\n");
let mut v = Viewport::new(20, 5, "f".into());
v.set_follow_mode(true);
for _ in 0..19 { v.tick_idle(); }
let f1 = v.frame(&m, &mut idx);
assert!(f1.status.contains("(F)"));
assert!(!f1.status.contains("idle"));
v.tick_idle();
let f2 = v.frame(&m, &mut idx);
assert!(f2.status.contains("(F idle)"), "{}", f2.status);
}
#[test]
fn note_growth_resets_idle() {
let (m, mut idx) = setup(b"a\nb\n");
let mut v = Viewport::new(20, 5, "f".into());
v.set_follow_mode(true);
for _ in 0..25 { v.tick_idle(); }
assert!(v.is_idle());
v.note_growth();
assert!(!v.is_idle());
let f = v.frame(&m, &mut idx);
assert!(!f.status.contains("idle"));
}
#[test]
fn qae_off_never_quits_even_at_bottom() {
let (m, mut idx) = setup(b"a\n");
let mut v = Viewport::new(20, 5, "f".into());
v.set_quit_at_eof(QuitAtEof::Off);
v.goto_bottom(&m, &mut idx);
assert!(!v.note_motion_for_eof(true, &m, &idx));
}
#[test]
fn qae_first_quits_immediately_at_bottom() {
let (m, mut idx) = setup(b"a\n");
let mut v = Viewport::new(20, 5, "f".into());
v.set_quit_at_eof(QuitAtEof::First);
v.goto_bottom(&m, &mut idx);
assert!(v.note_motion_for_eof(true, &m, &idx));
}
#[test]
fn qae_first_only_quits_at_eof_not_mid_file() {
let mut content = Vec::new();
for _ in 0..50 { content.extend_from_slice(b"x\n"); }
let (m, mut idx) = setup(&content);
idx.extend_to_end(&m); let mut v = Viewport::new(20, 5, "f".into());
v.set_quit_at_eof(QuitAtEof::First);
assert!(!v.is_at_bottom(&m, &idx));
assert!(!v.note_motion_for_eof(true, &m, &idx));
}
#[test]
fn qae_second_quits_on_second_hit() {
let (m, mut idx) = setup(b"a\n");
let mut v = Viewport::new(20, 5, "f".into());
v.set_quit_at_eof(QuitAtEof::Second);
v.goto_bottom(&m, &mut idx);
assert!(!v.note_motion_for_eof(true, &m, &idx));
assert!(v.note_motion_for_eof(true, &m, &idx));
}
#[test]
fn squeeze_collapses_consecutive_blanks() {
let (m, mut idx) = setup(b"a\n\n\n\nb\n");
let mut v = Viewport::new(10, 8, "f".into());
v.set_squeeze_blanks(true);
let f = v.frame(&m, &mut idx);
let stringify = |row: &Vec<Cell>| -> String {
row.iter().filter_map(|c| match c {
Cell::Char { ch, .. } => Some(*ch),
_ => None,
}).collect::<String>().trim().to_string()
};
let rows: Vec<String> = f.body.iter().map(stringify).collect();
assert_eq!(&rows[0], "a");
assert_eq!(&rows[1], "");
assert_eq!(&rows[2], "b");
}
#[test]
fn header_pins_top_rows_when_scrolling() {
let mut content = Vec::new();
for n in 0..12 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
let (m, mut idx) = setup(&content);
let mut v = Viewport::new(20, 6, "f".into());
v.set_header(2, 0);
v.scroll_lines(5, &m, &mut idx);
let f = v.frame(&m, &mut idx);
let chs = |row: &Vec<Cell>| -> String {
row.iter().filter_map(|c| match c {
Cell::Char { ch, .. } => Some(*ch),
_ => None,
}).collect::<String>().trim().to_string()
};
assert_eq!(&chs(&f.body[0]), "line0");
assert_eq!(&chs(&f.body[1]), "line1");
assert_eq!(&chs(&f.body[2]), "line7");
}
#[test]
fn page_size_when_set_overrides_body_rows() {
let mut content = Vec::new();
for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
let (m, mut idx) = setup(&content);
let mut v = Viewport::new(20, 10, "f".into());
v.set_page_size(Some(3));
let before = v.top_line();
v.page_down(&m, &mut idx);
assert_eq!(v.top_line(), before + 3);
v.page_up(&m, &mut idx);
assert_eq!(v.top_line(), before);
}
#[test]
fn page_size_unset_uses_body_rows() {
let mut content = Vec::new();
for n in 0..100 { content.extend_from_slice(format!("L{n}\n").as_bytes()); }
let (m, mut idx) = setup(&content);
let mut v = Viewport::new(20, 10, "f".into());
v.page_down(&m, &mut idx);
assert_eq!(v.top_line(), 9);
}
#[test]
fn header_zero_lines_renders_like_no_header() {
let mut content = Vec::new();
for n in 0..10 { content.extend_from_slice(format!("line{n}\n").as_bytes()); }
let (m, mut idx) = setup(&content);
let mut v = Viewport::new(20, 6, "f".into());
v.set_header(0, 0);
let f = v.frame(&m, &mut idx);
let chs = |row: &Vec<Cell>| -> String {
row.iter().filter_map(|c| match c {
Cell::Char { ch, .. } => Some(*ch),
_ => None,
}).collect::<String>().trim().to_string()
};
assert_eq!(&chs(&f.body[0]), "line0");
assert_eq!(&chs(&f.body[1]), "line1");
}
#[test]
fn squeeze_off_preserves_blanks() {
let (m, mut idx) = setup(b"a\n\n\n\nb\n");
let mut v = Viewport::new(10, 8, "f".into());
let f = v.frame(&m, &mut idx);
let stringify = |row: &Vec<Cell>| -> String {
row.iter().filter_map(|c| match c {
Cell::Char { ch, .. } => Some(*ch),
_ => None,
}).collect::<String>().trim().to_string()
};
let rows: Vec<String> = f.body.iter().map(stringify).collect();
assert_eq!(&rows[0], "a");
assert_eq!(&rows[1], "");
assert_eq!(&rows[2], "");
assert_eq!(&rows[3], "");
assert_eq!(&rows[4], "b");
}
#[test]
fn qae_second_resets_on_backward_motion() {
let (m, mut idx) = setup(b"a\n");
let mut v = Viewport::new(20, 5, "f".into());
v.set_quit_at_eof(QuitAtEof::Second);
v.goto_bottom(&m, &mut idx);
assert!(!v.note_motion_for_eof(true, &m, &idx));
v.note_motion_for_eof(false, &m, &idx);
assert!(!v.note_motion_for_eof(true, &m, &idx));
assert!(v.note_motion_for_eof(true, &m, &idx));
}
#[test]
fn flash_message_overrides_follow_suffix() {
let (m, mut idx) = setup(b"a\nb\n");
let mut v = Viewport::new(40, 5, "f".into());
v.set_follow_mode(true);
v.flash("(F reopened)", 3);
let f = v.frame(&m, &mut idx);
assert!(f.status.contains("(F reopened)"), "{}", f.status);
assert!(!f.status.contains("(F idle)"));
}
#[test]
fn flash_countdown_clears() {
let mut v = Viewport::new(10, 5, "f".into());
v.flash("hello", 2);
v.tick_flash();
assert!(v.status_flash.is_some());
v.tick_flash();
assert!(v.status_flash.is_none());
}
#[test]
fn suspend_follow_if_off_is_noop() {
let mut v = Viewport::new(10, 5, "f".into());
v.set_follow_mode(true);
v.suspend_follow_if(false);
assert!(v.follow_mode());
}
#[test]
fn suspend_follow_if_on_flips_off() {
let mut v = Viewport::new(10, 5, "f".into());
v.set_follow_mode(true);
v.suspend_follow_if(true);
assert!(!v.follow_mode());
}
#[test]
fn case_mode_sensitive_returns_pattern_unchanged() {
assert_eq!(CaseMode::Sensitive.apply_to_pattern("foo"), "foo");
assert_eq!(CaseMode::Sensitive.apply_to_pattern("FOO"), "FOO");
}
#[test]
fn case_mode_insensitive_prepends_i_flag() {
assert_eq!(CaseMode::Insensitive.apply_to_pattern("foo"), "(?i)foo");
assert_eq!(CaseMode::Insensitive.apply_to_pattern("FOO"), "(?i)FOO");
}
#[test]
fn case_mode_smart_lowercase_is_insensitive() {
assert_eq!(CaseMode::Smart.apply_to_pattern("foo"), "(?i)foo");
}
#[test]
fn case_mode_smart_with_uppercase_is_sensitive() {
assert_eq!(CaseMode::Smart.apply_to_pattern("Foo"), "Foo");
assert_eq!(CaseMode::Smart.apply_to_pattern("FOO"), "FOO");
}
#[test]
fn set_case_mode_recompiles_active_search() {
let (m, mut idx) = setup(b"hello WORLD\n");
let mut v = Viewport::new(40, 5, "f".into());
v.set_search("world".into(), SearchDirection::Forward).unwrap();
assert!(!v.search_repeat(&m, &mut idx, false));
v.set_case_mode(CaseMode::Insensitive);
assert!(v.search_repeat(&m, &mut idx, false));
}
#[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(src, 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(&m, &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(&m, &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, style: crate::ansi::Style::default(), hyperlink: None });
}
#[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(&m, &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()],
CaseMode::Sensitive,
)
.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()], crate::viewport::CaseMode::Sensitive).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()],
CaseMode::Sensitive,
).unwrap();
let g = GrepPredicate::compile(&["timeout".to_string()], crate::viewport::CaseMode::Sensitive).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()], crate::viewport::CaseMode::Sensitive).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 filter_in_records_mode_keeps_whole_record_when_header_matches() {
let m = MockSource::new();
m.append(
b"[1] kind=category\n body a\n body a2\n[2] kind=rule\n body b\n",
);
let mut idx = LineIndex::new();
idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
idx.extend_to_end(&m);
let fmt = crate::format::LogFormat::compile(
"rec",
r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
)
.unwrap();
let f = crate::filter::CompiledFilter::compile(
&fmt,
vec![crate::filter::FilterSpec::parse("kind~category").unwrap()],
CaseMode::Sensitive,
)
.unwrap();
let mut v = Viewport::new(40, 10, "f".into());
v.set_filter(Some(f));
v.extend_visible_lines(&idx, &m);
assert_eq!(v.visible_lines(), &[0usize, 1, 2]);
}
#[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()], crate::viewport::CaseMode::Sensitive).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()], CaseMode::Sensitive).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 mut 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_r_block_uses_real_lines_in_hide_mode() {
let m = MockSource::new();
let mut buf = Vec::new();
for n in 0..10 {
let kind = if n >= 8 { "B" } else { "A" };
buf.extend_from_slice(format!("[{}] kind={}\n body {}\n", n, kind, n).as_bytes());
}
m.append(&buf);
m.finish();
let mut idx = LineIndex::new();
idx.set_record_start(regex::bytes::Regex::new(r"^\[").unwrap());
idx.extend_to_end(&m);
let fmt = crate::format::LogFormat::compile(
"rec",
r"^\[(?P<id>\d+)\] kind=(?P<kind>.+)$",
)
.unwrap();
let f = crate::filter::CompiledFilter::compile(
&fmt,
vec![crate::filter::FilterSpec::parse("kind=B").unwrap()],
CaseMode::Sensitive,
)
.unwrap();
let mut v = Viewport::new(80, 5, "f".into());
v.set_filter(Some(f));
v.extend_visible_lines(&idx, &m);
v.goto_record(8, &m, &mut idx);
let frame = v.frame(&m, &mut idx);
assert!(
frame.status.contains("R9-10/10"),
"expected R9-10/10 in status, got: {}",
frame.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 mut 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}");
}
#[test]
fn format_status_uses_custom_template_when_set() {
let m = MockSource::new();
m.append(b"a\nb\nc\n");
m.finish();
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(20, 5, "f".into());
let prompt = crate::prompt::ParsedPrompt::parse("<label> <pct>%").unwrap();
v.set_prompt(Some(prompt));
let frame = v.frame(&m, &mut idx);
assert_eq!(frame.status, "f 100%");
}
#[test]
fn status_shows_preprocess_failed_tag_when_set() {
let m = MockSource::new();
m.append(b"a\n");
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(40, 5, "f".into());
v.set_preprocess_failure(Some("pdftotext: not found".to_string()));
let frame = v.frame(&m, &mut idx);
assert!(frame.status.contains("[preprocess-failed: pdftotext: not found]"),
"got: {}", frame.status);
}
#[test]
fn default_status_includes_help_hint() {
let (m, mut idx) = setup(b"a\nb\nc\n");
let mut v = Viewport::new(80, 5, "f".into());
let frame = v.frame(&m, &mut idx);
assert!(frame.status.ends_with(":help"), "got: {:?}", frame.status);
}
#[test]
fn custom_prompt_does_not_get_help_hint() {
let (m, mut idx) = setup(b"a\nb\nc\n");
let mut v = Viewport::new(80, 5, "f".into());
v.set_prompt(Some(crate::prompt::ParsedPrompt::parse("<label>").unwrap()));
let frame = v.frame(&m, &mut idx);
assert!(!frame.status.contains(":help"), "got: {:?}", frame.status);
}
#[test]
fn status_shows_file_index_when_multifile() {
let m = MockSource::new();
m.append(b"a\n");
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(60, 5, "f.log".into());
v.set_file_index(0, 3);
let frame = v.frame(&m, &mut idx);
assert!(frame.status.contains("f.log [1/3]"), "got: {}", frame.status);
}
#[test]
fn status_omits_file_index_when_single_file() {
let m = MockSource::new();
m.append(b"a\n");
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(60, 5, "f.log".into());
v.set_file_index(0, 1);
let frame = v.frame(&m, &mut idx);
assert!(!frame.status.contains('['), "should not show [1/1] for single-file: {}", frame.status);
}
#[test]
fn status_shows_tag_active_when_multimatch() {
let m = MockSource::new();
m.append(b"a\n");
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(80, 5, "f.log".into());
v.set_tag_active(Some(("foo".into(), 2, 3)));
let frame = v.frame(&m, &mut idx);
assert!(
frame.status.contains("[tag: foo (2/3)]"),
"got: {}",
frame.status
);
}
#[test]
fn status_omits_tag_active_when_single_match() {
let m = MockSource::new();
m.append(b"a\n");
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let mut v = Viewport::new(80, 5, "f.log".into());
v.set_tag_active(Some(("foo".into(), 1, 1)));
let frame = v.frame(&m, &mut idx);
assert!(
!frame.status.contains("[tag:"),
"should not show indicator for single match: {}",
frame.status
);
}
#[test]
fn reconstruct_picks_up_state_from_prior_lines() {
let m = MockSource::new();
m.append(b"\x1b[31mline 1\n");
m.append(b"line 2 (still red, no reset)\n");
m.append(b"line 3\n");
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let state = reconstruct_render_state(&m, &idx, 2);
assert_eq!(
state.style.fg,
Some(crate::ansi::Color::Ansi(1)),
"red SGR from line 0 should persist to line 2"
);
}
#[test]
fn reconstruct_respects_reset_between_lines() {
let m = MockSource::new();
m.append(b"\x1b[31mline 1\x1b[0m\n");
m.append(b"line 2 (default)\n");
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let state = reconstruct_render_state(&m, &idx, 1);
assert_eq!(state.style.fg, None);
}
#[test]
fn reconstruct_caps_walkback_at_max_lines() {
let m = MockSource::new();
m.append(b"\x1b[31mvery early\n");
for _ in 0..300 {
m.append(b"line\n");
}
let mut idx = LineIndex::new();
idx.extend_to_end(&m);
let state = reconstruct_render_state(&m, &idx, 290);
assert_eq!(state.style.fg, None);
}
}