Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,28 @@ cat data.json | jnv -- --write-to-stdout > result.json
| <kbd>↑</kbd> | Select previous suggestion |
| Others | Return to editor |

### Editor mode (vi-style)

Optional modal editing for the query editor, disabled by default. Enable it by
setting `enable = true` under `[editor.vi]` in the configuration file. The
editor then starts in NORMAL mode; the prefix (`❮❮ ` by default) indicates the
mode. Press <kbd>i</kbd>/<kbd>a</kbd>/<kbd>I</kbd>/<kbd>A</kbd> to enter INSERT
mode — where typing, completion, and the keybindings above all work as usual —
and <kbd>Esc</kbd> to return to NORMAL mode.

The query buffer is single-line, so linewise commands (`dd`, `cc`, `yy`) act on
the whole line, and `o`/`O`/`.`/multi-line motions are not supported.

| Category | Keys |
| :- | :- |
| Motions | `h` `l` `0` `$` `^` `w` `W` `b` `B` `e` `E` `f{char}` `F{char}` `t{char}` `T{char}` `gg` `G` |
| Insert | `i` `I` `a` `A` |
| Operators | `d`/`c`/`y` + motion, and linewise `dd` `cc` `yy` |
| Edits | `x` `X` `D` `C` `s` `S` `r{char}` `~` |
| Paste | `p` `P` (from the last delete/yank) |
| Counts | prefix a motion or operator, e.g. `3w`, `d2w`, `5x` |
| Undo / redo | `u` / <kbd>Ctrl + R</kbd> |

### JSON viewer mode

| Key | Action |
Expand Down
15 changes: 15 additions & 0 deletions default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ active_char_style = "attr=dim"
# Style for all other characters when unfocused
inactive_char_style = "attr=dim"

# vi-style modal editing for the query editor
[editor.vi]
# Enable vi-style modal editing.
# When true, the editor starts in NORMAL mode and interprets keys as vi
# motions, operators, and edit commands. Press i/a/I/A to enter INSERT mode
# (where typing, completion, and the keybinds above all work as usual), and
# Esc to return to NORMAL mode.
# Supported in NORMAL mode: motions h l 0 $ ^ w W b B e E f F t T gg G;
# operators d c y with those motions plus the linewise dd cc yy; edits
# x X D C s S r ~ p P; counts (e.g. 3w, d2w); and undo u / redo Ctrl+R.
# The buffer is single-line, so linewise commands act on the whole line.
enable = false
# Prefix shown while in NORMAL mode (INSERT mode shows on_focus.prefix above).
normal_prefix = "❮❮ "

# JSON display settings
[json]
# Maximum number of JSON objects to read from streams (e.g., JSON Lines format)
Expand Down
96 changes: 96 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,32 @@ use duration::duration_serde;
pub struct EditorConfig {
pub on_focus: text_editor::Config,
pub on_defocus: text_editor::Config,
/// vi-style modal editing settings. Optional so existing configuration
/// files written before this feature continue to load.
#[serde(default)]
pub vi: ViConfig,
}

/// Settings for vi-style modal editing in the query editor.
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ViConfig {
/// When `true`, the query editor starts in NORMAL mode and interprets keys
/// as vi motions, operators, and edit commands. Press `i`/`a` to insert and
/// <kbd>Esc</kbd> to return to NORMAL mode.
pub enable: bool,
/// Prefix shown while in NORMAL mode, so the current mode is visible. The
/// configured `on_focus.prefix` is shown while in INSERT mode.
pub normal_prefix: String,
}

impl Default for ViConfig {
fn default() -> Self {
Self {
enable: false,
normal_prefix: "❮❮ ".to_string(),
}
}
}

