summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTim Oram <dev@mitmaro.ca>2024-02-17 09:56:13 -0330
committerTim Oram <dev@mitmaro.ca>2024-02-18 19:21:50 -0330
commit578bd87571af87283844ee7857cc7dd5d8c10137 (patch)
tree86fb7d0f76dcf0359c71e7eb2c81724205910d48
parent12235df6a584cf44ec985d06df0cae74dbfc7ebd (diff)
Implement Searchable for list module search
-rw-r--r--src/components/search_bar.rs32
-rw-r--r--src/components/search_bar/options.rs15
-rw-r--r--src/components/search_bar/tests.rs48
-rw-r--r--src/input/event_handler.rs10
-rw-r--r--src/input/standard_event.rs2
-rw-r--r--src/main.rs6
-rw-r--r--src/modules/list.rs154
-rw-r--r--src/modules/list/search.rs817
-rw-r--r--src/modules/list/search/line_match.rs24
-rw-r--r--src/modules/list/search/state.rs235
-rw-r--r--src/modules/list/tests.rs1
-rw-r--r--src/modules/list/tests/activate.rs65
-rw-r--r--src/modules/list/tests/external_editor.rs20
-rw-r--r--src/modules/list/tests/search.rs613
-rw-r--r--src/modules/list/utils.rs46
-rw-r--r--src/search.rs3
-rw-r--r--src/search/status.rs (renamed from src/search/search_state.rs)2
-rw-r--r--src/test_helpers/assertions.rs2
-rw-r--r--src/test_helpers/assertions/assert_rendered_output/patterns.rs2
-rw-r--r--src/test_helpers/assertions/assert_results.rs95
-rw-r--r--src/test_helpers/testers.rs2
-rw-r--r--src/test_helpers/testers/searchable.rs27
-rw-r--r--src/todo_file.rs2
-rw-r--r--src/todo_file/search.rs565
24 files changed, 1700 insertions, 1088 deletions
diff --git a/src/components/search_bar.rs b/src/components/search_bar.rs
index 7b9991d..b91e225 100644
--- a/src/components/search_bar.rs
+++ b/src/components/search_bar.rs
@@ -54,10 +54,23 @@ impl SearchBar {
}
}
- pub(crate) const fn read_event(&self, event: Event) -> Option<Event> {
+ pub(crate) fn read_event(&self, event: Event) -> Option<Event> {
match self.state {
State::Deactivated | State::Searching => None,
- State::Editing => Some(event),
+ State::Editing => {
+ let evt = match event {
+ Event::Key(KeyEvent {
+ code: KeyCode::Enter,
+ modifiers: KeyModifiers::NONE,
+ }) => Event::from(StandardEvent::SearchFinish),
+ Event::Key(KeyEvent {
+ code: KeyCode::Esc,
+ modifiers: KeyModifiers::NONE,
+ }) => Event::from(StandardEvent::SearchCancel),
+ _ => event,
+ };
+ Some(evt)
+ },
}
}
@@ -72,11 +85,7 @@ impl SearchBar {
Event::Standard(StandardEvent::SearchPrevious) => {
SearchBarAction::Previous(String::from(self.editable_line.get_content()))
},
- Event::Standard(StandardEvent::SearchFinish)
- | Event::Key(KeyEvent {
- code: KeyCode::Enter,
- modifiers: KeyModifiers::NONE,
- }) => {
+ Event::Standard(StandardEvent::SearchFinish) => {
self.editable_line.set_read_only(true);
self.state = State::Searching;
SearchBarAction::Start(String::from(self.editable_line.get_content()))
@@ -85,10 +94,7 @@ impl SearchBar {
self.state = State::Deactivated;
SearchBarAction::Cancel
},
- Event::Key(KeyEvent {
- code: KeyCode::Esc,
- modifiers: KeyModifiers::NONE,
- }) => {
+ Event::Standard(StandardEvent::SearchCancel) => {
self.reset();
SearchBarAction::Cancel
},
@@ -121,10 +127,6 @@ impl SearchBar {
self.state == State::Editing
}
- pub(crate) fn is_searching(&self) -> bool {
- self.state == State::Searching
- }
-
pub(crate) fn build_view_line(&self) -> ViewLine {
ViewLine::from(self.editable_line.line_segments())
}
diff --git a/src/components/search_bar/options.rs b/src/components/search_bar/options.rs
deleted file mode 100644
index 3da59f1..0000000
--- a/src/components/search_bar/options.rs
+++ /dev/null
@@ -1,15 +0,0 @@
-use crate::input::Event;
-
-pub(crate) struct Options {
- pub(crate) next_result_event: Vec<Event>,
- pub(crate) previous_result_event: Vec<Event>,
-}
-
-impl Options {
- pub(crate) fn new(next_result_event: Vec<Event>, previous_result_event: Vec<Event>) -> Self {
- Self {
- next_result_event,
- previous_result_event,
- }
- }
-}
diff --git a/src/components/search_bar/tests.rs b/src/components/search_bar/tests.rs
index 382163d..7d2e44c 100644
--- a/src/components/search_bar/tests.rs
+++ b/src/components/search_bar/tests.rs
@@ -87,13 +87,33 @@ fn read_event_searching() {
}
#[test]
-fn read_event_editing() {
+fn read_event_editing_other() {
let mut search_bar = SearchBar::new();
search_bar.state = State::Editing;
assert_some_eq!(search_bar.read_event(Event::from('a')), Event::from('a'));
}
#[test]
+fn read_event_editing_with_enter() {
+ let mut search_bar = SearchBar::new();
+ search_bar.state = State::Editing;
+ assert_some_eq!(
+ search_bar.read_event(Event::from(KeyCode::Enter)),
+ Event::from(StandardEvent::SearchFinish)
+ );
+}
+
+#[test]
+fn read_event_editing_ith_esc() {
+ let mut search_bar = SearchBar::new();
+ search_bar.state = State::Editing;
+ assert_some_eq!(
+ search_bar.read_event(Event::from(KeyCode::Esc)),
+ Event::from(StandardEvent::SearchCancel)
+ );
+}
+
+#[test]
fn handle_event_inactive() {
let mut search_bar = SearchBar::new();
search_bar.state = State::Deactivated;
@@ -137,21 +157,10 @@ fn handle_event_search_finish() {
}
#[test]
-fn handle_event_search_finish_with_enter() {
- let mut search_bar = SearchBar::new();
- search_bar.start_search(Some("foo"));
- let event = Event::from(KeyCode::Enter);
- assert_eq!(
- search_bar.handle_event(event),
- SearchBarAction::Start(String::from("foo"))
- );
-}
-
-#[test]
-fn handle_event_search_cancel_with_esc() {
+fn handle_event_search_cancel_with_search_cancel() {
let mut search_bar = SearchBar::new();
search_bar.start_search(Some("foo"));
- let event = Event::from(KeyCode::Esc);
+ let event = Event::from(StandardEvent::SearchCancel);
assert_eq!(search_bar.handle_event(event), SearchBarAction::Cancel);
}
@@ -245,17 +254,6 @@ fn is_editing() {
}
#[test]
-fn is_searching() {
- let mut search_bar = SearchBar::new();
- search_bar.state = State::Editing;
- assert!(!search_bar.is_searching());
- search_bar.state = State::Deactivated;
- assert!(!search_bar.is_searching());
- search_bar.state = State::Searching;
- assert!(search_bar.is_searching());
-}
-
-#[test]
fn build_view_line() {
assert_rendered_output!(
Options AssertRenderOptions::INCLUDE_STYLE | AssertRenderOptions::BODY_ONLY,
diff --git a/src/input/event_handler.rs b/src/input/event_handler.rs
index 02d163c..0a2621f 100644
--- a/src/input/event_handler.rs
+++ b/src/input/event_handler.rs
@@ -120,6 +120,14 @@ impl EventHandler {
e if key_bindings.search_next.contains(&e) => Some(Event::from(StandardEvent::SearchNext)),
e if key_bindings.search_previous.contains(&e) => Some(Event::from(StandardEvent::SearchPrevious)),
e if key_bindings.search_start.contains(&e) => Some(Event::from(StandardEvent::SearchStart)),
+ Event::Key(KeyEvent {
+ code: KeyCode::Esc,
+ modifiers: KeyModifiers::NONE,
+ }) => Some(Event::from(StandardEvent::SearchCancel)),
+ Event::Key(KeyEvent {
+ code: KeyCode::Enter,
+ modifiers: KeyModifiers::NONE,
+ }) => Some(Event::from(StandardEvent::SearchFinish)),
_ => None,
}
}
@@ -256,6 +264,8 @@ mod tests {
#[case::search_next(Event::from('n'), Event::from(StandardEvent::SearchNext))]
#[case::search_previous(Event::from('N'), Event::from(StandardEvent::SearchPrevious))]
#[case::search_start(Event::from('/'), Event::from(StandardEvent::SearchStart))]
+ #[case::esc(Event::from(KeyCode::Esc), Event::from(StandardEvent::SearchCancel))]
+ #[case::enter(Event::from(KeyCode::Enter), Event::from(StandardEvent::SearchFinish))]
#[case::other(Event::from('a'), Event::from(KeyCode::Null))]
fn search_inputs(#[case] event: Event, #[case] expected: Event) {
let event_handler = EventHandler::new(create_test_keybindings());
diff --git a/src/input/standard_event.rs b/src/input/standard_event.rs
index ddbec51..69283a5 100644
--- a/src/input/standard_event.rs
+++ b/src/input/standard_event.rs
@@ -34,6 +34,8 @@ pub(crate) enum StandardEvent {
SearchNext,
/// Previous search result meta event.
SearchPrevious,
+ /// Cancel search mode meta event.
+ SearchCancel,
/// Finish search mode meta event.
SearchFinish,
/// The abort meta event.
diff --git a/src/main.rs b/src/main.rs
index 60e400a..9d536c1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,14 +2,13 @@
#![cfg_attr(allow_unknown_lints, allow(unknown_lints))]
#![cfg_attr(allow_unknown_lints, allow(renamed_and_removed_lints))]
// enable all rustc's built-in lints
-#![deny(
+#![warn(
future_incompatible,
nonstandard_style,
rust_2018_compatibility,
rust_2018_idioms,
rust_2021_compatibility,
- unused,
- warnings
+ unused
)]
// rustc's additional allowed by default lints
#![deny(
@@ -112,6 +111,7 @@
clippy::panic,
clippy::shadow_reuse,
clippy::shadow_unrelated,
+ clippy::struct_field_names,
clippy::undocumented_unsafe_blocks,
clippy::unimplemented,
clippy::unreachable
diff --git a/src/modules/list.rs b/src/modules/list.rs
index ea397ad..8f106f1 100644
--- a/src/modules/list.rs
+++ b/src/modules/list.rs
@@ -1,3 +1,4 @@
+mod search;
#[cfg(all(unix, test))]
mod tests;
mod utils;
@@ -5,20 +6,23 @@ mod utils;
use std::{cmp::min, sync::Arc};
use captur::capture;
-use if_chain::if_chain;
use parking_lot::Mutex;
-use self::utils::{
- get_list_normal_mode_help_lines,
- get_list_visual_mode_help_lines,
- get_todo_line_segments,
- TodoLineSegmentsOptions,
+use self::{
+ search::Search,
+ utils::{
+ get_list_normal_mode_help_lines,
+ get_list_visual_mode_help_lines,
+ get_todo_line_segments,
+ TodoLineSegmentsOptions,
+ },
};
use crate::{
components::{
edit::Edit,
help::Help,
search_bar::{SearchBar, SearchBarAction},
+ spin_indicator::SpinIndicator,
},
config::Config,
display::DisplayColor,
@@ -26,8 +30,9 @@ use crate::{
module::{ExitStatus, Module, State},
modules::list::utils::get_line_action_maximum_width,
process::Results,
+ search::Searchable,
select,
- todo_file::{Action, EditContext, Line, Search, TodoFile},
+ todo_file::{Action, EditContext, Line, TodoFile},
view::{LineSegment, RenderContext, ViewData, ViewLine},
};
@@ -60,6 +65,7 @@ pub(crate) struct List {
search: Search,
search_bar: SearchBar,
selected_line_action: Option<Action>,
+ spin_indicator: SpinIndicator,
state: ListState,
todo_file: Arc<Mutex<TodoFile>>,
view_data: ViewData,
@@ -70,7 +76,12 @@ pub(crate) struct List {
impl Module for List {
fn activate(&mut self, _: State) -> Results {
self.selected_line_action = self.todo_file.lock().get_selected_line().map(|line| *line.get_action());
- Results::new()
+ let searchable: Box<dyn Searchable> = Box::new(self.search.clone());
+ let mut results = Results::from(searchable);
+ if let Some(term) = self.search_bar.search_value() {
+ results.search_term(term);
+ }
+ results
}
fn build_view_data(&mut self, context: &RenderContext) -> &ViewData {
@@ -140,14 +151,17 @@ impl List {
updater.set_show_help(true);
});
+ let search = Search::new(Arc::clone(&todo_file));
+
Self {
auto_select_next: config.auto_select_next,
edit: Edit::new(),
height: 0,
normal_mode_help: Help::new_from_keybindings(&get_list_normal_mode_help_lines(&config.key_bindings)),
- search: Search::new(),
+ search,
search_bar: SearchBar::new(),
selected_line_action: None,
+ spin_indicator: SpinIndicator::new(),
state: ListState::Normal,
todo_file,
view_data,
@@ -301,7 +315,7 @@ impl List {
#[allow(clippy::unused_self)]
fn open_in_editor(&mut self, results: &mut Results) {
- self.search_bar.reset();
+ results.search_cancel();
results.state(State::ExternalEditor);
}
@@ -317,7 +331,20 @@ impl List {
}
fn search_start(&mut self) {
- self.search_bar.start_search(None);
+ self.search_bar.start_search(Some(""));
+ }
+
+ fn search_update(&mut self) {
+ self.spin_indicator.refresh();
+ // select the first match, if it is available and has not been previously selected
+ if let Some(selected) = self.search.current_match() {
+ _ = self.update_cursor(CursorUpdate::Set(selected.index()));
+ }
+ else if !self.search_bar.is_editing() {
+ if let Some(selected) = self.search.next() {
+ _ = self.update_cursor(CursorUpdate::Set(selected));
+ }
+ }
}
fn help(&mut self) {
@@ -388,6 +415,7 @@ impl List {
if let Some(selected_line) = todo_file.get_selected_line() {
if selected_line.is_editable() {
self.state = ListState::Edit;
+ self.edit.reset();
self.edit.set_content(selected_line.get_content());
self.edit.set_label(format!("{} ", selected_line.get_action()).as_str());
}
@@ -405,10 +433,12 @@ impl List {
let selected_index = todo_file.get_selected_line_index();
let visual_index = self.visual_index_start.unwrap_or(selected_index);
let search_view_line = self.search_bar.is_editing().then(|| self.search_bar.build_view_line());
- let search_results_total = self.search_bar.is_searching().then(|| self.search.total_results());
- let search_results_current = self.search.current_result_selected();
+ let search_results_total = self.search.total_results();
+ let search_results_current = self.search.current_result_selected().unwrap_or(0);
let search_term = self.search_bar.search_value();
let search_index = self.search.current_match();
+ let search_active = self.search.is_active();
+ let spin_indicator = self.spin_indicator.indicator();
self.view_data.update_view_data(|updater| {
capture!(todo_file);
@@ -422,6 +452,7 @@ impl List {
else {
let maximum_action_width = get_line_action_maximum_width(&todo_file);
for (index, line) in todo_file.lines_iter().enumerate() {
+ let search_match = self.search.match_at_index(index);
let selected_line = is_visual_mode
&& ((visual_index <= selected_index && index >= visual_index && index <= selected_index)
|| (visual_index > selected_index && index >= selected_index && index <= visual_index));
@@ -435,11 +466,17 @@ impl List {
if context.is_full_width() {
todo_line_segment_options.insert(TodoLineSegmentsOptions::FULL_WIDTH);
}
- if search_index.map_or(false, |v| v == index) {
+ if search_index.map_or(false, |v| v.index() == index) {
todo_line_segment_options.insert(TodoLineSegmentsOptions::SEARCH_LINE);
}
let mut view_line = ViewLine::new_with_pinned_segments(
- get_todo_line_segments(line, search_term, todo_line_segment_options, maximum_action_width),
+ get_todo_line_segments(
+ line,
+ search_term,
+ search_match,
+ todo_line_segment_options,
+ maximum_action_width,
+ ),
if line.has_reference() { 2 } else { 3 },
)
.set_selected(selected_index == index || selected_line);
@@ -456,16 +493,17 @@ impl List {
else if let Some(s_term) = search_term {
let mut search_line_segments = vec![];
search_line_segments.push(LineSegment::new(format!("[{s_term}]: ").as_str()));
- if_chain! {
- if let Some(s_total) = search_results_total;
- if let Some(s_index) = search_results_current;
- if s_total != 0;
- then {
- search_line_segments.push(LineSegment::new(format!("{}/{s_total}", s_index + 1).as_str()));
- }
- else {
- search_line_segments.push(LineSegment::new("No Results"));
- }
+ if search_results_total == 0 && !search_active {
+ search_line_segments.push(LineSegment::new("No Results"));
+ }
+ else {
+ search_line_segments.push(LineSegment::new(
+ format!("{}/{search_results_total}", search_results_current + 1).as_str(),
+ ));
+ }
+
+ if search_active {
+ search_line_segments.push(LineSegment::new(format!(" Searching [{spin_indicator}]").as_str()));
}
updater.push_trailing_line(ViewLine::from(search_line_segments));
}
@@ -567,34 +605,48 @@ impl List {
}
fn handle_search_input(&mut self, event: Event) -> Option<Results> {
- if self.search_bar.is_active() {
- let todo_file = self.todo_file.lock();
- match self.search_bar.handle_event(event) {
- SearchBarAction::Start(term) => {
- if term.is_empty() {
- self.search.cancel();
- self.search_bar.reset();
- }
- else {
- self.search.next(&todo_file, term.as_str());
- }
- },
- SearchBarAction::Next(term) => self.search.next(&todo_file, term.as_str()),
- SearchBarAction::Previous(term) => self.search.previous(&todo_file, term.as_str()),
- SearchBarAction::Cancel => {
- self.search.cancel();
- return Some(Results::from(event));
- },
- SearchBarAction::None | SearchBarAction::Update(_) => return None,
- }
- drop(todo_file);
+ if !self.search_bar.is_active() {
+ return None;
+ }
- if let Some(selected) = self.search.current_match() {
- _ = self.update_cursor(CursorUpdate::Set(selected));
- }
- return Some(Results::from(event));
+ let mut results = Results::from(event);
+ let todo_file = self.todo_file.lock();
+ match self.search_bar.handle_event(event) {
+ SearchBarAction::Update(term) => {
+ if term.is_empty() {
+ results.search_cancel();
+ }
+ else {
+ results.search_term(term.as_str());
+ }
+ },
+ SearchBarAction::Start(term) => {
+ if term.is_empty() {
+ results.search_cancel();
+ self.search_bar.reset();
+ }
+ else {
+ results.search_term(term.as_str());
+ }
+ },
+ SearchBarAction::Next(term) => {
+ results.search_term(term.as_str());
+ _ = self.search.next();
+ },
+ SearchBarAction::Previous(term) => {
+ results.search_term(term.as_str());
+ _ = self.search.previous();
+ },
+ SearchBarAction::Cancel => {
+ results.search_cancel();
+ return Some(results);
+ },
+ SearchBarAction::None => return None,
}
- None
+ drop(todo_file);
+
+ self.search_update();
+ Some(results)
}
#[allow(clippy::integer_division)]
diff --git a/src/modules/list/search.rs b/src/modules/list/search.rs
new file mode 100644
index 0000000..fd0e793
--- /dev/null
+++ b/src/modules/list/search.rs
@@ -0,0 +1,817 @@
+mod line_match;
+mod state;
+
+use std::{
+ sync::{
+ atomic::{AtomicUsize, Ordering},
+ Arc,
+ },
+ time::Duration,
+};
+
+use parking_lot::{Mutex, RwLock};
+
+pub(crate) use self::{line_match::LineMatch, state::State};
+use crate::{
+ search::{Interrupter, SearchResult, Searchable, Status},
+ todo_file::{Action, TodoFile},
+};
+
+const LOCK_DURATION: Duration = Duration::from_millis(100);
+
+#[derive(Clone, Debug)]
+pub(crate) struct Search {
+ cursor: Arc<AtomicUsize>,
+ state: Arc<RwLock<State>>,
+ todo_file: Arc<Mutex<TodoFile>>,
+}
+
+impl Searchable for Search {
+ fn reset(&mut self) {
+ self.state.write().reset();
+ }
+
+ fn search(&mut self, interrupter: Interrupter, term: &str) -> SearchResult {
+ let Some(todo_file) = self.todo_file.try_lock_for(LOCK_DURATION)
+ else {
+ return SearchResult::None;
+ };
+ let Some(mut state) = self.state.try_write_for(LOCK_DURATION)
+ else {
+ return SearchResult::None;
+ };
+ if state.try_invalidate_search(todo_file.version(), term) {
+ self.cursor.store(0, Ordering::Release);
+ }
+ let mut has_matches = false;
+ let mut complete = false;
+
+ state.set_status(Status::Active);
+ let mut cursor = self.cursor.load(Ordering::Acquire);
+ while interrupter.should_continue() {
+ let Some(line) = todo_file.get_line(cursor)
+ else {
+ complete = true;
+ break;
+ };
+
+ let action = *line.get_action();
+
+ let has_hash_match = match action {
+ Action::Break | Action::Noop | Action::Label | Action::Reset | Action::Merge | Action::Exec => false,
+ Action::Drop
+ | Action::Edit
+ | Action::Fixup
+ | Action::Pick
+ | Action::Reword
+ | Action::Squash
+ | Action::UpdateRef => line.get_hash().starts_with(term),
+ };
+ let has_content_match = match action {
+ Action::Break | Action::Noop => false,
+ Action::Drop
+ | Action::Edit
+ | Action::Fixup
+ | Action::Pick
+ | Action::Reword
+ | Action::Squash
+ | Action::UpdateRef
+ | Action::Label
+ | Action::Reset
+ | Action::Merge
+ | Action::Exec => line.get_content().contains(term),
+ };
+
+ has_matches = state.push_match(LineMatch::new(cursor, has_hash_match, has_content_match)) || has_matches;
+
+ cursor += 1;
+ }
+
+ self.cursor.store(cursor, Ordering::Release);
+
+ if has_matches {
+ SearchResult::Updated
+ }
+ else if complete {
+ state.set_status(Status::Complete);
+ SearchResult::Complete
+ }
+ else {
+ SearchResult::None
+ }
+ }
+}
+
+impl Search {
+ /// Create a new instance
+ #[inline]
+ #[must_use]
+ pub(crate) fn new(todo_file: Arc<Mutex<TodoFile>>) -> Self {
+ Self {
+ cursor: Arc::new(AtomicUsize::new(0)),
+ state: Arc::new(RwLock::new(State::new())),
+ todo_file,
+ }
+ }
+
+ /// Select the next search result
+ #[inline]
+ #[allow(clippy::missing_panics_doc)]
+ pub(crate) fn next(&mut self) -> Option<usize> {
+ let mut state = self.state.write();
+
+ if state.matches().is_empty() {
+ return None;
+ }
+
+ let new_selected = if let Some(mut current) = state.selected() {
+ current += 1;
+ if current >= state.number_matches() { 0 } else { current }
+ }
+ else {
+ // select the line after the hint that matches
+ let mut index_match = 0;
+ for (i, v) in state.matches().iter().copied().enumerate() {
+ if v.index() >= state.match_start_hint() {
+ index_match = i;
+ break;
+ }
+ }
+ index_match
+ };
+ state.set_selected(new_selected);
+
+ let new_match_hint = state.match_value(new_selected).map_or(0, |s| s.index());
+ state.set_match_start_hint(new_match_hint);
+ Some(new_match_hint)
+ }
+
+ /// Select the previous search result
+ #[inline]
+ #[allow(clippy::missing_panics_doc)]
+ pub(crate) fn previous(&mut self) -> Option<usize> {
+ let mut state = self.state.write();
+ if state.matches().is_empty() {
+ return None;
+ }
+
+ let new_selected = if let Some(current) = state.selected() {
+ if current == 0 {
+ state.number_matches().saturating_sub(1)
+ }
+ else {
+ current.saturating_sub(1)
+ }
+ }
+ else {
+ // select the line previous to hint that matches
+ let mut index_match = state.number_matches().saturating_sub(1);
+ for (i, v) in state.matches().iter().copied().enumerate().rev() {
+ if v.index() <= state.match_start_hint() {
+ index_match = i;
+ break;
+ }
+ }
+ index_match
+ };
+ state.set_selected(new_selected);
+
+ let new_match_hint = state.match_value(new_selected).map_or(0, |s| s.index());
+ state.set_match_start_hint(new_match_hint);
+ Some(new_match_hint)
+ }
+
+ /// Set a hint for which result to select first during search
+ #[inline]
+ pub(crate) fn set_search_start_hint(&mut self, hint: usize) {
+ self.state.write().set_match_start_hint(hint);
+ }
+
+ /// Get the index of the current selected result, if there is one
+ #[inline]
+ #[must_use]
+ pub(crate) fn current_match(&self) -> Option<LineMatch> {
+ let state = self.state.read();
+ let selected = state.selected()?;
+ state.match_value(selected)
+ }
+
+ /// Get the index of the current selected result, if there is one
+ #[inline]
+ #[must_use]
+ pub(crate) fn match_at_index(&self, index: usize) -> Option<LineMatch> {
+ self.state.read().match_value_for_line(index)
+ }
+
+ /// Get the selected result number, if there is one
+ #[inline]
+ #[must_use]
+ pub(crate) fn current_result_selected(&self) -> Option<usize> {
+ self.state.read().selected()
+ }
+
+ /// Get the total number of results
+ #[inline]
+ #[must_use]
+ pub(crate) fn total_results(&self) -> usize {
+ self.state.read().number_matches()
+ }
+
+ /// Is search active
+ #[inline]
+ #[must_use]
+ pub(crate) fn is_active(&self) -> bool {
+ self.state.read().status() == Status::Active
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use claims::{assert_none, assert_some_eq};
+ use rstest::rstest;
+
+ use super::*;
+ use crate::test_helpers::{testers, with_todo_file};
+
+ pub(crate) fn create_search(todo_file: TodoFile) -> Search {
+ Search::new(Arc::new(Mutex::new(todo_file)))
+ }
+
+ pub(crate) fn create_and_run_search(todo_file: TodoFile, term: &str, result: SearchResult) -> Search {
+ let search = Search::new(Arc::new(Mutex::new(todo_file)));
+ assert_eq!(testers::SearchableRunner::new(&search).run_search(term), result);
+ search
+ }
+
+ #[test]
+ fn reset() {
+ with_todo_file(&[], |context| {
+ let (_todo_file_path, todo_file) = context.to_owned();
+ let mut search = create_search(todo_file);
+ search.state.write().set_status(Status::Active);
+ search.reset();
+ assert!(!search.is_active());
+ });
+ }
+
+ #[test]
+ fn search_empty_rebase_file() {
+ with_todo_file(&[], |context| {
+ let (_todo_file_path, todo_file) = context.to_owned();
+ let mut search = create_and_run_search(todo_file, "foo", SearchResult::Complete);
+ assert_eq!(search.total_results(), 0);
+ });
+ }
+
+ #[test]
+ fn search_with_one_line_no_match() {
+ with_todo_file(&["pick abcdef bar"], |context| {
+ let (_todo_file_path, todo_file) = context.to_owned();
+ let search = create_and_run_search(todo_file, "foo", SearchResult::Complete);
+ assert_eq!(search.total_results(), 0);
+ });
+ }
+
+ #[test]
+ fn search_with_incomplete() {
+ with_todo_file(&["pick abcdef bar"; 10], |context| {
+ let (_todo_file_path, todo_file) = context.to_owned();
+ let search = create_search(todo_file);
+ assert_eq!(
+ testers::SearchableRunner::new(&search).run_search_with_time("foo", 0),
+ SearchResult::None
+ );
+ assert_eq!(search.total_results(), 0);
+ });
+ }
+
+ #[test]
+ fn search_with_one_line_match() {
+ with_todo_file(&["pick abcdef foo"], |context| {
+ let (_todo_file_path, todo_file) = context.to_owned();
+ let search = create_and_run_search(todo_file, "foo", SearchResult::Updated);
+ assert_eq!(search.total_results(), 1);
+ assert_some_eq!(search.match_at_index(0), LineMatch::new(0, false, true));
+ });
+ }
+
+ #[test]
+ fn search_standard_action_hash() {
+ with_todo_file(
+ &[
+ "pick aaaaa no match",
+ "drop abcdef foo",
+ "edit abcdef foo",
+ "fixup abcdef foo",
+ "pick abcdef foo",
+ "reword abcdef foo",
+ "squash abcdef foo",
+ ],
+ |context| {
+ let (_todo_file_path, todo_file) = context.to_owned();
+ let search = create_and_run_search(todo_file, "abcd", SearchResult::Updated);
+ assert_eq!(search.total_results(), 6);
+ assert_none!(search.match_at_index(0));
+ assert_some_eq!(search.match_at_index(1), LineMatch::new(1, true, false));
+ assert_some_eq!(search.match_at_index(2), LineMatch::new(2, true, false));
+ assert_some_eq!(search.match_at_index(3), LineMatch::new(3, true, false));
+ assert_some_eq!(search.match_at_index(4), LineMatch::new(4, true, false));
+ assert_some_eq!(search.match_at_index(5), LineMatch::new(5, true, false));
+ },
+ );
+ }
+
+ #[test]
+ fn search_content() {
+ with_todo_file(
+ &[
+ "pick abcdef no match",
+ "drop abcdef foobar",
+ "edit abcdef foobar",
+ "fixup abcdef foobar",
+ "pick abcdef foobar",
+ "reword abcdef foobar",
+ "squash abcdef foobar",
+ "label foobar",
+ "reset foobar",
+ "merge foobar",
+ "exec foobar",
+ "update-ref foobar",
+ ],
+ |context| {
+ let (_todo_file_path, todo_file) = context.to_owned();
+ let search = create_and_run_search(todo_file, "ooba", SearchResult::Updated);
+ assert_eq!(search.total_results(), 11);
+ assert_none!(search.match_at_index(0));
+ assert_some_eq!(search.match_at_index(1), LineMatch::new(1, false, true));
+ assert_some_eq!(search.match_at_index(2), LineMatch::new(2, false, true));
+ assert_some_eq!(search.match_at_index(3), LineMatch::new(3, false, true));
+ assert_some_eq!(search.match_at_index(4), LineMatch::new(4, false, true));
+ assert_some_eq!(search.match_at_index(5), LineMatch::new(5, false, true));
+ assert_some_eq!(search.match_at_index(6), LineMatch::new(6, false, true));
+ assert_some_eq!(search.match_at_index(7), LineMatch::new(7, false, true));
+ assert_some_eq!(search.match_at_index(8), LineMatch::new(8, false, true));
+ assert_some_eq!(search.match_at_index(9), LineMatch::new(9, false, true));
+ assert_some_eq!(search.match_at_index(10), LineMatch::new(10, false, true));
+ },
+ );
+ }
+
+ #[test]
+ fn search_standard_action_hash_starts_only() {
+ with_todo_file(&["pick abcdef foobar"], |context| {
+ let (_todo_file_path, todo_file) = context.to_owned();
+ let search = create_and_run_search(todo_file, "def", SearchResult::Complete);
+ assert_eq!(search.total_results(), 0);
+ });
+ }
+
+ #[rstest]
+ #[case::pick("noop")]
+ #[case::pick("break")]
+ #[case::pick("pick")]
+ #[case::drop("drop")]