use std::path::{Path, PathBuf};
pub const SIZE_CAP: u64 = 5 * 1024 * 1024;
const SNIFF: usize = 8192;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileLoad {
Loading,
Text(Vec<String>),
Binary(u64),
TooLarge(u64),
Error(String),
}
#[derive(Clone, Debug, Default)]
pub struct Search {
pub query: String,
pub editing: bool,
pub matches: Vec<(usize, usize)>,
pub current: usize,
}
pub struct FileView {
pub path: PathBuf,
pub load: FileLoad,
pub scroll: usize,
pub hscroll: u16,
pub wrap: bool,
pub mtime: Option<std::time::SystemTime>,
pub search: Option<Search>,
}
impl FileView {
pub fn new(path: PathBuf) -> Self {
FileView {
path,
load: FileLoad::Loading,
scroll: 0,
hscroll: 0,
wrap: true,
mtime: None,
search: None,
}
}
pub fn apply(&mut self, load: FileLoad) {
self.load = load;
let max = self.line_count().saturating_sub(1);
self.scroll = self.scroll.min(max);
self.hscroll = 0;
if let Some(s) = self.search.take() {
if !s.query.is_empty() {
self.run_search(&s.query);
}
}
}
pub fn line_count(&self) -> usize {
match &self.load {
FileLoad::Text(lines) => lines.len(),
_ => 0,
}
}
pub fn scroll_by(&mut self, delta: i32, viewport: usize) {
let max = self.line_count().saturating_sub(1);
let next = (self.scroll as i32 + delta).clamp(0, max as i32) as usize;
self.scroll = next;
let last_top = self.line_count().saturating_sub(viewport.max(1));
if self.scroll > last_top {
self.scroll = last_top;
}
}
pub fn goto_top(&mut self) {
self.scroll = 0;
}
pub fn goto_bottom(&mut self, viewport: usize) {
self.scroll = self.line_count().saturating_sub(viewport.max(1));
}
pub fn scroll_right(&mut self, delta: i16) {
if self.wrap {
return;
}
self.hscroll = (self.hscroll as i16 + delta).max(0) as u16;
}
pub fn search_begin(&mut self) {
self.search = Some(Search {
editing: true,
..Default::default()
});
}
pub fn search_push(&mut self, c: char) {
if let Some(s) = self.search.as_mut().filter(|s| s.editing) {
s.query.push(c);
}
}
pub fn search_backspace(&mut self) {
if let Some(s) = self.search.as_mut().filter(|s| s.editing) {
s.query.pop();
}
}
pub fn search_commit(&mut self) {
let Some(query) = self.search.as_ref().map(|s| s.query.clone()) else {
return;
};
if query.is_empty() {
self.search = None;
return;
}
self.run_search(&query);
}
pub fn search_cancel(&mut self) {
self.search = None;
}
pub fn search_step(&mut self, forward: bool, viewport: usize) {
let (len, next) = match self.search.as_ref() {
Some(s) if !s.matches.is_empty() => {
let n = s.matches.len();
let cur = s.current;
(
n,
if forward {
(cur + 1) % n
} else {
(cur + n - 1) % n
},
)
}
_ => return,
};
if let Some(s) = self.search.as_mut() {
s.current = next;
}
let _ = len;
self.reveal_current_match(viewport);
}
fn run_search(&mut self, query: &str) {
let needle = query.to_lowercase();
let mut matches = Vec::new();
if let FileLoad::Text(lines) = &self.load {
for (li, line) in lines.iter().enumerate() {
let hay = line.to_lowercase();
let mut from = 0;
while let Some(rel) = hay[from..].find(&needle) {
let col = from + rel;
matches.push((li, col));
from = col + needle.len().max(1);
}
}
}
let current = matches
.iter()
.position(|(l, _)| *l >= self.scroll)
.unwrap_or(0);
self.search = Some(Search {
query: query.to_string(),
editing: false,
matches,
current,
});
}
fn reveal_current_match(&mut self, viewport: usize) {
if let Some(s) = &self.search {
if let Some((line, _)) = s.matches.get(s.current).copied() {
if line < self.scroll || line >= self.scroll + viewport.max(1) {
self.scroll = line.saturating_sub(viewport / 2);
}
}
}
}
}
pub fn gutter_width(line_count: usize) -> u16 {
(line_count.max(1).to_string().len() as u16 + 1).max(4)
}
pub fn wrap_ranges(line: &str, width: usize) -> Vec<(usize, usize)> {
let chars: Vec<char> = line.chars().collect();
let n = chars.len();
if width == 0 || n <= width {
return vec![(0, n)];
}
let mut out = Vec::new();
let mut start = 0;
while start < n {
if n - start <= width {
out.push((start, n));
break;
}
let hard_end = start + width;
let mut brk = hard_end;
if let Some(pos) = chars[start..hard_end].iter().rposition(|&c| c == ' ') {
let abs = start + pos;
if abs > start {
brk = abs;
}
}
out.push((start, brk));
start = if brk < n && chars[brk] == ' ' {
brk + 1
} else {
brk
};
}
if out.is_empty() {
out.push((0, n));
}
out
}
pub fn seg_text(line: &str, range: (usize, usize)) -> String {
line.chars().skip(range.0).take(range.1 - range.0).collect()
}
pub fn selection_text(
v: &FileView,
content: ratatui::layout::Rect,
ordered: ((u16, u16), (u16, u16)),
) -> Option<String> {
let FileLoad::Text(lines) = &v.load else {
return None;
};
let ((sx, sy), (ex, ey)) = ordered;
let gutter = gutter_width(lines.len());
let text_x = content.x + gutter + 1;
let text_w = content.width.saturating_sub(gutter + 1) as usize;
let rows = content.height as usize;
let mut rowmap: Vec<(usize, usize, usize)> = Vec::new(); let mut li = v.scroll;
'build: while li < lines.len() {
if v.wrap {
for (s, e) in wrap_ranges(&lines[li], text_w) {
rowmap.push((li, s, e));
if rowmap.len() >= rows {
break 'build;
}
}
} else {
let n = lines[li].chars().count();
rowmap.push((li, 0, n));
if rowmap.len() >= rows {
break 'build;
}
}
li += 1;
}
let mut out = String::new();
let mut first = true;
for ty in sy..=ey {
let vi = (ty.saturating_sub(content.y)) as usize;
let Some(&(line, seg_s, seg_e)) = rowmap.get(vi) else {
continue;
};
let chars: Vec<char> = lines
.get(line)
.map(|l| l.chars().collect())
.unwrap_or_default();
let to_col = |screen_x: u16| {
(screen_x.saturating_sub(text_x)) as usize + if v.wrap { 0 } else { v.hscroll as usize }
};
let start = seg_s + if ty == sy { to_col(sx) } else { 0 };
let end = if ty == ey {
seg_s + to_col(ex) + 1
} else {
seg_e
};
let (start, end) = (
start.min(seg_e).min(chars.len()),
end.min(seg_e).min(chars.len()),
);
let seg: String = if start < end {
chars[start..end].iter().collect()
} else {
String::new()
};
if !first {
out.push('\n');
}
first = false;
out.push_str(seg.trim_end());
}
let out = out.trim_end_matches('\n').to_string();
(!out.is_empty()).then_some(out)
}
pub fn read_file(path: &Path) -> FileLoad {
let meta = match std::fs::metadata(path) {
Ok(m) => m,
Err(e) => return FileLoad::Error(e.to_string()),
};
if meta.len() > SIZE_CAP {
return FileLoad::TooLarge(meta.len());
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => return FileLoad::Error(e.to_string()),
};
if bytes.iter().take(SNIFF).any(|&b| b == 0) {
return FileLoad::Binary(meta.len());
}
let text = String::from_utf8_lossy(&bytes);
let lines: Vec<String> = text
.split('\n')
.map(|l| l.strip_suffix('\r').unwrap_or(l).replace('\t', " "))
.collect();
let lines = if lines.len() > 1 && lines.last().is_some_and(|l| l.is_empty()) {
lines[..lines.len() - 1].to_vec()
} else {
lines
};
FileLoad::Text(lines)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_text_binary_and_oversize() {
let dir = std::env::temp_dir().join(format!("bohay-fv-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("t.txt"), b"a\nb\tc\n").unwrap();
assert_eq!(
read_file(&dir.join("t.txt")),
FileLoad::Text(vec!["a".into(), "b c".into()]),
"tabs expanded, trailing newline dropped"
);
std::fs::write(dir.join("b.bin"), [0u8, 1, 2, 3]).unwrap();
assert!(matches!(read_file(&dir.join("b.bin")), FileLoad::Binary(4)));
assert!(matches!(
read_file(&dir.join("missing")),
FileLoad::Error(_)
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn wrap_ranges_word_wraps_and_hard_splits() {
let r = wrap_ranges("the quick brown fox", 10);
let segs: Vec<String> = r
.iter()
.map(|&rg| seg_text("the quick brown fox", rg))
.collect();
assert_eq!(segs, vec!["the quick", "brown fox"]);
let r = wrap_ranges("abcdefghijk", 4);
let segs: Vec<String> = r.iter().map(|&rg| seg_text("abcdefghijk", rg)).collect();
assert_eq!(segs, vec!["abcd", "efgh", "ijk"]);
assert_eq!(
segs.concat(),
"abcdefghijk",
"every character survives the wrap"
);
assert_eq!(wrap_ranges("hi", 10), vec![(0, 2)]);
assert_eq!(wrap_ranges("", 10), vec![(0, 0)]);
}
#[test]
fn wrap_is_the_default() {
assert!(
FileView::new(PathBuf::from("/x")).wrap,
"a file opens wrapped"
);
}
#[test]
fn scroll_clamps_to_content() {
let mut v = FileView::new(PathBuf::from("/x"));
v.apply(FileLoad::Text((0..10).map(|i| i.to_string()).collect()));
v.scroll_by(100, 4); assert_eq!(v.scroll, 6);
v.scroll_by(-100, 4);
assert_eq!(v.scroll, 0);
}
#[test]
fn search_finds_navigates_and_reveals() {
let mut v = FileView::new(PathBuf::from("/x"));
v.apply(FileLoad::Text(vec![
"let foo = 1;".into(),
"// nothing here".into(),
"foo(foo, FOO);".into(), ]));
v.search_begin();
for c in "foo".chars() {
v.search_push(c);
}
v.search_commit();
let s = v.search.as_ref().unwrap();
assert_eq!(s.matches.len(), 4);
assert_eq!(s.current, 0);
v.search_step(true, 2);
assert_eq!(v.search.as_ref().unwrap().current, 1);
v.search_step(false, 2);
assert_eq!(v.search.as_ref().unwrap().current, 0);
v.goto_top();
v.search_step(true, 1); assert!(v.scroll >= 1, "the match line was revealed");
v.apply(FileLoad::Text(vec!["only foo".into()]));
assert_eq!(v.search.as_ref().unwrap().matches.len(), 1);
v.search_cancel();
assert!(v.search.is_none());
}
}