diff --git a/Cargo.lock b/Cargo.lock index 624c4578..df5416bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,12 +1723,14 @@ dependencies = [ "filedescriptor", "filterable-enum", "futures", + "indexmap", "itertools", "kxxt-owo-colors", "lazy_static", "nix", "predicates", "ratatui", + "regex", "rstest", "seccompiler", "shell-quote", @@ -1742,6 +1744,7 @@ dependencies = [ "tracing-subscriber", "tracing-test", "tui-popup", + "tui-prompts", "tui-scrollview", "tui-term", "vt100", @@ -1866,6 +1869,17 @@ dependencies = [ "ratatui", ] +[[package]] +name = "tui-prompts" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581e0457c7a9f81db70431d3b6c82f8589bb556a722e414ca17d55367ce77c9d" +dependencies = [ + "crossterm", + "itertools", + "ratatui", +] + [[package]] name = "tui-scrollview" version = "0.3.5" diff --git a/Cargo.toml b/Cargo.toml index 05162e50..de814dfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,10 @@ tui-popup = "0.3.0" thiserror = "1.0.59" tui-scrollview = "0.3.5" bitflags = "2.5.0" +regex = "1.10.4" +indexmap = "2.2.6" +tui-prompts = "0.3.11" +# tui-prompts = { version = "0.3.11", path = "../../contrib/tui-prompts" } # tui-popup = { version = "0.3.0", path = "../../contrib/tui-popup" } [dev-dependencies] diff --git a/src/action.rs b/src/action.rs index e1c15933..f8db2322 100644 --- a/src/action.rs +++ b/src/action.rs @@ -5,7 +5,10 @@ use ratatui::layout::Size; use crate::{ event::TracerEvent, - tui::{copy_popup::CopyPopupState, details_popup::DetailsPopupState}, + tui::{ + copy_popup::CopyPopupState, details_popup::DetailsPopupState, error_popup::ErrorPopupState, + query::Query, + }, }; #[derive(Debug, Clone)] @@ -48,6 +51,12 @@ pub enum Action { target: CopyTarget, event: Arc, }, + // Query + BeginSearch, + EndSearch, + ExecuteSearch(Query), + NextMatch, + PrevMatch, // Terminal HandleTerminalKeyPress(KeyEvent), } @@ -77,4 +86,5 @@ pub enum ActivePopup { Help, ViewDetails(DetailsPopupState), CopyTargetSelection(CopyPopupState), + ErrorPopup(ErrorPopupState), } diff --git a/src/tracer/test.rs b/src/tracer/test.rs index ae7fd0ac..6f6ace76 100644 --- a/src/tracer/test.rs +++ b/src/tracer/test.rs @@ -51,15 +51,15 @@ async fn run_exe_and_collect_events( ) -> Vec { let tracer_thread = tracer.spawn(argv, None).unwrap(); tracer_thread.join().unwrap().unwrap(); - let events = async { + + async { let mut events = vec![]; while let Some(event) = rx.recv().await { events.push(event); } events } - .await; - events + .await } #[traced_test] diff --git a/src/tui.rs b/src/tui.rs index 426e2b86..d7259daf 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -41,10 +41,12 @@ use crate::event::{Event, TracerEvent}; pub mod app; pub mod copy_popup; pub mod details_popup; +pub mod error_popup; mod event_list; pub mod help; mod partial_line; mod pseudo_term; +pub mod query; mod sized_paragraph; pub mod theme; mod ui; diff --git a/src/tui/app.rs b/src/tui/app.rs index 2a06fb73..0712ff4c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -28,7 +28,7 @@ use ratatui::{ buffer::Buffer, layout::{Constraint, Layout, Rect}, text::Line, - widgets::{Block, Paragraph, StatefulWidgetRef, Widget, Wrap}, + widgets::{Block, Paragraph, StatefulWidget, StatefulWidgetRef, Widget, Wrap}, }; use strum::Display; use tokio::sync::mpsc; @@ -45,14 +45,17 @@ use crate::{ printer::PrinterArgs, proc::BaselineInfo, pty::{PtySize, UnixMasterPty}, + tui::{error_popup::ErrorPopupState, query::QueryKind}, }; use super::{ copy_popup::{CopyPopup, CopyPopupState}, details_popup::{DetailsPopup, DetailsPopupState}, + error_popup::ErrorPopup, event_list::EventList, help::{help, help_item}, pseudo_term::PseudoTerminalPane, + query::QueryBuilder, theme::THEME, ui::render_title, Tui, @@ -77,6 +80,7 @@ pub struct App { pub layout: AppLayout, pub should_handle_internal_resize: bool, pub popup: Option, + query_builder: Option, } impl App { @@ -99,7 +103,7 @@ impl App { printer_args: PrinterArgs::from_cli(tracing_args, modifier_args), split_percentage: if pty_master.is_some() { 50 } else { 100 }, term: if let Some(pty_master) = pty_master { - Some(PseudoTerminalPane::new( + let mut term = PseudoTerminalPane::new( PtySize { rows: 24, cols: 80, @@ -107,7 +111,11 @@ impl App { pixel_height: 0, }, pty_master, - )?) + )?; + if active_pane == ActivePane::Terminal { + term.focus(true); + } + Some(term) } else { None }, @@ -117,6 +125,7 @@ impl App { layout, should_handle_internal_resize: true, popup: None, + query_builder: None, }) } @@ -150,6 +159,11 @@ impl App { action_tx.send(Action::SwitchActivePane)?; // Cancel all popups self.popup = None; + // Cancel non-finished query + if self.query_builder.as_ref().map_or(false, |b| b.editing()) { + self.query_builder = None; + self.event_list.set_query(None); + } // action_tx.send(Action::Render)?; } else { trace!("TUI: Active pane: {}", self.active_pane); @@ -173,10 +187,48 @@ impl App { action_tx.send(action)?; } } + ActivePopup::ErrorPopup(state) => { + if let Some(action) = state.handle_key_event(ke) { + action_tx.send(action)?; + } + } } continue; } + // Handle query builder + if let Some(query_builder) = self.query_builder.as_mut() { + if query_builder.editing() { + match query_builder.handle_key_events(ke) { + Ok(result) => { + result.map(|action| action_tx.send(action)).transpose()?; + } + Err(e) => { + // Regex error + self.popup = Some(ActivePopup::ErrorPopup(ErrorPopupState { + title: "Regex Error".to_owned(), + message: e, + })); + } + } + continue; + } else { + match (ke.code, ke.modifiers) { + (KeyCode::Char('n'), KeyModifiers::NONE) => { + trace!("Query: Next match"); + action_tx.send(Action::NextMatch)?; + continue; + } + (KeyCode::Char('p'), KeyModifiers::NONE) => { + trace!("Query: Prev match"); + action_tx.send(Action::PrevMatch)?; + continue; + } + _ => {} + } + } + } + match ke.code { KeyCode::Char('q') if ke.modifiers == KeyModifiers::NONE => { if self.popup.is_some() { @@ -270,8 +322,12 @@ impl App { KeyCode::Char('l') if ke.modifiers == KeyModifiers::ALT => { action_tx.send(Action::SwitchLayout)?; } - KeyCode::Char('f') if ke.modifiers == KeyModifiers::NONE => { - action_tx.send(Action::ToggleFollow)?; + KeyCode::Char('f') => { + if ke.modifiers == KeyModifiers::NONE { + action_tx.send(Action::ToggleFollow)?; + } else if ke.modifiers == KeyModifiers::CONTROL { + action_tx.send(Action::BeginSearch)?; + } } KeyCode::Char('e') if ke.modifiers == KeyModifiers::NONE => { action_tx.send(Action::ToggleEnvDisplay)?; @@ -330,7 +386,17 @@ impl App { return Ok(()); } Action::Render => { - tui.draw(|f| self.render(f.size(), f.buffer_mut()))?; + tui.draw(|f| { + self.render(f.size(), f.buffer_mut()); + self + .query_builder + .as_ref() + .filter(|q| q.editing()) + .inspect(|q| { + let (x, y) = q.cursor(); + f.set_cursor(x, y); + }); + })?; } Action::NextItem => { self.event_list.next(); @@ -402,8 +468,23 @@ impl App { } Action::SwitchActivePane => { self.active_pane = match self.active_pane { - ActivePane::Events => ActivePane::Terminal, - ActivePane::Terminal => ActivePane::Events, + ActivePane::Events => { + if let Some(term) = self.term.as_mut() { + term.focus(true); + ActivePane::Terminal + } else { + if let Some(t) = self.term.as_mut() { + t.focus(false) + } + ActivePane::Events + } + } + ActivePane::Terminal => { + if let Some(t) = self.term.as_mut() { + t.focus(false) + } + ActivePane::Events + } } } Action::ShowCopyDialog(e) => { @@ -429,6 +510,30 @@ impl App { Action::CancelCurrentPopup => { self.popup = None; } + Action::BeginSearch => { + if let Some(query_builder) = self.query_builder.as_mut() { + // action_tx.send(query_builder.edit())?; + query_builder.edit(); + } else { + let mut query_builder = QueryBuilder::new(QueryKind::Search); + // action_tx.send(query_builder.edit())?; + query_builder.edit(); + self.query_builder = Some(query_builder); + } + } + Action::EndSearch => { + self.query_builder = None; + self.event_list.set_query(None); + } + Action::ExecuteSearch(query) => { + self.event_list.set_query(Some(query)); + } + Action::NextMatch => { + self.event_list.next_match(); + } + Action::PrevMatch => { + self.event_list.prev_match(); + } } } } @@ -458,11 +563,12 @@ impl Widget for &mut App { fn render(self, area: Rect, buf: &mut Buffer) { // Create a space for header, todo list and the footer. let vertical = Layout::vertical([ - Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), Constraint::Min(0), Constraint::Length(2), ]); - let [header_area, rest_area, footer_area] = vertical.areas(area); + let [header_area, search_bar_area, rest_area, footer_area] = vertical.areas(area); let horizontal_constraints = [ Constraint::Percentage(self.split_percentage), Constraint::Percentage(100 - self.split_percentage), @@ -478,6 +584,10 @@ impl Widget for &mut App { buf, format!(" tracexec {}", env!("CARGO_PKG_VERSION")), ); + if let Some(query_builder) = self.query_builder.as_mut() { + query_builder.render(search_bar_area, buf); + } + self.render_help(footer_area, buf); if event_area.width < 4 || (self.term.is_some() && term_area.width < 4) { @@ -546,6 +656,9 @@ impl Widget for &mut App { ActivePopup::CopyTargetSelection(state) => { CopyPopup.render_ref(area, buf, state); } + ActivePopup::ErrorPopup(state) => { + ErrorPopup.render(area, buf, state); + } _ => {} } } @@ -568,7 +681,12 @@ impl App { } fn render_help(&self, area: Rect, buf: &mut Buffer) { - let mut items = Vec::from_iter(help_item!("Ctrl+S", "Switch\u{00a0}Pane")); + let mut items = Vec::from_iter( + Some(help_item!("Ctrl+S", "Switch\u{00a0}Pane")) + .filter(|_| self.term.is_some()) + .into_iter() + .flatten(), + ); if let Some(popup) = &self.popup { items.extend(help_item!("Q", "Close Popup")); @@ -585,6 +703,8 @@ impl App { } _ => {} } + } else if let Some(query_builder) = self.query_builder.as_ref().filter(|q| q.editing()) { + items.extend(query_builder.help()); } else if self.active_pane == ActivePane::Events { if self.clipboard.is_some() { items.extend(help_item!("C", "Copy")); @@ -609,9 +729,16 @@ impl App { } ), help_item!("V", "View"), - help_item!("Q", "Quit"), - help_item!("F1", "Help"), - )) + help_item!("Ctrl+F", "Search"), + )); + if let Some(query_builder) = self.query_builder.as_ref() { + items.extend(query_builder.help()); + } + items.extend( + [help_item!("Q", "Quit"), help_item!("F1", "Help")] + .into_iter() + .flatten(), + ); } else { // Terminal }; diff --git a/src/tui/error_popup.rs b/src/tui/error_popup.rs new file mode 100644 index 00000000..4aa03ff5 --- /dev/null +++ b/src/tui/error_popup.rs @@ -0,0 +1,46 @@ +use crossterm::event::KeyEvent; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Stylize, + text::Line, + widgets::{Paragraph, StatefulWidget, WidgetRef, Wrap}, +}; +use tui_popup::Popup; + +use crate::action::Action; + +use super::{sized_paragraph::SizedParagraph, theme::THEME}; + +#[derive(Debug, Clone)] +pub struct ErrorPopupState { + pub title: String, + pub message: Vec>, +} + +impl ErrorPopupState { + pub fn handle_key_event(&mut self, _key: KeyEvent) -> Option { + Some(Action::CancelCurrentPopup) + } +} + +#[derive(Debug, Clone)] +pub struct ErrorPopup; + +impl StatefulWidget for ErrorPopup { + type State = ErrorPopupState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let help = Line::raw("Press any key to close this popup"); + let mut message = state.message.clone(); + message.push("".into()); + message.push(help.centered().bold()); + let paragraph = Paragraph::new(message).wrap(Wrap { trim: false }); + let popup = Popup::new( + Line::raw(state.title.as_str()).centered(), + SizedParagraph::new(paragraph, (area.width as f32 * 0.7) as usize), + ) + .style(THEME.error_popup); + popup.render_ref(area, buf); + } +} diff --git a/src/tui/event_list.rs b/src/tui/event_list.rs index 11e0e1b7..d8d0051f 100644 --- a/src/tui/event_list.rs +++ b/src/tui/event_list.rs @@ -18,6 +18,7 @@ use std::{collections::VecDeque, sync::Arc}; +use indexmap::IndexMap; use ratatui::{ layout::Alignment::Right, prelude::{Buffer, Rect}, @@ -31,17 +32,21 @@ use ratatui::{ use crate::{cli::args::ModifierArgs, event::TracerEvent, proc::BaselineInfo}; -use super::partial_line::PartialLine; +use super::{ + partial_line::PartialLine, + query::{Query, QueryResult}, + theme::THEME, +}; pub struct EventList { state: ListState, events: Vec>, /// The string representation of the events, used for searching - events_string: Vec, + event_strings: Vec, /// Current window of the event list, [start, end) window: (usize, usize), /// Cache of the lines in the window - lines_cache: VecDeque>, + lines_cache: VecDeque<(usize, Line<'static>)>, should_refresh_lines_cache: bool, /// Cache of the list items in the view list_cache: List<'static>, @@ -58,6 +63,8 @@ pub struct EventList { follow: bool, pub modifier_args: ModifierArgs, env_in_cmdline: bool, + query: Option, + query_result: Option, } impl EventList { @@ -65,7 +72,7 @@ impl EventList { Self { state: ListState::default(), events: vec![], - events_string: vec![], + event_strings: vec![], window: (0, 0), nr_items_in_window: 0, horizontal_offset: 0, @@ -80,6 +87,8 @@ impl EventList { list_cache: List::default(), modifier_args, env_in_cmdline: true, + query: None, + query_result: None, } } @@ -102,6 +111,7 @@ impl EventList { pub fn toggle_env_display(&mut self) { self.env_in_cmdline = !self.env_in_cmdline; self.should_refresh_lines_cache = true; + self.rebuild_event_strings(); } /// returns the index of the selected item if there is any @@ -160,12 +170,16 @@ impl Widget for &mut EventList { // Initialize the line cache, which will be kept in sync by the navigation methods self.lines_cache = events_in_window .iter() - .map(|evt| { - evt.to_tui_line( - &self.baseline, - false, - &self.modifier_args, - self.env_in_cmdline, + .enumerate() + .map(|(i, evt)| { + ( + i + self.window.0, + evt.to_tui_line( + &self.baseline, + false, + &self.modifier_args, + self.env_in_cmdline, + ), ) }) .collect(); @@ -174,13 +188,20 @@ impl Widget for &mut EventList { if self.nr_items_in_window > self.lines_cache.len() { // Push the new items to the cache self.should_refresh_list_cache = true; - for evt in events_in_window.iter().skip(self.lines_cache.len()) { + for (i, evt) in events_in_window + .iter() + .enumerate() + .skip(self.lines_cache.len()) + { tracing::debug!("Pushing new item to line cache"); - self.lines_cache.push_back(evt.to_tui_line( - &self.baseline, - false, - &self.modifier_args, - self.env_in_cmdline, + self.lines_cache.push_back(( + i, + evt.to_tui_line( + &self.baseline, + false, + &self.modifier_args, + self.env_in_cmdline, + ), )); } } @@ -190,13 +211,20 @@ impl Widget for &mut EventList { // ); if self.should_refresh_list_cache { self.should_refresh_list_cache = false; - let items = self.lines_cache.iter().map(|full_line| { + tracing::debug!("Refreshing list cache"); + let items = self.lines_cache.iter().map(|(i, full_line)| { max_len = max_len.max(full_line.width()); - ListItem::from( - full_line - .clone() - .substring(self.horizontal_offset, area.width), - ) + let highlighted = self + .query_result + .as_ref() + .map_or(false, |query_result| query_result.indices.contains_key(i)); + let mut base = full_line + .clone() + .substring(self.horizontal_offset, area.width); + if highlighted { + base = base.style(THEME.search_match); + } + ListItem::from(base) }); // Create a List from all list items and highlight the currently selected one let list = List::new(items) @@ -205,7 +233,7 @@ impl Widget for &mut EventList { .add_modifier(Modifier::BOLD) .bg(Color::DarkGray), ) - .highlight_symbol(">") + .highlight_symbol("➡️") .highlight_spacing(HighlightSpacing::Always); // FIXME: It's a little late to set the max width here. The max width is already used // Though this should only affect the first render. @@ -251,6 +279,106 @@ impl Widget for &mut EventList { .position(self.window.0 + self.state.selected().unwrap_or(0)), ); } + + if let Some(query_result) = self.query_result.as_ref() { + let statistics = query_result.statistics(); + let statistics_len = statistics.width(); + if statistics_len > buf.area().width as usize { + return; + } + let statistics_area = Rect { + x: buf.area().right().saturating_sub(statistics_len as u16), + y: 1, + width: statistics_len as u16, + height: 1, + }; + statistics.render(statistics_area, buf); + } + } +} + +/// Query Management +impl EventList { + pub fn set_query(&mut self, query: Option) { + if query.is_some() { + self.query = query; + self.search(); + } else { + self.query = None; + self.query_result = None; + self.should_refresh_list_cache = true; + } + } + + /// Search for the query in the event list + /// And update query result, + /// Then set the selection to the first result(if any) and scroll to it + pub fn search(&mut self) { + let Some(query) = self.query.as_ref() else { + return; + }; + let mut indices = IndexMap::new(); + // Events won't change during the search because this is Rust and we already have a reference to it. + // Rust really makes the code more easier to reason about. + let searched_len = self.events.len(); + for (i, evt) in self.event_strings.iter().enumerate() { + if query.matches(evt) { + indices.insert(i, 0); + } + } + let mut result = QueryResult { + indices, + searched_len, + selection: None, + }; + result.next_result(); + let selection = result.selection(); + self.query_result = Some(result); + self.should_refresh_list_cache = true; + self.scroll_to(selection); + } + + /// Incremental search for newly added events + pub fn incremental_search(&mut self) { + let Some(query) = self.query.as_ref() else { + return; + }; + let Some(existing_result) = self.query_result.as_mut() else { + self.search(); + return; + }; + let mut modified = false; + for (i, evt) in self + .event_strings + .iter() + .enumerate() + .skip(existing_result.searched_len) + { + if query.matches(evt) { + existing_result.indices.insert(i, 0); + modified = true; + } + } + existing_result.searched_len = self.event_strings.len(); + if modified { + self.should_refresh_list_cache = true; + } + } + + pub fn next_match(&mut self) { + if let Some(query_result) = self.query_result.as_mut() { + query_result.next_result(); + let selection = query_result.selection(); + self.scroll_to(selection); + } + } + + pub fn prev_match(&mut self) { + if let Some(query_result) = self.query_result.as_mut() { + query_result.prev_result(); + let selection = query_result.selection(); + self.scroll_to(selection); + } } } @@ -258,7 +386,7 @@ impl Widget for &mut EventList { impl EventList { pub fn push(&mut self, event: impl Into>) { let event = event.into(); - self.events_string.push( + self.event_strings.push( event .to_tui_line( &self.baseline, @@ -269,11 +397,55 @@ impl EventList { .to_string(), ); self.events.push(event.clone()); + self.incremental_search(); + } + + pub fn rebuild_event_strings(&mut self) { + self.event_strings = self + .events + .iter() + .map(|evt| { + evt + .to_tui_line( + &self.baseline, + false, + &self.modifier_args, + self.env_in_cmdline, + ) + .to_string() + }) + .collect(); } } /// Scrolling implementation for the EventList impl EventList { + /// Scroll to the given index and select it, + /// Usually the item will be at the top of the window, + /// but if there are not enough items or the item is already in current window, + /// no scrolling will be done, + /// And if the item is in the last window, we won't scroll past it. + fn scroll_to(&mut self, index: Option) { + let Some(index) = index else { + return; + }; + if index < self.window.0 { + // Scroll up + self.window.0 = index; + self.window.1 = self.window.0 + self.max_window_len; + self.should_refresh_lines_cache = true; + self.state.select(Some(0)); + } else if index >= self.window.1 { + // Scroll down + self.window.0 = index.min(self.events.len().saturating_sub(self.max_window_len)); + self.window.1 = self.window.0 + self.max_window_len; + self.should_refresh_lines_cache = true; + self.state.select(Some(index - self.window.0)); + } else { + self.state.select(Some(index - self.window.0)); + } + } + /// Returns the index(absolute) of the last item in the window fn last_item_in_window_absolute(&self) -> Option { if self.events.is_empty() { @@ -326,14 +498,16 @@ impl EventList { self.window.0 += 1; self.window.1 += 1; self.lines_cache.pop_front(); - self.lines_cache.push_back( - self.events[self.last_item_in_window_absolute().unwrap()].to_tui_line( + let last_index = self.last_item_in_window_absolute().unwrap(); + self.lines_cache.push_back(( + last_index, + self.events[last_index].to_tui_line( &self.baseline, false, &self.modifier_args, self.env_in_cmdline, ), - ); + )); self.should_refresh_list_cache = true; true } else { @@ -346,14 +520,16 @@ impl EventList { self.window.0 -= 1; self.window.1 -= 1; self.lines_cache.pop_back(); - self - .lines_cache - .push_front(self.events[self.window.0].to_tui_line( + let front_index = self.window.0; + self.lines_cache.push_front(( + front_index, + self.events[front_index].to_tui_line( &self.baseline, false, &self.modifier_args, self.env_in_cmdline, - )); + ), + )); self.should_refresh_list_cache = true; true } else { @@ -512,14 +688,16 @@ impl EventList { { // Special optimization for follow mode where scroll to bottom is called continuously self.lines_cache.pop_front(); - self.lines_cache.push_back( - self.events[self.last_item_in_window_absolute().unwrap()].to_tui_line( + let last_index = self.last_item_in_window_absolute().unwrap(); + self.lines_cache.push_back(( + last_index, + self.events[last_index].to_tui_line( &self.baseline, false, &self.modifier_args, self.env_in_cmdline, ), - ); + )); self.should_refresh_list_cache = true; } else { self.should_refresh_lines_cache = old_window != self.window; diff --git a/src/tui/pseudo_term.rs b/src/tui/pseudo_term.rs index 88de0576..8479e0b6 100644 --- a/src/tui/pseudo_term.rs +++ b/src/tui/pseudo_term.rs @@ -30,7 +30,7 @@ use std::io::{BufWriter, Write}; use std::sync::Arc; use tokio::sync::mpsc::channel; use tracing::{trace, warn}; -use tui_term::widget::PseudoTerminal; +use tui_term::widget::{Cursor, PseudoTerminal}; use tokio_util::sync::CancellationToken; @@ -50,6 +50,7 @@ pub struct PseudoTerminalPane { master_tx: tokio::sync::mpsc::Sender, master_cancellation_token: CancellationToken, size: PtySize, + focus: bool, } const ESCAPE: u8 = 27; @@ -117,6 +118,7 @@ impl PseudoTerminalPane { writer_task, master_tx: tx, master_cancellation_token, + focus: false, }) } @@ -187,6 +189,10 @@ impl PseudoTerminalPane { Ok(()) } + pub fn focus(&mut self, focus: bool) { + self.focus = focus; + } + /// Closes pty master pub fn exit(&self) { self.master_cancellation_token.cancel() @@ -199,7 +205,11 @@ impl Widget for &PseudoTerminalPane { Self: Sized, { let parser = self.parser.read().unwrap(); - let pseudo_term = PseudoTerminal::new(parser.screen()); + let mut cursor = Cursor::default(); + if !self.focus { + cursor.hide(); + } + let pseudo_term = PseudoTerminal::new(parser.screen()).cursor(cursor); pseudo_term.render(area, buf); } } diff --git a/src/tui/query.rs b/src/tui/query.rs new file mode 100644 index 00000000..0778ab36 --- /dev/null +++ b/src/tui/query.rs @@ -0,0 +1,258 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use indexmap::IndexMap; +use itertools::Itertools; +use ratatui::{ + style::Styled, + text::{Line, Span}, + widgets::{StatefulWidget, Widget}, +}; +use regex::{Regex, RegexBuilder}; +use tui_prompts::{State, TextPrompt, TextState}; + +use crate::action::Action; + +use super::{help::help_item, theme::THEME}; + +#[derive(Debug, Clone)] +pub struct Query { + pub kind: QueryKind, + pub value: QueryValue, + pub case_sensitive: bool, +} + +#[derive(Debug, Clone)] +pub enum QueryValue { + Regex(Regex), + Text(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueryKind { + Search, + Filter, +} + +#[derive(Debug)] +pub struct QueryResult { + /// The indices of matching events and the start of the match, use IndexMap to keep the order + pub indices: IndexMap, + /// The length of all searched items, used to implement incremental query + pub searched_len: usize, + /// The currently focused item in query result, an index of `indices` + pub selection: Option, +} + +impl Query { + pub fn new(kind: QueryKind, value: QueryValue, case_sensitive: bool) -> Self { + Self { + kind, + value, + case_sensitive, + } + } + + pub fn matches(&self, text: &str) -> bool { + let result = match &self.value { + QueryValue::Regex(re) => re.is_match(text), + QueryValue::Text(query) => { + if self.case_sensitive { + text.contains(query) + } else { + text.to_lowercase().contains(&query.to_lowercase()) + } + } + }; + if result { + tracing::trace!("{text:?} matches: {self:?}"); + } + result + } +} + +impl QueryResult { + pub fn next_result(&mut self) { + if let Some(selection) = self.selection { + if selection + 1 < self.indices.len() { + self.selection = Some(selection + 1); + } else { + // If the current selection is the last one, loop back to the first one + self.selection = Some(0) + } + } else if !self.indices.is_empty() { + self.selection = Some(0); + } + } + + pub fn prev_result(&mut self) { + if let Some(selection) = self.selection { + if selection > 0 { + self.selection = Some(selection - 1); + } else { + // If the current selection is the first one, loop back to the last one + self.selection = Some(self.indices.len() - 1); + } + } else if !self.indices.is_empty() { + self.selection = Some(self.indices.len() - 1); + } + } + + /// Return the index of the currently selected item in the event list + pub fn selection(&self) -> Option { + self + .selection + .map(|index| *self.indices.get_index(index).unwrap().0) + } + + pub fn statistics(&self) -> Line { + if self.indices.is_empty() { + "No match".set_style(THEME.query_no_match).into() + } else { + let total = self + .indices + .len() + .to_string() + .set_style(THEME.query_match_total_cnt); + let selected = self + .selection + .map(|index| index + 1) + .unwrap_or(0) + .to_string() + .set_style(THEME.query_match_current_no); + Line::default().spans(vec![selected, "/".into(), total]) + } + } +} + +pub struct QueryBuilder { + kind: QueryKind, + case_sensitive: bool, + is_regex: bool, + state: TextState<'static>, + editing: bool, +} + +impl QueryBuilder { + pub fn new(kind: QueryKind) -> Self { + Self { + kind, + case_sensitive: false, + state: TextState::new(), + editing: true, + is_regex: false, + } + } + + pub fn editing(&self) -> bool { + self.editing + } + + pub fn edit(&mut self) { + self.editing = true; + self.state.focus(); + } + + /// Get the current cursor position, + /// this should be called after render is called + pub fn cursor(&self) -> (u16, u16) { + self.state.cursor() + } + + pub fn handle_key_events(&mut self, key: KeyEvent) -> Result, Vec>> { + match (key.code, key.modifiers) { + (KeyCode::Enter, _) => { + let text = self.state.value(); + if text.is_empty() { + return Ok(Some(Action::EndSearch)); + } + let query = Query::new( + self.kind, + if self.is_regex { + QueryValue::Regex( + RegexBuilder::new(text) + .case_insensitive(!self.case_sensitive) + .build() + .map_err(|e| { + e.to_string() + .lines() + .map(|line| Line::raw(line.to_owned())) + .collect_vec() + })?, + ) + } else { + QueryValue::Text(text.to_owned()) + }, + self.case_sensitive, + ); + self.editing = false; + return Ok(Some(Action::ExecuteSearch(query))); + } + (KeyCode::Esc, KeyModifiers::NONE) => { + return Ok(Some(Action::EndSearch)); + } + (KeyCode::Char('i'), KeyModifiers::CONTROL) => { + self.case_sensitive = !self.case_sensitive; + } + (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + self.is_regex = !self.is_regex; + } + _ => { + self.state.handle_key_event(key); + } + } + Ok(None) + } +} + +impl QueryBuilder { + pub fn help(&self) -> Vec { + if self.editing { + [ + help_item!("Esc", "Cancel\u{00a0}Search"), + help_item!("Enter", "Execute\u{00a0}Search"), + help_item!( + "Ctrl+I", + if self.case_sensitive { + "Case\u{00a0}Sensitive" + } else { + "Case\u{00a0}Insensitive" + } + ), + help_item!( + "Ctrl+R", + if self.is_regex { + "Regex\u{00a0}Mode" + } else { + "Text\u{00a0}Mode" + } + ), + ] + .into_iter() + .flatten() + .collect() + } else { + [ + help_item!("N", "Next\u{00a0}Match"), + help_item!("P", "Previous\u{00a0}Match"), + ] + .into_iter() + .flatten() + .collect() + } + } +} + +impl Widget for &mut QueryBuilder { + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) + where + Self: Sized, + { + TextPrompt::new( + match self.kind { + QueryKind::Search => "🔍", + QueryKind::Filter => "☔", + } + .into(), + ) + .render(area, buf, &mut self.state); + } +} diff --git a/src/tui/theme.rs b/src/tui/theme.rs index 996e71f3..288c6a5f 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -1,5 +1,5 @@ use lazy_static::lazy_static; -use ratatui::style::{Style, Stylize}; +use ratatui::style::{Modifier, Style, Stylize}; pub struct Theme { // Color for UI Elements @@ -35,6 +35,11 @@ pub struct Theme { pub modified_env_var: Style, pub added_env_var: Style, pub argv: Style, + // Search & Filter + pub search_match: Style, + pub query_no_match: Style, + pub query_match_current_no: Style, + pub query_match_total_cnt: Style, // Details Popup pub exec_result_success: Style, pub exec_result_failure: Style, @@ -60,6 +65,8 @@ pub struct Theme { pub open_flag_status: Style, pub open_flag_other: Style, pub visual_separator: Style, + // Error Popup + pub error_popup: Style, // Tabs pub active_tab: Style, } @@ -103,6 +110,11 @@ impl Default for Theme { modified_env_var: Style::default().yellow(), added_env_var: Style::default().green(), argv: Style::default(), + // -- Search & Filter -- + search_match: Style::default().add_modifier(Modifier::REVERSED), + query_no_match: Style::default().light_red(), + query_match_current_no: Style::default().light_cyan(), + query_match_total_cnt: Style::default().white(), // -- Details Popup -- exec_result_success: Style::default().green(), exec_result_failure: Style::default().red(), @@ -128,6 +140,8 @@ impl Default for Theme { open_flag_status: Style::default().light_yellow().bold(), open_flag_other: Style::default().light_red().bold(), visual_separator: Style::default().light_green(), + // -- Error Popup -- + error_popup: Style::default().white().on_red(), // -- Tabs -- active_tab: Style::default().white().on_magenta(), }