diff --git a/CHANGELOG.md b/CHANGELOG.md index fc504f2..141306b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ for GitHub Release notes, so every published version must have a matching ## [Unreleased] +## [0.6.3] - 2026-08-04 + +### Added + +- Add a dedicated interactive explorer for CSV, TSV, and other delimiter- + separated files. It keeps a searchable field sidebar beside the complete + selected value, navigates records lazily, and reuses JSON/XML/scalar syntax + highlighting inside individual cells without changing redirected output. + Structured cells larger than 1 MiB stay in their complete raw layout to + avoid retaining another whole formatted copy. + ## [0.6.2] - 2026-07-22 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index a4cb907..0969574 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,6 +222,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "darling" version = "0.23.0" @@ -341,7 +362,7 @@ dependencies = [ [[package]] name = "fmtview" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", "assert_cmd", @@ -355,9 +376,10 @@ dependencies = [ [[package]] name = "fmtview-core" -version = "0.6.2" +version = "0.6.3" dependencies = [ "anyhow", + "csv", "memchr", "quick-xml", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index 4936075..6e27a94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,14 +5,14 @@ resolver = "3" [package] name = "fmtview" -version = "0.6.2" +version = "0.6.3" edition = "2024" rust-version = "1.85" -description = "Fast CLI viewer for highlighting, search, and diffs across JSON, JSONL, HTML/XML, Markdown, TOML, text, and Jinja" +description = "Fast CLI viewer for CSV, JSON, JSONL, HTML/XML, Markdown, TOML, text, and Jinja" license = "MIT" repository = "https://github.com/siriusctrl/fmtview" readme = "README.md" -keywords = ["json", "jsonl", "markdown", "toml", "cli"] +keywords = ["csv", "json", "jsonl", "viewer", "cli"] categories = ["command-line-utilities", "development-tools"] exclude = [".github/", "npm/"] @@ -20,7 +20,7 @@ exclude = [".github/", "npm/"] anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive"] } crossterm = "0.29.0" -fmtview-core = { version = "0.6.2", path = "crates/fmtview-core" } +fmtview-core = { version = "0.6.3", path = "crates/fmtview-core" } ratatui = { version = "0.30.0", default-features = false, features = ["crossterm"] } tempfile = "3.27.0" diff --git a/README.md b/README.md index b2abf5e..83ef9c7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # fmtview -Fast CLI viewing, highlighting, search, and diffing for JSON, JSONL, -XML-compatible markup, HTML, Markdown, TOML, plain text, and Jinja templates. +Fast CLI viewing, highlighting, search, and diffing for CSV/TSV/XSV, JSON, +JSONL, XML-compatible markup, HTML, Markdown, TOML, plain text, and Jinja +templates. `fmtview` is built for the workflow where you want to inspect data quickly in a terminal: open large files without waiting for a full render, keep format @@ -10,6 +11,7 @@ inputs without leaving the CLI. ```sh fmtview payload.json +fmtview export.csv fmtview events.jsonl fmtview --follow events.jsonl fmtview response.xml @@ -39,6 +41,8 @@ embedded markup, wrapped records, or formatted diffs. - View files in a terminal UI with line numbers, progress, and indent-aware soft wrap. +- Explore delimiter-separated records with a searchable field sidebar and a + complete, wrapped value pane instead of squeezing wide records into a grid. - Highlight JSON, XML-compatible markup, HTML, embedded markup in JSON strings, Markdown, TOML, and Jinja templates. - Search the visible text without loading rendered output into memory. @@ -202,6 +206,12 @@ formatting errors instead of silently changing output semantics. Known extensions still provide a fast, deterministic hint: +- `.csv` -> comma-separated record explorer in a TTY, exact passthrough when + redirected. +- `.tsv` and `.tab` -> tab-separated record explorer. +- `.xsv`, `.psv`, and `.ssv` -> delimiter-separated explorer; `.psv` and + `.ssv` select pipe and semicolon directly, while `.xsv` sniffs comma, tab, + pipe, or semicolon from at most 24 records and 256 KiB per candidate. - `.json` -> JSON formatting. - `.jsonl` and `.ndjson` -> lazy JSONL record formatting. - `.xml` and `.xhtml` -> XML-compatible markup formatting. @@ -237,6 +247,8 @@ Use `--type` when stdin or an unusual extension needs an explicit profile. Other types are intentionally passthrough: +- CSV/TSV/XSV data keeps its exact source bytes on redirected stdout. In a TTY, + the first record supplies field names and later records are indexed lazily. - Markdown is indexed, wrapped, and highlighted, but not rendered to HTML or reformatted. Known fenced code blocks reuse the same highlighters as top-level files. @@ -437,6 +449,36 @@ toggle, next/previous change navigation, and line/inline diff shading. ## Viewer +### Delimited record explorer + +CSV, TSV, and XSV files use a field/value explorer rather than the ordinary +line viewer. The left sidebar keeps field names visible and `/` filters them as +you type. The right pane shows the selected value completely with wrapping; it +does not replace long content with an ellipsis. JSON and XML values reuse the +matching formatter and highlighter, while scalar values use number, boolean, +null, or string colors. + +```text +Up/Down or j/k previous/next field +Left/Right or h/l previous/next record +/ find a field +Enter focus the complete value +Esc return from the value to fields; quit from fields +``` + +While the value is focused, `Up`/`Down`, `j`/`k`, `PageUp`/`PageDown`, +`Home`, and `End` scroll it. `/` searches inside the value and `n`/`N` repeat +the search. Record positions are indexed lazily, including quoted CSV fields +that contain physical newlines. Redirected output remains the original file; +the explorer is a TTY-only presentation. + +The CSV parser holds the selected logical record in memory so quoting and +multiline fields remain correct. Embedded JSON/XML values up to 1 MiB are +pretty-formatted for display. Larger structured values remain complete and +syntax-highlighted in their original layout, avoiding an additional formatted +copy; selecting an unusually large record still costs memory proportional to +that raw record. + The viewer is intentionally small and works with both keyboard and pointer input: @@ -658,8 +700,9 @@ rendered output in memory for browsing. queued, so a burst of scroll events is not delayed by speculative work. - Highlighting and wrapping scan only the visible prefix of long lines. - Viewer search scans the indexed visible text in bounded chunks. -- JSON, JSONL, XML-compatible markup, HTML, Markdown, TOML, plain text, and Jinja - templates are processed incrementally where their load strategy allows it. +- CSV/TSV/XSV cell content and JSON, JSONL, XML-compatible markup, HTML, + Markdown, TOML, plain text, and Jinja templates are processed incrementally + where their load strategy allows it. - JSON numbers are written from their original tokens instead of being coerced through native integer or floating-point types. diff --git a/crates/fmtview-core/Cargo.toml b/crates/fmtview-core/Cargo.toml index 6a964a4..ef63533 100644 --- a/crates/fmtview-core/Cargo.toml +++ b/crates/fmtview-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fmtview-core" -version = "0.6.2" +version = "0.6.3" edition = "2024" rust-version = "1.85" description = "Headless viewer engine for fmtview" @@ -16,6 +16,7 @@ path = "src/lib.rs" [dependencies] anyhow = "1.0.102" +csv = "1.3.1" memchr = "2.8.0" quick-xml = "0.39.2" ratatui = { version = "0.30.0", default-features = false } diff --git a/crates/fmtview-core/src/formats.rs b/crates/fmtview-core/src/formats.rs index 02469d5..2999599 100644 --- a/crates/fmtview-core/src/formats.rs +++ b/crates/fmtview-core/src/formats.rs @@ -1,4 +1,5 @@ mod checkpoints; +pub(crate) mod delimited; pub(crate) mod html; mod indent; pub(crate) mod jinja; @@ -30,6 +31,9 @@ use crate::{ }; pub(crate) const FORMAT_SPECS: &[FormatSpec] = &[ + delimited::CSV_SPEC, + delimited::TSV_SPEC, + delimited::XSV_SPEC, json::SPEC, jsonl::SPEC, xml::SPEC, @@ -54,6 +58,8 @@ pub enum ContentShape { LineIndexed, /// Input is a sequence of independent newline-delimited records. RecordStream, + /// Input is a sequence of delimiter-aware records with named fields. + DelimitedRecords, /// Formatting requires document-level parser state. WholeDocument, } @@ -90,6 +96,9 @@ pub(crate) fn highlight_content_window_indexed( let window_start = window_start.min(line.len()); let window_end = window_end.min(line.len()).max(window_start); match format { + FormatKind::Csv | FormatKind::Tsv | FormatKind::Xsv => { + plain::highlight::highlight_plain_window(line, window_start, window_end) + } FormatKind::Json | FormatKind::Jsonl => { json::highlight::highlight_json_like_window(line, window_start, window_end, index) } @@ -114,6 +123,33 @@ pub(crate) fn highlight_content_window_indexed( } } +pub(crate) fn highlight_large_content_window( + line: &str, + format: FormatKind, + window_start: usize, + window_end: usize, +) -> Vec> { + const CONTEXT_BYTES: usize = 4 * 1024; + + let window_start = window_start.min(line.len()); + let window_end = window_end.min(line.len()).max(window_start); + let mut context_start = window_start.saturating_sub(CONTEXT_BYTES); + while context_start > 0 && !line.is_char_boundary(context_start) { + context_start -= 1; + } + let mut context_end = window_end.saturating_add(CONTEXT_BYTES).min(line.len()); + while context_end < line.len() && !line.is_char_boundary(context_end) { + context_end += 1; + } + let context = &line[context_start..context_end]; + highlight_content_window( + context, + format, + window_start - context_start, + window_end - context_start, + ) +} + pub(crate) fn highlight_structured_window( line: &str, window_start: usize, @@ -160,6 +196,7 @@ pub(crate) fn structure_candidate_kind( previous_line: Option<&str>, ) -> Option { match format { + FormatKind::Csv | FormatKind::Tsv | FormatKind::Xsv => None, FormatKind::Json | FormatKind::Jsonl => json::structure::candidate_kind(line), FormatKind::Xml | FormatKind::Html => xml::structure::is_start_tag(line.trim_start()) .then_some(StructureCandidateKind::XmlStartTag), @@ -185,6 +222,7 @@ pub(crate) fn structure_candidate_kind_in_window( offset: usize, ) -> Option { match format { + FormatKind::Csv | FormatKind::Tsv | FormatKind::Xsv => None, FormatKind::Json | FormatKind::Jsonl => { json::structure::candidate_kind_in_window(lines, offset) } @@ -210,6 +248,7 @@ pub(crate) fn structure_block_end( line_count_exact: bool, ) -> Option { match format { + FormatKind::Csv | FormatKind::Tsv | FormatKind::Xsv => None, FormatKind::Json | FormatKind::Jsonl => { json::structure::block_end(lines, read_start, start_offset, viewport_bottom) } @@ -364,6 +403,15 @@ mod tests { assert_eq!(span_text(&spans), r#"{{ item.name }}"#); } + #[test] + fn large_highlight_preserves_only_the_requested_deep_window() { + let line = format!("[{}true]", "1234567890,".repeat(200_000)); + let start = line.len() - 64; + let spans = highlight_large_content_window(&line, FormatKind::Json, start, line.len()); + + assert_eq!(span_text(&spans), &line[start..]); + } + fn span_text(spans: &[Span<'static>]) -> String { spans .iter() diff --git a/crates/fmtview-core/src/formats/delimited.rs b/crates/fmtview-core/src/formats/delimited.rs new file mode 100644 index 0000000..74dd1c1 --- /dev/null +++ b/crates/fmtview-core/src/formats/delimited.rs @@ -0,0 +1,141 @@ +use std::{ffi::OsStr, io::Read, path::Path}; + +use anyhow::{Context, Result}; +use csv::{ByteRecord, ReaderBuilder}; + +use crate::{ + formats::{ContentShape, FormatSpec}, + load::LoadPlan, + transform::{FormatKind, TransformStrategy}, +}; + +const SNIFF_RECORDS: usize = 24; +const SNIFF_BYTES: u64 = 256 * 1024; +const DELIMITER_CANDIDATES: &[u8] = b",\t|;"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DelimitedDialect { + delimiter: u8, +} + +impl DelimitedDialect { + pub const fn delimiter(self) -> u8 { + self.delimiter + } + + pub fn display(self) -> &'static str { + match self.delimiter { + b',' => ",", + b'\t' => "TAB", + b'|' => "|", + b';' => ";", + _ => "custom", + } + } +} + +pub(crate) fn resolve_dialect(path: &Path, kind: FormatKind) -> Result { + let delimiter = match kind { + FormatKind::Csv => b',', + FormatKind::Tsv => b'\t', + FormatKind::Xsv => match path + .extension() + .and_then(OsStr::to_str) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("psv") => b'|', + Some("ssv") => b';', + _ => sniff_delimiter(path)?, + }, + _ => anyhow::bail!("{kind:?} is not a delimited format"), + }; + Ok(DelimitedDialect { delimiter }) +} + +fn sniff_delimiter(path: &Path) -> Result { + let mut best = None; + for &delimiter in DELIMITER_CANDIDATES { + let file = std::fs::File::open(path) + .with_context(|| format!("failed to open {}", path.display()))?; + let mut reader = ReaderBuilder::new() + .delimiter(delimiter) + .has_headers(false) + .flexible(true) + .from_reader(file.take(SNIFF_BYTES)); + let mut record = ByteRecord::new(); + let mut counts = Vec::new(); + while counts.len() < SNIFF_RECORDS && reader.read_byte_record(&mut record)? { + if !record.is_empty() { + counts.push(record.len()); + } + } + let Some((&columns, consistent)) = most_common_count(&counts) else { + continue; + }; + if columns <= 1 { + continue; + } + let score = (consistent, columns); + if best.is_none_or(|(_, best_score)| score > best_score) { + best = Some((delimiter, score)); + } + } + Ok(best.map(|(delimiter, _)| delimiter).unwrap_or(b',')) +} + +fn most_common_count(counts: &[usize]) -> Option<(&usize, usize)> { + counts + .iter() + .map(|candidate| { + let occurrences = counts.iter().filter(|count| *count == candidate).count(); + (candidate, occurrences) + }) + .max_by_key(|(columns, occurrences)| (*occurrences, **columns)) +} + +pub(crate) const CSV_SPEC: FormatSpec = FormatSpec { + kind: FormatKind::Csv, + extensions: &["csv"], + shape: ContentShape::DelimitedRecords, + load: LoadPlan::LazyDelimitedRecords, + transform: TransformStrategy::Passthrough, +}; + +pub(crate) const TSV_SPEC: FormatSpec = FormatSpec { + kind: FormatKind::Tsv, + extensions: &["tsv", "tab"], + shape: ContentShape::DelimitedRecords, + load: LoadPlan::LazyDelimitedRecords, + transform: TransformStrategy::Passthrough, +}; + +pub(crate) const XSV_SPEC: FormatSpec = FormatSpec { + kind: FormatKind::Xsv, + extensions: &["xsv", "psv", "ssv"], + shape: ContentShape::DelimitedRecords, + load: LoadPlan::LazyDelimitedRecords, + transform: TransformStrategy::Passthrough, +}; + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::Builder; + + use super::*; + + #[test] + fn xsv_sniff_does_not_read_past_its_byte_budget() { + let mut temp = Builder::new().suffix(".xsv").tempfile().unwrap(); + temp.write_all(&vec![b'x'; SNIFF_BYTES as usize + 32]) + .unwrap(); + temp.write_all(b"|outside|budget\n").unwrap(); + temp.flush().unwrap(); + + let dialect = resolve_dialect(temp.path(), FormatKind::Xsv).unwrap(); + + assert_eq!(dialect.delimiter(), b','); + } +} diff --git a/crates/fmtview-core/src/lib.rs b/crates/fmtview-core/src/lib.rs index 02c2e41..77cde61 100644 --- a/crates/fmtview-core/src/lib.rs +++ b/crates/fmtview-core/src/lib.rs @@ -16,8 +16,8 @@ pub use diff::{DiffView, diff_sources, diff_view}; pub use formats::ContentShape; pub use input::InputSource; pub use load::{ - LoadPlan, OpenedViewFile, RecordTimelineViewFile, ViewFile, ViewFileChange, - open_follow_view_file, open_view_file, open_view_file_with_fallback, + DelimitedDataset, LoadPlan, OpenedViewFile, RecordTimelineViewFile, ViewFile, ViewFileChange, + open_delimited_dataset, open_follow_view_file, open_view_file, open_view_file_with_fallback, }; pub use profile::TypeProfile; pub use timeline::{ @@ -28,8 +28,8 @@ pub use timeline::{ pub use transform::{FormatKind, FormatOptions}; pub use tui::screen::{RenderFrame, ScrollDirection, ScrollHint, ScrollPosition}; pub use viewer::{ - DiffViewer, FileViewer, InputEvent, KeyCode, KeyModifiers, MouseEventKind, ViewerAction, - ViewerCommand, + DelimitedViewer, DiffViewer, FileViewer, InputEvent, KeyCode, KeyModifiers, MouseEventKind, + ViewerAction, ViewerCommand, }; /// Transform one source according to an already resolved profile. diff --git a/crates/fmtview-core/src/load.rs b/crates/fmtview-core/src/load.rs index 5637cfd..f2cdeb6 100644 --- a/crates/fmtview-core/src/load.rs +++ b/crates/fmtview-core/src/load.rs @@ -1,3 +1,4 @@ +mod delimited; mod indexed; mod lazy; mod lazy_records; @@ -9,6 +10,7 @@ pub(crate) mod record_stream; mod timeline; mod view_file; +pub use delimited::{DelimitedDataset, open_delimited_dataset}; pub use indexed::IndexedTempFile; pub use lazy_records::LazyTransformedRecordsFile; pub use open::{ diff --git a/crates/fmtview-core/src/load/delimited.rs b/crates/fmtview-core/src/load/delimited.rs new file mode 100644 index 0000000..9d2c30e --- /dev/null +++ b/crates/fmtview-core/src/load/delimited.rs @@ -0,0 +1,306 @@ +use std::{ + collections::{HashMap, HashSet}, + fs::File, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result}; +use csv::{ByteRecord, Position, Reader, ReaderBuilder}; + +use crate::{ + formats::delimited::{DelimitedDialect, resolve_dialect}, + input::InputSource, + transform::FormatKind, +}; + +const DEFAULT_PRELOAD_RECORDS: usize = 128; + +pub struct DelimitedDataset { + source: InputSource, + label: String, + kind: FormatKind, + dialect: DelimitedDialect, + scanner: Reader, + headers: Vec, + positions: Vec, + complete: bool, + current_index: Option, + current: ByteRecord, +} + +pub fn open_delimited_dataset(source: InputSource, kind: FormatKind) -> Result { + DelimitedDataset::open(source, kind) +} + +impl DelimitedDataset { + fn open(source: InputSource, kind: FormatKind) -> Result { + let dialect = resolve_dialect(source.path(), kind)?; + let file = source.open()?; + let mut scanner = reader(file, dialect); + let mut header = ByteRecord::new(); + let has_header = scanner + .read_byte_record(&mut header) + .with_context(|| format!("failed to read headers from {}", source.label()))?; + let headers = if has_header { + display_headers(&header) + } else { + Vec::new() + }; + let mut dataset = Self { + label: source.label().to_owned(), + source, + kind, + dialect, + scanner, + headers, + positions: Vec::new(), + complete: !has_header, + current_index: None, + current: ByteRecord::new(), + }; + if has_header { + dataset.scan_one()?; + if !dataset.positions.is_empty() { + dataset.load_record(0)?; + } + } + Ok(dataset) + } + + pub fn label(&self) -> &str { + &self.label + } + + pub const fn kind(&self) -> FormatKind { + self.kind + } + + pub const fn dialect(&self) -> DelimitedDialect { + self.dialect + } + + pub fn headers(&self) -> &[String] { + &self.headers + } + + pub fn record_count(&self) -> usize { + self.positions.len() + } + + pub const fn record_count_exact(&self) -> bool { + self.complete + } + + pub const fn current_index(&self) -> Option { + self.current_index + } + + pub fn current_value(&self, field: usize) -> String { + String::from_utf8_lossy(self.current_value_bytes(field)).into_owned() + } + + pub fn current_value_bytes(&self, field: usize) -> &[u8] { + self.current.get(field).unwrap_or_default() + } + + pub fn move_record(&mut self, delta: isize) -> Result { + let Some(current) = self.current_index else { + return Ok(false); + }; + let target = if delta >= 0 { + current.saturating_add(delta as usize) + } else { + current.saturating_sub(delta.unsigned_abs()) + }; + self.set_record(target) + } + + pub fn set_record(&mut self, index: usize) -> Result { + while index >= self.positions.len() && !self.complete { + if !self.scan_one()? { + break; + } + } + if index >= self.positions.len() || self.current_index == Some(index) { + return Ok(false); + } + self.load_record(index)?; + Ok(true) + } + + pub fn preload(&mut self, budget: Duration) -> Result { + if self.complete { + return Ok(false); + } + let started = Instant::now(); + let before = self.positions.len(); + for _ in 0..DEFAULT_PRELOAD_RECORDS { + if started.elapsed() >= budget || !self.scan_one()? { + break; + } + } + Ok(self.positions.len() != before || self.complete) + } + + fn scan_one(&mut self) -> Result { + if self.complete { + return Ok(false); + } + let mut record = ByteRecord::new(); + if !self + .scanner + .read_byte_record(&mut record) + .with_context(|| format!("failed to index record in {}", self.label))? + { + self.complete = true; + return Ok(false); + } + let position = record + .position() + .cloned() + .context("CSV reader did not report a record position")?; + self.positions.push(position); + self.extend_headers(record.len()); + Ok(true) + } + + fn load_record(&mut self, index: usize) -> Result<()> { + let position = self + .positions + .get(index) + .cloned() + .context("record position was not indexed")?; + let file = self.source.open()?; + let mut reader = reader(file, self.dialect); + reader + .seek(position) + .with_context(|| format!("failed to seek record {}", index + 1))?; + self.current.clear(); + if !reader + .read_byte_record(&mut self.current) + .with_context(|| format!("failed to read record {}", index + 1))? + { + anyhow::bail!("record {} ended before its indexed position", index + 1); + } + self.extend_headers(self.current.len()); + self.current_index = Some(index); + Ok(()) + } + + fn extend_headers(&mut self, field_count: usize) { + while self.headers.len() < field_count { + self.headers + .push(format!("field_{}", self.headers.len() + 1)); + } + } +} + +fn reader(file: File, dialect: DelimitedDialect) -> Reader { + ReaderBuilder::new() + .delimiter(dialect.delimiter()) + .has_headers(false) + .flexible(true) + .from_reader(file) +} + +fn display_headers(record: &ByteRecord) -> Vec { + let mut headers = Vec::with_capacity(record.len()); + let mut used = HashSet::with_capacity(record.len()); + let mut next_suffix = HashMap::with_capacity(record.len()); + for (index, raw) in record.iter().enumerate() { + let decoded = String::from_utf8_lossy(raw).into_owned(); + let base = if decoded.trim().is_empty() { + format!("field_{}", index + 1) + } else { + decoded + }; + let suffix = next_suffix.entry(base.clone()).or_insert(1_usize); + let header = loop { + let candidate = if *suffix == 1 { + base.clone() + } else { + format!("{base} [{suffix}]") + }; + *suffix += 1; + if used.insert(candidate.clone()) { + break candidate; + } + }; + headers.push(header); + } + headers +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::Builder; + + use super::*; + + fn source(suffix: &str, body: &[u8]) -> (tempfile::NamedTempFile, InputSource) { + let mut temp = Builder::new().suffix(suffix).tempfile().unwrap(); + temp.write_all(body).unwrap(); + temp.flush().unwrap(); + let source = InputSource::from_path(temp.path(), "sample").unwrap(); + (temp, source) + } + + #[test] + fn indexes_quoted_multiline_records_and_seeks_back() { + let (_temp, source) = source( + ".csv", + b"id,payload\n1,\"line one\nline two\"\n2,\"{\"\"ok\"\":true}\"\n", + ); + let mut dataset = open_delimited_dataset(source, FormatKind::Csv).unwrap(); + + assert_eq!(dataset.headers(), &["id", "payload"]); + assert_eq!(dataset.current_value(1), "line one\nline two"); + assert!(dataset.move_record(1).unwrap()); + assert_eq!(dataset.current_value(0), "2"); + assert_eq!(dataset.current_value(1), r#"{"ok":true}"#); + assert!(dataset.move_record(-1).unwrap()); + assert_eq!(dataset.current_value(1), "line one\nline two"); + } + + #[test] + fn sniffs_pipe_delimited_xsv() { + let (_temp, source) = source(".xsv", b"id|status|payload\n1|ok|hello\n"); + let dataset = open_delimited_dataset(source, FormatKind::Xsv).unwrap(); + assert_eq!(dataset.dialect().delimiter(), b'|'); + assert_eq!(dataset.headers(), &["id", "status", "payload"]); + assert_eq!(dataset.current_value(2), "hello"); + } + + #[test] + fn duplicate_headers_receive_stable_unique_names() { + let (_temp, source) = source(".csv", b"a,a,a,a [2],a,,\n1,2,3,4,5,6,7\n"); + let dataset = open_delimited_dataset(source, FormatKind::Csv).unwrap(); + + assert_eq!( + dataset.headers(), + &[ + "a", + "a [2]", + "a [3]", + "a [2] [2]", + "a [4]", + "field_6", + "field_7" + ] + ); + } + + #[test] + fn dataset_keeps_temporary_input_open_after_source_drops() { + let mut temp = Builder::new().suffix(".csv").tempfile().unwrap(); + temp.write_all(b"id,value\n1,first\n2,second\n").unwrap(); + temp.flush().unwrap(); + let source = InputSource::from_temp(temp, "sample"); + let mut dataset = open_delimited_dataset(source, FormatKind::Csv).unwrap(); + + assert!(dataset.move_record(1).unwrap()); + assert_eq!(dataset.current_value(1), "second"); + } +} diff --git a/crates/fmtview-core/src/load/open.rs b/crates/fmtview-core/src/load/open.rs index 5de7edf..0f6ef72 100644 --- a/crates/fmtview-core/src/load/open.rs +++ b/crates/fmtview-core/src/load/open.rs @@ -45,6 +45,9 @@ pub fn open_view_file_with_fallback( allow_plain_fallback: bool, ) -> Result { match profile.load { + LoadPlan::LazyDelimitedRecords => { + anyhow::bail!("delimited inputs require the delimited viewer") + } LoadPlan::LazyTransformedRecords => Ok(OpenedViewFile { file: Box::new(LazyTransformedRecordsFile::new(input, *options)?), content: profile.content, @@ -108,6 +111,9 @@ impl FormatKindLabel for FormatKind { fn label(self) -> &'static str { match self { FormatKind::Auto => "input", + FormatKind::Csv => "CSV", + FormatKind::Tsv => "TSV", + FormatKind::Xsv => "XSV", FormatKind::Json => "JSON", FormatKind::Jsonl => "JSONL", FormatKind::Xml => "XML", diff --git a/crates/fmtview-core/src/load/plan.rs b/crates/fmtview-core/src/load/plan.rs index 04477b4..367afbe 100644 --- a/crates/fmtview-core/src/load/plan.rs +++ b/crates/fmtview-core/src/load/plan.rs @@ -1,6 +1,7 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoadPlan { LazyTransformedRecords, + LazyDelimitedRecords, EagerTransformedDocument, EagerIndexedSource, } diff --git a/crates/fmtview-core/src/perf/load.rs b/crates/fmtview-core/src/perf/load.rs index fba2f11..37cc6ac 100644 --- a/crates/fmtview-core/src/perf/load.rs +++ b/crates/fmtview-core/src/perf/load.rs @@ -6,10 +6,14 @@ use std::{ use tempfile::NamedTempFile; use crate::{ - load::{IndexedTempFile, LazyTransformedRecordsFile, RecordTimelineViewFile, ViewFile}, + InputSource, + load::{ + IndexedTempFile, LazyTransformedRecordsFile, RecordTimelineViewFile, ViewFile, + open_delimited_dataset, + }, timeline::{FileRecordTimeline, RecordLoadLimit}, transform::{FormatKind, FormatOptions, format_source_to_temp}, - viewer::FileViewer, + viewer::{DelimitedViewer, FileViewer, InputEvent, KeyCode, KeyModifiers}, }; use ratatui::layout::Size; @@ -23,6 +27,12 @@ use super::{ }; pub(super) const CASES: &[BenchCase] = &[ + BenchCase { + label: "wide delimited first-record open+render", + shape: "delimited-records/wide-row", + layer: "header+record-index+raw-cell-highlight+deep-viewport", + run: bench_wide_delimited_first_record, + }, BenchCase { label: "raw indexed load", shape: "line-indexed", @@ -108,6 +118,69 @@ pub(super) const CASES: &[BenchCase] = &[ run: bench_xml_whole_document_index_readback, }, ]; + +fn bench_wide_delimited_first_record() -> BenchSample { + const FIELDS: usize = 20_000; + let mut temp = NamedTempFile::new().unwrap(); + let headers = (1..FIELDS) + .map(|index| format!("field_{index}")) + .chain(std::iter::once("payload".to_owned())) + .collect::>() + .join(","); + writeln!(temp, "{headers}").unwrap(); + let payload = format!(r#"{{"message":"{}"}}"#, "long value ".repeat(200_000)); + let escaped = payload.replace('"', "\"\""); + let values = std::iter::repeat_n("value".to_owned(), FIELDS - 1) + .chain(std::iter::once(format!("\"{escaped}\""))) + .collect::>() + .join(","); + writeln!(temp, "{values}").unwrap(); + temp.flush().unwrap(); + let input_bytes = temp.as_file().metadata().unwrap().len() as usize; + let source = InputSource::from_temp(temp, "wide.csv"); + + let started = Instant::now(); + let dataset = open_delimited_dataset(source, FormatKind::Csv).unwrap(); + let mut viewer = DelimitedViewer::new(dataset); + let size = Size::new(120, 32); + viewer.handle_event( + InputEvent::Key { + code: KeyCode::End, + modifiers: KeyModifiers::NONE, + }, + DelimitedViewer::page_for_size(size), + ); + let _first = viewer.render(size); + viewer.handle_event( + InputEvent::Key { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + }, + DelimitedViewer::page_for_size(size), + ); + viewer.handle_event( + InputEvent::Key { + code: KeyCode::End, + modifiers: KeyModifiers::NONE, + }, + DelimitedViewer::page_for_size(size), + ); + let frame = viewer.render(size); + let elapsed = started.elapsed(); + + assert!(frame.title.contains(&format!("field {FIELDS}/{FIELDS}"))); + BenchSample { + elapsed, + records: 1, + items: FIELDS, + string_bytes: payload.len(), + lines: frame.styled.len(), + indexed_lines: 0, + window_lines: 0, + input_bytes, + output_bytes: 0, + } +} fn bench_raw_indexed_load() -> BenchSample { let mut temp = NamedTempFile::new().unwrap(); let line = format!("{}\n", "x".repeat(240)); diff --git a/crates/fmtview-core/src/profile/tests.rs b/crates/fmtview-core/src/profile/tests.rs index 509966a..68f2631 100644 --- a/crates/fmtview-core/src/profile/tests.rs +++ b/crates/fmtview-core/src/profile/tests.rs @@ -42,6 +42,30 @@ fn resolves_plain_extension_to_passthrough_profile() { assert_eq!(profile.transform, TransformStrategy::Passthrough); } +#[test] +fn resolves_delimited_extensions_to_lazy_table_profiles() { + for (suffix, expected) in [ + (".csv", FormatKind::Csv), + (".tsv", FormatKind::Tsv), + (".xsv", FormatKind::Xsv), + ] { + let (_temp, source) = source_with_suffix(b"id,payload\n1,hello\n", suffix); + let profile = TypeProfile::resolve( + &source, + &FormatOptions { + kind: FormatKind::Auto, + indent: 2, + }, + ) + .unwrap(); + + assert_eq!(profile.content, expected); + assert_eq!(profile.shape, ContentShape::DelimitedRecords); + assert_eq!(profile.load, LoadPlan::LazyDelimitedRecords); + assert_eq!(profile.transform, TransformStrategy::Passthrough); + } +} + #[test] fn resolves_jinja_extension_to_template_profile() { let (_temp, source) = source_with_suffix(b"