#[derive(Serialize, Deserialize)]
Expand Down Expand Up @@ -140,3 +166,73 @@ impl Config {
toml::from_str(content).map_err(Into::into)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn default_config_parses() {
let config = Config::load_from(DEFAULT_CONFIG).expect("default config must parse");
// vi editing ships disabled so existing keybindings are unchanged.
assert!(!config.editor.vi.enable);
assert_eq!(config.editor.vi.normal_prefix, "❮❮ ");
}

#[test]
fn vi_section_is_optional_for_backward_compatibility() {
// A config written before the vi feature has no `[editor.vi]` table; it
// must still load, falling back to the vi defaults.
let without_vi = r#"
no_hint = false

[reactivity_control]
query_debounce_duration = "600ms"
resize_debounce_duration = "200ms"
spin_duration = "300ms"

[editor.on_focus]
[editor.on_defocus]

[json]
[json.stream]

[completion]
search_result_chunk_size = 100
search_load_chunk_size = 50000
[completion.listbox]

[keybinds]
exit = ["Ctrl+C"]
copy_query = ["Ctrl+Q"]
copy_result = ["Ctrl+O"]
switch_mode = ["Shift+Down"]

[keybinds.on_editor]
backward = ["Left"]
forward = ["Right"]
move_to_head = ["Ctrl+A"]
move_to_tail = ["Ctrl+E"]
move_to_previous_nearest = ["Alt+B"]
move_to_next_nearest = ["Alt+F"]
erase = ["Backspace"]
erase_all = ["Ctrl+U"]
erase_to_previous_nearest = ["Ctrl+W"]
erase_to_next_nearest = ["Alt+D"]
completion = ["Tab"]
on_completion.up = ["Up"]
on_completion.down = ["Down"]

[keybinds.on_json_viewer]
up = ["Up"]
down = ["Down"]
move_to_head = ["Ctrl+L"]
move_to_tail = ["Ctrl+H"]
toggle = ["Enter"]
expand = ["Ctrl+P"]
collapse = ["Ctrl+N"]
"#;
let config = Config::load_from(without_vi).expect("config without [editor.vi] must parse");
assert!(!config.editor.vi.enable);
}
}
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ mod runtime_tasks;
mod stdout_redirect;
use stdout_redirect::StdoutRedirect;
mod utils;
mod vi;

/// JSON navigator and interactive filter leveraging jq
#[derive(Parser)]
Expand Down Expand Up @@ -226,6 +227,7 @@ async fn main() -> anyhow::Result<()> {
config.editor.on_defocus,
// TODO: remove clones
config.keybinds.on_editor.clone(),
config.editor.vi,
);

