use std::error::Error;
use std::io;
use std::io::Write;
use clipboard::{ClipboardContext, ClipboardProvider};
use rustyline::error::ReadlineError;
use rustyline::Editor;
use termion::event::Key;
use termion::event::MouseButton::{Left, WheelDown, WheelUp};
use termion::event::MouseEvent::Press;
use termion::raw::RawTerminal;
use termion::screen::{ToAlternateScreen, ToMainScreen};
use crate::flatjson;
use crate::input::TuiEvent;
use crate::input::TuiEvent::{KeyEvent, MouseEvent, WinChEvent};
use crate::jsonstringunescaper::unescape_json_string;
use crate::lineprinter::JS_IDENTIFIER;
use crate::options::{DataFormat, Opt};
use crate::screenwriter::{MessageSeverity, ScreenWriter};
use crate::search::{JumpDirection, SearchDirection, SearchState};
use crate::types::TTYDimensions;
use crate::viewer::{Action, JsonViewer, Mode};
pub struct App {
viewer: JsonViewer,
screen_writer: ScreenWriter,
input_state: InputState,
input_buffer: Vec<u8>,
input_filename: String,
search_state: SearchState,
message: Option<(String, MessageSeverity)>,
clipboard_context: Result<ClipboardContext, Box<dyn Error>>,
}
#[derive(PartialEq)]
enum InputState {
Default,
PendingPCommand,
PendingYCommand,
PendingZCommand,
WaitingForAnyKeyPress,
}
#[derive(Copy, Clone)]
enum ContentTarget {
PrettyPrintedValue,
OneLineValue,
String,
Key,
DotPath,
BracketPath,
QueryPath,
}
enum Command {
Quit,
Help,
SetShowLineNumber(Option<bool>),
SetShowRelativeLineNumber(Option<bool>),
Unknown,
}
const HELP: &str = std::include_str!("./jless.help");
pub const MAX_BUFFER_SIZE: usize = 9;
const BELL: &str = "\x07";
const DISABLE_MOUSE_BUTTON_TRACKING: &str = "\x1b[?1002l";
const ENABLE_MOUSE_BUTTON_TRACKING: &str = "\x1b[?1002h";
impl App {
pub fn new(
opt: &Opt,
data: String,
data_format: DataFormat,
input_filename: String,
stdout: RawTerminal<Box<dyn Write>>,
) -> Result<App, String> {
let flatjson = match Self::parse_input(data, data_format) {
Ok(flatjson) => flatjson,
Err(err) => return Err(format!("Unable to parse input: {err:?}")),
};
let mut viewer = JsonViewer::new(flatjson, opt.mode);
viewer.scrolloff_setting = opt.scrolloff;
let screen_writer =
ScreenWriter::init(opt, stdout, Editor::<()>::new(), TTYDimensions::default());
Ok(App {
viewer,
screen_writer,
input_state: InputState::Default,
input_buffer: vec![],
input_filename,
search_state: SearchState::empty(),
message: None,
clipboard_context: ClipboardProvider::new(),
})
}
fn parse_input(data: String, data_format: DataFormat) -> Result<flatjson::FlatJson, String> {
match data_format {
DataFormat::Json => flatjson::parse_top_level_json(data),
DataFormat::Yaml => flatjson::parse_top_level_yaml(data),
}
}
pub fn run(&mut self, input: Box<dyn Iterator<Item = io::Result<TuiEvent>>>) {
let dimensions = TTYDimensions::from_size(termion::terminal_size().unwrap());
self.viewer.dimensions = dimensions.without_status_bar();
self.screen_writer.dimensions = dimensions;
self.draw_screen();
for event in input {
let event = match event {
Ok(event) => event,
Err(io_error) => {
self.set_error_message(format!("Error: {io_error}"));
self.draw_status_bar();
continue;
}
};
if self.input_state == InputState::WaitingForAnyKeyPress {
if matches!(event, KeyEvent(_)) {
let _ = write!(self.screen_writer.stdout, "{ToAlternateScreen}");
let _ = write!(self.screen_writer.stdout, "{ENABLE_MOUSE_BUTTON_TRACKING}");
self.input_state = InputState::Default;
self.draw_screen();
self.message = None;
}
continue;
}
let mut jumped_to_search_match = false;
let focused_row_before = self.viewer.focused_row;
let previous_collapsed_state_of_focused_row =
self.viewer.flatjson[focused_row_before].is_collapsed();
let action = match event {
WinChEvent => {
let dimensions = TTYDimensions::from_size(termion::terminal_size().unwrap());
self.screen_writer.dimensions = dimensions;
Some(Action::ResizeViewerDimensions(
dimensions.without_status_bar(),
))
}
event if self.input_state == InputState::PendingPCommand => {
let content_target = match event {
KeyEvent(Key::Char('p')) => Some(ContentTarget::PrettyPrintedValue),
KeyEvent(Key::Char('v')) => Some(ContentTarget::OneLineValue),
KeyEvent(Key::Char('s')) => Some(ContentTarget::String),
KeyEvent(Key::Char('k')) => Some(ContentTarget::Key),
KeyEvent(Key::Char('P')) => Some(ContentTarget::DotPath),
KeyEvent(Key::Char('b')) => Some(ContentTarget::BracketPath),
KeyEvent(Key::Char('q')) => Some(ContentTarget::QueryPath),
_ => None,
};
self.input_buffer.clear();
if let Some(content_target) = content_target {
if self.print_content(content_target) {
self.input_state = InputState::WaitingForAnyKeyPress;
continue;
}
}
self.input_state = InputState::Default;
None
}
event if self.input_state == InputState::PendingYCommand => {
let content_target = match event {
KeyEvent(Key::Char('y')) => Some(ContentTarget::PrettyPrintedValue),
KeyEvent(Key::Char('v')) => Some(ContentTarget::OneLineValue),
KeyEvent(Key::Char('s')) => Some(ContentTarget::String),
KeyEvent(Key::Char('k')) => Some(ContentTarget::Key),
KeyEvent(Key::Char('p')) => Some(ContentTarget::DotPath),
KeyEvent(Key::Char('b')) => Some(ContentTarget::BracketPath),
KeyEvent(Key::Char('q')) => Some(ContentTarget::QueryPath),
_ => None,
};
if let Some(content_target) = content_target {
self.copy_content(content_target);
}
self.input_state = InputState::Default;
self.input_buffer.clear();
None
}
event if self.input_state == InputState::PendingZCommand => {
let z_action = match event {
KeyEvent(Key::Char('t')) => Some(Action::MoveFocusedLineToTop),
KeyEvent(Key::Char('z')) => Some(Action::MoveFocusedLineToCenter),
KeyEvent(Key::Char('b')) => Some(Action::MoveFocusedLineToBottom),
_ => None,
};
self.input_state = InputState::Default;
self.input_buffer.clear();
z_action
}
KeyEvent(Key::Ctrl('c') | Key::Char('q')) => break,
KeyEvent(Key::F(1)) => {
self.show_help();
None
}
KeyEvent(Key::Esc) => {
self.input_buffer.clear();
self.search_state.set_no_longer_actively_searching();
None
}
KeyEvent(Key::Char(ch @ '0'..='9')) => {
if ch == '0' && self.input_buffer.is_empty() {
Some(Action::FocusFirstSibling)
} else {
self.buffer_input(ch as u8);
None
}
}
KeyEvent(Key::Char('p')) => {
self.input_state = InputState::PendingPCommand;
self.input_buffer.clear();
self.buffer_input(b'p');
None
}
KeyEvent(Key::Char('y')) => {
match &self.clipboard_context {
Ok(_) => {
self.input_state = InputState::PendingYCommand;
self.input_buffer.clear();
self.buffer_input(b'y');
}
Err(err) => {
let msg = format!("Unable to access clipboard: {err}");
self.set_error_message(msg);
}
}
None
}
KeyEvent(Key::Char('z')) => {
self.input_state = InputState::PendingZCommand;
self.input_buffer.clear();
self.buffer_input(b'z');
None
}
KeyEvent(key) => {
let action = match key {
Key::Up | Key::Char('k') | Key::Ctrl('p') | Key::Backspace => {
let lines = self.parse_input_buffer_as_number();
Some(Action::MoveUp(lines))
}
Key::Down | Key::Char('j') | Key::Ctrl('n') | Key::Char('\n') => {
let lines = self.parse_input_buffer_as_number();
Some(Action::MoveDown(lines))
}
Key::Ctrl('e') => {
let lines = self.parse_input_buffer_as_number();
Some(Action::ScrollDown(lines))
}
Key::Ctrl('y') => {
let lines = self.parse_input_buffer_as_number();
Some(Action::ScrollUp(lines))
}
Key::Ctrl('d') => {
let maybe_distance = self.maybe_parse_input_buffer_as_number();
Some(Action::JumpDown(maybe_distance))
}
Key::Ctrl('u') => {
let maybe_distance = self.maybe_parse_input_buffer_as_number();
Some(Action::JumpUp(maybe_distance))
}
Key::Ctrl('b') | Key::PageUp => {
let count = self.parse_input_buffer_as_number();
Some(Action::PageUp(count))
}
Key::Ctrl('f') | Key::PageDown => {
let count = self.parse_input_buffer_as_number();
Some(Action::PageDown(count))
}
Key::Char('K') => {
let lines = self.parse_input_buffer_as_number();
Some(Action::FocusPrevSibling(lines))
}
Key::Char('J') => {
let lines = self.parse_input_buffer_as_number();
Some(Action::FocusNextSibling(lines))
}
Key::Char('n') => {
let count = self.parse_input_buffer_as_number();
jumped_to_search_match = true;
self.jump_to_search_match(JumpDirection::Next, count)
}
Key::Char('N') => {
let count = self.parse_input_buffer_as_number();
jumped_to_search_match = true;
self.jump_to_search_match(JumpDirection::Prev, count)
}
Key::Char('g') => match self.maybe_parse_input_buffer_as_number() {
None => Some(Action::FocusTop),
Some(n) => Some(Action::JumpTo {
line: n - 1,
make_visible: false,
}),
},
Key::Char('G') => match self.maybe_parse_input_buffer_as_number() {
None => Some(Action::FocusBottom),
Some(n) => Some(Action::JumpTo {
line: n - 1,
make_visible: true,
}),
},
Key::Char('.') => {
let count = self.parse_input_buffer_as_number();
self.screen_writer
.scroll_focused_line_right(&self.viewer, count);
None
}
Key::Char(',') => {
let count = self.parse_input_buffer_as_number();
self.screen_writer
.scroll_focused_line_left(&self.viewer, count);
None
}
Key::Char('/') => {
let count = self.parse_input_buffer_as_number();
let action = self
.get_search_input_and_start_search(SearchDirection::Forward, count);
jumped_to_search_match = action.is_some();
action
}
Key::Char('?') => {
let count = self.parse_input_buffer_as_number();
let action = self
.get_search_input_and_start_search(SearchDirection::Reverse, count);
jumped_to_search_match = action.is_some();
action
}
Key::Char('*') => {
let count = self.parse_input_buffer_as_number();
let action =
self.start_object_key_search(SearchDirection::Forward, count);
jumped_to_search_match = action.is_some();
action
}
Key::Char('#') => {
let count = self.parse_input_buffer_as_number();
let action =
self.start_object_key_search(SearchDirection::Reverse, count);
jumped_to_search_match = action.is_some();
action
}
Key::Char('w') => Some(Action::MoveDownUntilDepthChange),
Key::Char('b') => Some(Action::MoveUpUntilDepthChange),
Key::Left | Key::Char('h') => Some(Action::MoveLeft),
Key::Right | Key::Char('l') => Some(Action::MoveRight),
Key::Char('H') => Some(Action::FocusParent),
Key::Char('c') => Some(Action::CollapseNodeAndSiblings),
Key::Char('C') => Some(Action::DeepCollapseNodeAndSiblings),
Key::Char('e') => Some(Action::ExpandNodeAndSiblings),
Key::Char('E') => Some(Action::DeepExpandNodeAndSiblings),
Key::Char(' ') => Some(Action::ToggleCollapsed),
Key::Char('^') => Some(Action::FocusFirstSibling),
Key::Char('$') => Some(Action::FocusLastSibling),
Key::Home => Some(Action::FocusTop),
Key::End => Some(Action::FocusBottom),
Key::Char('%') => Some(Action::FocusMatchingPair),
Key::Char('m') => Some(Action::ToggleMode),
Key::Char('<') => {
self.screen_writer
.decrease_indentation_level(self.viewer.flatjson.2 as u16);
None
}
Key::Char('>') => {
self.screen_writer.increase_indentation_level();
None
}
Key::Char(';') => {
self.screen_writer
.scroll_focused_line_to_an_end(&self.viewer);
None
}
Key::Char(':') => {
if let Some(command) = self.readline(":", "command") {
match Self::parse_command(&command) {
Command::Quit => break,
Command::Help => self.show_help(),
Command::SetShowLineNumber(Some(new_val)) => {
self.screen_writer.show_line_numbers = new_val
}
Command::SetShowLineNumber(None) => {
self.screen_writer.show_line_numbers =
!self.screen_writer.show_line_numbers
}
Command::SetShowRelativeLineNumber(Some(new_val)) => {
self.screen_writer.show_relative_line_numbers = new_val
}
Command::SetShowRelativeLineNumber(None) => {
self.screen_writer.show_relative_line_numbers =
!self.screen_writer.show_relative_line_numbers
}
Command::Unknown => {
self.set_warning_message(format!(
"Unknown command: {command}"
));
}
}
}
None
}
_ => {
eprint!("{BELL}\r");
None
}
};
self.input_buffer.clear();
action
}
MouseEvent(me) => {
self.input_buffer.clear();
match me {
Press(Left, _, h) => {
if h > self.screen_writer.dimensions.without_status_bar().height {
continue;
} else {
Some(Action::Click(h))
}
}
Press(WheelUp, _, _) => Some(Action::ScrollUp(3)),
Press(WheelDown, _, _) => Some(Action::ScrollDown(3)),
_ => {
continue;
}
}
}
TuiEvent::Unknown(bytes) => {
self.set_error_message(format!("Unknown byte sequence: {bytes:?}"));
None
}
};
if let Some(action) = action {
self.viewer.perform_action(action);
}
if jumped_to_search_match {
self.screen_writer.scroll_line_to_search_match(
&self.viewer,
self.search_state.current_match_range(),
);
} else {
if focused_row_before != self.viewer.focused_row {
self.search_state.set_no_longer_actively_searching();
} else if previous_collapsed_state_of_focused_row
!= self.viewer.flatjson[focused_row_before].is_collapsed()
{
self.search_state
.set_matches_visible_if_actively_searching();
}
}
self.draw_screen();
self.message = None;
}
}
fn draw_screen(&mut self) {
self.screen_writer.print(
&self.viewer,
&self.input_buffer,
&self.input_filename,
&self.search_state,
&self.message,
);
}
fn draw_status_bar(&mut self) {
self.screen_writer.print_status_bar(
&self.viewer,
&self.input_buffer,
&self.input_filename,
&self.search_state,
&self.message,
);
}
fn set_info_message(&mut self, s: String) {
self.message = Some((s, MessageSeverity::Info));
}
fn set_warning_message(&mut self, s: String) {
self.message = Some((s, MessageSeverity::Warn));
}
fn set_error_message(&mut self, s: String) {
self.message = Some((s, MessageSeverity::Error));
}
fn readline(&mut self, prompt: &str, purpose: &str) -> Option<String> {
match self.screen_writer.get_command(prompt) {
Ok(s) => Some(s),
Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => None,
Err(err) => {
self.set_error_message(format!("Error getting {purpose}: {err}"));
None
}
}
}
fn buffer_input(&mut self, ch: u8) {
if self.input_buffer.is_empty() && ch == b'0' {
return;
}
if self.input_buffer.len() >= MAX_BUFFER_SIZE {
self.input_buffer.rotate_left(1);
self.input_buffer.pop();
}
self.input_buffer.push(ch);
}
fn maybe_parse_input_buffer_as_number(&mut self) -> Option<usize> {
let n = str::parse::<usize>(std::str::from_utf8(&self.input_buffer).unwrap());
self.input_buffer.clear();
n.ok()
}
fn parse_input_buffer_as_number(&mut self) -> usize {
self.maybe_parse_input_buffer_as_number().unwrap_or(1)
}
fn get_search_input_and_start_search(
&mut self,
direction: SearchDirection,
jumps: usize,
) -> Option<Action> {
let prompt_str = match direction {
SearchDirection::Forward => "/",
SearchDirection::Reverse => "?",
};
let search_term = self.readline(prompt_str, "search input")?;
if search_term.is_empty() {
self.search_state.direction = direction;
self.jump_to_search_match(JumpDirection::Next, jumps)
} else {
if self.initialize_search(direction, search_term) {
if !self.search_state.any_matches() {
self.set_warning_message(self.search_state.no_matches_message());
None
} else {
self.jump_to_search_match(JumpDirection::Next, jumps)
}
} else {
None
}
}
}
fn initialize_search(&mut self, direction: SearchDirection, search_term: String) -> bool {
match SearchState::initialize_search(search_term, &self.viewer.flatjson.1, direction) {
Ok(ss) => {
self.search_state = ss;
true
}
Err(err_message) => {
self.set_error_message(err_message);
false
}
}
}
fn start_object_key_search(
&mut self,
direction: SearchDirection,
jumps: usize,
) -> Option<Action> {
if self.initialize_object_key_search(direction) {
self.jump_to_search_match(JumpDirection::Next, jumps)
} else {
let message = match direction {
SearchDirection::Forward => "Must be focused on Object key to use '*'",
SearchDirection::Reverse => "Must be focused on Object key to use '#'",
};
self.set_warning_message(message.to_string());
None
}
}
fn initialize_object_key_search(&mut self, direction: SearchDirection) -> bool {
if let Some(key_range) = &self.viewer.flatjson[self.viewer.focused_row].key_range {
let object_key = format!("{}: ", &self.viewer.flatjson.1[key_range.clone()]);
self.initialize_search(direction, object_key)
} else {
false
}
}
fn jump_to_search_match(
&mut self,
jump_direction: JumpDirection,
jumps: usize,
) -> Option<Action> {
if !self.search_state.ever_searched {
self.set_info_message("Type / to search".to_string());
return None;
} else if !self.search_state.any_matches() {
self.set_warning_message(self.search_state.no_matches_message());
return None;
}
let destination = self.search_state.jump_to_match(
self.viewer.focused_row,
&self.viewer.flatjson,
jump_direction,
jumps,
);
Some(Action::JumpTo {
line: destination,
make_visible: false,
})
}
fn parse_command(command: &str) -> Command {
match command {
"h" | "he" | "hel" | "help" => Command::Help,
"q" | "qu" | "qui" | "quit" | "quit()" | "exit" | "exit()" => Command::Quit,
"set number" => Command::SetShowLineNumber(Some(true)),
"set number!" => Command::SetShowLineNumber(None),
"set nonumber" => Command::SetShowLineNumber(Some(false)),
"set relativenumber" => Command::SetShowRelativeLineNumber(Some(true)),
"set relativenumber!" => Command::SetShowRelativeLineNumber(None),
"set norelativenumber" => Command::SetShowRelativeLineNumber(Some(false)),
_ => Command::Unknown,
}
}
fn show_help(&mut self) {
let _ = write!(self.screen_writer.stdout, "{ToMainScreen}");
let child = std::process::Command::new("less")
.arg("-r")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::inherit())
.spawn();
match child {
Ok(mut child) => {
if let Some(ref mut stdin) = child.stdin {
let _ = stdin.write(HELP.as_bytes());
let _ = stdin.flush();
}
let _ = child.wait();
}
Err(err) => {
self.set_error_message(format!("Error piping help documentation to less: {err}"));
}
}
let _ = write!(self.screen_writer.stdout, "{ToAlternateScreen}");
}
fn get_content_target_data(&self, content_target: ContentTarget) -> Result<String, String> {
let json = &self.viewer.flatjson.1;
let focused_row_index = self.viewer.focused_row;
let focused_row = &self.viewer.flatjson[focused_row_index];
let data = match content_target {
ContentTarget::PrettyPrintedValue if focused_row.is_container() => self
.viewer
.flatjson
.pretty_printed_value(focused_row_index)
.unwrap(),
ContentTarget::PrettyPrintedValue | ContentTarget::OneLineValue => {
let range = focused_row.range.clone();
json[range].to_string()
}
ContentTarget::String => {
if !focused_row.is_string() {
return Err("Current value is not a string".to_string());
}
let range = focused_row.range.clone();
let quoteless_range = (range.start + 1)..(range.end - 1);
let string_value = &json[quoteless_range];
match unescape_json_string(string_value) {
Ok(unescaped) => unescaped,
Err(err) => {
return Err(format!("{err}"));
}
}
}
ContentTarget::Key => {
let Some(key_range) = &focused_row.key_range else {
return Err("No object key to copy".to_string());
};
let quoteless_range = (key_range.start + 1)..(key_range.end - 1);
if self.viewer.mode == Mode::Data
&& JS_IDENTIFIER.is_match(&json[quoteless_range.clone()])
{
json[quoteless_range].to_string()
} else {
json[key_range.clone()].to_string()
}
}
ct @ (ContentTarget::DotPath
| ContentTarget::BracketPath
| ContentTarget::QueryPath) => {
let path_type = match ct {
ContentTarget::DotPath => flatjson::PathType::Dot,
ContentTarget::BracketPath => flatjson::PathType::Bracket,
ContentTarget::QueryPath => flatjson::PathType::Query,
_ => unreachable!(),
};
match self
.viewer
.flatjson
.build_path_to_node(path_type, focused_row_index)
{
Ok(path) => path,
Err(err) => return Err(err),
}
}
};
Ok(data)
}
fn copy_content(&mut self, content_target: ContentTarget) {
match self.get_content_target_data(content_target) {
Ok(content) => {
let clipboard = self.clipboard_context.as_mut().unwrap();
let focused_row = &self.viewer.flatjson[self.viewer.focused_row];
let content_type = match content_target {
ContentTarget::PrettyPrintedValue if focused_row.is_container() => {
"pretty-printed value"
}
ContentTarget::PrettyPrintedValue | ContentTarget::OneLineValue => "value",
ContentTarget::String => "string contents",
ContentTarget::Key => "key",
ContentTarget::DotPath => "path",
ContentTarget::BracketPath => "bracketed path",
ContentTarget::QueryPath => "query path",
};
if let Err(err) = clipboard.set_contents(content) {
self.set_error_message(format!(
"Unable to copy {content_type} to clipboard: {err}"
));
} else {
self.set_info_message(format!("Copied {content_type} to clipboard"));
}
}
Err(err) => self.set_warning_message(err),
}
}
fn print_content(&mut self, content_target: ContentTarget) -> bool {
match self.get_content_target_data(content_target) {
Ok(content) => {
let _ = self.screen_writer.stdout.suspend_raw_mode();
let _ = write!(self.screen_writer.stdout, "{ToMainScreen}");
let _ = write!(self.screen_writer.stdout, "{DISABLE_MOUSE_BUTTON_TRACKING}");
let _ = write!(
self.screen_writer.stdout,
"{}{}{}\n\nPress any key to continue.",
termion::clear::All,
termion::cursor::Goto(1, 1),
content
);
let _ = self.screen_writer.stdout.flush();
let _ = self.screen_writer.stdout.activate_raw_mode();
true
}
Err(err) => {
self.set_warning_message(err);
false
}
}
}
}