{{ title }}

\n", ".html.j2"); @@ -137,6 +161,12 @@ fn explicit_format_kinds_choose_profile_without_sniffing() { let (_temp, source) = source(b"{\"broken\":\n"); let cases = [ + ( + FormatKind::Csv, + ContentShape::DelimitedRecords, + LoadPlan::LazyDelimitedRecords, + TransformStrategy::Passthrough, + ), ( FormatKind::Jsonl, ContentShape::RecordStream, diff --git a/crates/fmtview-core/src/transform.rs b/crates/fmtview-core/src/transform.rs index 5f18635..027b944 100644 --- a/crates/fmtview-core/src/transform.rs +++ b/crates/fmtview-core/src/transform.rs @@ -10,7 +10,6 @@ pub(crate) const IO_BUFFER_BYTES: usize = 256 * 1024; #[cfg(test)] pub(crate) use engine::format_record_bytes; pub(crate) use engine::format_record_display_bytes; -#[cfg(test)] pub(crate) use engine::format_record_to_string; #[cfg(test)] pub(crate) use engine::format_source_to_temp; diff --git a/crates/fmtview-core/src/transform/engine.rs b/crates/fmtview-core/src/transform/engine.rs index 673ec76..31c11b8 100644 --- a/crates/fmtview-core/src/transform/engine.rs +++ b/crates/fmtview-core/src/transform/engine.rs @@ -119,9 +119,13 @@ fn try_format_source_to_writer( options.indent, ) .with_context(|| format!("failed to format {} as HTML", source.label()))?, - FormatKind::Toml | FormatKind::Markdown | FormatKind::Plain | FormatKind::Jinja => { - passthrough_source_to_writer(source, output)? - } + FormatKind::Csv + | FormatKind::Tsv + | FormatKind::Xsv + | FormatKind::Toml + | FormatKind::Markdown + | FormatKind::Plain + | FormatKind::Jinja => passthrough_source_to_writer(source, output)?, } Ok(()) } @@ -153,9 +157,13 @@ pub fn format_record_to_bytes(input: &[u8], kind: FormatKind, indent: usize) -> output.pop(); } } - FormatKind::Toml | FormatKind::Markdown | FormatKind::Plain | FormatKind::Jinja => { - output.extend_from_slice(input) - } + FormatKind::Csv + | FormatKind::Tsv + | FormatKind::Xsv + | FormatKind::Toml + | FormatKind::Markdown + | FormatKind::Plain + | FormatKind::Jinja => output.extend_from_slice(input), } Ok(output) } @@ -219,7 +227,13 @@ pub(crate) fn format_record_bytes(line: &[u8], options: FormatOptions) -> Result FormatKind::Html, options.indent, )?), - FormatKind::Toml | FormatKind::Markdown | FormatKind::Plain | FormatKind::Jinja => None, + FormatKind::Csv + | FormatKind::Tsv + | FormatKind::Xsv + | FormatKind::Toml + | FormatKind::Markdown + | FormatKind::Plain + | FormatKind::Jinja => None, }; Ok(formatted.unwrap_or_else(|| trimmed.to_vec())) diff --git a/crates/fmtview-core/src/transform/types.rs b/crates/fmtview-core/src/transform/types.rs index 490338d..b7a01f9 100644 --- a/crates/fmtview-core/src/transform/types.rs +++ b/crates/fmtview-core/src/transform/types.rs @@ -1,6 +1,9 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FormatKind { Auto, + Csv, + Tsv, + Xsv, Json, Jsonl, Xml, @@ -11,6 +14,12 @@ pub enum FormatKind { Jinja, } +impl FormatKind { + pub const fn is_delimited(self) -> bool { + matches!(self, Self::Csv | Self::Tsv | Self::Xsv) + } +} + #[derive(Debug, Clone, Copy)] pub struct FormatOptions { pub kind: FormatKind, diff --git a/crates/fmtview-core/src/viewer.rs b/crates/fmtview-core/src/viewer.rs index a6af0ca..8dfdf15 100644 --- a/crates/fmtview-core/src/viewer.rs +++ b/crates/fmtview-core/src/viewer.rs @@ -1,7 +1,9 @@ +mod delimited; mod diff; mod file; mod input; +pub use delimited::DelimitedViewer; pub use diff::DiffViewer; pub use file::FileViewer; pub use input::{InputEvent, KeyCode, KeyModifiers, MouseEventKind, ViewerAction, ViewerCommand}; diff --git a/crates/fmtview-core/src/viewer/delimited.rs b/crates/fmtview-core/src/viewer/delimited.rs new file mode 100644 index 0000000..62cd571 --- /dev/null +++ b/crates/fmtview-core/src/viewer/delimited.rs @@ -0,0 +1,397 @@ +mod render; + +use std::time::Duration; + +use anyhow::Result; +use ratatui::layout::Size; + +use crate::{ + load::DelimitedDataset, + tui::screen::{RenderFrame, ScrollPosition}, + viewer::{InputEvent, KeyCode, KeyModifiers, MouseEventKind, ViewerAction}, +}; + +use render::{CellDisplay, CellMatch}; + +const PRELOAD_BUDGET: Duration = Duration::from_millis(6); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focus { + Fields, + Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PromptKind { + Field, + Value, +} + +struct Prompt { + kind: PromptKind, + buffer: String, + original_field: usize, +} + +pub struct DelimitedViewer { + dataset: DelimitedDataset, + field: usize, + focus: Focus, + prompt: Option, + value_query: String, + value_search_match: Option, + value_scroll: usize, + last_total_rows: usize, + last_value_height: usize, + last_value_width: usize, + cell: CellDisplay, + notice: Option, +} + +impl DelimitedViewer { + pub fn new(dataset: DelimitedDataset) -> Self { + let cell = CellDisplay::new(dataset.current_value(0)); + Self { + dataset, + field: 0, + focus: Focus::Fields, + prompt: None, + value_query: String::new(), + value_search_match: None, + value_scroll: 0, + last_total_rows: 1, + last_value_height: 1, + last_value_width: 1, + cell, + notice: None, + } + } + + pub fn handle_event(&mut self, event: InputEvent, page: usize) -> ViewerAction { + let result = match event { + InputEvent::Key { code, modifiers } => self.handle_key(code, modifiers, page), + InputEvent::Mouse { kind, .. } => self.handle_mouse(kind), + InputEvent::Resize => KeyResult::Dirty, + InputEvent::Command(_) | InputEvent::Ignore => KeyResult::Clean, + }; + match result { + KeyResult::Dirty => ViewerAction { + dirty: true, + ..ViewerAction::default() + }, + KeyResult::Quit => ViewerAction { + quit: true, + ..ViewerAction::default() + }, + KeyResult::Clean => ViewerAction::default(), + } + } + + pub fn render(&mut self, size: Size) -> RenderFrame { + let rendered = render::render(self, size); + self.last_total_rows = rendered.total_value_rows; + self.last_value_height = rendered.value_height; + self.last_value_width = rendered.value_width; + self.value_scroll = self + .value_scroll + .min(self.last_total_rows.saturating_sub(self.last_value_height)); + RenderFrame { + area: ratatui::layout::Rect::new(0, 0, size.width, size.height), + styled: rendered.lines, + sticky: Vec::new(), + selection_mode: false, + title: rendered.title, + footer_text: rendered.footer, + footer_style: crate::tui::palette::gutter_style(), + position: ScrollPosition { + top: self.dataset.current_index().unwrap_or(0), + row_offset: self.value_scroll, + }, + scroll_hint: None, + } + } + + pub fn preload(&mut self) -> Result { + self.dataset.preload(PRELOAD_BUDGET) + } + + pub const fn page_for_size(size: Size) -> usize { + size.height.saturating_sub(4) as usize + } + + fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers, page: usize) -> KeyResult { + if matches!(code, KeyCode::Char('c')) && modifiers.contains(KeyModifiers::CONTROL) { + return KeyResult::Quit; + } + if self.prompt.is_some() { + return self.handle_prompt_key(code, modifiers); + } + match code { + KeyCode::Char('q') => KeyResult::Quit, + KeyCode::Esc if self.focus == Focus::Value => { + self.focus = Focus::Fields; + KeyResult::Dirty + } + KeyCode::Esc => KeyResult::Quit, + KeyCode::Enter if self.focus == Focus::Fields => { + self.focus = Focus::Value; + KeyResult::Dirty + } + KeyCode::Enter => KeyResult::Clean, + KeyCode::Char('/') => { + self.prompt = Some(Prompt { + kind: if self.focus == Focus::Fields { + PromptKind::Field + } else { + PromptKind::Value + }, + buffer: String::new(), + original_field: self.field, + }); + KeyResult::Dirty + } + KeyCode::Char('n') if self.focus == Focus::Value => self.repeat_value_search(true), + KeyCode::Char('N') if self.focus == Focus::Value => self.repeat_value_search(false), + KeyCode::Up | KeyCode::Char('k') if self.focus == Focus::Fields => self.move_field(-1), + KeyCode::Down | KeyCode::Char('j') if self.focus == Focus::Fields => self.move_field(1), + KeyCode::Left | KeyCode::Char('h') if self.focus == Focus::Fields => { + self.move_record(-1) + } + KeyCode::Right | KeyCode::Char('l') if self.focus == Focus::Fields => { + self.move_record(1) + } + KeyCode::PageUp if self.focus == Focus::Fields => self.move_record(-(page as isize)), + KeyCode::PageDown if self.focus == Focus::Fields => self.move_record(page as isize), + KeyCode::Home if self.focus == Focus::Fields => self.set_field(0), + KeyCode::End if self.focus == Focus::Fields => { + self.set_field(self.dataset.headers().len().saturating_sub(1)) + } + KeyCode::Up | KeyCode::Char('k') if self.focus == Focus::Value => self.scroll_value(-1), + KeyCode::Down | KeyCode::Char('j') if self.focus == Focus::Value => { + self.scroll_value(1) + } + KeyCode::PageUp if self.focus == Focus::Value => self.scroll_value(-(page as isize)), + KeyCode::PageDown if self.focus == Focus::Value => self.scroll_value(page as isize), + KeyCode::Home if self.focus == Focus::Value => { + self.value_scroll = 0; + KeyResult::Dirty + } + KeyCode::End if self.focus == Focus::Value => { + self.value_scroll = self.last_total_rows.saturating_sub(self.last_value_height); + KeyResult::Dirty + } + _ => KeyResult::Clean, + } + } + + fn handle_prompt_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyResult { + let Some(prompt) = self.prompt.as_mut() else { + return KeyResult::Clean; + }; + match code { + KeyCode::Esc => { + let original = prompt.original_field; + let kind = prompt.kind; + self.prompt = None; + if kind == PromptKind::Field { + let _ = self.set_field(original); + KeyResult::Dirty + } else { + KeyResult::Dirty + } + } + KeyCode::Enter => { + let prompt = self.prompt.take().expect("prompt was present"); + if prompt.kind == PromptKind::Value { + self.value_query = prompt.buffer; + self.value_search_match = None; + self.repeat_value_search(true) + } else { + KeyResult::Dirty + } + } + KeyCode::Backspace => { + prompt.buffer.pop(); + if prompt.kind == PromptKind::Field { + self.select_first_field_match(); + } + KeyResult::Dirty + } + KeyCode::Char(ch) + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + prompt.buffer.push(ch); + if prompt.kind == PromptKind::Field { + self.select_first_field_match(); + } + KeyResult::Dirty + } + _ => KeyResult::Clean, + } + } + + fn handle_mouse(&mut self, kind: MouseEventKind) -> KeyResult { + match kind { + MouseEventKind::ScrollUp if self.focus == Focus::Value => self.scroll_value(-3), + MouseEventKind::ScrollDown if self.focus == Focus::Value => self.scroll_value(3), + MouseEventKind::ScrollUp => self.move_field(-1), + MouseEventKind::ScrollDown => self.move_field(1), + _ => KeyResult::Clean, + } + } + + fn move_field(&mut self, delta: isize) -> KeyResult { + if self.dataset.headers().is_empty() { + return KeyResult::Clean; + } + let target = if delta >= 0 { + self.field.saturating_add(delta as usize) + } else { + self.field.saturating_sub(delta.unsigned_abs()) + } + .min(self.dataset.headers().len() - 1); + self.set_field(target) + } + + fn set_field(&mut self, field: usize) -> KeyResult { + if self.dataset.headers().is_empty() || self.field == field { + return KeyResult::Clean; + } + self.field = field.min(self.dataset.headers().len() - 1); + self.refresh_cell(); + KeyResult::Dirty + } + + fn move_record(&mut self, delta: isize) -> KeyResult { + match self.dataset.move_record(delta) { + Ok(true) => { + self.notice = None; + self.field = self + .field + .min(self.dataset.headers().len().saturating_sub(1)); + self.refresh_cell(); + KeyResult::Dirty + } + Ok(false) => KeyResult::Clean, + Err(error) => { + self.notice = Some(format!("record navigation failed: {error:#}")); + KeyResult::Dirty + } + } + } + + fn scroll_value(&mut self, delta: isize) -> KeyResult { + let max = self.last_total_rows.saturating_sub(self.last_value_height); + let next = if delta >= 0 { + self.value_scroll.saturating_add(delta as usize).min(max) + } else { + self.value_scroll.saturating_sub(delta.unsigned_abs()) + }; + if next == self.value_scroll { + return KeyResult::Clean; + } + self.value_scroll = next; + KeyResult::Dirty + } + + fn select_first_field_match(&mut self) { + let Some(prompt) = self.prompt.as_ref() else { + return; + }; + let query = prompt.buffer.to_lowercase(); + if query.is_empty() { + return; + } + if let Some(index) = self + .dataset + .headers() + .iter() + .position(|header| header.to_lowercase().contains(&query)) + && index != self.field + { + self.field = index; + self.refresh_cell(); + } + } + + fn repeat_value_search(&mut self, forward: bool) -> KeyResult { + if self.value_query.is_empty() { + return KeyResult::Clean; + } + let found = self + .cell + .find_match(&self.value_query, self.value_search_match, forward); + let Some(found) = found else { + self.notice = Some(format!("not found: {}", self.value_query)); + return KeyResult::Dirty; + }; + let scroll = self.cell.visual_offset_for_match(found); + if self.value_search_match == Some(found) && self.value_scroll == scroll { + return KeyResult::Clean; + } + self.notice = None; + self.value_search_match = Some(found); + self.value_scroll = scroll; + KeyResult::Dirty + } + + fn refresh_cell(&mut self) { + self.cell = CellDisplay::new(self.dataset.current_value(self.field)); + self.cell.ensure_layout(self.last_value_width); + self.value_scroll = 0; + self.last_total_rows = 1; + self.value_query.clear(); + self.value_search_match = None; + self.notice = None; + } + + fn visible_fields(&self, height: usize) -> Vec { + if height == 0 || self.dataset.headers().is_empty() { + return Vec::new(); + } + let query = self + .prompt + .as_ref() + .filter(|prompt| prompt.kind == PromptKind::Field) + .map(|prompt| prompt.buffer.to_lowercase()) + .unwrap_or_default(); + if query.is_empty() { + let start = self + .field + .saturating_sub(height / 2) + .min(self.dataset.headers().len().saturating_sub(height)); + return (start..self.dataset.headers().len()).take(height).collect(); + } + let mut match_count = 0_usize; + let mut selected = None; + for (index, header) in self.dataset.headers().iter().enumerate() { + if header.to_lowercase().contains(&query) { + if index == self.field { + selected = Some(match_count); + } + match_count += 1; + } + } + let start = selected + .unwrap_or(0) + .saturating_sub(height / 2) + .min(match_count.saturating_sub(height)); + self.dataset + .headers() + .iter() + .enumerate() + .filter(|(_, header)| header.to_lowercase().contains(&query)) + .map(|(index, _)| index) + .skip(start) + .take(height) + .collect() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyResult { + Clean, + Dirty, + Quit, +} diff --git a/crates/fmtview-core/src/viewer/delimited/render.rs b/crates/fmtview-core/src/viewer/delimited/render.rs new file mode 100644 index 0000000..903ae85 --- /dev/null +++ b/crates/fmtview-core/src/viewer/delimited/render.rs @@ -0,0 +1,687 @@ +use std::ops::Range; + +use ratatui::{ + layout::Size, + style::{Modifier, Style}, + text::{Line, Span}, +}; +use unicode_width::UnicodeWidthChar; + +use crate::{ + formats::{detect_markup_kind, highlight_content_window, highlight_large_content_window}, + transform::{FormatKind, format_record_to_string}, + tui::{ + palette::{ + bool_style, gutter_style, key_style, null_style, number_style, plain_style, + search_inactive_match_bg, string_style, + }, + text::format_count, + wrap::{ + WrapCheckpointIndex, continuation_indent, next_wrap_end, wrap_ranges_window_indexed, + wrapped_row_count, + }, + }, +}; + +use super::{DelimitedViewer, Focus, PromptKind}; +use crate::viewer::file::render::apply_search_ranges_to_spans; + +const MAX_FORMATTED_CELL_BYTES: usize = 1024 * 1024; + +pub(super) struct RenderedDelimitedView { + pub(super) lines: Vec>, + pub(super) title: String, + pub(super) footer: String, + pub(super) total_value_rows: usize, + pub(super) value_height: usize, + pub(super) value_width: usize, +} + +pub(super) struct CellDisplay { + text: String, + lines: Vec>, + mode: FormatKind, + scalar_style: Option