// Redirects stdout to prevent interference with TUI interface.
Expand Down
154 changes: 151 additions & 3 deletions src/query_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ use tokio::{

use crate::{
completion::CompletionAction,
config::EditorKeybinds,
config::{EditorKeybinds, ViConfig},
context::{Index, SharedContext},
guide::{self, GuideAction},
vi,
};

/// Editor for inputting jq query. It manages the state of the text editor
Expand All @@ -27,6 +28,15 @@ pub struct QueryEditor {
focus_config: text_editor::Config,
defocus_config: text_editor::Config,
editor_keybinds: EditorKeybinds,
/// vi modal state, present only when vi-style editing is enabled.
vi: Option<vi::Editor>,
/// Prefix shown in NORMAL mode (vi only).
vi_normal_prefix: String,
/// Prefix shown in INSERT mode (the configured focus prefix).
insert_prefix: String,
/// Undo / redo stacks of `(text, cursor)` snapshots (vi only).
undo_stack: Vec<(String, usize)>,
redo_stack: Vec<(String, usize)>,
}

impl QueryEditor {
Expand All @@ -35,18 +45,34 @@ impl QueryEditor {
focus_config: text_editor::Config,
defocus_config: text_editor::Config,
editor_keybinds: EditorKeybinds,
vi_config: ViConfig,
) -> Self {
Self {
let insert_prefix = focus_config.prefix.clone();
let mut editor = Self {
state,
focus_config,
defocus_config,
editor_keybinds,
vi: vi_config.enable.then(|| vi::Editor::new(vi::Mode::Normal)),
vi_normal_prefix: vi_config.normal_prefix,
insert_prefix,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
};
if editor.vi.is_some() {
// NORMAL-mode cursor rests on a char, not the trailing append slot.
let len = editor.text().chars().count();
let cursor = editor.cursor().min(len.saturating_sub(1));
editor.set_cursor(cursor);
editor.refresh_prefix();
}
editor
}

/// Focus the query editor, applying the focus configuration.
pub fn focus(&mut self) {
self.state.config = self.focus_config.clone();
self.refresh_prefix();
}

/// Defocus the query editor, applying the defocus configuration.
Expand All @@ -69,13 +95,136 @@ impl QueryEditor {
self.state.texteditor.replace(text);
}

/// The current cursor position as a `char` index.
fn cursor(&self) -> usize {
self.state.texteditor.position()
}

/// Move the cursor to an absolute `char` index.
fn set_cursor(&mut self, pos: usize) {
self.state.texteditor.move_to_head();
for _ in 0..pos {
if !self.state.texteditor.forward() {
break;
}
}
}

/// Replace the buffer and place the cursor at an absolute `char` index.
fn set_buffer(&mut self, text: &str, cursor: usize) {
self.state.texteditor.replace(text);
self.set_cursor(cursor);
}

/// Apply the vi NORMAL-mode prefix or the INSERT-mode (focus) prefix.
fn refresh_prefix(&mut self) {
let Some(mode) = self.vi.as_ref().map(|v| v.mode) else {
return;
};
self.state.config.prefix = match mode {
vi::Mode::Normal => self.vi_normal_prefix.clone(),
vi::Mode::Insert => self.insert_prefix.clone(),
};
}

fn push_undo(&mut self, text: String, cursor: usize) {
self.undo_stack.push((text, cursor));
self.redo_stack.clear();
}

fn undo(&mut self) {
if let Some((text, cursor)) = self.undo_stack.pop() {
self.redo_stack.push((self.text(), self.cursor()));
self.set_buffer(&text, cursor);
}
}

fn redo(&mut self) {
if let Some((text, cursor)) = self.redo_stack.pop() {
self.undo_stack.push((self.text(), self.cursor()));
self.set_buffer(&text, cursor);
}
}

/// Handle a user input event to update the query editor's state accordingly.
/// Returns `true` if the event triggers the completion action, otherwise `false`.
fn handle_user_event(&mut self, event: &Event) -> bool {
if self.editor_keybinds.completion.contains(event) {
return true;
}

if self.vi.is_some() {
self.handle_vi_event(event);
} else {
self.handle_plain_event(event);
}
false
}

/// Route an event through the vi state machine. In INSERT mode all keys
/// except <kbd>Esc</kbd> fall through to the normal text-editing path, so
/// character insertion and the configured keybinds keep working.
fn handle_vi_event(&mut self, event: &Event) {
let Event::Key(key) = event else {
return;
};
if key.kind != KeyEventKind::Press {
return;
}

let mode = self.vi.as_ref().map(|v| v.mode).unwrap_or(vi::Mode::Insert);
match mode {
vi::Mode::Insert => {
if key.code == KeyCode::Esc {
let cursor = self.cursor();
let new_cursor = self.vi.as_mut().expect("vi enabled").leave_insert(cursor);
self.set_cursor(new_cursor);
self.refresh_prefix();
} else {
self.handle_plain_event(event);
}
}
vi::Mode::Normal => {
let pending = self.vi.as_ref().expect("vi enabled").is_pending();
// `u` / Ctrl+R are undo/redo, but only when not mid-command
// (so `fu`, `ru`, etc. still see `u` as their argument).
if !pending {
if key.code == KeyCode::Char('u') && key.modifiers == KeyModifiers::NONE {
self.undo();
return;
}
if key.code == KeyCode::Char('r') && key.modifiers == KeyModifiers::CONTROL {
self.redo();
return;
}
}

let text = self.text();
let cursor = self.cursor();
let outcome = self
.vi
.as_mut()
.expect("vi enabled")
.handle_normal(key, &text, cursor);
match outcome {
vi::Outcome::Move(pos) => self.set_cursor(pos),
vi::Outcome::Replace {
text: new_text,
cursor: new_cursor,
} => {
self.push_undo(text, cursor);
self.set_buffer(&new_text, new_cursor);
}
vi::Outcome::Noop => {}
}
self.refresh_prefix();
}
}
}

/// Handle an event as plain (non-modal) text editing — the original editor
/// behavior, also used for vi INSERT mode.
fn handle_plain_event(&mut self, event: &Event) {
match event {
key if self.editor_keybinds.backward.contains(key) => {
self.state.texteditor.backward();
Expand Down Expand Up @@ -132,7 +281,6 @@ impl QueryEditor {
},
_ => {}
}
false
}
}

Expand Down
Loading