use ratatui::{
layout::{Alignment, Constraint, Flex, Layout, Rect},
style::{Color, Style, Stylize},
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Wrap},
Frame,
};
use serde_json::Value;
use unicode_width::UnicodeWidthStr;
use crate::app::{App, LoadingState};
const INDENT: &str = " ";
pub fn render(frame: &mut Frame, app: &mut App) {
match app.loading_state() {
LoadingState::Loading => {
render_loading_screen(frame, "Loading...");
return;
}
LoadingState::Failed(msg) => {
render_error_screen(frame, msg);
return;
}
LoadingState::Ready => {} }
let filter_height = if app.filter_error().is_some() { 2 } else { 1 };
let chunks = Layout::vertical([
Constraint::Min(1),
Constraint::Length(filter_height),
Constraint::Length(1),
])
.split(frame.area());
let viewport_height = chunks[0].height as usize;
let viewport_width = chunks[0].width as usize;
app.set_viewport(viewport_width, viewport_height);
let visible_lines = render_viewport(
app.output_values(),
&app.render_options(),
app.scroll_offset(),
viewport_height,
);
let mut json_view = Paragraph::new(visible_lines);
if app.wrap_lines() {
json_view = json_view.wrap(Wrap { trim: false });
} else {
json_view = json_view.scroll((0, app.horizontal_offset() as u16));
}
frame.render_widget(json_view, chunks[0]);
render_filter_input(frame, app, chunks[1]);
let total = app.total_lines();
let start = app.scroll_offset() + 1;
let end = (app.scroll_offset() + viewport_height).min(total);
let wrap_indicator = if app.wrap_lines() { " [wrap]" } else { "" };
let input_indicator = if app.input_count() > 1 {
format!(" ({} inputs)", app.input_count())
} else {
String::new()
};
let mode_indicators = {
let mut modes = Vec::new();
if app.raw_output() {
modes.push("raw");
}
if app.slurp_mode() {
modes.push("slurp");
}
if app.compact_output() {
modes.push("compact");
}
if modes.is_empty() {
String::new()
} else {
format!(" [{}]", modes.join("+"))
}
};
let eval_indicator = if app.is_evaluating() {
" [eval...]"
} else if app.eval_paused() {
" [paused]"
} else {
""
};
let status = if total == 0 {
format!(
" (empty){}{}{}{} ^h=help",
input_indicator, wrap_indicator, mode_indicators, eval_indicator
)
} else if app.horizontal_offset() > 0 && !app.wrap_lines() {
format!(
" Lines {}-{} of {} | Col {}{}{}{}{} ^h=help",
start,
end,
total,
app.horizontal_offset() + 1,
input_indicator,
wrap_indicator,
mode_indicators,
eval_indicator
)
} else {
format!(
" Lines {}-{} of {}{}{}{}{} ^h=help",
start, end, total, input_indicator, wrap_indicator, mode_indicators, eval_indicator
)
};
let status_line = if app.is_evaluating() {
Paragraph::new(status).style(Style::default().fg(Color::Yellow))
} else {
Paragraph::new(status).style(Style::default().fg(Color::DarkGray))
};
frame.render_widget(status_line, chunks[2]);
if app.show_help() {
render_help_popup(frame, app.help_for_filter_mode());
}
}
fn render_filter_input(frame: &mut Frame, app: &App, area: Rect) {
let prompt = if app.partial_eval_mode() {
"Filter[→]> "
} else if app.editing_filter() {
"Filter> "
} else {
"Filter: "
};
let prompt_style = if app.partial_eval_mode() {
Style::default().fg(Color::Magenta)
} else if app.editing_filter() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
if let Some(error) = app.filter_error() {
let filter_area = Rect { height: 1, ..area };
let error_area = Rect {
y: area.y + 1,
height: 1,
..area
};
let filter_text = app.filter_text();
let error_pos = match error {
crate::error::FilterError::Parse(e) => e.position,
crate::error::FilterError::Eval(e) => e.position(),
};
let (valid_part, invalid_part) = filter_text.split_at(error_pos.min(filter_text.len()));
let filter_line = Line::from(vec![
Span::styled(prompt, prompt_style),
Span::raw(valid_part),
Span::styled(invalid_part, Style::default().fg(Color::Red)),
]);
frame.render_widget(Paragraph::new(filter_line), filter_area);
let error_line = Line::from(vec![
Span::styled(" Error: ", Style::default().fg(Color::Red)),
Span::styled(error.to_string(), Style::default().fg(Color::Red).dim()),
]);
frame.render_widget(Paragraph::new(error_line), error_area);
} else {
let filter_line = Line::from(vec![
Span::styled(prompt, prompt_style),
Span::raw(app.filter_text()),
]);
frame.render_widget(Paragraph::new(filter_line), area);
}
if app.editing_filter() {
let cursor_x = area.x + prompt.width() as u16 + app.filter_cursor_display_col() as u16;
frame.set_cursor_position((cursor_x, area.y));
}
}
fn render_help_popup(frame: &mut Frame, filter_mode: bool) {
if filter_mode {
render_filter_help(frame);
} else {
render_navigation_help(frame);
}
}
fn render_filter_help(frame: &mut Frame) {
let col1 = vec![
Line::from("Filter Syntax".bold()),
Line::from(vec![Span::styled(". ", Style::default().fg(Color::Cyan)), Span::raw("Identity")]),
Line::from(vec![Span::styled(".foo ", Style::default().fg(Color::Cyan)), Span::raw("Field access")]),
Line::from(vec![Span::styled(".[] ", Style::default().fg(Color::Cyan)), Span::raw("Iterate")]),
Line::from(vec![Span::styled(".[0] ", Style::default().fg(Color::Cyan)), Span::raw("Index")]),
Line::from(vec![Span::styled("a | b ", Style::default().fg(Color::Cyan)), Span::raw("Pipe")]),
Line::from(vec![Span::styled("[.a,.b] ", Style::default().fg(Color::Cyan)), Span::raw("Array")]),
Line::from(vec![Span::styled("{a:.x} ", Style::default().fg(Color::Cyan)), Span::raw("Object")]),
Line::from(""),
Line::from("Builtins".bold()),
Line::from(vec![Span::styled("length ", Style::default().fg(Color::Cyan)), Span::raw("Size")]),
Line::from(vec![Span::styled("keys ", Style::default().fg(Color::Cyan)), Span::raw("Keys")]),
Line::from(vec![Span::styled("values ", Style::default().fg(Color::Cyan)), Span::raw("Values")]),
Line::from(vec![Span::styled("type ", Style::default().fg(Color::Cyan)), Span::raw("Type")]),
Line::from(vec![Span::styled("sort ", Style::default().fg(Color::Cyan)), Span::raw("Sort")]),
Line::from(vec![Span::styled("reverse ", Style::default().fg(Color::Cyan)), Span::raw("Reverse")]),
Line::from(vec![Span::styled("unique ", Style::default().fg(Color::Cyan)), Span::raw("Dedupe")]),
];
let col2 = vec![
Line::from("More Builtins".bold()),
Line::from(vec![Span::styled("flatten ", Style::default().fg(Color::Cyan)), Span::raw("Flatten")]),
Line::from(vec![Span::styled("first ", Style::default().fg(Color::Cyan)), Span::raw("First")]),
Line::from(vec![Span::styled("last ", Style::default().fg(Color::Cyan)), Span::raw("Last")]),
Line::from(vec![Span::styled("min max ", Style::default().fg(Color::Cyan)), Span::raw("Min/Max")]),
Line::from(vec![Span::styled("add ", Style::default().fg(Color::Cyan)), Span::raw("Sum")]),
Line::from(vec![Span::styled("not ", Style::default().fg(Color::Cyan)), Span::raw("Negate")]),
Line::from(vec![Span::styled("empty ", Style::default().fg(Color::Cyan)), Span::raw("No output")]),
Line::from(""),
Line::from("Keys".bold()),
Line::from(vec![Span::styled("Esc ", Style::default().fg(Color::Cyan)), Span::raw("Exit edit")]),
Line::from(vec![Span::styled("Tab ", Style::default().fg(Color::Cyan)), Span::raw("Partial eval")]),
Line::from(vec![Span::styled("Ctrl+p ", Style::default().fg(Color::Cyan)), Span::raw("Pause eval")]),
Line::from(vec![Span::styled("Ctrl+c ", Style::default().fg(Color::Cyan)), Span::raw("Cancel eval")]),
Line::from(vec![Span::styled("Enter ", Style::default().fg(Color::Cyan)), Span::raw("Eval (paused)")]),
Line::from(vec![Span::styled("Ctrl+h ", Style::default().fg(Color::Cyan)), Span::raw("Toggle help")]),
Line::from(""),
];
render_two_column_help(frame, " Filter Help ", &col1, &col2);
}
fn render_navigation_help(frame: &mut Frame) {
let col1 = vec![
Line::from("Navigation".bold()),
Line::from(vec![Span::styled("j/↓ k/↑ ", Style::default().fg(Color::Cyan)), Span::raw("Scroll")]),
Line::from(vec![Span::styled("h/← l/→ ", Style::default().fg(Color::Cyan)), Span::raw("Horizontal")]),
Line::from(vec![Span::styled("g / G ", Style::default().fg(Color::Cyan)), Span::raw("Top/Bottom")]),
Line::from(vec![Span::styled("0 ", Style::default().fg(Color::Cyan)), Span::raw("Line start")]),
Line::from(vec![Span::styled("Ctrl+d/u ", Style::default().fg(Color::Cyan)), Span::raw("Half page")]),
Line::from(vec![Span::styled("PgDn/Up ", Style::default().fg(Color::Cyan)), Span::raw("Full page")]),
Line::from(""),
Line::from("Display".bold()),
Line::from(vec![Span::styled("w ", Style::default().fg(Color::Cyan)), Span::raw("Wrap lines")]),
Line::from(vec![Span::styled("r ", Style::default().fg(Color::Cyan)), Span::raw("Raw output")]),
Line::from(vec![Span::styled("c ", Style::default().fg(Color::Cyan)), Span::raw("Compact")]),
Line::from(vec![Span::styled("s ", Style::default().fg(Color::Cyan)), Span::raw("Slurp mode")]),
];
let col2 = vec![
Line::from("Filter".bold()),
Line::from(vec![Span::styled("i or / ", Style::default().fg(Color::Cyan)), Span::raw("Edit filter")]),
Line::from(""),
Line::from("General".bold()),
Line::from(vec![Span::styled("Ctrl+h ", Style::default().fg(Color::Cyan)), Span::raw("Toggle help")]),
Line::from(vec![Span::styled("q / Esc ", Style::default().fg(Color::Cyan)), Span::raw("Quit")]),
Line::from(""),
Line::from(""),
Line::from(""),
Line::from(""),
Line::from(""),
Line::from(""),
Line::from(""),
];
render_two_column_help(frame, " Help ", &col1, &col2);
}
fn render_two_column_help(frame: &mut Frame, title: &str, col1: &[Line], col2: &[Line]) {
let col_width = 24;
let popup_width = col_width * 2 + 3; let popup_height = col1.len().max(col2.len()) as u16 + 3;
let area = centered_rect(popup_width as u16, popup_height, frame.area());
let block = Block::bordered().title(title);
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let cols = Layout::horizontal([
Constraint::Length(col_width as u16),
Constraint::Length(1),
Constraint::Length(col_width as u16),
])
.split(inner);
frame.render_widget(Paragraph::new(col1.to_vec()), cols[0]);
frame.render_widget(Paragraph::new(col2.to_vec()), cols[2]);
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center);
let horizontal = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
area
}
fn render_loading_screen(frame: &mut Frame, message: &str) {
let area = frame.area();
let text = Paragraph::new(message)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center);
let centered = centered_rect(30, 3, area);
frame.render_widget(text, centered);
}
fn render_error_screen(frame: &mut Frame, message: &str) {
let area = frame.area();
let lines = vec![
Line::from(Span::styled(
"Error loading input",
Style::default().fg(Color::Red).bold(),
)),
Line::from(""),
Line::from(Span::styled(
message.to_string(),
Style::default().fg(Color::Red),
)),
Line::from(""),
Line::from(Span::styled(
"Press 'q' to quit",
Style::default().fg(Color::DarkGray),
)),
];
let text = Paragraph::new(lines).alignment(Alignment::Center);
let height = 5.min(area.height);
let width = (message.len() as u16 + 4).min(area.width).max(25);
let centered = centered_rect(width, height, area);
frame.render_widget(text, centered);
}
#[derive(Default, Clone)]
pub struct RenderOptions {
pub raw_output: bool,
pub compact: bool,
}
pub fn count_total_lines(values: &[Value], options: &RenderOptions) -> usize {
values.iter().map(|v| count_value_lines(v, options)).sum()
}
fn count_value_lines(value: &Value, options: &RenderOptions) -> usize {
if options.compact {
count_lines_compact(value, options.raw_output)
} else {
count_lines_pretty(value, options.raw_output)
}
}
fn count_lines_compact(value: &Value, raw: bool) -> usize {
if raw && let Value::String(s) = value {
return s.lines().count().max(1);
}
1 }
fn count_lines_pretty(value: &Value, raw: bool) -> usize {
if raw && let Value::String(s) = value {
return s.lines().count().max(1);
}
match value {
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => 1,
Value::Array(arr) if arr.is_empty() => 1,
Value::Object(obj) if obj.is_empty() => 1,
Value::Array(arr) => {
2 + arr.iter().map(|v| count_lines_pretty(v, false)).sum::<usize>()
}
Value::Object(obj) => {
2 + obj.values().map(|v| count_lines_pretty(v, false)).sum::<usize>()
}
}
}
pub fn render_viewport(
values: &[Value],
options: &RenderOptions,
skip: usize,
take: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::with_capacity(take);
let mut remaining_skip = skip;
for value in values {
if lines.len() >= take {
break;
}
let value_lines = count_value_lines(value, options);
if remaining_skip >= value_lines {
remaining_skip -= value_lines;
continue;
}
if options.compact {
render_value_viewport_compact(value, options.raw_output, &mut lines, &mut remaining_skip, take);
} else {
render_value_viewport_pretty(value, None, 0, &mut lines, false, &mut remaining_skip, take, options.raw_output);
}
}
lines
}
fn render_value_viewport_compact(
value: &Value,
raw: bool,
lines: &mut Vec<Line<'static>>,
remaining_skip: &mut usize,
take: usize,
) {
if lines.len() >= take {
return;
}
if raw && let Value::String(s) = value {
for line_str in s.lines() {
if *remaining_skip > 0 {
*remaining_skip -= 1;
continue;
}
if lines.len() >= take {
return;
}
lines.push(Line::raw(line_str.to_string()));
}
return;
}
if *remaining_skip > 0 {
*remaining_skip -= 1;
return;
}
let mut spans = Vec::new();
render_value_compact(value, &mut spans);
lines.push(Line::from(spans));
}
#[allow(clippy::too_many_arguments)]
fn render_value_viewport_pretty(
value: &Value,
key: Option<&str>,
depth: usize,
lines: &mut Vec<Line<'static>>,
trailing_comma: bool,
remaining_skip: &mut usize,
take: usize,
raw: bool,
) {
if lines.len() >= take {
return;
}
if raw && depth == 0 && key.is_none() && let Value::String(s) = value {
for line_str in s.lines() {
if *remaining_skip > 0 {
*remaining_skip -= 1;
continue;
}
if lines.len() >= take {
return;
}
lines.push(Line::raw(line_str.to_string()));
}
return;
}
let indent = INDENT.repeat(depth);
let comma_span = if trailing_comma {
Span::styled(",", Style::default().fg(Color::DarkGray))
} else {
Span::raw("")
};
match value {
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
if *remaining_skip > 0 {
*remaining_skip -= 1;
return;
}
let mut spans = vec![Span::raw(indent)];
if let Some(k) = key {
spans.extend(key_spans(k));
}
spans.push(value_styled(value));
spans.push(comma_span);
lines.push(Line::from(spans));
}
Value::Array(arr) if arr.is_empty() => {
if *remaining_skip > 0 {
*remaining_skip -= 1;
return;
}
let mut spans = vec![Span::raw(indent)];
if let Some(k) = key {
spans.extend(key_spans(k));
}
spans.push(Span::styled("[]", Style::default().fg(Color::DarkGray)));
spans.push(comma_span);
lines.push(Line::from(spans));
}
Value::Object(obj) if obj.is_empty() => {
if *remaining_skip > 0 {
*remaining_skip -= 1;
return;
}
let mut spans = vec![Span::raw(indent)];
if let Some(k) = key {
spans.extend(key_spans(k));
}
spans.push(Span::styled("{}", Style::default().fg(Color::DarkGray)));
spans.push(comma_span);
lines.push(Line::from(spans));
}
Value::Array(arr) => {
if *remaining_skip > 0 {
*remaining_skip -= 1;
} else if lines.len() < take {
let mut spans = vec![Span::raw(indent.clone())];
if let Some(k) = key {
spans.extend(key_spans(k));
}
spans.push(Span::styled("[", Style::default().fg(Color::DarkGray)));
lines.push(Line::from(spans));
}
for (i, item) in arr.iter().enumerate() {
if lines.len() >= take {
return;
}
let is_last = i == arr.len() - 1;
render_value_viewport_pretty(item, None, depth + 1, lines, !is_last, remaining_skip, take, false);
}
if lines.len() >= take {
return;
}
if *remaining_skip > 0 {
*remaining_skip -= 1;
} else {
lines.push(Line::from(vec![
Span::raw(indent),
Span::styled("]", Style::default().fg(Color::DarkGray)),
comma_span,
]));
}
}
Value::Object(obj) => {
if *remaining_skip > 0 {
*remaining_skip -= 1;
} else if lines.len() < take {
let mut spans = vec![Span::raw(indent.clone())];
if let Some(k) = key {
spans.extend(key_spans(k));
}
spans.push(Span::styled("{", Style::default().fg(Color::DarkGray)));
lines.push(Line::from(spans));
}
let entries: Vec<_> = obj.iter().collect();
for (i, (k, v)) in entries.iter().enumerate() {
if lines.len() >= take {
return;
}
let is_last = i == entries.len() - 1;
render_value_viewport_pretty(v, Some(k), depth + 1, lines, !is_last, remaining_skip, take, false);
}
if lines.len() >= take {
return;
}
if *remaining_skip > 0 {
*remaining_skip -= 1;
} else {
lines.push(Line::from(vec![
Span::raw(indent),
Span::styled("}", Style::default().fg(Color::DarkGray)),
comma_span,
]));
}
}
}
}
fn render_value_compact(value: &Value, spans: &mut Vec<Span<'static>>) {
match value {
Value::Null => spans.push(Span::styled("null", Style::default().fg(Color::Magenta))),
Value::Bool(b) => {
spans.push(Span::styled(b.to_string(), Style::default().fg(Color::Magenta)))
}
Value::Number(n) => {
spans.push(Span::styled(n.to_string(), Style::default().fg(Color::Yellow)))
}
Value::String(s) => spans.push(Span::styled(
format!("\"{}\"", escape_string(s)),
Style::default().fg(Color::Green),
)),
Value::Array(arr) => {
spans.push(Span::styled("[", Style::default().fg(Color::DarkGray)));
for (i, v) in arr.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(",", Style::default().fg(Color::DarkGray)));
}
render_value_compact(v, spans);
}
spans.push(Span::styled("]", Style::default().fg(Color::DarkGray)));
}
Value::Object(obj) => {
spans.push(Span::styled("{", Style::default().fg(Color::DarkGray)));
for (i, (k, v)) in obj.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(",", Style::default().fg(Color::DarkGray)));
}
spans.push(Span::styled(
format!("\"{}\"", escape_string(k)),
Style::default().fg(Color::Cyan),
));
spans.push(Span::styled(":", Style::default().fg(Color::DarkGray)));
render_value_compact(v, spans);
}
spans.push(Span::styled("}", Style::default().fg(Color::DarkGray)));
}
}
}
fn value_styled(value: &Value) -> Span<'static> {
match value {
Value::Null => Span::styled("null", Style::default().fg(Color::Magenta)),
Value::Bool(b) => Span::styled(b.to_string(), Style::default().fg(Color::Magenta)),
Value::Number(n) => Span::styled(n.to_string(), Style::default().fg(Color::Yellow)),
Value::String(s) => {
Span::styled(format!("\"{}\"", escape_string(s)), Style::default().fg(Color::Green))
}
_ => unreachable!("value_styled only handles primitives"),
}
}
fn key_spans(key: &str) -> Vec<Span<'static>> {
vec![
Span::styled(
format!("\"{}\"", escape_string(key)),
Style::default().fg(Color::Cyan),
),
Span::styled(": ", Style::default().fg(Color::DarkGray)),
]
}
fn escape_string(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
c if c.is_control() => {
result.push_str(&format!("\\u{:04x}", c as u32));
}
c => result.push(c),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn render_all(values: &[Value], options: &RenderOptions) -> Vec<Line<'static>> {
let total = count_total_lines(values, options);
render_viewport(values, options, 0, total)
}
#[test]
fn test_raw_string_output() {
let value = json!("hello world");
let options = RenderOptions {
raw_output: true,
compact: false,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "hello world");
}
#[test]
fn test_raw_non_string_unchanged() {
let value = json!(42);
let options = RenderOptions {
raw_output: true,
compact: false,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "42");
}
#[test]
fn test_raw_null_unchanged() {
let value = json!(null);
let options = RenderOptions {
raw_output: true,
compact: false,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "null");
}
#[test]
fn test_raw_string_with_newline() {
let value = json!("line1\nline2");
let options = RenderOptions {
raw_output: true,
compact: false,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].to_string(), "line1");
assert_eq!(lines[1].to_string(), "line2");
}
#[test]
fn test_compact_output() {
let value = json!({"a": 1, "b": 2});
let options = RenderOptions {
raw_output: false,
compact: true,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
let text = lines[0].to_string();
assert!(text.contains("\"a\":1") || text.contains("\"a\": 1"));
}
#[test]
fn test_compact_array() {
let value = json!([1, 2, 3]);
let options = RenderOptions {
raw_output: false,
compact: true,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "[1,2,3]");
}
#[test]
fn test_compact_raw_string() {
let value = json!("test");
let options = RenderOptions {
raw_output: true,
compact: true,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].to_string(), "test");
}
#[test]
fn test_compact_raw_non_string() {
let value = json!({"x": 1});
let options = RenderOptions {
raw_output: true,
compact: true,
};
let lines = render_all(&[value], &options);
assert_eq!(lines.len(), 1);
assert!(lines[0].to_string().contains("\"x\""));
}
#[test]
fn test_line_count_simple() {
let value = json!({"name": "alice"});
let options = RenderOptions::default();
assert_eq!(count_total_lines(&[value], &options), 3);
}
#[test]
fn test_viewport_skip_and_take() {
let value = json!([1, 2, 3, 4, 5]);
let options = RenderOptions::default();
assert_eq!(count_total_lines(&[value.clone()], &options), 7);
let lines = render_viewport(&[value], &options, 2, 2);
assert_eq!(lines.len(), 2);
assert!(lines[0].to_string().contains("2"));
assert!(lines[1].to_string().contains("3"));
}
}