From d050fe453090759a187713b2c894702b50e27689 Mon Sep 17 00:00:00 2001 From: Ewart Nijburg Date: Fri, 15 May 2026 13:51:21 +0200 Subject: [PATCH 1/2] Refactor main into UI prompt and dedicated tests module --- src/main.rs | 301 +---------------------------------------------- src/tests.rs | 207 ++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + src/ui/prompt.rs | 103 ++++++++++++++++ 4 files changed, 314 insertions(+), 298 deletions(-) create mode 100644 src/tests.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/prompt.rs diff --git a/src/main.rs b/src/main.rs index 26ba891..09ffc25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use crossterm::{ terminal::{self, ClearType}, }; use memmap2::Mmap; +use ui::prompt::{prompt_find, prompt_goto_line}; #[derive(Parser, Debug)] #[command(version, about = "Memory-mapped large file viewer for Windows x64")] @@ -1421,303 +1422,7 @@ fn run_event_loop(viewer: &mut Viewer, out: &mut impl Write) -> Result<()> { Ok(()) } -fn prompt_goto_line(viewer: &Viewer, out: &mut impl Write) -> Result> { - let mut input = String::new(); - - loop { - let (width, height) = terminal::size().context("Failed to get terminal size")?; - let prompt = format!( - "Goto line (1-{}, Enter=go, Esc=cancel): {}", - viewer.line_count(), - input - ); - let clipped_prompt = clip_to_width(&prompt, width as usize); - let y = height.saturating_sub(1); - - queue!( - out, - cursor::MoveTo(0, y), - terminal::Clear(ClearType::CurrentLine), - style::PrintStyledContent(clipped_prompt.reverse()) - )?; - out.flush().context("Failed to flush terminal output")?; - - match event::read().context("Failed reading terminal event")? { - Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { - KeyCode::Esc => return Ok(None), - KeyCode::Enter => { - if input.is_empty() { - return Ok(None); - } - - if let Ok(line_number) = input.parse::() { - if line_number >= 1 { - return Ok(Some(line_number)); - } - } - } - KeyCode::Backspace => { - input.pop(); - } - KeyCode::Char(c) if c.is_ascii_digit() => { - input.push(c); - } - _ => {} - }, - _ => {} - } - } -} - -fn prompt_find(viewer: &Viewer, out: &mut impl Write) -> Result> { - let mut input = viewer - .search_query - .as_ref() - .map(|query| String::from_utf8_lossy(query).to_string()) - .unwrap_or_default(); - - loop { - let (width, height) = terminal::size().context("Failed to get terminal size")?; - let prompt = format!("Find text (Enter=find, Esc=cancel): {}", input); - let clipped_prompt = clip_to_width(&prompt, width as usize); - let y = height.saturating_sub(1); - - queue!( - out, - cursor::MoveTo(0, y), - terminal::Clear(ClearType::CurrentLine), - style::PrintStyledContent(clipped_prompt.reverse()) - )?; - out.flush().context("Failed to flush terminal output")?; - - match event::read().context("Failed reading terminal event")? { - Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { - KeyCode::Esc => return Ok(None), - KeyCode::Enter => { - if input.is_empty() { - return Ok(None); - } - return Ok(Some(input)); - } - KeyCode::Backspace => { - input.pop(); - } - KeyCode::Char(c) => { - if !c.is_control() { - input.push(c); - } - } - _ => {} - }, - _ => {} - } - } -} +mod ui; #[cfg(test)] -mod tests { - use super::{ - centered_top_line, classify_json_line, classify_xml_line, detect_csv_separator, - format_json_for_display, format_xml_for_display, parse_csv_separator, skipped_prefix_len, - JsonTokenClass, Viewer, XmlTokenClass, - }; - use std::{ - fs, - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, - }; - - #[test] - fn indexes_lines() { - let offsets = Viewer::index_lines(b"a\nb\nlast"); - assert_eq!(offsets, vec![0, 2, 4]); - } - - #[test] - fn indexes_empty() { - let offsets = Viewer::index_lines(b""); - assert_eq!(offsets, vec![0]); - } - - #[test] - fn centers_target_line_when_possible() { - assert_eq!(centered_top_line(50, 20, 200), 40); - } - - #[test] - fn centers_target_line_with_small_targets() { - assert_eq!(centered_top_line(3, 20, 200), 0); - } - - #[test] - fn centers_target_line_near_end() { - assert_eq!(centered_top_line(199, 20, 200), 189); - } - - #[test] - fn finds_forward_and_backward() { - let viewer = test_viewer_from_bytes(b"abc\ndef abc xyz abc"); - assert_eq!(viewer.find_forward(b"abc", 0), Some((0, 3))); - assert_eq!(viewer.find_forward(b"abc", 4), Some((8, 11))); - assert_eq!(viewer.find_backward(b"abc", 18), Some((16, 19))); - assert_eq!(viewer.find_backward(b"missing", 18), None); - } - - #[test] - fn maps_offsets_to_lines() { - let viewer = test_viewer_from_bytes(b"line1\nline2\nline3"); - assert_eq!(viewer.line_of_offset(0), 0); - assert_eq!(viewer.line_of_offset(6), 1); - assert_eq!(viewer.line_of_offset(12), 2); - } - - fn with_temp_file(bytes: &[u8], f: impl FnOnce(PathBuf) -> Viewer) -> Viewer { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time went backwards") - .as_nanos(); - let path = std::env::temp_dir().join(format!("large-file-viewer-test-{nonce}.txt")); - fs::write(&path, bytes).expect("failed to write temp file"); - let viewer = f(path.clone()); - fs::remove_file(path).expect("failed to remove temp file"); - viewer - } - - fn test_viewer_from_bytes(bytes: &[u8]) -> Viewer { - with_temp_file(bytes, |path| { - Viewer::open(path, 4, false, None, false, false, false).expect("failed to open viewer") - }) - } - - #[test] - fn indexes_csv_column_widths() { - let widths = Viewer::index_csv_column_widths(b"a,bbb\ncccc,d", 4, b','); - assert_eq!(widths, vec![4, 3]); - } - - #[test] - fn csv_mode_starts_below_pinned_header() { - let viewer = test_viewer_with_options(b"h1,h2\nv1,v2\nv3,v4", 4, true); - assert_eq!(viewer.top_line, 1); - } - - #[test] - fn csv_scroll_up_keeps_header_pinned() { - let mut viewer = test_viewer_with_options(b"h1,h2\nv1,v2\nv3,v4", 4, true); - viewer.scroll_up(10); - assert_eq!(viewer.top_line, 1); - } - - #[test] - fn auto_detects_semicolon_separator() { - assert_eq!(detect_csv_separator(b"h1;h2\na;b\nc;d"), b';'); - } - - #[test] - fn parses_tab_separator_escape() { - assert_eq!(parse_csv_separator(r"\t").expect("should parse"), b'\t'); - } - - fn test_viewer_with_options(bytes: &[u8], tab_width: usize, csv: bool) -> Viewer { - with_temp_file(bytes, |path| { - Viewer::open(path, tab_width, csv, None, false, false, false) - .expect("failed to open viewer") - }) - } - - #[test] - fn classifies_xml_tags_and_attributes() { - let classes = classify_xml_line(br#"text"#); - assert_eq!(classes[0], XmlTokenClass::TagDelimiter); - assert_eq!(classes[1], XmlTokenClass::TagName); - assert_eq!(classes[6], XmlTokenClass::AttributeName); - assert_eq!(classes[11], XmlTokenClass::AttributeValue); - assert_eq!(classes[19], XmlTokenClass::Text); - } - - #[test] - fn classifies_xml_comments() { - let classes = classify_xml_line(br#" "#); - assert_eq!(classes[0], XmlTokenClass::Comment); - assert_eq!(classes[10], XmlTokenClass::Comment); - assert_eq!(classes[16], XmlTokenClass::Text); - assert_eq!(classes[17], XmlTokenClass::TagDelimiter); - assert_eq!(classes[18], XmlTokenClass::TagName); - } - - #[test] - fn skips_utf8_bom_prefix_on_first_line_only() { - let bom_prefixed = [0xEF_u8, 0xBB_u8, 0xBF_u8, b'<', b'x', b'>']; - assert_eq!(skipped_prefix_len(0, &bom_prefixed), 3); - assert_eq!(skipped_prefix_len(1, &bom_prefixed), 0); - assert_eq!(skipped_prefix_len(0, b""), 0); - } - - #[test] - fn classifies_json_tokens() { - let json = br#"{"name":"bob","age":42,"ok":true,"v":null}"#; - let classes = classify_json_line(json); - - let first_colon = json - .iter() - .position(|&b| b == b':') - .expect("json should contain colon"); - let age_index = json - .windows(3) - .position(|w| w == b"age") - .expect("json should contain age"); - let number_index = json - .iter() - .position(|&b| b == b'4') - .expect("json should contain number"); - let true_index = json - .windows(4) - .position(|w| w == b"true") - .expect("json should contain true"); - let null_index = json - .windows(4) - .position(|w| w == b"null") - .expect("json should contain null"); - - assert_eq!(classes[0], JsonTokenClass::Delimiter); - assert_eq!(classes[1], JsonTokenClass::Key); - assert_eq!(classes[first_colon], JsonTokenClass::Delimiter); - assert_eq!(classes[age_index], JsonTokenClass::Key); - assert_eq!(classes[number_index], JsonTokenClass::Number); - assert_eq!(classes[true_index], JsonTokenClass::Keyword); - assert_eq!(classes[null_index], JsonTokenClass::Keyword); - } - - #[test] - fn formats_single_line_xml_into_indented_lines() { - let xml = br#"text"#; - let formatted = format_xml_for_display(xml); - let formatted = String::from_utf8(formatted).expect("formatted xml should be utf8"); - let expected = - "\n \n \n \n text\n"; - assert_eq!(formatted, expected); - } - - #[test] - fn formats_xml_with_comments_and_header() { - let xml = br#""#; - let formatted = format_xml_for_display(xml); - let formatted = String::from_utf8(formatted).expect("formatted xml should be utf8"); - let expected = "\n\n \n \n"; - assert_eq!(formatted, expected); - } - - #[test] - fn formats_json_into_pretty_lines() { - let json = br#"{"item":"Test","count":2,"ok":true,"arr":[1,2]}"#; - let formatted = format_json_for_display(json).expect("json should format"); - let formatted = String::from_utf8(formatted).expect("formatted json should be utf8"); - let expected = "{\n \"item\": \"Test\",\n \"count\": 2,\n \"ok\": true,\n \"arr\": [\n 1,\n 2\n ]\n}"; - assert_eq!(formatted, expected); - } - - #[test] - fn invalid_json_does_not_format() { - assert!(format_json_for_display(br#"{"bad":"unterminated}"#).is_none()); - } -} +mod tests; diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..c2d5436 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,207 @@ +use super::*; +mod tests { + use super::{ + centered_top_line, classify_json_line, classify_xml_line, detect_csv_separator, + format_json_for_display, format_xml_for_display, parse_csv_separator, skipped_prefix_len, + JsonTokenClass, Viewer, XmlTokenClass, + }; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + #[test] + fn indexes_lines() { + let offsets = Viewer::index_lines(b"a\nb\nlast"); + assert_eq!(offsets, vec![0, 2, 4]); + } + + #[test] + fn indexes_empty() { + let offsets = Viewer::index_lines(b""); + assert_eq!(offsets, vec![0]); + } + + #[test] + fn centers_target_line_when_possible() { + assert_eq!(centered_top_line(50, 20, 200), 40); + } + + #[test] + fn centers_target_line_with_small_targets() { + assert_eq!(centered_top_line(3, 20, 200), 0); + } + + #[test] + fn centers_target_line_near_end() { + assert_eq!(centered_top_line(199, 20, 200), 189); + } + + #[test] + fn finds_forward_and_backward() { + let viewer = test_viewer_from_bytes(b"abc\ndef abc xyz abc"); + assert_eq!(viewer.find_forward(b"abc", 0), Some((0, 3))); + assert_eq!(viewer.find_forward(b"abc", 4), Some((8, 11))); + assert_eq!(viewer.find_backward(b"abc", 18), Some((16, 19))); + assert_eq!(viewer.find_backward(b"missing", 18), None); + } + + #[test] + fn maps_offsets_to_lines() { + let viewer = test_viewer_from_bytes(b"line1\nline2\nline3"); + assert_eq!(viewer.line_of_offset(0), 0); + assert_eq!(viewer.line_of_offset(6), 1); + assert_eq!(viewer.line_of_offset(12), 2); + } + + fn with_temp_file(bytes: &[u8], f: impl FnOnce(PathBuf) -> Viewer) -> Viewer { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos(); + let path = std::env::temp_dir().join(format!("large-file-viewer-test-{nonce}.txt")); + fs::write(&path, bytes).expect("failed to write temp file"); + let viewer = f(path.clone()); + fs::remove_file(path).expect("failed to remove temp file"); + viewer + } + + fn test_viewer_from_bytes(bytes: &[u8]) -> Viewer { + with_temp_file(bytes, |path| { + Viewer::open(path, 4, false, None, false, false, false).expect("failed to open viewer") + }) + } + + #[test] + fn indexes_csv_column_widths() { + let widths = Viewer::index_csv_column_widths(b"a,bbb\ncccc,d", 4, b','); + assert_eq!(widths, vec![4, 3]); + } + + #[test] + fn csv_mode_starts_below_pinned_header() { + let viewer = test_viewer_with_options(b"h1,h2\nv1,v2\nv3,v4", 4, true); + assert_eq!(viewer.top_line, 1); + } + + #[test] + fn csv_scroll_up_keeps_header_pinned() { + let mut viewer = test_viewer_with_options(b"h1,h2\nv1,v2\nv3,v4", 4, true); + viewer.scroll_up(10); + assert_eq!(viewer.top_line, 1); + } + + #[test] + fn auto_detects_semicolon_separator() { + assert_eq!(detect_csv_separator(b"h1;h2\na;b\nc;d"), b';'); + } + + #[test] + fn parses_tab_separator_escape() { + assert_eq!(parse_csv_separator(r"\t").expect("should parse"), b'\t'); + } + + fn test_viewer_with_options(bytes: &[u8], tab_width: usize, csv: bool) -> Viewer { + with_temp_file(bytes, |path| { + Viewer::open(path, tab_width, csv, None, false, false, false) + .expect("failed to open viewer") + }) + } + + #[test] + fn classifies_xml_tags_and_attributes() { + let classes = classify_xml_line(br#"text"#); + assert_eq!(classes[0], XmlTokenClass::TagDelimiter); + assert_eq!(classes[1], XmlTokenClass::TagName); + assert_eq!(classes[6], XmlTokenClass::AttributeName); + assert_eq!(classes[11], XmlTokenClass::AttributeValue); + assert_eq!(classes[19], XmlTokenClass::Text); + } + + #[test] + fn classifies_xml_comments() { + let classes = classify_xml_line(br#" "#); + assert_eq!(classes[0], XmlTokenClass::Comment); + assert_eq!(classes[10], XmlTokenClass::Comment); + assert_eq!(classes[16], XmlTokenClass::Text); + assert_eq!(classes[17], XmlTokenClass::TagDelimiter); + assert_eq!(classes[18], XmlTokenClass::TagName); + } + + #[test] + fn skips_utf8_bom_prefix_on_first_line_only() { + let bom_prefixed = [0xEF_u8, 0xBB_u8, 0xBF_u8, b'<', b'x', b'>']; + assert_eq!(skipped_prefix_len(0, &bom_prefixed), 3); + assert_eq!(skipped_prefix_len(1, &bom_prefixed), 0); + assert_eq!(skipped_prefix_len(0, b""), 0); + } + + #[test] + fn classifies_json_tokens() { + let json = br#"{"name":"bob","age":42,"ok":true,"v":null}"#; + let classes = classify_json_line(json); + + let first_colon = json + .iter() + .position(|&b| b == b':') + .expect("json should contain colon"); + let age_index = json + .windows(3) + .position(|w| w == b"age") + .expect("json should contain age"); + let number_index = json + .iter() + .position(|&b| b == b'4') + .expect("json should contain number"); + let true_index = json + .windows(4) + .position(|w| w == b"true") + .expect("json should contain true"); + let null_index = json + .windows(4) + .position(|w| w == b"null") + .expect("json should contain null"); + + assert_eq!(classes[0], JsonTokenClass::Delimiter); + assert_eq!(classes[1], JsonTokenClass::Key); + assert_eq!(classes[first_colon], JsonTokenClass::Delimiter); + assert_eq!(classes[age_index], JsonTokenClass::Key); + assert_eq!(classes[number_index], JsonTokenClass::Number); + assert_eq!(classes[true_index], JsonTokenClass::Keyword); + assert_eq!(classes[null_index], JsonTokenClass::Keyword); + } + + #[test] + fn formats_single_line_xml_into_indented_lines() { + let xml = br#"text"#; + let formatted = format_xml_for_display(xml); + let formatted = String::from_utf8(formatted).expect("formatted xml should be utf8"); + let expected = + "\n \n \n \n text\n"; + assert_eq!(formatted, expected); + } + + #[test] + fn formats_xml_with_comments_and_header() { + let xml = br#""#; + let formatted = format_xml_for_display(xml); + let formatted = String::from_utf8(formatted).expect("formatted xml should be utf8"); + let expected = "\n\n \n \n"; + assert_eq!(formatted, expected); + } + + #[test] + fn formats_json_into_pretty_lines() { + let json = br#"{"item":"Test","count":2,"ok":true,"arr":[1,2]}"#; + let formatted = format_json_for_display(json).expect("json should format"); + let formatted = String::from_utf8(formatted).expect("formatted json should be utf8"); + let expected = "{\n \"item\": \"Test\",\n \"count\": 2,\n \"ok\": true,\n \"arr\": [\n 1,\n 2\n ]\n}"; + assert_eq!(formatted, expected); + } + + #[test] + fn invalid_json_does_not_format() { + assert!(format_json_for_display(br#"{"bad":"unterminated}"#).is_none()); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..8bf8478 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs new file mode 100644 index 0000000..1091bea --- /dev/null +++ b/src/ui/prompt.rs @@ -0,0 +1,103 @@ +use crate::{clip_to_width, Viewer}; +use anyhow::{Context, Result}; +use crossterm::{ + cursor, + event::{self, Event, KeyCode, KeyEventKind}, + queue, + style::Stylize, + terminal::{self, ClearType}, +}; +use std::io::Write; + +pub(crate) fn prompt_goto_line(viewer: &Viewer, out: &mut impl Write) -> Result> { + let mut input = String::new(); + + loop { + let (width, height) = terminal::size().context("Failed to get terminal size")?; + let prompt = format!( + "Goto line (1-{}, Enter=go, Esc=cancel): {}", + viewer.line_count(), + input + ); + let clipped_prompt = clip_to_width(&prompt, width as usize); + let y = height.saturating_sub(1); + + queue!( + out, + cursor::MoveTo(0, y), + terminal::Clear(ClearType::CurrentLine), + style::PrintStyledContent(clipped_prompt.reverse()) + )?; + out.flush().context("Failed to flush terminal output")?; + + match event::read().context("Failed reading terminal event")? { + Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { + KeyCode::Esc => return Ok(None), + KeyCode::Enter => { + if input.is_empty() { + return Ok(None); + } + + if let Ok(line_number) = input.parse::() { + if line_number >= 1 { + return Ok(Some(line_number)); + } + } + } + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(c) if c.is_ascii_digit() => { + input.push(c); + } + _ => {} + }, + _ => {} + } + } +} + +pub(crate) fn prompt_find(viewer: &Viewer, out: &mut impl Write) -> Result> { + let mut input = viewer + .search_query + .as_ref() + .map(|query| String::from_utf8_lossy(query).to_string()) + .unwrap_or_default(); + + loop { + let (width, height) = terminal::size().context("Failed to get terminal size")?; + let prompt = format!("Find text (Enter=find, Esc=cancel): {}", input); + let clipped_prompt = clip_to_width(&prompt, width as usize); + let y = height.saturating_sub(1); + + queue!( + out, + cursor::MoveTo(0, y), + terminal::Clear(ClearType::CurrentLine), + style::PrintStyledContent(clipped_prompt.reverse()) + )?; + out.flush().context("Failed to flush terminal output")?; + + match event::read().context("Failed reading terminal event")? { + Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { + KeyCode::Esc => return Ok(None), + KeyCode::Enter => { + if input.is_empty() { + return Ok(None); + } + return Ok(Some(input)); + } + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(c) => { + if !c.is_control() { + input.push(c); + } + } + _ => {} + }, + _ => {} + } + } +} From 2da61c1f4058971c4e913cb4e187afdec78f018b Mon Sep 17 00:00:00 2001 From: Ewart Nijburg Date: Fri, 15 May 2026 17:33:15 +0200 Subject: [PATCH 2/2] Fix prompt module style import for PrintStyledContent --- src/ui/prompt.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index 1091bea..1bb2e0b 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -4,7 +4,7 @@ use crossterm::{ cursor, event::{self, Event, KeyCode, KeyEventKind}, queue, - style::Stylize, + style::{self, Stylize}, terminal::{self, ClearType}, }; use std::io::Write;