From d187559af9d0cbc706cbdd02ff6837736cb5d83d Mon Sep 17 00:00:00 2001 From: Clement Tsang <34804052+ClementTsang@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:24:24 -0400 Subject: [PATCH] refactor: generalize input field logic for easier reuse (#2035) * add generalized input field file * partial migration * even more! * app is clean * move over canvas * finish migrating * fmt * update and generate some tests * Add test comments * fix logic issue with me trying to avoid a second cursor * fix delete word behaviour with unicode * update changelog * remove unused reset * some test updates + cleanup * clippy * comment * remove redundant shrink_to_fit --- CHANGELOG.md | 4 + src/app.rs | 199 ++----- src/app/states.rs | 263 +-------- src/canvas/widgets/process_table.rs | 15 +- src/lib.rs | 1 + src/utils/input.rs | 844 ++++++++++++++++++++++++++++ src/utils/int_hash.rs | 29 +- src/widgets/process_table.rs | 37 +- 8 files changed, 928 insertions(+), 464 deletions(-) create mode 100644 src/utils/input.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aeb66ba..a8266d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ That said, these are more guidelines rather than hard rules, though the project ## [0.12.4]/[0.13.0] - Unreleased +### Bug Fixes + +- [#2035](https://github.com/ClementTsang/bottom/pull/2035): Fix panic when deleting unicode words in search. + ### Features - [#1938](https://github.com/ClementTsang/bottom/pull/1938), [#1980](https://github.com/ClementTsang/bottom/pull/1980): Report average packet size and packet rate. diff --git a/src/app.rs b/src/app.rs index e5f8e554..1dac95f8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,13 +5,11 @@ pub mod states; use std::time::Instant; -use concat_string::concat_string; use data::*; use filter::*; use layout_manager::*; use rustc_hash::FxHashMap as HashMap; pub use states::*; -use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; use crate::{ canvas::{ @@ -499,34 +497,13 @@ impl App { .widget_states .get_mut(&(self.current_widget.widget_id - 1)) { - if is_in_search_widget - && proc_widget_state.proc_search.search_state.is_enabled - && proc_widget_state.cursor_char_index() - < proc_widget_state - .proc_search - .search_state - .current_search_query - .len() + if is_in_search_widget && proc_widget_state.proc_search.search_state.is_enabled { - let current_cursor = proc_widget_state.cursor_char_index(); - proc_widget_state.search_walk_forward(); - - let _ = proc_widget_state + proc_widget_state .proc_search .search_state - .current_search_query - .drain(current_cursor..proc_widget_state.cursor_char_index()); - - proc_widget_state.proc_search.search_state.grapheme_cursor = - GraphemeCursor::new( - current_cursor, - proc_widget_state - .proc_search - .search_state - .current_search_query - .len(), - true, - ); + .input_field_state + .delete_at_cursor(); proc_widget_state.update_query(); } @@ -548,33 +525,12 @@ impl App { .widget_states .get_mut(&(self.current_widget.widget_id - 1)) { - if is_in_search_widget - && proc_widget_state.proc_search.search_state.is_enabled - && proc_widget_state.cursor_char_index() > 0 - { - let current_cursor = proc_widget_state.cursor_char_index(); - proc_widget_state.search_walk_back(); - - // Remove the indices in between. - let _ = proc_widget_state + if is_in_search_widget && proc_widget_state.proc_search.search_state.is_enabled { + proc_widget_state .proc_search .search_state - .current_search_query - .drain(proc_widget_state.cursor_char_index()..current_cursor); - - proc_widget_state.proc_search.search_state.grapheme_cursor = - GraphemeCursor::new( - proc_widget_state.cursor_char_index(), - proc_widget_state - .proc_search - .search_state - .current_search_query - .len(), - true, - ); - - proc_widget_state.proc_search.search_state.cursor_direction = - CursorDirection::Left; + .input_field_state + .delete_behind_cursor(); proc_widget_state.update_query(); } @@ -626,12 +582,11 @@ impl App { .get_mut_widget_state(self.current_widget.widget_id - 1) { if is_in_search_widget { - let prev_cursor = proc_widget_state.cursor_char_index(); - proc_widget_state.search_walk_back(); - if proc_widget_state.cursor_char_index() < prev_cursor { - proc_widget_state.proc_search.search_state.cursor_direction = - CursorDirection::Left; - } + proc_widget_state + .proc_search + .search_state + .input_field_state + .move_left(); } } } @@ -674,12 +629,11 @@ impl App { .get_mut_widget_state(self.current_widget.widget_id - 1) { if is_in_search_widget { - let prev_cursor = proc_widget_state.cursor_char_index(); - proc_widget_state.search_walk_forward(); - if proc_widget_state.cursor_char_index() > prev_cursor { - proc_widget_state.proc_search.search_state.cursor_direction = - CursorDirection::Right; - } + proc_widget_state + .proc_search + .search_state + .input_field_state + .move_right(); } } } @@ -816,19 +770,11 @@ impl App { .get_mut(&(self.current_widget.widget_id - 1)) { if is_in_search_widget { - proc_widget_state.proc_search.search_state.grapheme_cursor = - GraphemeCursor::new( - 0, - proc_widget_state - .proc_search - .search_state - .current_search_query - .len(), - true, - ); - - proc_widget_state.proc_search.search_state.cursor_direction = - CursorDirection::Left; + proc_widget_state + .proc_search + .search_state + .input_field_state + .skip_to_beginning(); } } } @@ -846,16 +792,11 @@ impl App { .get_mut(&(self.current_widget.widget_id - 1)) { if is_in_search_widget { - let query_len = proc_widget_state + proc_widget_state .proc_search .search_state - .current_search_query - .len(); - - proc_widget_state.proc_search.search_state.grapheme_cursor = - GraphemeCursor::new(query_len, query_len, true); - proc_widget_state.proc_search.search_state.cursor_direction = - CursorDirection::Right; + .input_field_state + .skip_to_end(); } } } @@ -883,52 +824,11 @@ impl App { .widget_states .get_mut(&(self.current_widget.widget_id - 1)) { - // Traverse backwards from the current cursor location until you hit - // non-whitespace characters, then continue to traverse (and - // delete) backwards until you hit a whitespace character. Halt. - - // So... first, let's get our current cursor position in terms of char indices. - let end_index = proc_widget_state.cursor_char_index(); - - // Then, let's crawl backwards until we hit our location, and store the - // "head"... - let query = proc_widget_state.current_search_query(); - let mut start_index = 0; - let mut saw_non_whitespace = false; - - for (itx, c) in query - .chars() - .rev() - .enumerate() - .skip(query.len() - end_index) - { - if c.is_whitespace() { - if saw_non_whitespace { - start_index = query.len() - itx; - break; - } - } else { - saw_non_whitespace = true; - } - } - - let _ = proc_widget_state + proc_widget_state .proc_search .search_state - .current_search_query - .drain(start_index..end_index); - - proc_widget_state.proc_search.search_state.grapheme_cursor = GraphemeCursor::new( - start_index, - proc_widget_state - .proc_search - .search_state - .current_search_query - .len(), - true, - ); - - proc_widget_state.proc_search.search_state.cursor_direction = CursorDirection::Left; + .input_field_state + .delete_previous_word(); proc_widget_state.update_query(); } @@ -967,24 +867,10 @@ impl App { proc_widget_state .proc_search .search_state - .current_search_query - .insert(proc_widget_state.cursor_char_index(), caught_char); - - proc_widget_state.proc_search.search_state.grapheme_cursor = - GraphemeCursor::new( - proc_widget_state.cursor_char_index(), - proc_widget_state - .proc_search - .search_state - .current_search_query - .len(), - true, - ); - proc_widget_state.search_walk_forward(); + .input_field_state + .insert_char(caught_char); proc_widget_state.update_query(); - proc_widget_state.proc_search.search_state.cursor_direction = - CursorDirection::Right; return; } @@ -2618,9 +2504,6 @@ impl App { /// A quick and dirty way to handle paste events. pub fn handle_paste(&mut self, paste: String) { - // Partially copy-pasted from the single-char variant; should probably clean up - // this process in the future. In particular, encapsulate this entire - // logic and add some tests to make it less potentially error-prone. let is_in_search_widget = self.is_in_search_widget(); if let Some(proc_widget_state) = self .states @@ -2628,28 +2511,14 @@ impl App { .widget_states .get_mut(&(self.current_widget.widget_id - 1)) { - let num_runes = UnicodeSegmentation::graphemes(paste.as_str(), true).count(); - if is_in_search_widget && proc_widget_state.is_search_enabled() { - let left_bound = proc_widget_state.cursor_char_index(); - - let curr_query = &mut proc_widget_state + proc_widget_state .proc_search .search_state - .current_search_query; - let (left, right) = curr_query.split_at(left_bound); - *curr_query = concat_string!(left, paste, right); - - proc_widget_state.proc_search.search_state.grapheme_cursor = - GraphemeCursor::new(left_bound, curr_query.len(), true); - - for _ in 0..num_runes { - proc_widget_state.search_walk_forward(); - } + .input_field_state + .insert_string(paste); proc_widget_state.update_query(); - proc_widget_state.proc_search.search_state.cursor_direction = - CursorDirection::Right; } } } diff --git a/src/app/states.rs b/src/app/states.rs index b7c3c589..d40aa4d4 100644 --- a/src/app/states.rs +++ b/src/app/states.rs @@ -1,13 +1,9 @@ -use std::ops::Range; - -use indexmap::IndexMap; use rustc_hash::FxHashMap as HashMap; -use unicode_ellipsis::grapheme_width; -use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete, UnicodeSegmentation}; use crate::{ app::layout_manager::BottomWidgetType, constants, + utils::input::InputFieldState, widgets::{ BatteryWidgetState, CpuWidgetState, DiskTableWidget, MemWidgetState, NetWidgetState, ProcWidgetState, TempWidgetState, query::ProcessQuery, @@ -50,39 +46,18 @@ impl Default for AppHelpDialogState { } /// AppSearchState deals with generic searching (I might do this in the future). +#[derive(Default)] pub struct AppSearchState { pub is_enabled: bool, - pub current_search_query: String, - pub is_blank_search: bool, pub is_invalid_search: bool, - pub grapheme_cursor: GraphemeCursor, - pub cursor_direction: CursorDirection, - pub display_start_char_index: usize, - pub size_mappings: IndexMap>, + pub input_field_state: InputFieldState, /// The query. TODO: Merge this as one enum. pub query: Option, pub error_message: Option, } -impl Default for AppSearchState { - fn default() -> Self { - AppSearchState { - is_enabled: false, - current_search_query: String::default(), - is_invalid_search: false, - is_blank_search: true, - grapheme_cursor: GraphemeCursor::new(0, 0, true), - cursor_direction: CursorDirection::Right, - display_start_char_index: 0, - size_mappings: IndexMap::default(), - query: None, - error_message: None, - } - } -} - impl AppSearchState { /// Resets the [`AppSearchState`] to its default state, albeit still /// enabled. @@ -95,148 +70,7 @@ impl AppSearchState { /// Returns whether the [`AppSearchState`] has an invalid or blank search. pub fn is_invalid_or_blank_search(&self) -> bool { - self.is_blank_search || self.is_invalid_search - } - - /// Sets the starting grapheme index to draw from. - pub fn get_start_position(&mut self, available_width: usize, is_force_redraw: bool) { - // Remember - the number of columns != the number of grapheme slots/sizes, you - // cannot use index to determine this reliably! - - let start_index = if is_force_redraw { - 0 - } else { - self.display_start_char_index - }; - let cursor_index = self.grapheme_cursor.cur_cursor(); - - if let Some(start_range) = self.size_mappings.get(&start_index) { - let cursor_range = self - .size_mappings - .get(&cursor_index) - .cloned() - .unwrap_or_else(|| { - self.size_mappings - .last() - .map(|(_, r)| r.end..(r.end + 1)) - .unwrap_or(start_range.end..(start_range.end + 1)) - }); - - // Cases to handle in both cases: - // - The current start index can show the cursor's word. - // - The current start index cannot show the cursor's word. - // - // What differs is how we "scroll" based on the cursor movement direction. - - self.display_start_char_index = match self.cursor_direction { - CursorDirection::Right => { - if start_range.start + available_width >= cursor_range.end { - // Use the current index. - start_index - } else if cursor_range.end >= available_width { - // If the current position is past the last visible element, skip until we - // see it. - - let mut index = 0; - for i in 0..(cursor_index + 1) { - if let Some(r) = self.size_mappings.get(&i) { - if r.start + available_width >= cursor_range.end { - index = i; - break; - } - } - } - - index - } else { - 0 - } - } - CursorDirection::Left => { - if cursor_range.start < start_range.end { - let mut index = 0; - for i in cursor_index..(self.current_search_query.len()) { - if let Some(r) = self.size_mappings.get(&i) { - if r.start + available_width >= cursor_range.end { - index = i; - break; - } - } - } - index - } else { - start_index - } - } - }; - } else { - // If we fail here somehow, just reset to 0 index + scroll left. - self.display_start_char_index = 0; - self.cursor_direction = CursorDirection::Left; - }; - } - - pub(crate) fn walk_forward(&mut self) { - // TODO: Add tests for this. - let start_position = self.grapheme_cursor.cur_cursor(); - let chunk = &self.current_search_query[start_position..]; - - match self.grapheme_cursor.next_boundary(chunk, start_position) { - Ok(_) => {} - Err(err) => match err { - GraphemeIncomplete::PreContext(ctx) => { - // Provide the entire string as context. Not efficient but should resolve - // failures. - self.grapheme_cursor - .provide_context(&self.current_search_query[0..ctx], 0); - - self.grapheme_cursor - .next_boundary(chunk, start_position) - .expect("another grapheme boundary should exist after the cursor with the provided context"); - } - _ => panic!("{err:?}"), - }, - } - } - - pub(crate) fn walk_backward(&mut self) { - // TODO: Add tests for this. - let start_position = self.grapheme_cursor.cur_cursor(); - let chunk = &self.current_search_query[..start_position]; - - match self.grapheme_cursor.prev_boundary(chunk, 0) { - Ok(_) => {} - Err(err) => match err { - GraphemeIncomplete::PreContext(ctx) => { - // Provide the entire string as context. Not efficient but should resolve - // failures. - self.grapheme_cursor - .provide_context(&self.current_search_query[0..ctx], 0); - - self.grapheme_cursor - .prev_boundary(chunk, 0) - .expect("another grapheme boundary should exist before the cursor with the provided context"); - } - _ => panic!("{err:?}"), - }, - } - } - - pub(crate) fn update_sizes(&mut self) { - self.size_mappings.clear(); - let mut curr_offset = 0; - for (index, grapheme) in - UnicodeSegmentation::grapheme_indices(self.current_search_query.as_str(), true) - { - let width = grapheme_width(grapheme); - let end = curr_offset + width; - - self.size_mappings.insert(index, curr_offset..end); - - curr_offset = end; - } - - self.size_mappings.shrink_to_fit(); + self.input_field_state.current_query().is_empty() || self.is_invalid_search } } @@ -362,92 +196,3 @@ pub struct ParagraphScrollState { pub current_scroll_index: u16, pub max_scroll_index: u16, } - -#[cfg(test)] -mod test { - use super::*; - - fn move_right(state: &mut AppSearchState) { - state.walk_forward(); - state.cursor_direction = CursorDirection::Right; - } - - fn move_left(state: &mut AppSearchState) { - state.walk_backward(); - state.cursor_direction = CursorDirection::Left; - } - - #[test] - fn search_cursor_moves() { - let mut state = AppSearchState::default(); - state.current_search_query = "Hi, 你好! 🇦🇶".to_string(); - state.grapheme_cursor = GraphemeCursor::new(0, state.current_search_query.len(), true); - state.update_sizes(); - - // Moving right. - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 0); - assert_eq!(state.display_start_char_index, 0); - - move_right(&mut state); - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 1); - assert_eq!(state.display_start_char_index, 0); - - move_right(&mut state); - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 2); - assert_eq!(state.display_start_char_index, 0); - - move_right(&mut state); - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 3); - assert_eq!(state.display_start_char_index, 0); - - move_right(&mut state); - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 4); - assert_eq!(state.display_start_char_index, 2); - - move_right(&mut state); - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 7); - assert_eq!(state.display_start_char_index, 4); - - move_right(&mut state); - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 10); - assert_eq!(state.display_start_char_index, 7); - - move_right(&mut state); - move_right(&mut state); - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 12); - assert_eq!(state.display_start_char_index, 10); - - // Moving left. - move_left(&mut state); - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 11); - assert_eq!(state.display_start_char_index, 10); - - move_left(&mut state); - move_left(&mut state); - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 7); - assert_eq!(state.display_start_char_index, 7); - - move_left(&mut state); - move_left(&mut state); - move_left(&mut state); - move_left(&mut state); - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 1); - assert_eq!(state.display_start_char_index, 1); - - move_left(&mut state); - state.get_start_position(4, false); - assert_eq!(state.grapheme_cursor.cur_cursor(), 0); - assert_eq!(state.display_start_char_index, 0); - } -} diff --git a/src/canvas/widgets/process_table.rs b/src/canvas/widgets/process_table.rs index 352e9256..44ff8646 100644 --- a/src/canvas/widgets/process_table.rs +++ b/src/canvas/widgets/process_table.rs @@ -5,7 +5,6 @@ use tui::{ text::{Line, Span}, widgets::Paragraph, }; -use unicode_segmentation::UnicodeSegmentation; use crate::{ app::{App, AppSearchState}, @@ -110,16 +109,16 @@ impl Painter { search_state: &AppSearchState, available_width: usize, is_on_widget: bool, currently_selected_text_style: Style, text_style: Style, ) -> Vec> { - let start_index = search_state.display_start_char_index; - let cursor_index = search_state.grapheme_cursor.cur_cursor(); + let start_index = search_state.input_field_state.display_start_index(); + let cursor_index = search_state.input_field_state.cursor_index(); let mut current_width = 0; - let query = search_state.current_search_query.as_str(); + let query = search_state.input_field_state.current_query(); if is_on_widget { let mut res = Vec::with_capacity(available_width); - for ((index, grapheme), lengths) in - UnicodeSegmentation::grapheme_indices(query, true) - .zip(search_state.size_mappings.values()) + + for (index, grapheme, lengths) in + search_state.input_field_state.graphemes_with_ranges() { if index < start_index { continue; @@ -145,7 +144,6 @@ impl Painter { } else { // This is easier - we just need to get a range of graphemes, rather than // dealing with possibly inserting a cursor (as none is shown!) - vec![Span::styled(query.to_string(), text_style)] } } @@ -171,6 +169,7 @@ impl Painter { proc_widget_state .proc_search .search_state + .input_field_state .get_start_position(available_width, app_state.is_force_redraw); // TODO: [CURSOR] blinking cursor? diff --git a/src/lib.rs b/src/lib.rs index a338be56..b2e13f79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ mod utils { pub(crate) mod conversion; pub(crate) mod data_units; pub(crate) mod general; + pub(crate) mod input; pub(crate) mod int_hash; pub(crate) mod logging; pub(crate) mod process_killer; diff --git a/src/utils/input.rs b/src/utils/input.rs new file mode 100644 index 00000000..8044030e --- /dev/null +++ b/src/utils/input.rs @@ -0,0 +1,844 @@ +//! Generalized input logic, to make it easier to reuse logic like unicode handling +//! and cursor movement. + +use std::ops::Range; + +use concat_string::concat_string; +use unicode_ellipsis::grapheme_width; +use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete, UnicodeSegmentation}; + +use crate::{app::CursorDirection, utils::int_hash::IntIndexMap}; + +/// An input field's state. +pub struct InputFieldState { + /// The search query itself, what is shown. + current_query: String, + + /// The internal grapheme cursor to track the current location. + grapheme_cursor: GraphemeCursor, + + /// The direction the cursor is heading at the moment. Modified + /// by user actions, e.g. adding text moves it right, deleting + /// text moves it left, the user scrolling changes it based + /// on where they scroll, etc. + cursor_direction: CursorDirection, + + /// Determines where we start _displaying_ the search based on + /// the user's scroll. For example, if they move the cursor 5 + /// units to the right from 0, the index should be 5. + display_start_index: usize, + + /// Used for internal tracking of _byte_ indices to the widths + /// of the graphemes they represent. This is mostly used to cache + /// and avoid having to re-calculate widths each time it needs to + /// be accessed. + /// + /// Should always be updated after the search query updates in any way. + size_mappings: IntIndexMap>, +} + +impl Default for InputFieldState { + fn default() -> Self { + Self { + current_query: String::default(), + grapheme_cursor: GraphemeCursor::new(0, 0, true), + cursor_direction: CursorDirection::Right, + display_start_index: 0, + size_mappings: IntIndexMap::default(), + } + } +} + +impl InputFieldState { + /// Get a reference to the current query. + #[inline] + pub(crate) fn current_query(&self) -> &str { + &self.current_query + } + + /// Get the current cursor index. + #[inline] + pub(crate) fn cursor_index(&self) -> usize { + self.grapheme_cursor.cur_cursor() + } + + /// Get the display start index. + #[inline] + pub(crate) fn display_start_index(&self) -> usize { + self.display_start_index + } + + /// Sets the starting grapheme index to draw from. + /// + /// TODO: This is kinda weird, we might want to decouple this in some way such that this + /// is clear this only matters for drawing... but it also changes states... + pub(crate) fn get_start_position(&mut self, available_width: usize, is_force_redraw: bool) { + // Remember - the number of columns != the number of grapheme slots/sizes, you + // cannot use index to determine this reliably! + + let start_index = if is_force_redraw { + 0 + } else { + self.display_start_index + }; + let cursor_index = self.cursor_index(); + + if let Some(start_range) = self.size_mappings.get(&start_index) { + let cursor_range = self + .size_mappings + .get(&cursor_index) + .cloned() + .unwrap_or_else(|| { + self.size_mappings + .last() + .map(|(_, r)| r.end..(r.end + 1)) + .unwrap_or(start_range.end..(start_range.end + 1)) + }); + + // Cases to handle in both cases: + // - The current start index can show the cursor's word. + // - The current start index cannot show the cursor's word. + // + // What differs is how we "scroll" based on the cursor movement direction. + + self.display_start_index = match self.cursor_direction { + CursorDirection::Right => { + if start_range.start + available_width >= cursor_range.end { + // Use the current index. + start_index + } else if cursor_range.end >= available_width { + // If the current position is past the last visible element, skip until we + // see it. + + let mut index = 0; + for i in 0..(cursor_index + 1) { + if let Some(r) = self.size_mappings.get(&i) { + if r.start + available_width >= cursor_range.end { + index = i; + break; + } + } + } + + index + } else { + 0 + } + } + CursorDirection::Left => { + if cursor_range.start < start_range.end { + let mut index = 0; + for i in cursor_index..(self.current_query.len()) { + if let Some(r) = self.size_mappings.get(&i) { + if r.start + available_width >= cursor_range.end { + index = i; + break; + } + } + } + index + } else { + start_index + } + } + }; + } else { + // If we fail here somehow, just reset to 0 index + scroll left. + self.display_start_index = 0; + self.cursor_direction = CursorDirection::Left; + }; + } + + /// Move the cursor one _grapheme_ forward. + fn walk_forward(&mut self) { + let start_position = self.cursor_index(); + let chunk = &self.current_query[start_position..]; + + match self.grapheme_cursor.next_boundary(chunk, start_position) { + Ok(_) => {} + Err(err) => match err { + GraphemeIncomplete::PreContext(ctx) => { + // Provide the entire string as context. Not efficient but should resolve + // failures. + self.grapheme_cursor + .provide_context(&self.current_query[0..ctx], 0); + + self.grapheme_cursor + .next_boundary(chunk, start_position) + .expect("another grapheme boundary should exist after the cursor with the provided context"); + } + _ => panic!("{err:?}"), + }, + } + } + + /// Move the cursor one _grapheme_ backward. + fn walk_backward(&mut self) { + let start_position = self.cursor_index(); + let chunk = &self.current_query[..start_position]; + + match self.grapheme_cursor.prev_boundary(chunk, 0) { + Ok(_) => {} + Err(err) => match err { + GraphemeIncomplete::PreContext(ctx) => { + // Provide the entire string as context. Not efficient but should resolve + // failures. + self.grapheme_cursor + .provide_context(&self.current_query[0..ctx], 0); + + self.grapheme_cursor + .prev_boundary(chunk, 0) + .expect("another grapheme boundary should exist before the cursor with the provided context"); + } + _ => panic!("{err:?}"), + }, + } + } + + /// Update the size mappings (mapping of index to the display width range) after the query has been updated in any + /// way. This should be called whenever the query is updated. + /// + /// TODO: This might be a bit expensive, maybe we could update this a bit more iteratively? + fn update_sizes(&mut self) { + self.size_mappings.clear(); + let mut curr_offset = 0; + for (index, grapheme) in + UnicodeSegmentation::grapheme_indices(self.current_query.as_str(), true) + { + let width = grapheme_width(grapheme); + let end = curr_offset + width; + + self.size_mappings.insert(index, curr_offset..end); + + curr_offset = end; + } + } + + /// Delete whatever the cursor is currently highlighting, if anything. This is analogous to pressing `Delete`. + pub(crate) fn delete_at_cursor(&mut self) { + let current_cursor = self.cursor_index(); + if current_cursor < self.current_query.len() { + self.walk_forward(); + let new_cursor = self.cursor_index(); + + let _ = self.current_query.drain(current_cursor..new_cursor); + + self.grapheme_cursor = + GraphemeCursor::new(current_cursor, self.current_query.len(), true); + + self.update_sizes(); + } + } + + /// Delete what is _behind_ the cursor. This is analogous to pressing `Backspace`. + pub(crate) fn delete_behind_cursor(&mut self) { + let current_cursor = self.cursor_index(); + + if current_cursor > 0 { + self.walk_backward(); + let new_cursor = self.cursor_index(); + + // Remove the indices in between. + let _ = self.current_query.drain(new_cursor..current_cursor); + + self.grapheme_cursor = GraphemeCursor::new(new_cursor, self.current_query.len(), true); + + self.cursor_direction = CursorDirection::Left; + + self.update_sizes(); + } + } + + /// Move the cursor left one unit if possible. + pub(crate) fn move_left(&mut self) { + let current_cursor = self.cursor_index(); + self.walk_backward(); + if self.cursor_index() < current_cursor { + self.cursor_direction = CursorDirection::Left; + } + } + + /// Move the cursor right one unit if possible. + pub(crate) fn move_right(&mut self) { + let current_cursor = self.cursor_index(); + self.walk_forward(); + if self.cursor_index() > current_cursor { + self.cursor_direction = CursorDirection::Right; + } + } + + /// Move the cursor to the start. + pub(crate) fn skip_to_beginning(&mut self) { + self.grapheme_cursor = GraphemeCursor::new(0, self.current_query.len(), true); + self.cursor_direction = CursorDirection::Left; + } + + /// Move the cursor to the end. + pub(crate) fn skip_to_end(&mut self) { + let query_len = self.current_query.len(); + self.grapheme_cursor = GraphemeCursor::new(query_len, query_len, true); + self.cursor_direction = CursorDirection::Right; + } + + /// Delete the previous "word". + pub(crate) fn delete_previous_word(&mut self) { + // Traverse backwards from the current cursor location until you hit + // non-whitespace characters, then continue to traverse (and + // delete) backwards until you hit a whitespace character. Halt. + + // So... first, let's get our current cursor position in terms of char + // indices. This is the "end" index we care about. + let current_cursor = self.cursor_index(); + + // Then, let's crawl backwards until we hit our location, and store the + // "head"... + let query = self.current_query(); + let mut start_index = 0; + let mut saw_non_whitespace = false; + + for (itx, c) in query[..current_cursor].char_indices().rev() { + if c.is_whitespace() { + if saw_non_whitespace { + start_index = itx + c.len_utf8(); + break; + } + } else { + saw_non_whitespace = true; + } + } + + let _ = self.current_query.drain(start_index..current_cursor); + + self.grapheme_cursor = GraphemeCursor::new(start_index, self.current_query.len(), true); + + self.cursor_direction = CursorDirection::Left; + + self.update_sizes(); + } + + /// Insert a single [`char`]. + pub(crate) fn insert_char(&mut self, ch: char) { + self.current_query.insert(self.cursor_index(), ch); + + self.grapheme_cursor = + GraphemeCursor::new(self.cursor_index(), self.current_query.len(), true); + + self.walk_forward(); + self.cursor_direction = CursorDirection::Right; + + self.update_sizes(); + } + + /// Insert a [`String`]. + pub(crate) fn insert_string(&mut self, s: String) { + let left_bound = self.cursor_index(); + + let current_query = &mut self.current_query; + let (left, right) = current_query.split_at(left_bound); + let num_runes = UnicodeSegmentation::graphemes(s.as_str(), true).count(); + + *current_query = concat_string!(left, s, right); + + self.grapheme_cursor = GraphemeCursor::new(left_bound, current_query.len(), true); + + for _ in 0..num_runes { + self.walk_forward(); + } + + self.cursor_direction = CursorDirection::Right; + + self.update_sizes(); + } + + /// Returns an iterator over graphemes with the byte index + display-width range. + pub(crate) fn graphemes_with_ranges( + &self, + ) -> impl Iterator)> { + let query = self.current_query(); + let mut iter = self.size_mappings.iter().peekable(); + + std::iter::from_fn(move || { + let (&start, lengths) = iter.next()?; + let end = iter.peek().map(|&(&next, _)| next).unwrap_or(query.len()); + let grapheme = &query[start..end]; + + Some((start, grapheme, lengths)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Tests that inserting ASCII chars appends them to the query and advances the cursor by 1 byte each. + #[test] + fn insert_char_ascii() { + let mut state = InputFieldState::default(); + + state.insert_char('H'); + assert_eq!(state.current_query(), "H"); + assert_eq!(state.cursor_index(), 1); + + state.insert_char('i'); + assert_eq!(state.current_query(), "Hi"); + assert_eq!(state.cursor_index(), 2); + } + + /// Tests that inserting multi-byte Unicode chars (e.g. CJK) advances the cursor by the correct byte width. + #[test] + fn insert_char_unicode() { + let mut state = InputFieldState::default(); + + state.insert_char('你'); // 3-byte UTF-8 + assert_eq!(state.current_query(), "你"); + assert_eq!(state.cursor_index(), 3); + + state.insert_char('好'); + assert_eq!(state.current_query(), "你好"); + assert_eq!(state.cursor_index(), 6); + } + + /// Tests that inserting a 4-byte emoji advances the cursor to byte offset 4. + #[test] + fn insert_char_emoji() { + let mut state = InputFieldState::default(); + + state.insert_char('🦀'); // 4-byte UTF-8 + assert_eq!(state.current_query(), "🦀"); + assert_eq!(state.cursor_index(), 4); + } + + /// Tests that inserting a char at a mid-string cursor position shifts the rest of the string + /// right and places the cursor immediately after the newly inserted char. + #[test] + fn insert_char_at_middle() { + let mut state = InputFieldState::default(); + state.insert_char('H'); + state.insert_char('i'); + state.insert_char('!'); + + // Move back to before '!' + state.move_left(); + assert_eq!(state.cursor_index(), 2); + + state.insert_char(' '); + assert_eq!(state.current_query(), "Hi !"); + assert_eq!(state.cursor_index(), 3); + } + + /// Tests that inserting an ASCII string at once places the entire string in the query + /// and lands the cursor at the end. + #[test] + fn insert_string_ascii() { + let mut state = InputFieldState::default(); + state.insert_string("Hello".to_string()); + assert_eq!(state.current_query(), "Hello"); + assert_eq!(state.cursor_index(), 5); + } + + /// Tests that inserting a mixed multi-byte + emoji string reflects the correct total byte length + /// in the cursor position. + #[test] + fn insert_string_unicode() { + let mut state = InputFieldState::default(); + state.insert_string("你好🇨🇦🦀".to_string()); + assert_eq!(state.current_query(), "你好🇨🇦🦀"); + // '你'=3, '好'=3, '🦀'=4, '🇨🇦'=8, so 18 bytes + assert_eq!(state.cursor_index(), 18); + } + + /// Tests that inserting a string at position 0 prepends it, leaving the cursor after the + /// inserted portion and the rest of the original string intact. + #[test] + fn insert_string_at_middle() { + let mut state = InputFieldState::default(); + state.insert_string("Hello".to_string()); + state.skip_to_beginning(); + state.insert_string("Say ".to_string()); + assert_eq!(state.current_query(), "Say Hello"); + assert_eq!(state.cursor_index(), 4); + } + + /// Tests that [`InputFieldState::delete_at_cursor`] removes the grapheme under the cursor + /// without moving the cursor position. + #[test] + fn delete_at_cursor_basic() { + let mut state = InputFieldState::default(); + state.insert_string("Hello".to_string()); + state.skip_to_beginning(); + + state.delete_at_cursor(); // removes 'H' + assert_eq!(state.current_query(), "ello"); + assert_eq!(state.cursor_index(), 0); + + state.delete_at_cursor(); // removes 'e' + assert_eq!(state.current_query(), "llo"); + assert_eq!(state.cursor_index(), 0); + } + + /// Tests that [`InputFieldState::delete_at_cursor`] is a no-op when the cursor is already + /// at the end of the string. + #[test] + fn delete_at_cursor_at_end_is_noop() { + let mut state = InputFieldState::default(); + state.insert_string("Hi".to_string()); + // cursor is already at end after inserting + + state.delete_at_cursor(); + assert_eq!(state.current_query(), "Hi"); + assert_eq!(state.cursor_index(), 2); + } + + /// Tests that [`InputFieldState::delete_at_cursor`] correctly removes a full multi-byte + /// grapheme cluster in one operation. + #[test] + fn delete_at_cursor_unicode() { + let mut state = InputFieldState::default(); + state.insert_string("你好".to_string()); + state.skip_to_beginning(); + + state.delete_at_cursor(); // removes '你' (3 bytes) + assert_eq!(state.current_query(), "好"); + assert_eq!(state.cursor_index(), 0); + } + + /// Tests that [`InputFieldState::delete_behind_cursor`] removes the grapheme immediately + /// before the cursor and moves the cursor back accordingly. + #[test] + fn delete_behind_cursor_basic() { + let mut state = InputFieldState::default(); + state.insert_string("Hello".to_string()); + + state.delete_behind_cursor(); // removes 'o' + assert_eq!(state.current_query(), "Hell"); + assert_eq!(state.cursor_index(), 4); + + state.delete_behind_cursor(); // removes 'l' + assert_eq!(state.current_query(), "Hel"); + assert_eq!(state.cursor_index(), 3); + } + + /// Tests that [`InputFieldState::delete_behind_cursor`] is a no-op when the cursor is at + /// position 0. + #[test] + fn delete_behind_cursor_at_start_is_noop() { + let mut state = InputFieldState::default(); + state.insert_string("Hi".to_string()); + state.skip_to_beginning(); + + state.delete_behind_cursor(); + assert_eq!(state.current_query(), "Hi"); + assert_eq!(state.cursor_index(), 0); + } + + /// Tests that [`InputFieldState::delete_behind_cursor`] correctly removes multi-byte grapheme + /// clusters one at a time, adjusting the byte cursor each time. + #[test] + fn delete_behind_cursor_unicode() { + let mut state = InputFieldState::default(); + state.insert_string("你好".to_string()); + + state.delete_behind_cursor(); // removes '好' (3 bytes) + assert_eq!(state.current_query(), "你"); + assert_eq!(state.cursor_index(), 3); + + state.delete_behind_cursor(); // removes '你' (3 bytes) + assert_eq!(state.current_query(), ""); + assert_eq!(state.cursor_index(), 0); + } + + /// Tests that [`InputFieldState::move_left`] and [`InputFieldState::move_right`] step one + /// byte per ASCII grapheme and are clamped at both ends of the string. + #[test] + fn move_left_right_ascii() { + let mut state = InputFieldState::default(); + state.insert_string("abc".to_string()); + assert_eq!(state.cursor_index(), 3); + + state.move_left(); + assert_eq!(state.cursor_index(), 2); + state.move_left(); + assert_eq!(state.cursor_index(), 1); + state.move_left(); + assert_eq!(state.cursor_index(), 0); + + // At the start — no further movement + state.move_left(); + assert_eq!(state.cursor_index(), 0); + + state.move_right(); + assert_eq!(state.cursor_index(), 1); + state.move_right(); + assert_eq!(state.cursor_index(), 2); + state.move_right(); + assert_eq!(state.cursor_index(), 3); + + // At the end — no further movement + state.move_right(); + assert_eq!(state.cursor_index(), 3); + } + + /// Tests that [`InputFieldState::move_left`] and [`InputFieldState::move_right`] jump the + /// full byte width of each grapheme, including multi-byte CJK characters. + #[test] + fn move_left_right_unicode() { + let mut state = InputFieldState::default(); + state.insert_string("a你b".to_string()); // 1 + 3 + 1 = 5 bytes + assert_eq!(state.cursor_index(), 5); + + state.move_left(); // over 'b' (1 byte) + assert_eq!(state.cursor_index(), 4); + + state.move_left(); // over '你' (3 bytes) + assert_eq!(state.cursor_index(), 1); + + state.move_left(); // over 'a' (1 byte) + assert_eq!(state.cursor_index(), 0); + } + + /// Tests that [`InputFieldState::skip_to_beginning`] moves the cursor to byte 0 and + /// [`InputFieldState::skip_to_end`] moves it past the last byte. + #[test] + fn skip_to_beginning_and_end() { + let mut state = InputFieldState::default(); + state.insert_string("Hello".to_string()); + + state.skip_to_beginning(); + assert_eq!(state.cursor_index(), 0); + + state.skip_to_end(); + assert_eq!(state.cursor_index(), 5); + } + + /// Tests that [`InputFieldState::skip_to_beginning`] sets the cursor direction to + /// [`CursorDirection::Left`] so that scrolling logic behaves correctly. + #[test] + fn skip_to_beginning_sets_direction_left() { + let mut state = InputFieldState::default(); + state.insert_string("Hello".to_string()); + state.skip_to_beginning(); + assert!(matches!(state.cursor_direction, CursorDirection::Left)); + } + + /// Tests that [`InputFieldState::skip_to_end`] sets the cursor direction to + /// [`CursorDirection::Right`] so that scrolling logic behaves correctly. + #[test] + fn skip_to_end_sets_direction_right() { + let mut state = InputFieldState::default(); + state.insert_string("Hello".to_string()); + state.skip_to_beginning(); + state.skip_to_end(); + assert!(matches!(state.cursor_direction, CursorDirection::Right)); + } + + /// Tests that [`InputFieldState::delete_previous_word`] removes a single word with no + /// preceding whitespace, leaving an empty query. + #[test] + fn delete_previous_word_single_word() { + let mut state = InputFieldState::default(); + state.insert_string("Hello".to_string()); + + state.delete_previous_word(); + assert_eq!(state.current_query(), ""); + assert_eq!(state.cursor_index(), 0); + } + + /// Tests that [`InputFieldState::delete_previous_word`] removes exactly one word per call + /// when the query contains multiple words separated by a space. + #[test] + fn delete_previous_word_two_words() { + let mut state = InputFieldState::default(); + state.insert_string("Hello World".to_string()); + + state.delete_previous_word(); // deletes "World" + assert_eq!(state.current_query(), "Hello "); + assert_eq!(state.cursor_index(), 6); + + state.delete_previous_word(); // deletes "Hello " + assert_eq!(state.current_query(), ""); + assert_eq!(state.cursor_index(), 0); + } + + /// Tests that [`InputFieldState::delete_previous_word`] skips trailing whitespace before + /// deleting the preceding non-whitespace word. + #[test] + fn delete_previous_word_trailing_spaces() { + let mut state = InputFieldState::default(); + state.insert_string("Hello ".to_string()); // trailing spaces + + // Should skip spaces first, then delete "Hello" + state.delete_previous_word(); + assert_eq!(state.current_query(), ""); + assert_eq!(state.cursor_index(), 0); + } + + /// Tests that [`InputFieldState::delete_previous_word`] only removes the portion of a word + /// that lies behind the cursor when the cursor is mid-word. + #[test] + fn delete_previous_word_from_middle() { + let mut state = InputFieldState::default(); + state.insert_string("Hello World".to_string()); + state.skip_to_beginning(); + // Advance 3 chars ('H','e','l') + state.move_right(); + state.move_right(); + state.move_right(); + assert_eq!(state.cursor_index(), 3); + + state.delete_previous_word(); // deletes "Hel" + assert_eq!(state.current_query(), "lo World"); + assert_eq!(state.cursor_index(), 0); + } + + /// Tests that [`InputFieldState::delete_previous_word`] correctly handles multibyte + /// characters. The char/byte index mismatch bug would cause `.skip(query.len() - + /// current_cursor)` to skip the wrong number of chars and `query.len() - itx` to land + /// on a non-char-boundary, either producing the wrong result or panicking. + #[test] + fn delete_previous_word_unicode() { + // "你好 world" — '你'=3 bytes, '好'=3 bytes, ' '=1, "world"=5, so 12 bytes total + let mut state = InputFieldState::default(); + state.insert_string("你好 world".to_string()); + + // Cursor is at the end (byte 12). Deleting the previous word should remove "world", + // leaving "你好 " (7 bytes). + state.delete_previous_word(); + assert_eq!(state.current_query(), "你好 "); + assert_eq!(state.cursor_index(), 7); + + // Deleting again skips the trailing space then removes "你好", leaving "". + state.delete_previous_word(); + assert_eq!(state.current_query(), ""); + assert_eq!(state.cursor_index(), 0); + } + + /// Test that [`InputFieldState::graphemes_with_ranges`] returns the correct graphemes along with their + /// byte indices and display width ranges. + #[test] + fn test_graphemes_with_ranges() { + let query = "Test你🇨🇦".to_string(); + let query_len = query.len(); + + assert_eq!(query_len, 15); // 4 + 3 + 8 + + let mut state = InputFieldState::default(); + state.insert_string(query); + + let graphemes: Vec<(usize, &str, &Range)> = state.graphemes_with_ranges().collect(); + assert_eq!(graphemes.len(), 6); + + assert_eq!(graphemes[0], (0, "T", &(0..1))); + assert_eq!(graphemes[1], (1, "e", &(1..2))); + assert_eq!(graphemes[2], (2, "s", &(2..3))); + assert_eq!(graphemes[3], (3, "t", &(3..4))); + assert_eq!( + graphemes[4], + (4, "你", &(4..6)), + "你 is 3 bytes long, total grapheme is 2-wide" + ); + assert_eq!( + graphemes[5], + (7, "🇨🇦", &(6..8)), + "🇨🇦 is 8 bytes long, total grapheme is 2-wide" + ); + + assert_eq!( + query_len - graphemes[5].0, + 8, + "flag grapheme is 8 bytes long" + ); + } + + /// Tests that the cursor moves correctly when moving left and right, as well as things work correctly around + /// updating the display start index. + #[test] + fn search_cursor_moves() { + let mut state = InputFieldState::default(); + state.insert_string("Hi, 你好! 🇨🇦".to_string()); + state.skip_to_beginning(); + + // Moving right. + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 0); + assert_eq!(state.display_start_index, 0); + + state.move_right(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 1); + assert_eq!(state.display_start_index, 0); + + state.move_right(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 2); + assert_eq!(state.display_start_index, 0); + + state.move_right(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 3); + assert_eq!(state.display_start_index, 0); + + state.move_right(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 4); + assert_eq!(state.display_start_index, 2); + + state.move_right(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 7); + assert_eq!(state.display_start_index, 4); + + state.move_right(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 10); + assert_eq!(state.display_start_index, 7); + + state.move_right(); + state.move_right(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 12); + assert_eq!(state.display_start_index, 10); + + // Move past the flag emoji (🇨🇦 = 8 bytes) to the end of string (byte 20). + state.move_right(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 20); + assert_eq!(state.display_start_index, 11); + + // Clamped at the end — no further movement. + state.move_right(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 20); + assert_eq!(state.display_start_index, 11); + + // Moving left — back over the flag emoji. + state.move_left(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 12); + assert_eq!(state.display_start_index, 11); + + state.move_left(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 11); + assert_eq!(state.display_start_index, 11); + + state.move_left(); + state.move_left(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 7); + assert_eq!(state.display_start_index, 7); + + state.move_left(); + state.move_left(); + state.move_left(); + state.move_left(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 1); + assert_eq!(state.display_start_index, 1); + + state.move_left(); + state.get_start_position(4, false); + assert_eq!(state.grapheme_cursor.cur_cursor(), 0); + assert_eq!(state.display_start_index, 0); + } +} diff --git a/src/utils/int_hash.rs b/src/utils/int_hash.rs index 97efbcc5..29821c5f 100644 --- a/src/utils/int_hash.rs +++ b/src/utils/int_hash.rs @@ -7,12 +7,19 @@ use std::{ marker::PhantomData, }; +use indexmap::IndexMap; + +type IntHasherState = BuildHasherDefault>; + /// A hash map that directly maps from an integer key to a value. -pub type IntHashMap = std::collections::HashMap>>; +pub type IntHashMap = std::collections::HashMap>; /// A hash set that directly uses integer keys. #[allow(dead_code)] -pub type IntHashSet = std::collections::HashSet>>; +pub type IntHashSet = std::collections::HashSet>; + +/// An [`IndexMap`] wrapper such that it tracks insertion order, but uses integer keys. +pub type IntIndexMap = IndexMap>; pub trait SupportedInt {} @@ -176,4 +183,22 @@ mod tests { assert!(set.contains(&2)); assert!(!set.contains(&3)); } + + #[test] + fn test_int_index_map() { + let mut map = IntIndexMap::::default(); + map.insert(1, "one"); + map.insert(3, "three"); + map.insert(2, "two"); + assert_eq!(map.get(&1), Some(&"one")); + assert_eq!(map.get(&2), Some(&"two")); + assert_eq!(map.get(&3), Some(&"three")); + assert_eq!(map.get(&4), None); + + assert_eq!(map.keys().cloned().collect::>(), vec![1, 3, 2]); + assert_eq!( + map.values().cloned().collect::>(), + vec!["one", "three", "two"] + ); + } } diff --git a/src/widgets/process_table.rs b/src/widgets/process_table.rs index 36c40b52..17e39a8d 100644 --- a/src/widgets/process_table.rs +++ b/src/widgets/process_table.rs @@ -32,6 +32,7 @@ use crate::{ /// state. #[derive(Default)] pub struct ProcessSearchState { + // TODO: Flatten AppSearchState as it's been generalized further. pub search_state: AppSearchState, pub query_options: QueryOptions, } @@ -1066,44 +1067,31 @@ impl ProcWidgetState { .collect::>() } - pub fn cursor_char_index(&self) -> usize { - self.proc_search.search_state.grapheme_cursor.cur_cursor() - } - pub fn is_search_enabled(&self) -> bool { self.proc_search.search_state.is_enabled } - pub fn current_search_query(&self) -> &str { - &self.proc_search.search_state.current_search_query - } - /// Update the current search query. /// /// TODO: Maybe debounce this. pub fn update_query(&mut self) { - if self + let current_query = self .proc_search .search_state - .current_search_query - .is_empty() - { - self.proc_search.search_state.is_blank_search = true; + .input_field_state + .current_query(); + + if current_query.is_empty() { self.proc_search.search_state.is_invalid_search = false; self.proc_search.search_state.error_message = None; } else { - match parse_query( - &self.proc_search.search_state.current_search_query, - &self.proc_search.query_options, - ) { + match parse_query(current_query, &self.proc_search.query_options) { Ok(parsed_query) => { self.proc_search.search_state.query = Some(parsed_query); - self.proc_search.search_state.is_blank_search = false; self.proc_search.search_state.is_invalid_search = false; self.proc_search.search_state.error_message = None; } Err(err) => { - self.proc_search.search_state.is_blank_search = false; self.proc_search.search_state.is_invalid_search = true; self.proc_search.search_state.error_message = Some(err.to_string()); } @@ -1112,9 +1100,6 @@ impl ProcWidgetState { self.table.state.display_start_index = 0; self.table.state.current_index = 0; - // Update the internal sizes too. - self.proc_search.search_state.update_sizes(); - self.force_data_update(); } @@ -1123,14 +1108,6 @@ impl ProcWidgetState { self.force_data_update(); } - pub fn search_walk_forward(&mut self) { - self.proc_search.search_state.walk_forward(); - } - - pub fn search_walk_back(&mut self) { - self.proc_search.search_state.walk_backward(); - } - /// Sets the [`ProcWidgetState`]'s current sort index to whatever was in the /// sort table if possible, then closes the sort table. pub(crate) fn use_sort_table_value(&mut self) {