diff --git a/Cargo.lock b/Cargo.lock index f5a7f0a..ae940c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,17 +103,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -161,9 +150,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -336,72 +325,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -575,12 +498,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -758,12 +675,6 @@ dependencies = [ "cc", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "image" version = "0.25.10" @@ -843,13 +754,11 @@ dependencies = [ [[package]] name = "jnv" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "arboard", - "async-trait", "clap", - "derive_builder", "dirs", "duration-string", "futures", @@ -861,7 +770,6 @@ dependencies = [ "serde", "termcfg", "tokio", - "tokio-stream", "toml", ] @@ -941,9 +849,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -1390,9 +1298,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "slab" @@ -1526,22 +1434,11 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" dependencies = [ "indexmap", "serde_core", @@ -1549,14 +1446,14 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow 0.7.15", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] @@ -1567,7 +1464,7 @@ version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow 1.0.0", + "winnow", ] [[package]] @@ -1601,9 +1498,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -1914,12 +1811,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.0" @@ -1995,9 +1886,9 @@ checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index ace8337..7d5489e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jnv" -version = "0.7.0" +version = "0.7.1" authors = ["ynqa "] edition = "2021" description = "JSON navigator and interactive filter leveraging jq" @@ -11,18 +11,15 @@ readme = "README.md" [dependencies] anyhow = "1.0.102" arboard = { version = "3.6.1", features = ["wayland-data-control"] } -async-trait = "0.1.89" clap = { version = "4.6.0", features = ["derive"] } duration-string = { version = "0.5.3", features = ["serde"] } -derive_builder = "0.20.2" dirs = "6.0.0" futures = "0.3.32" rustix = { version = "1.1.4", features = ["stdio"] } serde = "1.0.228" termcfg = { version = "0.2.0", features = ["crossterm_0_29_0"] } tokio = { version = "1.50.0", features = ["full"] } -tokio-stream = "0.1.18" -toml = "0.9.8" +toml = "1.1.0" # jaq dependencies jaq-core = "2.2.1" diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 0000000..419f016 --- /dev/null +++ b/src/completion.rs @@ -0,0 +1,312 @@ +use std::{collections::BTreeSet, sync::Arc}; + +use promkit_widgets::{ + core::{crossterm::event::Event, grapheme::StyledGraphemes, Widget}, + listbox::{self, Listbox}, +}; +use tokio::{ + sync::{mpsc, Mutex, RwLock}, + task::{self, JoinHandle}, +}; + +use crate::{ + config::CompletionKeybinds, + context::{Index, SharedContext}, + guide::{GuideAction, GuideMessage}, + json, + query_editor::QueryEditorAction, +}; + +/// Progress information for loading suggestions +#[derive(Clone, Default)] +pub struct SuggestionLoadProgress { + pub is_complete: bool, + pub loaded_path_count: usize, +} + +/// Store for suggestions with thread-safe access +struct SuggestionStore { + /// Set of all paths extracted from JSON input + paths: BTreeSet, + progress: SuggestionLoadProgress, +} + +#[derive(Clone)] +pub struct SharedSuggestionStore(Arc>); + +impl SharedSuggestionStore { + /// Collect suggestions that start with the given prefix + pub async fn collect_matches(&self, prefix: &str) -> (Vec, SuggestionLoadProgress) { + let store = self.0.lock().await; + let items = store + .paths + .iter() + .filter(|p| p.starts_with(prefix)) + .cloned() + .collect::>(); + (items, store.progress.clone()) + } +} + +/// Spawn a background loader and return shared suggestion store with task handle. +pub fn spawn_initialize( + input: &'static str, + max_streams: Option, + chunk_size: usize, +) -> (SharedSuggestionStore, JoinHandle<()>) { + let shared = SharedSuggestionStore(Arc::new(Mutex::new(SuggestionStore { + paths: BTreeSet::new(), + progress: SuggestionLoadProgress::default(), + }))); + + let shared_for_loading = shared.clone(); + let loader_task = task::spawn(async move { + // Load paths in a streaming manner and update the shared store incrementally + let iter = match json::get_all_paths(input, max_streams).await { + Ok(iter) => iter, + Err(_) => { + let mut store = shared_for_loading.0.lock().await; + store.progress.is_complete = true; + return; + } + }; + + // Process paths in chunks to avoid holding the lock for too long + let mut batch = Vec::with_capacity(chunk_size); + for path in iter { + batch.push(path); + + if batch.len() >= chunk_size { + let loaded = batch.len(); + let mut store = shared_for_loading.0.lock().await; + for item in batch.drain(..) { + store.paths.insert(item); + } + store.progress.loaded_path_count += loaded; + } + } + + // Insert any remaining paths after the loop + let remaining = batch.len(); + let mut store = shared_for_loading.0.lock().await; + for item in batch { + store.paths.insert(item); + } + + // Mark loading as complete and update progress + store.progress.loaded_path_count += remaining; + store.progress.is_complete = true; + }); + + (shared, loader_task) +} + +/// Navigator for managing the state of suggestions +/// and interactions in the completion view. +pub struct CompletionNavigator { + shared_suggestions: SharedSuggestionStore, + state: listbox::State, + /// Number of suggestions to load in each chunk + /// when the user scrolls near the end of the list. + search_result_chunk_size: usize, + /// Buffered suggestions that are not yet visible in the listbox. + remaining_items: Vec, +} + +impl CompletionNavigator { + pub fn new( + shared_suggestions: SharedSuggestionStore, + state: listbox::State, + search_result_chunk_size: usize, + ) -> Self { + Self { + shared_suggestions, + state, + search_result_chunk_size, + remaining_items: Default::default(), + } + } + + /// Get the currently selected item in listbox. + fn get_current_item(&self) -> String { + self.state.listbox.get().to_string() + } + + /// Create graphemes for rendering the completion navigator. + pub fn create_graphemes(&self, width: u16, height: u16) -> StyledGraphemes { + self.state.create_graphemes(width, height) + } + + /// Returns true when the cursor is close enough to the visible tail + /// and preloading the next chunk is beneficial. + fn is_near_visible_tail(&self) -> bool { + self.state + .listbox + .len() + .saturating_sub(self.state.listbox.position()) + < self.state.config.lines.unwrap_or(1) + } + + fn move_down(&mut self) { + // First, move the cursor down by one item. + self.state.listbox.forward(); + + // Then, check if we need to load more items + // when the cursor is close to the end. + if self.is_near_visible_tail() { + self.append_next_chunk_if_needed(); + } + } + + fn append_next_chunk_if_needed(&mut self) { + if self.remaining_items.is_empty() { + return; + } + let items = self.remaining_items.drain( + ..self + .search_result_chunk_size + .min(self.remaining_items.len()), + ); + for item in items { + self.state.listbox.push_string(item); + } + } + + /// Handle a user input event to update the completion navigator's state accordingly. + /// Returns `Some(String)` if the event triggers a selection change that should update the query editor, + fn handle_user_event( + &mut self, + event: &Event, + completion_keybinds: &CompletionKeybinds, + ) -> Option { + if self.state.listbox.is_empty() { + return None; + } + + // Move up. + if completion_keybinds.up.contains(event) { + self.state.listbox.backward(); + return Some(self.get_current_item()); + } + + // Move down (and load more if near the end). + if completion_keybinds.down.contains(event) { + self.move_down(); + return Some(self.get_current_item()); + } + + None + } + + async fn enter(&mut self, prefix: &str) -> (Option, SuggestionLoadProgress) { + let (items, progress) = self.shared_suggestions.collect_matches(prefix).await; + let head_item = self.initialize_session_items(items); + (head_item, progress) + } + + /// Initialize a completion session with a new search result set. + /// This method always resets previous session state first. + fn initialize_session_items(&mut self, mut items: Vec) -> Option { + self.clear_session_state(); + + if items.is_empty() { + return None; + } + + let used = items + .drain(..self.search_result_chunk_size.min(items.len())) + .collect::>(); + self.remaining_items = items; + self.state.listbox = Listbox::from(used); + Some(self.state.listbox.get().to_string()) + } + + /// Reset completion session state. + /// This clears both visible list items and buffered remaining items. + fn clear_session_state(&mut self) { + self.state.listbox = Listbox::from(Vec::::new()); + self.remaining_items.clear(); + } +} + +pub enum CompletionAction { + /// Triggered when the user enters the completion view with a current query as prefix. + Enter { prefix: String }, + /// Triggered when the user leaves the completion view. + Leave, + /// Triggered on user input events within the completion view, such as navigation keys. + UserEvent(Event), +} + +/// Spawn a background task to manage the completion navigator's state and interactions. +pub fn start_completion_task( + mut action_rx: mpsc::Receiver, + shared_ctx: SharedContext, + shared_completion: Arc>, + shared_renderer: promkit_widgets::core::render::SharedRenderer, + query_editor_action_tx: mpsc::Sender, + guide_action_tx: mpsc::Sender, + completion_keybinds: CompletionKeybinds, +) -> JoinHandle> { + tokio::spawn(async move { + loop { + tokio::select! { + Some(action) = action_rx.recv() => { + let area = shared_ctx.area().await; + let completion_view = { + let mut completion = shared_completion.write().await; + match action { + CompletionAction::Enter { prefix } => { + let (head_item, load_progress) = completion.enter(&prefix).await; + match head_item { + Some(head) => { + let message = if load_progress.is_complete { + GuideMessage::LoadedAllSuggestions(load_progress.loaded_path_count) + } else { + GuideMessage::LoadedPartiallySuggestions(load_progress.loaded_path_count) + }; + guide_action_tx.send(GuideAction::Show(message)).await?; + query_editor_action_tx + .send(QueryEditorAction::ReplaceText(head)) + .await?; + } + None => { + guide_action_tx + .send(GuideAction::Show(GuideMessage::NoSuggestionFound(prefix))) + .await?; + shared_ctx.set_active_index(Index::QueryEditor).await; + completion.clear_session_state(); + } + } + } + CompletionAction::UserEvent(event) => { + if let Some(text) = completion.handle_user_event(&event, &completion_keybinds) { + query_editor_action_tx + .send(QueryEditorAction::ReplaceText(text)) + .await?; + } else { + shared_ctx.set_active_index(Index::QueryEditor).await; + completion.clear_session_state(); + query_editor_action_tx + .send(QueryEditorAction::UserEvent(event)) + .await?; + } + } + CompletionAction::Leave => { + completion.clear_session_state(); + } + } + completion.create_graphemes(area.0, area.1) + }; + + shared_renderer + .update([(Index::Completion, completion_view)]) + .render() + .await?; + } + else => break, + } + } + Ok(()) + }) +} diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..0b8bcc7 --- /dev/null +++ b/src/context.rs @@ -0,0 +1,96 @@ +use std::{future::Future, sync::Arc}; + +use promkit_widgets::spinner; +use tokio::{sync::Mutex, task::JoinHandle}; + +/// Represent the different sections of the UI, which can be used to manage focus and input handling. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Index { + QueryEditor = 0, + Guide = 1, + Completion = 2, + JsonViewer = 3, +} + +#[derive(PartialEq)] +/// Represent the current state of the JSON viewer, +/// which can be used to control rendering behavior +/// and manage concurrent tasks like query processing and spinner animation. +pub enum State { + /// The viewer is idle and ready for user interactions or query processing. + Idle, + /// The viewer is currently loading the JSON stream, which may involve deserialization + Loading, + /// The viewer is actively processing a jq query, which may involve executing the query + /// and updating the view with the results. + Processing, +} + +pub struct Context { + /// The current state of the processor, which can be Idle, Loading, or Processing. + pub state: State, + /// Current active index for user input handling. + pub active_index: Index, + /// The current size of the terminal area. + /// + /// PERF NOTE: This currently lives with `state/current_task` in the same mutex + /// for simplicity. If lock contention becomes visible, this can be split into + /// a dedicated shared store (e.g. `Arc>`) to reduce lock + /// granularity. + pub area: (u16, u16), + /// The current task being executed, if any. + pub current_task: Option>, +} + +#[derive(Clone)] +pub struct SharedContext(Arc>); + +impl SharedContext { + pub fn new(area: (u16, u16)) -> Self { + Self(Arc::new(Mutex::new(Context { + state: State::Idle, + active_index: Index::QueryEditor, + area, + current_task: None, + }))) + } + + pub async fn area(&self) -> (u16, u16) { + let ctx = self.0.lock().await; + ctx.area + } + + pub async fn set_area(&self, area: (u16, u16)) { + let mut ctx = self.0.lock().await; + ctx.area = area; + } + + pub async fn active_index(&self) -> Index { + let ctx = self.0.lock().await; + ctx.active_index + } + + /// Set the active index, which controls which input field is currently focused. + /// If the index is `Guide`, it will be ignored to prevent focus on the guide section. + pub async fn set_active_index(&self, index: Index) { + if index == Index::Guide { + return; + } + let mut ctx = self.0.lock().await; + ctx.active_index = index; + } + + pub(crate) async fn lock(&self) -> tokio::sync::MutexGuard<'_, Context> { + self.0.lock().await + } +} + +impl spinner::State for SharedContext { + fn is_idle(&self) -> impl Future + Send { + let shared = self.0.clone(); + async move { + let context = shared.lock().await; + context.state == State::Idle + } + } +} diff --git a/src/editor.rs b/src/editor.rs deleted file mode 100644 index 7a39e60..0000000 --- a/src/editor.rs +++ /dev/null @@ -1,243 +0,0 @@ -use std::{future::Future, pin::Pin}; - -use promkit_widgets::{ - core::{ - crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, - grapheme::StyledGraphemes, - Widget, - }, - status::{self, Severity}, - text_editor, -}; - -use crate::{config::EditorKeybinds, search::IncrementalSearcher}; - -pub struct Editor { - handler: Handler, - state: text_editor::State, - focus_config: text_editor::Config, - defocus_config: text_editor::Config, - guide: status::State, - searcher: IncrementalSearcher, - editor_keybinds: EditorKeybinds, -} - -impl Editor { - pub fn new( - state: text_editor::State, - searcher: IncrementalSearcher, - focus_config: text_editor::Config, - defocus_config: text_editor::Config, - editor_keybinds: EditorKeybinds, - ) -> Self { - Self { - handler: BOXED_EDITOR_HANDLER, - state, - focus_config, - defocus_config, - guide: status::State::default(), - searcher, - editor_keybinds, - } - } - - pub fn focus(&mut self) { - self.state.config = self.focus_config.clone(); - } - - pub fn defocus(&mut self) { - self.state.config = self.defocus_config.clone(); - - self.searcher.leave_search(); - self.handler = BOXED_EDITOR_HANDLER; - - self.guide = status::State::default(); - } - - pub fn text(&self) -> String { - self.state.texteditor.text_without_cursor().to_string() - } - - pub fn create_editor_pane(&self, width: u16, height: u16) -> StyledGraphemes { - self.state.create_graphemes(width, height) - } - - pub fn create_searcher_pane(&self, width: u16, height: u16) -> StyledGraphemes { - self.searcher.create_pane(width, height) - } - - pub fn create_guide_pane(&self, width: u16, height: u16) -> StyledGraphemes { - self.guide.create_graphemes(width, height) - } - - pub async fn operate(&mut self, event: &Event) -> anyhow::Result<()> { - (self.handler)(event, self).await - } -} - -pub type Handler = for<'a> fn( - &'a Event, - &'a mut Editor, -) -> Pin> + Send + 'a>>; - -const BOXED_EDITOR_HANDLER: Handler = - |event, editor| -> Pin> + Send + '_>> { - Box::pin(edit(event, editor)) - }; -const BOXED_SEARCHER_HANDLER: Handler = - |event, editor| -> Pin> + Send + '_>> { - Box::pin(search(event, editor)) - }; - -pub async fn edit<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Result<()> { - editor.guide = status::State::default(); - - match event { - key if editor.editor_keybinds.completion.contains(key) => { - let prefix = editor.state.texteditor.text_without_cursor().to_string(); - match editor.searcher.start_search(&prefix) { - Ok(result) => match result.head_item { - Some(head) => { - if result.load_state.loaded { - editor.guide = status::State::new( - format!( - "Loaded all ({}) suggestions", - result.load_state.loaded_item_len - ), - Severity::Success, - ); - } else { - editor.guide = status::State::new( - format!( - "Loaded partially ({}) suggestions", - result.load_state.loaded_item_len - ), - Severity::Success, - ); - } - editor.state.texteditor.replace(&head); - editor.handler = BOXED_SEARCHER_HANDLER; - } - None => { - editor.guide = status::State::new( - format!("No suggestion found for '{prefix}'"), - Severity::Warning, - ); - } - }, - Err(e) => { - editor.guide = status::State::new( - format!("Failed to lookup suggestions: {e}"), - Severity::Warning, - ); - } - } - } - - // Move cursor. - key if editor.editor_keybinds.backward.contains(key) => { - editor.state.texteditor.backward(); - } - key if editor.editor_keybinds.forward.contains(key) => { - editor.state.texteditor.forward(); - } - key if editor.editor_keybinds.move_to_head.contains(key) => { - editor.state.texteditor.move_to_head(); - } - key if editor.editor_keybinds.move_to_tail.contains(key) => { - editor.state.texteditor.move_to_tail(); - } - - // Move cursor to the nearest character. - key if editor - .editor_keybinds - .move_to_previous_nearest - .contains(key) => - { - editor - .state - .texteditor - .move_to_previous_nearest(&editor.state.config.word_break_chars); - } - key if editor.editor_keybinds.move_to_next_nearest.contains(key) => { - editor - .state - .texteditor - .move_to_next_nearest(&editor.state.config.word_break_chars); - } - - // Erase char(s). - key if editor.editor_keybinds.erase.contains(key) => { - editor.state.texteditor.erase(); - } - key if editor.editor_keybinds.erase_all.contains(key) => { - editor.state.texteditor.erase_all(); - } - - // Erase to the nearest character. - key if editor - .editor_keybinds - .erase_to_previous_nearest - .contains(key) => - { - editor - .state - .texteditor - .erase_to_previous_nearest(&editor.state.config.word_break_chars); - } - key if editor.editor_keybinds.erase_to_next_nearest.contains(key) => { - editor - .state - .texteditor - .erase_to_next_nearest(&editor.state.config.word_break_chars); - } - - // Input char. - Event::Key(KeyEvent { - code: KeyCode::Char(ch), - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) - | Event::Key(KeyEvent { - code: KeyCode::Char(ch), - modifiers: KeyModifiers::SHIFT, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) => match editor.state.config.edit_mode { - text_editor::Mode::Insert => editor.state.texteditor.insert(*ch), - text_editor::Mode::Overwrite => editor.state.texteditor.overwrite(*ch), - }, - - _ => {} - } - Ok(()) -} - -pub async fn search<'a>(event: &'a Event, editor: &'a mut Editor) -> anyhow::Result<()> { - match event { - key if editor.editor_keybinds.on_completion.down.contains(key) => { - editor.searcher.down_with_load(); - editor - .state - .texteditor - .replace(&editor.searcher.get_current_item()); - } - - key if editor.editor_keybinds.on_completion.up.contains(key) => { - editor.searcher.up(); - editor - .state - .texteditor - .replace(&editor.searcher.get_current_item()); - } - - _ => { - editor.searcher.leave_search(); - editor.handler = BOXED_EDITOR_HANDLER; - return edit(event, editor).await; - } - } - - Ok(()) -} diff --git a/src/event_dispatcher.rs b/src/event_dispatcher.rs new file mode 100644 index 0000000..047b193 --- /dev/null +++ b/src/event_dispatcher.rs @@ -0,0 +1,167 @@ +use std::io; + +use futures::StreamExt; +use promkit_widgets::{ + core::crossterm::{ + event::{ + DisableMouseCapture, EnableMouseCapture, Event, EventStream, MouseEvent, MouseEventKind, + }, + execute, terminal, + }, + spinner::State, +}; +use tokio::{sync::mpsc, task::JoinHandle}; + +use crate::{ + completion::CompletionAction, + config::Keybinds, + context::{Index, SharedContext}, + guide::{GuideAction, GuideMessage}, + json_viewer, + query_editor::QueryEditorAction, +}; + +/// Actions that can be triggered by terminal events, +/// which are dispatched to the appropriate components. +enum Action { + Resize(u16, u16), + Exit, + CopyQuery, + CopyResult, + /// Switch between query-editor/completion and JSON viewer. + SwitchMode, +} + +/// Spawn a background task to listen for terminal events and dispatch corresponding actions +/// to the appropriate components (query editor, completion navigator, JSON viewer, guide). +pub fn spawn_terminal_event_dispatch_task( + ctx: SharedContext, + keybinds: Keybinds, + debounce_resize_tx: mpsc::Sender<(u16, u16)>, + editor_action_tx: mpsc::Sender, + completion_action_tx: mpsc::Sender, + json_viewer_action_tx: mpsc::Sender, + guide_action_tx: mpsc::Sender, +) -> JoinHandle> { + let mut stream = EventStream::new(); + tokio::spawn(async move { + 'main: loop { + tokio::select! { + Some(Ok(event)) = stream.next() => { + // Note: `HashSet::contains` compares full mouse events (including `column`/`row`), + // so wheel events are normalized to `(0, 0)` to match configured `ScrollUp`/`ScrollDown` bindings. + let event = match event { + Event::Mouse(mouse) + if matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) => + { + Event::Mouse(MouseEvent { + kind: mouse.kind, + column: 0, + row: 0, + modifiers: mouse.modifiers, + }) + } + other => other, + }; + guide_action_tx.send(GuideAction::Clear).await?; + + let action = if let Event::Resize(width, height) = event { + Some(Action::Resize(width, height)) + } else if keybinds.exit.contains(&event) { + Some(Action::Exit) + } else if keybinds.copy_query.contains(&event) { + Some(Action::CopyQuery) + } else if keybinds.copy_result.contains(&event) { + Some(Action::CopyResult) + } else if keybinds.switch_mode.contains(&event) { + Some(Action::SwitchMode) + } else { + None + }; + + if let Some(action) = action { + match action { + Action::Resize(width, height) => { + debounce_resize_tx.send((width, height)).await?; + } + Action::Exit => break 'main, + Action::CopyQuery => { + editor_action_tx.send(QueryEditorAction::CopyQuery).await?; + } + Action::CopyResult => { + if ctx.is_idle().await { + json_viewer_action_tx + .send(json_viewer::ViewerAction::CopyResult) + .await?; + } else { + guide_action_tx + .send(GuideAction::Show( + GuideMessage::FailedToCopyWhileRenderingInProgress, + )) + .await?; + } + } + Action::SwitchMode => match ctx.active_index().await { + Index::QueryEditor | Index::Completion => { + if ctx.is_idle().await { + ctx.set_active_index(Index::JsonViewer).await; + completion_action_tx.send(CompletionAction::Leave).await?; + editor_action_tx.send(QueryEditorAction::Leave).await?; + execute!( + io::stdout(), + terminal::EnterAlternateScreen, + EnableMouseCapture, + )?; + } else { + guide_action_tx + .send(GuideAction::Show( + GuideMessage::FailedToSwitchModeWhileRenderingInProgress, + )) + .await?; + } + } + Index::JsonViewer => { + ctx.set_active_index(Index::QueryEditor).await; + editor_action_tx.send(QueryEditorAction::Enter).await?; + execute!( + io::stdout(), + terminal::LeaveAlternateScreen, + DisableMouseCapture, + )?; + } + Index::Guide => {} + }, + } + continue; + } + + match ctx.active_index().await { + Index::QueryEditor => { + editor_action_tx + .send(QueryEditorAction::UserEvent(event)) + .await?; + } + Index::Completion => { + completion_action_tx + .send(CompletionAction::UserEvent(event)) + .await?; + } + Index::JsonViewer => { + json_viewer_action_tx + .send(json_viewer::ViewerAction::UserEvent(event)) + .await?; + } + Index::Guide => {} + } + }, + else => { + break 'main; + } + } + } + Ok(()) + }) +} diff --git a/src/guide.rs b/src/guide.rs new file mode 100644 index 0000000..f3f51eb --- /dev/null +++ b/src/guide.rs @@ -0,0 +1,110 @@ +use arboard::Clipboard; +use promkit_widgets::{ + core::{render::SharedRenderer, Widget}, + status::{self, Severity}, +}; +use tokio::{sync::mpsc, task::JoinHandle}; + +use crate::context::{Index, SharedContext}; + +/// Represent a message to be shown in the guide. +/// This is used to decouple the logic of generating messages from the logic of rendering them. +pub enum GuideMessage { + CopiedToClipboard, + FailedToCopyToClipboard(String), + FailedToSetupClipboard(String), + FailedToCopyWhileRenderingInProgress, + FailedToSwitchModeWhileRenderingInProgress, + LoadedAllSuggestions(usize), + LoadedPartiallySuggestions(usize), + NoSuggestionFound(String), + JqReturnedNull(String), + JqFailed(String), +} + +/// Represent an action to be performed on the guide. +pub enum GuideAction { + Clear, + Show(GuideMessage), +} + +fn message_to_state(message: GuideMessage) -> status::State { + match message { + GuideMessage::CopiedToClipboard => { + status::State::new("Copied to clipboard", Severity::Success) + } + GuideMessage::FailedToCopyToClipboard(e) => { + status::State::new(format!("Failed to copy to clipboard: {e}"), Severity::Error) + } + GuideMessage::FailedToSetupClipboard(e) => { + status::State::new(format!("Failed to setup clipboard: {e}"), Severity::Error) + } + GuideMessage::FailedToCopyWhileRenderingInProgress => status::State::new( + "Failed to copy while rendering is in progress.", + Severity::Warning, + ), + GuideMessage::FailedToSwitchModeWhileRenderingInProgress => status::State::new( + "Failed to switch mode while rendering is in progress.", + Severity::Warning, + ), + GuideMessage::LoadedAllSuggestions(count) => status::State::new( + format!("Loaded all ({count}) suggestions"), + Severity::Success, + ), + GuideMessage::LoadedPartiallySuggestions(count) => status::State::new( + format!("Loaded partially ({count}) suggestions"), + Severity::Success, + ), + GuideMessage::NoSuggestionFound(prefix) => status::State::new( + format!("No suggestion found for '{prefix}'"), + Severity::Warning, + ), + GuideMessage::JqReturnedNull(input) => status::State::new( + format!("jq returned 'null', which may indicate a typo or incorrect filter: `{input}`"), + Severity::Warning, + ), + GuideMessage::JqFailed(e) => { + status::State::new(format!("jq failed: `{e}`"), Severity::Error) + } + } +} + +/// Copy the given content to the clipboard and return a message indicating the result. +pub fn copy_to_clipboard_message(content: &str) -> GuideMessage { + match Clipboard::new() { + Ok(mut clipboard) => match clipboard.set_text(content) { + Ok(_) => GuideMessage::CopiedToClipboard, + Err(e) => GuideMessage::FailedToCopyToClipboard(e.to_string()), + }, + Err(e) => GuideMessage::FailedToSetupClipboard(e.to_string()), + } +} + +/// Spawn a task that listens for guide actions and updates the guide view accordingly. +pub fn start_guide_task( + mut action_rx: mpsc::Receiver, + shared_renderer: SharedRenderer, + shared_ctx: SharedContext, + no_hint: bool, +) -> JoinHandle> { + tokio::spawn(async move { + loop { + tokio::select! { + Some(action) = action_rx.recv() => { + let area = shared_ctx.area().await; + let view = if no_hint { + Default::default() + } else { + match action { + GuideAction::Clear => status::State::default().create_graphemes(area.0, area.1), + GuideAction::Show(message) => message_to_state(message).create_graphemes(area.0, area.1), + } + }; + shared_renderer.update([(Index::Guide, view)]).render().await?; + } + else => break, + } + } + Ok(()) + }) +} diff --git a/src/json.rs b/src/json.rs index 348b0d7..f12a377 100644 --- a/src/json.rs +++ b/src/json.rs @@ -5,140 +5,39 @@ use jaq_core::{ use jaq_json::Val; use promkit_widgets::{ - core::{crossterm::event::Event, grapheme::StyledGraphemes, Widget}, - jsonstream::{self, config::Config as JsonStreamConfig, jsonz, JsonStream}, + jsonstream::jsonz, serde_json::{self, Deserializer, Value}, - status::{self, Severity}, }; -use crate::{ - config::JsonViewerKeybinds, - processor::{ViewProvider, Visualizer}, - search::SearchProvider, -}; - -// #[derive(Clone)] -pub struct Json { - state: jsonstream::State, - json: &'static [serde_json::Value], - keybinds: JsonViewerKeybinds, -} - -impl Json { - pub fn new( - formatter: JsonStreamConfig, - input_stream: &'static [serde_json::Value], - keybinds: JsonViewerKeybinds, - ) -> anyhow::Result { - Ok(Self { - json: input_stream, - state: jsonstream::State { - stream: JsonStream::new(input_stream.iter()), - config: formatter, - }, - keybinds, - }) - } - - fn operate(&mut self, event: &Event) { - match event { - // Move up. - event if self.keybinds.up.contains(event) => { - self.state.stream.up(); - } - - // Move down. - event if self.keybinds.down.contains(event) => { - self.state.stream.down(); - } - - // Move to head - event if self.keybinds.move_to_head.contains(event) => { - self.state.stream.head(); - } - - // Move to tail - event if self.keybinds.move_to_tail.contains(event) => { - self.state.stream.tail(); - } - - // Toggle collapse/expand - event if self.keybinds.toggle.contains(event) => { - self.state.stream.toggle(); - } - - event if self.keybinds.expand.contains(event) => { - self.state.stream.set_nodes_visibility(false); - } - - event if self.keybinds.collapse.contains(event) => { - self.state.stream.set_nodes_visibility(true); - } - - _ => (), - } - } +/// Get all JSON paths from the input JSON string, +/// respecting the max_streams limit if provided. +pub async fn get_all_paths( + json_str: &str, + max_streams: Option, +) -> anyhow::Result> { + let stream = deserialize(json_str, max_streams)?; + let paths = jsonz::get_all_paths(stream.iter()).collect::>(); + Ok(paths.into_iter()) } -#[async_trait::async_trait] -impl Visualizer for Json { - async fn content_to_copy(&self) -> String { - self.state.config.format_raw_json(self.state.stream.rows()) - } - - async fn create_init_pane(&mut self, area: (u16, u16)) -> StyledGraphemes { - self.state.create_graphemes(area.0, area.1) - } - - async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes { - self.operate(event); - self.state.create_graphemes(area.0, area.1) - } - - async fn create_panes_from_query( - &mut self, - area: (u16, u16), - input: String, - ) -> (Option, Option) { - match run_jaq(&input, self.json) { - Ok(ret) => { - let mut guide = None; - if ret.iter().all(|val| *val == Value::Null) { - guide = Some( - status::State::new( - format!( - "jq returned 'null', which may indicate a typo or incorrect filter: `{input}`" - ), - Severity::Warning, - ) - .create_graphemes(area.0, area.1), - ); - - self.state.stream = JsonStream::new(self.json.iter()); - } else { - self.state.stream = JsonStream::new(ret.iter()); - } - - (guide, Some(self.state.create_graphemes(area.0, area.1))) - } - Err(e) => { - self.state.stream = JsonStream::new(self.json.iter()); - - ( - Some( - status::State::new(format!("jq failed: `{e}`"), Severity::Error) - .create_graphemes(area.0, area.1), - ), - Some(self.state.create_graphemes(area.0, area.1)), - ) - } - } - } +/// Deserialize JSON string into a vector of serde_json::Value. +/// If max_streams is given, only deserialize up to that many JSON values. +pub fn deserialize( + json_str: &str, + max_streams: Option, +) -> anyhow::Result> { + let deserializer: serde_json::StreamDeserializer<'_, serde_json::de::StrRead<'_>, Value> = + Deserializer::from_str(json_str).into_iter::(); + let results = match max_streams { + Some(l) => deserializer.take(l).collect::, _>>(), + None => deserializer.collect::, _>>(), + }; + results.map_err(anyhow::Error::from) } -fn run_jaq( +pub fn run_jaq( query: &str, - json_stream: &'static [serde_json::Value], + json_stream: &[serde_json::Value], ) -> anyhow::Result> { let arena = Arena::default(); let loader = Loader::new(jaq_std::defs().chain(jaq_json::defs())); @@ -171,53 +70,3 @@ fn run_jaq( Ok(ret) } - -#[derive(Clone)] -pub struct JsonStreamProvider { - formatter: JsonStreamConfig, - max_streams: Option, -} - -impl JsonStreamProvider { - pub fn new(formatter: JsonStreamConfig, max_streams: Option) -> Self { - Self { - formatter, - max_streams, - } - } - - fn deserialize_json(&self, json_str: &str) -> anyhow::Result> { - let deserializer: serde_json::StreamDeserializer<'_, serde_json::de::StrRead<'_>, Value> = - Deserializer::from_str(json_str).into_iter::(); - let results = match self.max_streams { - Some(l) => deserializer.take(l).collect::, _>>(), - None => deserializer.collect::, _>>(), - }; - results.map_err(anyhow::Error::from) - } -} - -#[async_trait::async_trait] -impl ViewProvider for JsonStreamProvider { - async fn provide( - &mut self, - item: &'static str, - keybinds: JsonViewerKeybinds, - ) -> anyhow::Result { - let stream = self.deserialize_json(item)?; - let static_stream = Box::leak(stream.into_boxed_slice()); - Json::new(std::mem::take(&mut self.formatter), static_stream, keybinds) - } -} - -#[async_trait::async_trait] -impl SearchProvider for JsonStreamProvider { - async fn provide( - &mut self, - item: &str, - ) -> anyhow::Result + Send>> { - let stream = self.deserialize_json(item)?; - let static_stream = Box::leak(stream.into_boxed_slice()); - Ok(Box::new(jsonz::get_all_paths(static_stream.iter()))) - } -} diff --git a/src/json_viewer.rs b/src/json_viewer.rs new file mode 100644 index 0000000..be1fcf5 --- /dev/null +++ b/src/json_viewer.rs @@ -0,0 +1,391 @@ +use std::sync::Arc; + +use promkit_widgets::{ + core::{crossterm::event::Event, grapheme::StyledGraphemes, render::SharedRenderer, Widget}, + jsonstream::{self, JsonStream}, + serde_json::{self, Value}, +}; +use tokio::{ + sync::{mpsc, Mutex}, + task::JoinHandle, +}; + +use crate::{ + config::{JsonConfig, JsonViewerKeybinds}, + context::{Index, SharedContext, State}, + guide::{self, GuideAction, GuideMessage}, + json, +}; + +/// Represent the trigger for rendering views. +pub enum RenderTrigger { + /// User actions such as key presses + UserAction(Event), + /// Query changes such as new jq filter input + QueryChanged { query: String }, + /// Terminal resize events + AreaResized { query: String }, +} + +/// JSON viewer that maintains the state of JSON stream +/// and handles user interactions and query processing. +pub struct JsonViewer { + state: jsonstream::State, + json: Vec, + keybinds: JsonViewerKeybinds, +} + +pub type SharedJsonViewer = Arc>; + +impl JsonViewer { + /// Get the formatted content of current JSON stream. + pub fn formatted_content(&self) -> String { + self.state.config.format_raw_json(self.state.stream.rows()) + } + + /// Handle user event and update the viewer state accordingly. + fn handle_user_event(&mut self, event: &Event) { + match event { + // Move up. + event if self.keybinds.up.contains(event) => { + self.state.stream.up(); + } + + // Move down. + event if self.keybinds.down.contains(event) => { + self.state.stream.down(); + } + + // Move to head + event if self.keybinds.move_to_head.contains(event) => { + self.state.stream.head(); + } + + // Move to tail + event if self.keybinds.move_to_tail.contains(event) => { + self.state.stream.tail(); + } + + // Toggle collapse/expand + event if self.keybinds.toggle.contains(event) => { + self.state.stream.toggle(); + } + + event if self.keybinds.expand.contains(event) => { + self.state.stream.set_nodes_visibility(false); + } + + event if self.keybinds.collapse.contains(event) => { + self.state.stream.set_nodes_visibility(true); + } + + _ => (), + } + } + + /// Process jq query and update the viewer state with the results. + async fn refresh_view_with_query( + &mut self, + area: (u16, u16), + input: String, + ) -> (Option, Option) { + match json::run_jaq(&input, &self.json) { + Ok(ret) => { + let mut guide = None; + if ret.iter().all(|val| *val == Value::Null) { + guide = Some(GuideMessage::JqReturnedNull(input)); + + self.state.stream = JsonStream::new(self.json.iter()); + } else { + self.state.stream = JsonStream::new(ret.iter()); + } + + (guide, Some(self.state.create_graphemes(area.0, area.1))) + } + Err(e) => { + self.state.stream = JsonStream::new(self.json.iter()); + + ( + Some(GuideMessage::JqFailed(e.to_string())), + Some(self.state.create_graphemes(area.0, area.1)), + ) + } + } + } +} + +/// Initialize the JSON viewer with the given input, configuration, keybinds, and shared context. +pub async fn initialize( + input: &'static str, + config: JsonConfig, + keybinds: JsonViewerKeybinds, + shared_ctx: SharedContext, + shared_renderer: SharedRenderer, +) -> anyhow::Result { + // Set state to Loading to prevent overwriting by spinner frames in terminal. + { + let mut ctx = shared_ctx.lock().await; + if let Some(task) = ctx.current_task.take() { + task.abort(); + } + ctx.state = State::Loading; + } + + let input_stream = json::deserialize(input, config.max_streams)?; + let stream = JsonStream::new(input_stream.iter()); + let state = jsonstream::State { + stream, + config: config.stream, + }; + + // Set state to Idle to prevent overwriting by spinner frames in terminal. + { + let mut ctx = shared_ctx.lock().await; + ctx.state = State::Idle; + } + + { + let ctx = shared_ctx.lock().await; + let area = ctx.area; + drop(ctx); + + // TODO: error handling + let _ = shared_renderer + .update([(Index::JsonViewer, state.create_graphemes(area.0, area.1))]) + .render() + .await; + } + + Ok(Arc::new(Mutex::new(JsonViewer { + json: input_stream, + state, + keybinds, + }))) +} + +pub async fn render( + trigger: RenderTrigger, + shared_ctx: SharedContext, + shared_viewer_state: SharedJsonViewer, + shared_renderer: SharedRenderer, + guide_action_tx: mpsc::Sender, +) { + match trigger { + RenderTrigger::UserAction(event) => { + handle_user_event(shared_viewer_state, shared_renderer, shared_ctx, event).await; + } + RenderTrigger::QueryChanged { query } => { + handle_query_changed( + shared_viewer_state, + shared_renderer, + shared_ctx, + guide_action_tx, + query, + ) + .await; + } + RenderTrigger::AreaResized { query } => { + handle_area_resized( + shared_viewer_state, + shared_renderer, + shared_ctx, + guide_action_tx, + query, + ) + .await; + } + } +} + +async fn handle_user_event( + shared_viewer_state: SharedJsonViewer, + shared_renderer: SharedRenderer, + shared_ctx: SharedContext, + event: Event, +) { + let area = { + let ctx = shared_ctx.lock().await; + ctx.area + }; + + let graphemes = { + let mut viewer = shared_viewer_state.lock().await; + viewer.handle_user_event(&event); + viewer.state.create_graphemes(area.0, area.1) + }; + + // TODO: error handling + let _ = shared_renderer + .update([(Index::JsonViewer, graphemes)]) + .render() + .await; +} + +async fn handle_query_changed( + shared_viewer_state: SharedJsonViewer, + shared_renderer: SharedRenderer, + shared_ctx: SharedContext, + guide_action_tx: mpsc::Sender, + query: String, +) { + // Abort any ongoing processing task to prevent race conditions + // and ensure the new render reflects the latest terminal size. + { + let mut ctx = shared_ctx.lock().await; + if let Some(task) = ctx.current_task.take() { + task.abort(); + } + } + + let task = spawn_query_update_task( + shared_viewer_state.clone(), + shared_ctx.clone(), + guide_action_tx, + shared_renderer, + query, + ); + + // Store the new processing task handle in shared context + // to allow future cancellation if needed. + { + let mut ctx = shared_ctx.lock().await; + ctx.current_task = Some(task); + } +} + +async fn handle_area_resized( + shared_viewer_state: SharedJsonViewer, + shared_renderer: SharedRenderer, + shared_ctx: SharedContext, + guide_action_tx: mpsc::Sender, + query: String, +) { + { + let mut ctx = shared_ctx.lock().await; + // Abort any ongoing processing task to prevent race conditions + // and ensure the new render reflects the latest terminal size. + if let Some(task) = ctx.current_task.take() { + task.abort(); + } + } + + let task = spawn_query_update_task( + shared_viewer_state.clone(), + shared_ctx.clone(), + guide_action_tx, + shared_renderer, + query, + ); + + // Store the new processing task handle in shared context + // to allow future cancellation if needed. + { + let mut ctx = shared_ctx.lock().await; + ctx.current_task = Some(task); + } +} + +// Spawn a background task to process the jq query and update the viewer state with the results, +// while managing the viewer state to prevent race conditions and ensure the view reflects the latest terminal size. +fn spawn_query_update_task( + shared_viewer_state: SharedJsonViewer, + shared_ctx: SharedContext, + guide_action_tx: mpsc::Sender, + shared_renderer: SharedRenderer, + query: String, +) -> JoinHandle<()> { + tokio::spawn(async move { + // Set state to Processing to prevent overwriting by spinner frames in terminal. + { + let mut ctx = shared_ctx.lock().await; + ctx.state = State::Processing; + } + + let (maybe_guide, maybe_resp) = { + let ctx = shared_ctx.lock().await; + let area = ctx.area; + drop(ctx); + + let mut runtime = shared_viewer_state.lock().await; + runtime.refresh_view_with_query(area, query).await + }; + + // Set state to Idle to allow rendering of spinner frames in terminal. + { + let mut ctx = shared_ctx.lock().await; + ctx.state = State::Idle; + } + + if let Some(message) = maybe_guide { + let _ = guide_action_tx.send(GuideAction::Show(message)).await; + } + + // TODO: error handling + let _ = shared_renderer + .update([( + Index::JsonViewer, + maybe_resp.unwrap_or(StyledGraphemes::default()), + )]) + .render() + .await; + }) +} + +/// Represent the actions that can be performed in JSON viewer, +/// including copying results to clipboard, handling user events, and processing query changes. +pub enum ViewerAction { + /// Copy the current JSON stream results to clipboard. + CopyResult, + /// Handle user events such as key presses for navigation and toggling. + UserEvent(Event), + /// Handle changes in jq query input for dynamic filtering of JSON stream. + QueryChanged(String), +} + +/// Spawn a background task to handle viewer actions such as user events and query changes, +/// and update the viewer state and rendered view accordingly. +pub fn start_viewer_task( + mut action_rx: mpsc::Receiver, + shared_ctx: SharedContext, + shared_viewer_state: SharedJsonViewer, + shared_renderer: SharedRenderer, + guide_action_tx: mpsc::Sender, +) -> JoinHandle> { + tokio::spawn(async move { + loop { + tokio::select! { + Some(action) = action_rx.recv() => { + match action { + ViewerAction::CopyResult => { + let runtime = shared_viewer_state.lock().await; + let message = guide::copy_to_clipboard_message(&runtime.formatted_content()); + let _ = guide_action_tx.send(GuideAction::Show(message)).await; + } + ViewerAction::UserEvent(event) => { + render( + RenderTrigger::UserAction(event), + shared_ctx.clone(), + shared_viewer_state.clone(), + shared_renderer.clone(), + guide_action_tx.clone(), + ) + .await; + } + ViewerAction::QueryChanged(query) => { + render( + RenderTrigger::QueryChanged { query }, + shared_ctx.clone(), + shared_viewer_state.clone(), + shared_renderer.clone(), + guide_action_tx.clone(), + ) + .await; + } + } + } + else => break, + } + } + Ok(()) + }) +} diff --git a/src/main.rs b/src/main.rs index 38e2ee7..15807d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,32 +2,40 @@ use std::{ fs::File, io::{self, Read, Write}, path::PathBuf, + sync::Arc, }; use anyhow::anyhow; use clap::Parser; -use config::Config; use promkit_widgets::{ + core::{ + crossterm, + grapheme::StyledGraphemes, + render::{Renderer, SharedRenderer}, + }, listbox::{self, Listbox}, + spinner::{self, Spinner}, text_editor::{self, TextEditor}, }; +use tokio::sync::{mpsc, RwLock}; -mod editor; -use editor::Editor; +mod completion; +use completion::{CompletionAction, CompletionNavigator}; mod config; +use config::{Config, DEFAULT_CONFIG}; +mod context; +use context::{Index, SharedContext}; +mod event_dispatcher; +mod guide; +use guide::GuideAction; mod json; -use json::JsonStreamProvider; +mod json_viewer; +mod query_editor; +use query_editor::{QueryEditor, QueryEditorAction}; +mod runtime_tasks; mod stdout_redirect; use stdout_redirect::StdoutRedirect; -mod processor; -use processor::{ - init::ViewInitializer, monitor::ContextMonitor, Context, Processor, ViewProvider, Visualizer, -}; -mod prompt; -mod search; -use search::{IncrementalSearcher, SearchProvider}; - -use crate::config::DEFAULT_CONFIG; +mod utils; /// JSON navigator and interactive filter leveraging jq #[derive(Parser)] @@ -148,11 +156,29 @@ fn determine_config_file(config_path: Option) -> anyhow::Result anyhow::Result<()> { let args = Args::parse(); + + // Load input data let input = parse_input(&args)?; + let input: &'static str = Box::leak(input.into_boxed_str()); + // Load configuration let config = determine_config_file(args.config_file) .and_then(|config_file| { std::fs::read_to_string(&config_file) @@ -163,63 +189,232 @@ async fn main() -> anyhow::Result<()> { Config::load_from(DEFAULT_CONFIG).expect("Failed to load default configuration") }); - let listbox_state = listbox::State { - listbox: Listbox::default(), - config: config.completion.listbox.clone(), - }; + // Set up terminal + crossterm::terminal::enable_raw_mode()?; + let _terminal_cleanup_guard = TerminalCleanupGuard; + crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?; - let searcher = - IncrementalSearcher::new(listbox_state, config.completion.search_result_chunk_size); + // Spawn the completion loader task, which will asynchronously load suggestions based on the input data. + let (shared_suggestions, completion_loader_task) = completion::spawn_initialize( + input, + config.json.max_streams, + config.completion.search_load_chunk_size, + ); - let text_editor_state = text_editor::State { - texteditor: if let Some(filter) = args.default_filter { - TextEditor::new(filter) - } else { - Default::default() + // Initialize the completion navigator with shared suggestions and configuration. + let completion_navigator = CompletionNavigator::new( + shared_suggestions, + listbox::State { + listbox: Listbox::default(), + config: config.completion.listbox, }, - history: Default::default(), - config: config.editor.on_focus.clone(), - }; - - let provider = - &mut JsonStreamProvider::new(config.json.stream.clone(), config.json.max_streams); - - let item = Box::leak(input.into_boxed_str()); - - let loading_suggestions_task = - searcher.spawn_load_task(provider, item, config.completion.search_load_chunk_size); + config.completion.search_result_chunk_size, + ); - // TODO: re-consider put editor_task of prompt::run into Editor construction time. - // Overall, there are several cases where it would be sufficient to - // launch a background thread during construction. - let editor = Editor::new( - text_editor_state, - searcher, + // Initialize the query editor with the default filter, configuration, and keybindings. + let query_editor = QueryEditor::new( + text_editor::State { + texteditor: if let Some(ref filter) = args.default_filter { + TextEditor::new(filter) + } else { + Default::default() + }, + history: Default::default(), + config: config.editor.on_focus.clone(), + }, config.editor.on_focus, config.editor.on_defocus, // TODO: remove clones config.keybinds.on_editor.clone(), ); + // Redirects stdout to prevent interference with TUI interface. let mut stdout_redirect = StdoutRedirect::try_new_for_tui(args.write_to_stdout)?; - // TODO: put all logics here. - let maybe_output = prompt::run( - item, - config.reactivity_control, - provider, - editor, - loading_suggestions_task, + // Get terminal size for rendering purposes. + let terminal_size = crossterm::terminal::size()?; + + // Initialize the shared renderer with graphemes for each UI component. + let renderer = SharedRenderer::new( + Renderer::try_new_with_graphemes( + [ + ( + Index::QueryEditor, + query_editor.create_graphemes(terminal_size.0, terminal_size.1), + ), + (Index::Guide, StyledGraphemes::default()), + (Index::Completion, StyledGraphemes::default()), + (Index::JsonViewer, StyledGraphemes::default()), + ] + .into_iter(), + true, + ) + .await?, + ); + + // Initialize the shared context with the terminal size, + // which can be used by various components for rendering and state management. + let ctx = SharedContext::new(terminal_size); + + // Load input data into JSON viewer, initializing it with the provided configuration and keybindings. + let load_for_json_viewer = json_viewer::initialize( + input, + config.json, + config.keybinds.on_json_viewer.clone(), + ctx.clone(), + renderer.clone(), + ); + + // Spawn the spinner task, which will display a loading spinner in JSON viewer while processing is ongoing. + let spinner_task = tokio::spawn({ + let shared_renderer = renderer.clone(); + let ctx = ctx.clone(); + async move { + let spinner = Spinner::default().duration(config.reactivity_control.spin_duration); + let _ = spinner::run(&spinner, ctx, Index::JsonViewer, shared_renderer).await; + } + }); + + // Set up the debouncer for the query editor input, which will manage the timing of query updates + // to prevent excessive processing while the user is typing. + let (debounce_query_tx, last_query_rx, query_debouncer) = + utils::setup_debouncer::(config.reactivity_control.query_debounce_duration); + + // If a default filter is provided via command-line arguments, send it to the query debouncer + // to initialize the interface with that filter applied. + if let Some(default_filter) = args.default_filter { + debounce_query_tx.send(default_filter).await?; + } + + // Set up the debouncer for terminal resize events, which will manage the timing of resize handling + // to prevent excessive re-rendering while the terminal is being resized. + let (debounce_resize_tx, last_resize_rx, resize_debouncer) = + utils::setup_debouncer::<(u16, u16)>(config.reactivity_control.resize_debounce_duration); + + // Create channels for communication between the main event loop and various components + // (query editor, completion navigator, JSON viewer, and guide). + let (editor_action_tx, editor_action_rx) = mpsc::channel::(1); + let (completion_action_tx, completion_action_rx) = mpsc::channel::(1); + let (json_viewer_action_tx, json_viewer_action_rx) = + mpsc::channel::(8); + let (guide_action_tx, guide_action_rx) = mpsc::channel::(8); + + // Spawn the terminal event dispatcher task, which will listen for user input and terminal events, + // and forward them to the appropriate channels for handling by the main event loop and components. + let event_dispacher_task = event_dispatcher::spawn_terminal_event_dispatch_task( + ctx.clone(), + config.keybinds.clone(), + debounce_resize_tx, + editor_action_tx.clone(), + completion_action_tx.clone(), + json_viewer_action_tx.clone(), + guide_action_tx.clone(), + ); + + // Spawn a task to forward query changes from the debouncer to the JSON viewer, ensuring that + // the viewer updates in response to user input in the query editor. + let query_change_forward_task = runtime_tasks::spawn_query_change_forward_task( + last_query_rx, + json_viewer_action_tx.clone(), + ); + + // Spawn the guide task, which will manage the display of hints and guidance + // to the user based on their interactions with the interface. + let guide_task = guide::start_guide_task( + guide_action_rx, + renderer.clone(), + ctx.clone(), config.no_hint, - config.keybinds, - args.write_to_stdout, - ) - .await; + ); + + // Wrap the query editor and completion navigator in Arc> to allow shared mutable access across async tasks. + let shared_query_editor = Arc::new(RwLock::new(query_editor)); + let shared_completion_navigator = Arc::new(RwLock::new(completion_navigator)); + + // Spawn the query editor task, which will handle user input in the query editor and update the interface accordingly. + let query_editor_task = query_editor::start_query_editor_task( + editor_action_rx, + ctx.clone(), + shared_query_editor.clone(), + renderer.clone(), + completion_action_tx.clone(), + debounce_query_tx.clone(), + guide_action_tx.clone(), + ); + + // Spawn the completion task, which will handle user input in the completion navigator and update the interface accordingly. + let completion_navigator_task = completion::start_completion_task( + completion_action_rx, + ctx.clone(), + shared_completion_navigator.clone(), + renderer.clone(), + editor_action_tx.clone(), + guide_action_tx.clone(), + config.keybinds.on_editor.on_completion.clone(), + ); + + // Await JSON viewer bootstrap to complete, which will initialize the viewer with the input data and configuration. + let shared_json_viewer = load_for_json_viewer.await?; + // Spawn the JSON viewer processor task, which will handle updates to the JSON viewer based on user input and query changes. + let json_viewer_task = json_viewer::start_viewer_task( + json_viewer_action_rx, + ctx.clone(), + shared_json_viewer.clone(), + renderer.clone(), + guide_action_tx.clone(), + ); + + // Spawn the resize render task, which will listen for terminal resize events and trigger re-rendering of the UI components accordingly. + let resize_render_task = runtime_tasks::spawn_resize_render_task( + last_resize_rx, + ctx.clone(), + renderer.clone(), + shared_query_editor.clone(), + shared_completion_navigator.clone(), + shared_json_viewer.clone(), + guide_action_tx.clone(), + ); + + let maybe_output_result: anyhow::Result> = match event_dispacher_task.await { + Ok(Ok(())) if args.write_to_stdout => { + let runtime = shared_json_viewer.lock().await; + Ok(Some(runtime.formatted_content())) + } + Ok(Ok(())) => Ok(None), + // `event_dispacher_task` itself joined successfully, but the task body returned an application error. + Ok(Err(err)) => Err(err), + // The join operation failed (e.g. panic/cancel) before the task body could return its own result. + Err(err) => Err(err.into()), + }; + spinner_task.abort(); + query_debouncer.abort(); + resize_debouncer.abort(); + completion_loader_task.abort(); + query_change_forward_task.abort(); + resize_render_task.abort(); + guide_task.abort(); + query_editor_task.abort(); + completion_navigator_task.abort(); + json_viewer_task.abort(); + + // Ensure all aborted tasks are fully stopped before terminal cleanup runs. + let _ = spinner_task.await; + let _ = query_debouncer.await; + let _ = resize_debouncer.await; + let _ = completion_loader_task.await; + let _ = query_change_forward_task.await; + let _ = resize_render_task.await; + let _ = guide_task.await; + let _ = query_editor_task.await; + let _ = completion_navigator_task.await; + let _ = json_viewer_task.await; + + // Restore terminal state and write output to stdout if the option is enabled. stdout_redirect.restore()?; - let maybe_output = maybe_output?; - if let Some(output) = maybe_output { + // If the user has enabled the option to write the current JSON result to stdout on exit, output it now. + if let Some(output) = maybe_output_result? { let mut stdout = io::stdout(); stdout.write_all(output.as_bytes())?; if !output.ends_with('\n') { diff --git a/src/processor.rs b/src/processor.rs deleted file mode 100644 index 3e588e0..0000000 --- a/src/processor.rs +++ /dev/null @@ -1,146 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use promkit_widgets::core::{ - crossterm::event::Event, grapheme::StyledGraphemes, render::SharedRenderer, -}; -use tokio::{sync::Mutex, task::JoinHandle}; - -pub mod init; -pub use init::ViewProvider; - -use crate::prompt::Index; -pub mod monitor; - -fn empty_pane() -> StyledGraphemes { - StyledGraphemes::default() -} - -#[derive(PartialEq)] -enum State { - Idle, - Loading, - Processing, -} - -#[async_trait] -pub trait Visualizer: Send + Sync + 'static { - async fn content_to_copy(&self) -> String; - async fn create_init_pane(&mut self, area: (u16, u16)) -> StyledGraphemes; - async fn create_pane_from_event(&mut self, area: (u16, u16), event: &Event) -> StyledGraphemes; - async fn create_panes_from_query( - &mut self, - area: (u16, u16), - query: String, - ) -> (Option, Option); -} - -pub struct Context { - state: State, - area: (u16, u16), - current_task: Option>, -} - -impl Context { - pub fn new(area: (u16, u16)) -> Self { - Self { - state: State::Idle, - area, - current_task: None, - } - } -} - -pub struct Processor { - shared: Arc>, -} - -impl Processor { - pub fn new(shared: Arc>) -> Self { - Self { shared } - } - - fn spawn_process_task( - &self, - query: String, - shared_visualizer: Arc>, - shared_renderer: SharedRenderer, - ) -> JoinHandle<()> { - let shared = self.shared.clone(); - tokio::spawn(async move { - { - let mut shared_state = shared.lock().await; - shared_state.state = State::Processing; - } - - let (maybe_guide, maybe_resp) = { - let shared_state = shared.lock().await; - let area = shared_state.area; - drop(shared_state); - - let mut visualizer = shared_visualizer.lock().await; - visualizer.create_panes_from_query(area, query).await - }; - - // Set state to Idle to prevent overwriting by spinner frames in terminal. - { - let mut shared_state = shared.lock().await; - shared_state.state = State::Idle; - } - { - // TODO: error handling - let _ = shared_renderer - .update([ - (Index::Guide, maybe_guide.unwrap_or_else(empty_pane)), - (Index::Processor, maybe_resp.unwrap_or_else(empty_pane)), - ]) - .render() - .await; - } - }) - } - - pub async fn render_on_resize( - &self, - shared_visualizer: Arc>, - area: (u16, u16), - query: String, - shared_renderer: SharedRenderer, - ) { - { - let mut shared_state = self.shared.lock().await; - shared_state.area = area; - if let Some(task) = shared_state.current_task.take() { - task.abort(); - } - } - - let process_task = self.spawn_process_task(query, shared_visualizer, shared_renderer); - - { - let mut shared_state = self.shared.lock().await; - shared_state.current_task = Some(process_task); - } - } - - pub async fn render_result( - &self, - shared_visualizer: Arc>, - query: String, - shared_renderer: SharedRenderer, - ) { - { - let mut shared_state = self.shared.lock().await; - if let Some(task) = shared_state.current_task.take() { - task.abort(); - } - } - - let process_task = self.spawn_process_task(query, shared_visualizer, shared_renderer); - - { - let mut shared_state = self.shared.lock().await; - shared_state.current_task = Some(process_task); - } - } -} diff --git a/src/processor/init.rs b/src/processor/init.rs deleted file mode 100644 index 4b8e6f6..0000000 --- a/src/processor/init.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use promkit_widgets::core::render::SharedRenderer; -use tokio::sync::Mutex; - -use super::{Context, State, Visualizer}; -use crate::{config::JsonViewerKeybinds, prompt::Index}; - -#[async_trait] -pub trait ViewProvider { - async fn provide( - &mut self, - item: &'static str, - keybinds: JsonViewerKeybinds, - ) -> anyhow::Result; -} - -pub struct ViewInitializer { - shared: Arc>, -} - -impl ViewInitializer { - pub fn new(shared: Arc>) -> Self { - Self { shared } - } - - pub async fn initialize<'a, T: ViewProvider>( - &self, - provider: &'a mut T, - item: &'static str, - area: (u16, u16), - shared_renderer: SharedRenderer, - keybinds: JsonViewerKeybinds, - ) -> anyhow::Result { - { - let mut shared_state = self.shared.lock().await; - if let Some(task) = shared_state.current_task.take() { - task.abort(); - } - shared_state.state = State::Loading; - } - - let mut visualizer = provider.provide(item, keybinds).await?; - let pane = visualizer.create_init_pane(area).await; - - // Set state to Idle to prevent overwriting by spinner frames in terminal. - { - let mut shared_state = self.shared.lock().await; - shared_state.state = State::Idle; - } - { - // TODO: error handling - let _ = shared_renderer - .update([(Index::Processor, pane)]) - .render() - .await; - } - - Ok(visualizer) - } -} diff --git a/src/processor/monitor.rs b/src/processor/monitor.rs deleted file mode 100644 index 7775d5f..0000000 --- a/src/processor/monitor.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::{future::Future, sync::Arc}; - -use promkit_widgets::spinner; -use tokio::sync::Mutex; - -use super::{Context, State}; - -pub struct ContextMonitor { - shared: Arc>, -} - -impl ContextMonitor { - pub fn new(shared: Arc>) -> Self { - Self { shared } - } -} - -impl spinner::State for ContextMonitor { - fn is_idle(&self) -> impl Future + Send { - let shared = self.shared.clone(); - async move { - let context = shared.lock().await; - context.state == State::Idle - } - } -} diff --git a/src/prompt.rs b/src/prompt.rs deleted file mode 100644 index 52afd75..0000000 --- a/src/prompt.rs +++ /dev/null @@ -1,453 +0,0 @@ -use std::{io, sync::Arc, time::Duration}; - -use arboard::Clipboard; -use futures::StreamExt; -use promkit_widgets::{ - core::{ - crossterm::{ - cursor, - event::{ - DisableMouseCapture, EnableMouseCapture, Event, EventStream, MouseEvent, - MouseEventKind, - }, - execute, - terminal::{self, disable_raw_mode, enable_raw_mode}, - }, - grapheme::StyledGraphemes, - render::{Renderer, SharedRenderer}, - Widget, - }, - spinner::{self, Spinner, State}, - status::{self, Severity}, -}; -use tokio::{ - sync::{mpsc, Mutex, RwLock}, - task::JoinHandle, -}; - -use crate::{ - config::{Keybinds, ReactivityControl}, - Context, ContextMonitor, Editor, Processor, SearchProvider, ViewInitializer, ViewProvider, - Visualizer, -}; - -fn spawn_debouncer( - mut debounce_rx: mpsc::Receiver, - last_tx: mpsc::Sender, - duration: Duration, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let mut last_query = None; - let mut delay = tokio::time::interval(duration); - loop { - tokio::select! { - maybe_query = debounce_rx.recv() => { - if let Some(query) = maybe_query { - last_query = Some(query); - } else { - break; - } - }, - _ = delay.tick() => { - if let Some(text) = last_query.take() { - let _ = last_tx.send(text).await; - } - }, - } - } - }) -} - -fn copy_to_clipboard(content: &str) -> status::State { - match Clipboard::new() { - Ok(mut clipboard) => match clipboard.set_text(content) { - Ok(_) => status::State::new("Copied to clipboard", Severity::Success), - Err(e) => { - status::State::new(format!("Failed to copy to clipboard: {e}"), Severity::Error) - } - }, - // arboard fails (in the specific environment like linux?) on Clipboard::new() - // suppress the errors (but still show them) not to break the prompt - // https://github.com/1Password/arboard/issues/153 - Err(e) => status::State::new(format!("Failed to setup clipboard: {e}"), Severity::Error), - } -} - -fn empty_pane() -> StyledGraphemes { - StyledGraphemes::default() -} - -enum Focus { - Editor, - Processor, -} - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub enum Index { - Editor = 0, - Guide = 1, - Search = 2, - Processor = 3, -} - -#[allow(clippy::too_many_arguments)] -pub async fn run( - item: &'static str, - reactivity_control: ReactivityControl, - provider: &mut T, - editor: Editor, - loading_suggestions_task: JoinHandle>, - no_hint: bool, - keybinds: Keybinds, - write_to_stdout: bool, -) -> anyhow::Result> { - enable_raw_mode()?; - execute!(io::stdout(), cursor::Hide)?; - - let size = terminal::size()?; - - let shared_renderer = SharedRenderer::new( - Renderer::try_new_with_graphemes( - [ - (Index::Editor, editor.create_editor_pane(size.0, size.1)), - (Index::Guide, empty_pane()), - (Index::Search, empty_pane()), - (Index::Processor, empty_pane()), - ] - .into_iter(), - true, - ) - .await?, - ); - - let ctx = Arc::new(Mutex::new(Context::new(size))); - - let (last_query_tx, mut last_query_rx) = mpsc::channel(1); - let (debounce_query_tx, debounce_query_rx) = mpsc::channel(1); - let query_debouncer = spawn_debouncer( - debounce_query_rx, - last_query_tx, - reactivity_control.query_debounce_duration, - ); - if !editor.text().is_empty() { - debounce_query_tx.send(editor.text()).await?; - } - - let (last_resize_tx, mut last_resize_rx) = mpsc::channel::<(u16, u16)>(1); - let (debounce_resize_tx, debounce_resize_rx) = mpsc::channel(1); - let resize_debouncer = spawn_debouncer( - debounce_resize_rx, - last_resize_tx, - reactivity_control.resize_debounce_duration, - ); - - let spinning = tokio::spawn({ - let shared_renderer = shared_renderer.clone(); - let state = ContextMonitor::new(ctx.clone()); - let spin_duration = reactivity_control.spin_duration; - async move { - let spinner = Spinner::default().duration(spin_duration); - let _ = spinner::run(&spinner, state, Index::Processor, shared_renderer).await; - } - }); - - let mut focus = Focus::Editor; - let (editor_event_tx, mut editor_event_rx) = mpsc::channel::(1); - let (processor_event_tx, mut processor_event_rx) = mpsc::channel::(1); - - let (editor_copy_tx, mut editor_copy_rx) = mpsc::channel::<()>(1); - let (processor_copy_tx, mut processor_copy_rx) = mpsc::channel::<()>(1); - - let (editor_focus_tx, mut editor_focus_rx) = mpsc::channel::(1); - - let mut text_diff = [editor.text(), editor.text()]; - let shared_editor = Arc::new(RwLock::new(editor)); - let processor = Processor::new(ctx.clone()); - let context_monitor = ContextMonitor::new(ctx.clone()); - let initializer = ViewInitializer::new(ctx.clone()); - let initializing = initializer.initialize( - provider, - item, - size, - shared_renderer.clone(), - keybinds.on_json_viewer, - ); - - let main_task: JoinHandle> = { - let mut stream = EventStream::new(); - let shared_renderer = shared_renderer.clone(); - tokio::spawn(async move { - 'main: loop { - tokio::select! { - Some(Ok(event)) = stream.next() => { - // Note: `HashSet::contains` compares full mouse events (including `column`/`row`), - // so wheel events are normalized to `(0, 0)` to match configured `ScrollUp`/`ScrollDown` bindings. - let event = match event { - Event::Mouse(mouse) - if matches!( - mouse.kind, - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) => - { - Event::Mouse(MouseEvent { - kind: mouse.kind, - column: 0, - row: 0, - modifiers: mouse.modifiers, - }) - } - other => other, - }; - - match event { - Event::Resize(width, height) => { - debounce_resize_tx.send((width, height)).await?; - }, - event if keybinds.exit.contains(&event) => { - break 'main - }, - event if keybinds.copy_query.contains(&event) => { - editor_copy_tx.send(()).await?; - }, - event if keybinds.copy_result.contains(&event) => { - if context_monitor.is_idle().await { - processor_copy_tx.send(()).await?; - } else if !no_hint{ - let size = terminal::size()?; - shared_renderer.update([ - ( - Index::Guide, - status::State::new( - "Failed to copy while rendering is in progress.", - Severity::Warning, - ) - .create_graphemes(size.0, size.1), - ), - ]).render().await?; - } - }, - event if keybinds.switch_mode.contains(&event) => { - match focus { - Focus::Editor => { - if context_monitor.is_idle().await { - focus = Focus::Processor; - editor_focus_tx.send(false).await?; - execute!( - io::stdout(), - terminal::EnterAlternateScreen, - EnableMouseCapture, - )?; - } else if !no_hint{ - let size = terminal::size()?; - shared_renderer.update([ - ( - Index::Guide, - status::State::new( - "Failed to switch pane while rendering is in progress.", - Severity::Warning, - ) - .create_graphemes(size.0, size.1), - ), - ]).render().await?; - } - }, - Focus::Processor => { - focus = Focus::Editor; - editor_focus_tx.send(true).await?; - execute!( - io::stdout(), - terminal::LeaveAlternateScreen, - DisableMouseCapture, - )?; - }, - } - }, - event => { - match focus { - Focus::Editor => { - editor_event_tx.send(event).await?; - }, - Focus::Processor => { - processor_event_tx.send(event).await?; - }, - } - }, - } - }, - else => { - break 'main; - } - } - } - Ok(()) - }) - }; - - let editor_task: JoinHandle> = { - let shared_renderer = shared_renderer.clone(); - let shared_editor = shared_editor.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - Some(focus) = editor_focus_rx.recv() => { - let (editor_pane, guide_pane) = { - let mut editor = shared_editor.write().await; - if focus { - editor.focus(); - } else { - editor.defocus(); - } - ( - editor.create_editor_pane(size.0, size.1), - editor.create_guide_pane(size.0, size.1), - ) - }; - shared_renderer.update([ - (Index::Editor, editor_pane), - (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), - ]).render().await?; - } - Some(()) = editor_copy_rx.recv() => { - let text = { - let editor = shared_editor.read().await; - editor.text() - }; - let guide = copy_to_clipboard(&text); - if !no_hint { - let size = terminal::size()?; - let pane = guide.create_graphemes(size.0, size.1); - shared_renderer.update([ - (Index::Guide, pane), - ]).render().await?; - } - } - Some(event) = editor_event_rx.recv() => { - let size = terminal::size()?; - let (editor_pane, guide_pane, searcher_pane) = { - - let mut editor = shared_editor.write().await; - editor.operate(&event).await?; - - let current_text = editor.text(); - if current_text != text_diff[1] { - debounce_query_tx.send(current_text.clone()).await?; - text_diff[0] = text_diff[1].clone(); - text_diff[1] = current_text; - } - ( - editor.create_editor_pane(size.0, size.1), - editor.create_guide_pane(size.0, size.1), - editor.create_searcher_pane(size.0, size.1), - ) - }; - { - shared_renderer.update([ - (Index::Editor, editor_pane), - (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), - (Index::Search, searcher_pane), - ]).render().await?; - } - } - else => { - break - } - } - } - Ok(()) - }) - }; - - let shared_visualizer = Arc::new(Mutex::new(initializing.await?)); - let processor_task: JoinHandle> = { - let shared_renderer = shared_renderer.clone(); - let shared_editor = shared_editor.clone(); - let shared_visualizer = shared_visualizer.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - Some(()) = processor_copy_rx.recv() => { - let visualizer = shared_visualizer.lock().await; - let guide = copy_to_clipboard(&visualizer.content_to_copy().await); - if !no_hint { - let size = terminal::size()?; - let pane = guide.create_graphemes(size.0, size.1); - shared_renderer.update([ - (Index::Guide, pane), - ]).render().await?; - } - } - Some(event) = processor_event_rx.recv() => { - let pane = { - let mut visualizer = shared_visualizer.lock().await; - visualizer.create_pane_from_event((size.0, size.1), &event).await - }; - { - shared_renderer.update([ - (Index::Processor, pane), - ]).render().await?; - } - } - Some(query) = last_query_rx.recv() => { - processor.render_result( - shared_visualizer.clone(), - query, - shared_renderer.clone(), - ).await; - } - Some(area) = last_resize_rx.recv() => { - let (editor_pane, guide_pane, searcher_pane) = { - let editor = shared_editor.read().await; - ( - editor.create_editor_pane(size.0, size.1), - editor.create_guide_pane(size.0, size.1), - editor.create_searcher_pane(size.0, size.1), - ) - }; - { - shared_renderer.update([ - (Index::Editor, editor_pane), - (Index::Guide, if !no_hint { guide_pane } else { empty_pane() }), - (Index::Search, searcher_pane), - ]).render().await?; - } - let text = { - let editor = shared_editor.read().await; - editor.text() - }; - processor.render_on_resize( - shared_visualizer.clone(), - area, - text, - shared_renderer.clone(), - ).await; - } - else => { - break - } - } - } - Ok(()) - }) - }; - - main_task.await??; - - let output = if write_to_stdout { - let visualizer = shared_visualizer.lock().await; - Some(visualizer.content_to_copy().await) - } else { - None - }; - - loading_suggestions_task.abort(); - spinning.abort(); - query_debouncer.abort(); - resize_debouncer.abort(); - editor_task.abort(); - processor_task.abort(); - - execute!(io::stdout(), cursor::Show, DisableMouseCapture)?; - disable_raw_mode()?; - - Ok(output) -} diff --git a/src/query_editor.rs b/src/query_editor.rs new file mode 100644 index 0000000..54dac54 --- /dev/null +++ b/src/query_editor.rs @@ -0,0 +1,217 @@ +use std::sync::Arc; + +use promkit_widgets::{ + core::{ + crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, + grapheme::StyledGraphemes, + Widget, + }, + text_editor, +}; +use tokio::{ + sync::{mpsc, RwLock}, + task::JoinHandle, +}; + +use crate::{ + completion::CompletionAction, + config::EditorKeybinds, + context::{Index, SharedContext}, + guide::{self, GuideAction}, +}; + +/// Editor for inputting jq query. It manages the state of the text editor +/// and handles user input events to update the query text accordingly. +pub struct QueryEditor { + state: text_editor::State, + focus_config: text_editor::Config, + defocus_config: text_editor::Config, + editor_keybinds: EditorKeybinds, +} + +impl QueryEditor { + pub fn new( + state: text_editor::State, + focus_config: text_editor::Config, + defocus_config: text_editor::Config, + editor_keybinds: EditorKeybinds, + ) -> Self { + Self { + state, + focus_config, + defocus_config, + editor_keybinds, + } + } + + /// Focus the query editor, applying the focus configuration. + pub fn focus(&mut self) { + self.state.config = self.focus_config.clone(); + } + + /// Defocus the query editor, applying the defocus configuration. + pub fn defocus(&mut self) { + self.state.config = self.defocus_config.clone(); + } + + /// Get the current text of the query editor without the cursor. + pub fn text(&self) -> String { + self.state.texteditor.text_without_cursor().to_string() + } + + /// Create graphemes for rendering the query editor. + pub fn create_graphemes(&self, width: u16, height: u16) -> StyledGraphemes { + self.state.create_graphemes(width, height) + } + + /// Replace the current text of the query editor with the given text. + pub fn replace_text(&mut self, text: &str) { + self.state.texteditor.replace(text); + } + + /// 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; + } + + match event { + key if self.editor_keybinds.backward.contains(key) => { + self.state.texteditor.backward(); + } + key if self.editor_keybinds.forward.contains(key) => { + self.state.texteditor.forward(); + } + key if self.editor_keybinds.move_to_head.contains(key) => { + self.state.texteditor.move_to_head(); + } + key if self.editor_keybinds.move_to_tail.contains(key) => { + self.state.texteditor.move_to_tail(); + } + key if self.editor_keybinds.move_to_previous_nearest.contains(key) => { + self.state + .texteditor + .move_to_previous_nearest(&self.state.config.word_break_chars); + } + key if self.editor_keybinds.move_to_next_nearest.contains(key) => { + self.state + .texteditor + .move_to_next_nearest(&self.state.config.word_break_chars); + } + key if self.editor_keybinds.erase.contains(key) => { + self.state.texteditor.erase(); + } + key if self.editor_keybinds.erase_all.contains(key) => { + self.state.texteditor.erase_all(); + } + key if self.editor_keybinds.erase_to_previous_nearest.contains(key) => { + self.state + .texteditor + .erase_to_previous_nearest(&self.state.config.word_break_chars); + } + key if self.editor_keybinds.erase_to_next_nearest.contains(key) => { + self.state + .texteditor + .erase_to_next_nearest(&self.state.config.word_break_chars); + } + Event::Key(KeyEvent { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) + | Event::Key(KeyEvent { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::SHIFT, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) => match self.state.config.edit_mode { + text_editor::Mode::Insert => self.state.texteditor.insert(*ch), + text_editor::Mode::Overwrite => self.state.texteditor.overwrite(*ch), + }, + _ => {} + } + false + } +} + +/// Represent the actions that can be performed on the query editor, +/// such as focusing, copying the query, or handling user events. +pub enum QueryEditorAction { + /// Focus the query editor. + Enter, + /// Defocus the query editor. + Leave, + /// Copy the current query text to clipboard. + CopyQuery, + /// Replace the current query text. + ReplaceText(String), + /// Handle user input events to update the query editor's state. + UserEvent(Event), +} + +/// Spawn a background task to manage the query editor's state and interactions. +pub fn start_query_editor_task( + mut action_rx: mpsc::Receiver, + shared_ctx: SharedContext, + shared_editor: Arc>, + shared_renderer: promkit_widgets::core::render::SharedRenderer, + completion_action_tx: mpsc::Sender, + debounce_query_tx: mpsc::Sender, + guide_action_tx: mpsc::Sender, +) -> JoinHandle> { + tokio::spawn(async move { + let mut last_text = { + let editor = shared_editor.read().await; + editor.text() + }; + loop { + tokio::select! { + Some(action) = action_rx.recv() => { + let area = shared_ctx.area().await; + let (editor_view, current_text) = { + let mut editor = shared_editor.write().await; + match action { + QueryEditorAction::Enter => editor.focus(), + QueryEditorAction::Leave => editor.defocus(), + QueryEditorAction::CopyQuery => { + let message = guide::copy_to_clipboard_message(&editor.text()); + guide_action_tx.send(GuideAction::Show(message)).await?; + } + QueryEditorAction::ReplaceText(text) => { + editor.replace_text(&text); + } + QueryEditorAction::UserEvent(event) => { + if editor.handle_user_event(&event) { + shared_ctx.set_active_index(Index::Completion).await; + completion_action_tx + .send(CompletionAction::Enter { + prefix: editor.text(), + }) + .await?; + } + } + } + let current_text = editor.text(); + (editor.create_graphemes(area.0, area.1), current_text) + }; + + // If the text has changed, send it to the debounce channel for processing. + if current_text != last_text { + debounce_query_tx.send(current_text.clone()).await?; + last_text = current_text; + } + + // Update the renderer with the new editor view and render it. + shared_renderer + .update([(Index::QueryEditor, editor_view)]) + .render() + .await?; + } + else => break, + } + } + Ok(()) + }) +} diff --git a/src/runtime_tasks.rs b/src/runtime_tasks.rs new file mode 100644 index 0000000..74d4217 --- /dev/null +++ b/src/runtime_tasks.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; + +use promkit_widgets::core::render::SharedRenderer; +use tokio::{ + sync::{mpsc, RwLock}, + task::JoinHandle, +}; + +use crate::{ + completion::CompletionNavigator, + context::{Index, SharedContext}, + guide::GuideAction, + json_viewer::{self, RenderTrigger, SharedJsonViewer}, + query_editor::QueryEditor, +}; + +/// Spawns a task that listens for query changes and forwards them to the JSON viewer action channel. +pub fn spawn_query_change_forward_task( + mut last_query_rx: mpsc::Receiver, + json_viewer_action_tx: mpsc::Sender, +) -> JoinHandle<()> { + tokio::spawn(async move { + while let Some(query) = last_query_rx.recv().await { + let _ = json_viewer_action_tx + .send(json_viewer::ViewerAction::QueryChanged(query)) + .await; + } + }) +} + +/// Spawns a task that listens for terminal resize events and triggers re-rendering of the UI components accordingly. +#[allow(clippy::too_many_arguments)] +pub fn spawn_resize_render_task( + mut last_resize_rx: mpsc::Receiver<(u16, u16)>, + ctx: SharedContext, + shared_renderer: SharedRenderer, + shared_editor: Arc>, + shared_completion: Arc>, + shared_viewer_state: SharedJsonViewer, + guide_action_tx: mpsc::Sender, +) -> JoinHandle> { + tokio::spawn(async move { + while let Some(area) = last_resize_rx.recv().await { + ctx.set_area(area).await; + let (editor_view, completion_view) = { + let editor = shared_editor.read().await; + let completion = shared_completion.read().await; + ( + editor.create_graphemes(area.0, area.1), + completion.create_graphemes(area.0, area.1), + ) + }; + shared_renderer + .update([ + (Index::QueryEditor, editor_view), + (Index::Completion, completion_view), + ]) + .render() + .await?; + let text = { + let editor = shared_editor.read().await; + editor.text() + }; + json_viewer::render( + RenderTrigger::AreaResized { query: text }, + ctx.clone(), + shared_viewer_state.clone(), + shared_renderer.clone(), + guide_action_tx.clone(), + ) + .await; + } + Ok::<(), anyhow::Error>(()) + }) +} diff --git a/src/search.rs b/src/search.rs deleted file mode 100644 index b46869f..0000000 --- a/src/search.rs +++ /dev/null @@ -1,169 +0,0 @@ -use std::{collections::BTreeSet, sync::Arc}; - -use anyhow::anyhow; -use async_trait::async_trait; -use promkit_widgets::{ - core::{grapheme::StyledGraphemes, Widget}, - listbox::{self, Listbox}, -}; -use tokio::{ - sync::{Mutex, RwLock}, - task::JoinHandle, -}; - -#[async_trait] -pub trait SearchProvider: Clone + Send + 'static { - async fn provide( - &mut self, - item: &str, - ) -> anyhow::Result + Send>>; -} - -#[derive(Clone, Default)] -pub struct LoadState { - pub loaded: bool, - pub loaded_item_len: usize, -} - -pub struct StartSearchResult { - pub head_item: Option, - pub load_state: LoadState, -} - -pub struct IncrementalSearcher { - shared_set: Arc>>, - shared_load_state: Arc>, - state: listbox::State, - search_result_chunk_size: usize, - search_chunk_remaining: Vec, -} - -impl IncrementalSearcher { - pub fn new(state: listbox::State, search_result_chunk_size: usize) -> Self { - Self { - shared_set: Default::default(), - shared_load_state: Default::default(), - state, - search_result_chunk_size, - search_chunk_remaining: Default::default(), - } - } - - pub fn spawn_load_task( - &self, - provider: &mut T, - item: &'static str, - chunk_size: usize, - ) -> JoinHandle> { - let shared_set = self.shared_set.clone(); - let shared_load_state = self.shared_load_state.clone(); - let mut provider = provider.clone(); - tokio::spawn(async move { - let mut batch = Vec::with_capacity(chunk_size); - let iter = provider.provide(item).await?; - - for v in iter { - batch.push(v); - - if batch.len() >= chunk_size { - let mut set = shared_set.lock().await; - for item in batch.drain(..) { - set.insert(item); - } - let mut state = shared_load_state.write().await; - state.loaded_item_len += chunk_size; - } - } - - let remaining = batch.len(); - if !batch.is_empty() { - let mut set = shared_set.lock().await; - for item in batch { - set.insert(item); - } - } - - let mut state = shared_load_state.write().await; - state.loaded = true; - state.loaded_item_len += remaining; - Ok(()) - }) - } - - pub fn up(&mut self) { - self.state.listbox.backward(); - } - - pub fn down_with_load(&mut self) { - self.state.listbox.forward(); - if self - .state - .listbox - .len() - .saturating_sub(self.state.listbox.position()) - < self.state.config.lines.unwrap_or(1) - { - self.load_more(); - } - } - - pub fn get_current_item(&self) -> String { - self.state.listbox.get().to_string() - } - - pub fn create_pane(&self, width: u16, height: u16) -> StyledGraphemes { - self.state.create_graphemes(width, height) - } - - pub fn leave_search(&mut self) { - self.state.listbox = Listbox::from(Vec::::new()); - self.search_chunk_remaining = Vec::::new(); - } - - pub fn start_search(&mut self, prefix: &str) -> anyhow::Result { - match ( - self.shared_load_state.try_read(), - self.shared_set.try_lock(), - ) { - (Ok(state), Ok(set)) => { - let mut items: Vec<_> = set - .iter() - .filter(|p| p.starts_with(prefix)) - .cloned() - .collect(); - if items.is_empty() { - return Ok(StartSearchResult { - head_item: None, - load_state: state.clone(), - }); - } - let used = items - .drain(..self.search_result_chunk_size.min(items.len())) - .collect::>(); - self.search_chunk_remaining = items; - self.state.listbox = Listbox::from(used); - Ok(StartSearchResult { - head_item: Some(self.state.listbox.get().to_string()), - load_state: state.clone(), - }) - } - (Err(_), _) | (_, Err(_)) => Err(anyhow!( - "Failed to acquire lock for suggestions. Please try again." - )), - } - } - - fn load_more(&mut self) { - if self.search_chunk_remaining.is_empty() { - return; - } - let items = self.search_chunk_remaining.drain( - ..self - .search_result_chunk_size - .min(self.search_chunk_remaining.len()), - ); - for item in items { - self.state.listbox.push_string(item); - } - } -} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..f8fbd56 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,2 @@ +mod debounce; +pub use debounce::setup_debouncer; diff --git a/src/utils/debounce.rs b/src/utils/debounce.rs new file mode 100644 index 0000000..ebf10f3 --- /dev/null +++ b/src/utils/debounce.rs @@ -0,0 +1,49 @@ +use std::time::Duration; + +use tokio::{sync::mpsc, task::JoinHandle}; + +/// Initializes the debouncer input/output channels and task handle as a set. +/// +/// Returns: +/// - `debounce_tx`: Input channel for values that should be debounced. +/// - Send a value here whenever the source value changes. +/// - If multiple values arrive within a short period, only the latest one is kept as a candidate. +/// - `last_rx`: Output channel for debounced values. +/// - On each `duration` tick, one value is emitted if a latest candidate exists. +/// - Consumers can process only the "settled" latest value by reading from this channel. +/// - `debouncer`: Join handle of the background task running the debounce loop. +pub fn setup_debouncer( + duration: Duration, +) -> (mpsc::Sender, mpsc::Receiver, JoinHandle<()>) { + let (last_tx, last_rx) = mpsc::channel(1); + let (debounce_tx, debounce_rx) = mpsc::channel(1); + let debouncer = spawn_debouncer(debounce_rx, last_tx, duration); + (debounce_tx, last_rx, debouncer) +} + +fn spawn_debouncer( + mut debounce_rx: mpsc::Receiver, + last_tx: mpsc::Sender, + duration: Duration, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut last_query = None; + let mut delay = tokio::time::interval(duration); + loop { + tokio::select! { + maybe_query = debounce_rx.recv() => { + if let Some(query) = maybe_query { + last_query = Some(query); + } else { + break; + } + }, + _ = delay.tick() => { + if let Some(text) = last_query.take() { + let _ = last_tx.send(text).await; + } + }, + } + } + }) +}