mirror of
https://github.com/ClementTsang/bottom.git
synced 2026-08-18 17:36:42 +00:00
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
This commit is contained in:
@@ -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.
|
||||
|
||||
+34
-165
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-259
@@ -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<usize, Range<usize>>,
|
||||
pub input_field_state: InputFieldState,
|
||||
|
||||
/// The query. TODO: Merge this as one enum.
|
||||
pub query: Option<ProcessQuery>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Span<'_>> {
|
||||
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?
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<usize, Range<usize>>,
|
||||
}
|
||||
|
||||
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<Item = (usize, &str, &Range<usize>)> {
|
||||
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<usize>)> = 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);
|
||||
}
|
||||
}
|
||||
+27
-2
@@ -7,12 +7,19 @@ use std::{
|
||||
marker::PhantomData,
|
||||
};
|
||||
|
||||
use indexmap::IndexMap;
|
||||
|
||||
type IntHasherState<K> = BuildHasherDefault<IntHasher<K>>;
|
||||
|
||||
/// A hash map that directly maps from an integer key to a value.
|
||||
pub type IntHashMap<K, V> = std::collections::HashMap<K, V, BuildHasherDefault<IntHasher<K>>>;
|
||||
pub type IntHashMap<K, V> = std::collections::HashMap<K, V, IntHasherState<K>>;
|
||||
|
||||
/// A hash set that directly uses integer keys.
|
||||
#[allow(dead_code)]
|
||||
pub type IntHashSet<K> = std::collections::HashSet<K, BuildHasherDefault<IntHasher<K>>>;
|
||||
pub type IntHashSet<K> = std::collections::HashSet<K, IntHasherState<K>>;
|
||||
|
||||
/// An [`IndexMap`] wrapper such that it tracks insertion order, but uses integer keys.
|
||||
pub type IntIndexMap<K, V> = IndexMap<K, V, IntHasherState<K>>;
|
||||
|
||||
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::<u32, &str>::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<_>>(), vec![1, 3, 2]);
|
||||
assert_eq!(
|
||||
map.values().cloned().collect::<Vec<_>>(),
|
||||
vec!["one", "three", "two"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user