use crate::browser::FileBrowser;
use crate::theme::Theme;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
const PAGE_SCROLL_LINES: usize = 20;
#[derive(Debug, PartialEq)]
pub enum TextMode {
Normal,
Search,
}
#[derive(Default, Debug)]
pub struct TextSearchState {
pub query: String,
pub matches: Vec<(usize, usize)>,
pub cursor: usize,
}
pub struct TextApp {
pub title: String,
pub theme: &'static Theme,
pub should_quit: bool,
lines: Vec<String>,
truncated: Option<u64>,
is_pretty_json: bool,
row_offset: usize,
col_offset: usize,
wrap: bool,
show_line_numbers: bool,
search: TextSearchState,
mode: TextMode,
last_g: bool,
}
#[derive(Debug)]
pub struct TextLoad {
pub lines: Vec<String>,
pub truncated: Option<u64>,
pub is_pretty_json: bool,
}
#[derive(Debug)]
pub enum TextLoadError {
Binary,
Io(String),
}
impl std::fmt::Display for TextLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TextLoadError::Binary => write!(f, "not a text file"),
TextLoadError::Io(s) => write!(f, "{}", s),
}
}
}
impl std::error::Error for TextLoadError {}
pub fn load_text(
path: &str,
backend: &dyn FileBrowser,
max_bytes: usize,
) -> Result<TextLoad, TextLoadError> {
let bytes = read_bytes(path, backend).map_err(TextLoadError::Io)?;
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
parse_text(&bytes, &ext, max_bytes)
}
fn read_bytes(path: &str, backend: &dyn FileBrowser) -> Result<Vec<u8>, String> {
if path.starts_with("az://") || path.starts_with("s3://") {
backend.download_bytes(path).map_err(|e| e.to_string())
} else {
std::fs::read(path).map_err(|e| e.to_string())
}
}
pub fn parse_text(bytes: &[u8], ext: &str, max_bytes: usize) -> Result<TextLoad, TextLoadError> {
let total = bytes.len() as u64;
let truncated = if (total as usize) > max_bytes {
Some(total)
} else {
None
};
let slice = if truncated.is_some() {
&bytes[..max_bytes]
} else {
bytes
};
let s = std::str::from_utf8(slice).map_err(|_| TextLoadError::Binary)?;
let (content, is_pretty_json) = if ext == "json" && truncated.is_none() {
match serde_json::from_str::<serde_json::Value>(s) {
Ok(v) => (
serde_json::to_string_pretty(&v).unwrap_or_else(|_| s.to_string()),
true,
),
Err(_) => (s.to_string(), false),
}
} else {
(s.to_string(), false)
};
let lines: Vec<String> = content.split('\n').map(|s| s.to_string()).collect();
Ok(TextLoad {
lines,
truncated,
is_pretty_json,
})
}
impl TextApp {
pub fn new(load: TextLoad, title: String, theme: &'static Theme) -> Self {
Self {
title,
theme,
should_quit: false,
lines: load.lines,
truncated: load.truncated,
is_pretty_json: load.is_pretty_json,
row_offset: 0,
col_offset: 0,
wrap: true,
show_line_numbers: true,
search: TextSearchState::default(),
mode: TextMode::Normal,
last_g: false,
}
}
pub fn is_typing(&self) -> bool {
self.mode == TextMode::Search
}
fn line_count(&self) -> usize {
self.lines.len()
}
fn scroll_down(&mut self, n: usize) {
let max = self.line_count().saturating_sub(1);
self.row_offset = (self.row_offset + n).min(max);
}
fn scroll_up(&mut self, n: usize) {
self.row_offset = self.row_offset.saturating_sub(n);
}
fn go_top(&mut self) {
self.row_offset = 0;
}
fn go_bottom(&mut self) {
self.row_offset = self.line_count().saturating_sub(1);
}
fn h_scroll_right(&mut self) {
self.col_offset = self.col_offset.saturating_add(1);
}
fn h_scroll_left(&mut self) {
self.col_offset = self.col_offset.saturating_sub(1);
}
fn toggle_wrap(&mut self) {
self.wrap = !self.wrap;
if self.wrap {
self.col_offset = 0;
}
}
fn toggle_line_numbers(&mut self) {
self.show_line_numbers = !self.show_line_numbers;
}
fn enter_search(&mut self) {
self.mode = TextMode::Search;
self.search.query.clear();
self.search.matches.clear();
self.search.cursor = 0;
}
fn exit_search(&mut self) {
self.mode = TextMode::Normal;
self.search.query.clear();
self.search.matches.clear();
self.search.cursor = 0;
}
fn confirm_search(&mut self) {
self.mode = TextMode::Normal;
self.recompute_matches();
if let Some(&(row, _)) = self.search.matches.first() {
self.row_offset = row;
self.search.cursor = 0;
}
}
fn recompute_matches(&mut self) {
self.search.matches.clear();
self.search.cursor = 0;
if self.search.query.is_empty() {
return;
}
let q = self.search.query.to_lowercase();
for (i, line) in self.lines.iter().enumerate() {
let lower = line.to_lowercase();
let mut start = 0;
while let Some(pos) = lower[start..].find(&q) {
let abs = start + pos;
self.search.matches.push((i, abs));
start = abs + 1;
if start >= lower.len() {
break;
}
}
}
}
fn next_match(&mut self) {
if self.search.matches.is_empty() {
return;
}
self.search.cursor = (self.search.cursor + 1) % self.search.matches.len();
if let Some(&(row, _)) = self.search.matches.get(self.search.cursor) {
self.row_offset = row;
}
}
fn prev_match(&mut self) {
if self.search.matches.is_empty() {
return;
}
self.search.cursor = if self.search.cursor == 0 {
self.search.matches.len() - 1
} else {
self.search.cursor - 1
};
if let Some(&(row, _)) = self.search.matches.get(self.search.cursor) {
self.row_offset = row;
}
}
#[cfg(test)]
fn row_offset(&self) -> usize {
self.row_offset
}
#[cfg(test)]
fn col_offset(&self) -> usize {
self.col_offset
}
#[cfg(test)]
fn wrap(&self) -> bool {
self.wrap
}
#[cfg(test)]
fn show_line_numbers(&self) -> bool {
self.show_line_numbers
}
#[cfg(test)]
fn mode(&self) -> &TextMode {
&self.mode
}
#[cfg(test)]
fn matches_len(&self) -> usize {
self.search.matches.len()
}
}
pub fn dispatch_text_viewer_key(app: &mut TextApp, key: &KeyEvent) {
match app.mode {
TextMode::Normal => handle_normal_key(app, key),
TextMode::Search => handle_search_key(app, key),
}
}
fn handle_normal_key(app: &mut TextApp, key: &KeyEvent) {
let was_g = app.last_g;
app.last_g = false;
match key.code {
KeyCode::Char('j') | KeyCode::Down => app.scroll_down(1),
KeyCode::Char('k') | KeyCode::Up => app.scroll_up(1),
KeyCode::PageDown => app.scroll_down(PAGE_SCROLL_LINES),
KeyCode::PageUp => app.scroll_up(PAGE_SCROLL_LINES),
KeyCode::Char('g') => {
if was_g {
app.go_top();
} else {
app.last_g = true;
}
}
KeyCode::Char('G') => app.go_bottom(),
KeyCode::Char('h') | KeyCode::Left if !app.wrap => app.h_scroll_left(),
KeyCode::Char('l') | KeyCode::Right if !app.wrap => app.h_scroll_right(),
KeyCode::Char('w') => app.toggle_wrap(),
KeyCode::Char('L') => app.toggle_line_numbers(),
KeyCode::Char('/') => app.enter_search(),
KeyCode::Char('n') => app.next_match(),
KeyCode::Char('N') => app.prev_match(),
KeyCode::Char('q') => app.should_quit = true,
_ => {}
}
}
fn handle_search_key(app: &mut TextApp, key: &KeyEvent) {
match key.code {
KeyCode::Char(c) => {
app.search.query.push(c);
app.recompute_matches();
}
KeyCode::Backspace => {
app.search.query.pop();
app.recompute_matches();
}
KeyCode::Enter => app.confirm_search(),
KeyCode::Esc => app.exit_search(),
_ => {}
}
}
pub fn render_text_viewer(frame: &mut Frame, app: &mut TextApp, area: Rect, theme: &Theme) {
let title_text = build_title(app);
let block = Block::default()
.title(Line::from(title_text).alignment(Alignment::Left))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_idle))
.style(Style::default().bg(theme.bg));
let inner = block.inner(area);
frame.render_widget(block, area);
let body_area = if app.mode == TextMode::Search {
let [body, status] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
let count = app.search.matches.len();
let suffix = if count > 0 {
format!(" — {} match{}", count, if count == 1 { "" } else { "es" })
} else if app.search.query.is_empty() {
" — type to search, Enter to jump, Esc to cancel".to_string()
} else {
" — no matches".to_string()
};
let q = format!(" /{}_", app.search.query);
let style = Style::default()
.fg(theme.bg)
.bg(theme.warn)
.add_modifier(Modifier::BOLD);
frame.render_widget(
Paragraph::new(format!("{}{}", q, suffix)).style(style),
status,
);
body
} else if !app.search.matches.is_empty() {
let [body, status] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner);
let n = app.search.matches.len();
let cursor = app.search.cursor + 1;
let info = format!(
" {}/{} for /{}/ — n next, N prev ",
cursor, n, app.search.query
);
let style = Style::default().fg(theme.fg_dim).bg(theme.bg_alt);
frame.render_widget(Paragraph::new(info).style(style), status);
body
} else {
inner
};
let line_num_width = if app.show_line_numbers {
let n = app.lines.len().max(1);
((n as f64).log10().floor() as usize) + 1
} else {
0
};
let visible_h = body_area.height as usize;
let start = app.row_offset;
let end = (start + visible_h).min(app.lines.len());
let mut text_lines: Vec<Line> = Vec::with_capacity(end.saturating_sub(start));
for i in start..end {
let mut spans: Vec<Span> = Vec::new();
if app.show_line_numbers {
spans.push(Span::styled(
format!("{:>width$} ", i + 1, width = line_num_width),
Style::default().fg(theme.fg_dim),
));
}
spans.extend(highlight_spans(&app.lines[i], &app.search.query, theme));
text_lines.push(Line::from(spans));
}
let mut paragraph = Paragraph::new(text_lines).style(Style::default().fg(theme.fg));
if app.wrap {
paragraph = paragraph.wrap(Wrap { trim: false });
} else {
paragraph = paragraph.scroll((0, app.col_offset as u16));
}
frame.render_widget(paragraph, body_area);
}
fn build_title(app: &TextApp) -> String {
let mut parts = vec![app.title.clone(), format!("{} lines", app.lines.len())];
if app.is_pretty_json {
parts.push("pretty JSON".to_string());
}
if let Some(total) = app.truncated {
let mb = total as f64 / (1024.0 * 1024.0);
parts.push(format!("truncated, {:.1} MB total", mb));
}
parts.push("UTF-8".to_string());
format!(" {} ", parts.join(" · "))
}
fn highlight_spans(text: &str, query: &str, theme: &Theme) -> Vec<Span<'static>> {
if query.is_empty() {
return vec![Span::raw(text.to_string())];
}
let q = query.to_lowercase();
let lower = text.to_lowercase();
let hit_style = Style::default()
.fg(theme.bg)
.bg(theme.warn)
.add_modifier(Modifier::BOLD);
let mut spans: Vec<Span<'static>> = Vec::new();
let mut last = 0;
let mut start = 0;
while let Some(pos) = lower[start..].find(&q) {
let abs = start + pos;
if abs > last {
spans.push(Span::raw(text[last..abs].to_string()));
}
let end = abs + q.len();
spans.push(Span::styled(text[abs..end].to_string(), hit_style));
last = end;
start = end;
if start >= lower.len() {
break;
}
}
if last < text.len() {
spans.push(Span::raw(text[last..].to_string()));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
fn theme() -> &'static Theme {
crate::theme::default_theme()
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn make_app(content: &str) -> TextApp {
let bytes = content.as_bytes();
let load = parse_text(bytes, "txt", 1024 * 1024).expect("parse");
TextApp::new(load, "test.txt".to_string(), theme())
}
#[test]
fn parse_text_plain_utf8_succeeds() {
let load = parse_text(b"hello\nworld\n", "txt", 1024).expect("ok");
assert_eq!(load.lines, vec!["hello", "world", ""]);
assert!(load.truncated.is_none());
assert!(!load.is_pretty_json);
}
#[test]
fn parse_text_non_utf8_returns_binary() {
let bytes = [0xff_u8, 0xfe, 0xfd];
let err = parse_text(&bytes, "txt", 1024).unwrap_err();
assert!(matches!(err, TextLoadError::Binary));
}
#[test]
fn parse_text_truncates_when_over_cap() {
let big = "a".repeat(2048);
let load = parse_text(big.as_bytes(), "txt", 1024).expect("ok");
assert_eq!(load.truncated, Some(2048));
assert_eq!(load.lines.iter().map(|s| s.len()).sum::<usize>(), 1024);
}
#[test]
fn parse_text_does_not_truncate_when_under_cap() {
let load = parse_text(b"hi", "txt", 1024).expect("ok");
assert!(load.truncated.is_none());
}
#[test]
fn parse_text_pretty_prints_valid_json() {
let load = parse_text(br#"{"a":1,"b":2}"#, "json", 1024).expect("ok");
assert!(load.is_pretty_json);
assert!(load.lines.len() > 1);
}
#[test]
fn parse_text_leaves_invalid_json_as_raw_text() {
let load = parse_text(b"{not valid json", "json", 1024).expect("ok");
assert!(!load.is_pretty_json);
}
#[test]
fn parse_text_does_not_pretty_print_truncated_json() {
let big = format!("[{}]", "1,".repeat(5000));
let load = parse_text(big.as_bytes(), "json", 100).expect("ok");
assert!(load.truncated.is_some());
assert!(!load.is_pretty_json);
}
#[test]
fn parse_text_does_not_pretty_print_ndjson() {
let load = parse_text(b"{\"a\":1}\n{\"a\":2}\n", "ndjson", 1024).expect("ok");
assert!(!load.is_pretty_json);
}
#[test]
fn new_starts_at_top_with_defaults() {
let app = make_app("a\nb\nc\n");
assert_eq!(app.row_offset(), 0);
assert_eq!(app.col_offset(), 0);
assert!(app.wrap());
assert!(app.show_line_numbers());
assert_eq!(app.mode(), &TextMode::Normal);
}
#[test]
fn j_scrolls_down() {
let mut app = make_app("a\nb\nc\nd\n");
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('j')));
assert_eq!(app.row_offset(), 1);
}
#[test]
fn k_scrolls_up() {
let mut app = make_app("a\nb\nc\nd\n");
app.scroll_down(2);
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('k')));
assert_eq!(app.row_offset(), 1);
}
#[test]
fn j_clamps_at_last_line() {
let mut app = make_app("a\nb\n");
for _ in 0..10 {
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('j')));
}
assert_eq!(app.row_offset(), 2);
}
#[test]
fn gg_jumps_to_top() {
let mut app = make_app("a\nb\nc\nd\n");
app.scroll_down(3);
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('g')));
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('g')));
assert_eq!(app.row_offset(), 0);
}
#[test]
fn capital_g_jumps_to_bottom() {
let mut app = make_app("a\nb\nc\nd\n");
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('G')));
assert_eq!(app.row_offset(), 4);
}
#[test]
fn page_down_scrolls_by_page_amount() {
let many = "x\n".repeat(50);
let mut app = make_app(&many);
dispatch_text_viewer_key(&mut app, &key(KeyCode::PageDown));
assert_eq!(app.row_offset(), PAGE_SCROLL_LINES);
}
#[test]
fn h_and_l_only_scroll_when_wrap_off() {
let mut app = make_app("very long line content here\n");
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('l')));
assert_eq!(app.col_offset(), 0);
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('w')));
assert!(!app.wrap());
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('l')));
assert_eq!(app.col_offset(), 1);
}
#[test]
fn toggle_wrap_resets_col_offset() {
let mut app = make_app("line\n");
app.toggle_wrap(); app.col_offset = 5;
app.toggle_wrap(); assert_eq!(app.col_offset(), 0);
}
#[test]
fn capital_l_toggles_line_numbers() {
let mut app = make_app("a\n");
assert!(app.show_line_numbers());
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('L')));
assert!(!app.show_line_numbers());
}
#[test]
fn q_quits() {
let mut app = make_app("a\n");
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('q')));
assert!(app.should_quit);
}
#[test]
fn slash_enters_search_mode() {
let mut app = make_app("a\n");
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('/')));
assert_eq!(app.mode(), &TextMode::Search);
assert!(app.is_typing());
}
#[test]
fn typing_in_search_populates_matches() {
let mut app = make_app("hello\nworld\nhello again\n");
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('/')));
for c in "hello".chars() {
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char(c)));
}
assert_eq!(app.matches_len(), 2);
}
#[test]
fn search_is_case_insensitive() {
let mut app = make_app("Hello\nHELLO\nhello\n");
app.search.query = "hello".to_string();
app.recompute_matches();
assert_eq!(app.matches_len(), 3);
}
#[test]
fn search_finds_overlapping_matches() {
let mut app = make_app("aaaa\n");
app.search.query = "aa".to_string();
app.recompute_matches();
assert_eq!(app.matches_len(), 3);
}
#[test]
fn enter_confirms_search_and_returns_to_normal() {
let mut app = make_app("a\nfoo\nb\n");
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('/')));
for c in "foo".chars() {
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char(c)));
}
dispatch_text_viewer_key(&mut app, &key(KeyCode::Enter));
assert_eq!(app.mode(), &TextMode::Normal);
assert_eq!(app.row_offset(), 1);
}
#[test]
fn esc_cancels_search_and_clears_matches() {
let mut app = make_app("a\nfoo\n");
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('/')));
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('f')));
dispatch_text_viewer_key(&mut app, &key(KeyCode::Esc));
assert_eq!(app.mode(), &TextMode::Normal);
assert_eq!(app.matches_len(), 0);
}
#[test]
fn n_cycles_to_next_match() {
let mut app = make_app("foo\nbar\nfoo\nbaz\nfoo\n");
app.search.query = "foo".to_string();
app.recompute_matches();
app.confirm_search(); dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('n')));
assert_eq!(app.row_offset(), 2);
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('n')));
assert_eq!(app.row_offset(), 4);
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('n')));
assert_eq!(app.row_offset(), 0);
}
#[test]
fn capital_n_cycles_backward() {
let mut app = make_app("foo\nbar\nfoo\n");
app.search.query = "foo".to_string();
app.recompute_matches();
app.confirm_search();
dispatch_text_viewer_key(&mut app, &key(KeyCode::Char('N')));
assert_eq!(app.row_offset(), 2); }
#[test]
fn is_typing_is_true_only_in_search_mode() {
let mut app = make_app("a\n");
assert!(!app.is_typing());
app.enter_search();
assert!(app.is_typing());
app.exit_search();
assert!(!app.is_typing());
}
#[test]
fn highlight_spans_returns_single_span_when_no_query() {
let spans = highlight_spans("hello", "", theme());
assert_eq!(spans.len(), 1);
}
#[test]
fn highlight_spans_splits_on_match() {
let spans = highlight_spans("hello world hello", "hello", theme());
assert!(spans.len() >= 3);
}
}