diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md
index fc075908..294d2c1c 100644
--- a/docs/src/configurations/config-file-format.md
+++ b/docs/src/configurations/config-file-format.md
@@ -196,14 +196,14 @@ The width mode for each graph row image.
### `core.search.ignore_case`
-Whether to enable ignore case by default.
+Whether to enable ignore case when the application starts. The option can be toggled while the commit list is displayed.
- type: `boolean`
- default: `false`
### `core.search.fuzzy`
-Whether to enable fuzzy matching by default.
+Whether to enable fuzzy matching when the application starts. The option can be toggled while the commit list is displayed.
- type: `boolean`
- default: `false`
diff --git a/docs/src/keybindings/index.md b/docs/src/keybindings/index.md
index 20ffcb94..d4bcdaa0 100644
--- a/docs/src/keybindings/index.md
+++ b/docs/src/keybindings/index.md
@@ -30,8 +30,8 @@ The default key bindings can be overridden.
| / | Start search | `search` |
| Esc | Cancel search | `cancel` |
| n/N | Go to next/previous search match | `go_to_next` `go_to_previous` |
-| Ctrl-g | Toggle ignore case (if searching) | `ignore_case_toggle` |
-| Ctrl-x | Toggle fuzzy match (if searching) | `fuzzy_toggle` |
+| Ctrl-g | Toggle ignore case | `ignore_case_toggle` |
+| Ctrl-x | Toggle fuzzy match | `fuzzy_toggle` |
| R | Refresh | `refresh` |
| c/C | Copy commit short/full hash | `short_copy` `full_copy` |
| d | Toggle custom user command view | `user_command_1` |
diff --git a/src/app.rs b/src/app.rs
index fcb31cc4..a89636d6 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -33,6 +33,7 @@ enum StatusLine {
#[default]
None,
Input(String, Option, Option),
+ Transient(String),
NotificationInfo(String),
NotificationSuccess(String),
NotificationWarn(String),
@@ -161,7 +162,8 @@ impl App<'_> {
StatusLine::None | StatusLine::Input(_, _, _) => {
// do nothing
}
- StatusLine::NotificationInfo(_)
+ StatusLine::Transient(_)
+ | StatusLine::NotificationInfo(_)
| StatusLine::NotificationSuccess(_)
| StatusLine::NotificationWarn(_) => {
// Clear message and pass key input as is
@@ -275,6 +277,9 @@ impl App<'_> {
AppEvent::UpdateStatusInput(msg, cursor_pos, msg_r) => {
self.update_status_input(msg, cursor_pos, msg_r);
}
+ AppEvent::UpdateStatusTransient(msg) => {
+ self.update_status_transient(msg);
+ }
AppEvent::NotifyInfo(msg) => {
self.info_notification(msg);
}
@@ -348,7 +353,8 @@ impl App<'_> {
let msg_w = console::measure_text_width(msg.as_str());
if let Some(t_msg) = transient_msg {
let t_msg_w = console::measure_text_width(t_msg.as_str());
- let pad_w = area.width as usize - msg_w - t_msg_w - 2 /* pad */;
+ let pad_w =
+ (area.width as usize).saturating_sub(msg_w + t_msg_w + 2 /* pad */);
Line::from(vec![
msg.as_str().fg(self.ctx.color_theme.status_input_fg),
" ".repeat(pad_w).into(),
@@ -360,6 +366,15 @@ impl App<'_> {
Line::raw(msg).fg(self.ctx.color_theme.status_input_fg)
}
}
+ StatusLine::Transient(msg) => {
+ let msg_w = console::measure_text_width(msg.as_str());
+ let pad_w = (area.width as usize).saturating_sub(msg_w + 2 /* pad */);
+ Line::from(vec![
+ " ".repeat(pad_w).into(),
+ msg.as_str()
+ .fg(self.ctx.color_theme.status_input_transient_fg),
+ ])
+ }
StatusLine::NotificationInfo(msg) => {
Line::raw(msg).fg(self.ctx.color_theme.status_info_fg)
}
@@ -683,6 +698,10 @@ impl App<'_> {
self.app_status.status_line = StatusLine::Input(msg, cursor_pos, transient_msg);
}
+ fn update_status_transient(&mut self, msg: String) {
+ self.app_status.status_line = StatusLine::Transient(msg);
+ }
+
fn info_notification(&mut self, msg: String) {
self.app_status.status_line = StatusLine::NotificationInfo(msg);
}
diff --git a/src/event.rs b/src/event.rs
index 80cf90df..89adb010 100644
--- a/src/event.rs
+++ b/src/event.rs
@@ -35,6 +35,7 @@ pub enum AppEvent {
Refresh(RefreshViewContext),
ClearStatusLine,
UpdateStatusInput(String, Option, Option),
+ UpdateStatusTransient(String),
NotifyInfo(String),
NotifySuccess(String),
NotifyWarn(String),
diff --git a/src/view/list.rs b/src/view/list.rs
index 36fd080a..d94282dd 100644
--- a/src/view/list.rs
+++ b/src/view/list.rs
@@ -45,16 +45,16 @@ impl<'a> ListView<'a> {
self.clear_search_query();
}
UserEvent::IgnoreCaseToggle => {
- self.as_mut_list_state().toggle_ignore_case();
- self.update_search_query();
+ let message = self.as_mut_list_state().toggle_ignore_case();
+ self.update_search_status(Some(message));
}
UserEvent::FuzzyToggle => {
- self.as_mut_list_state().toggle_fuzzy();
- self.update_search_query();
+ let message = self.as_mut_list_state().toggle_fuzzy();
+ self.update_search_status(Some(message));
}
_ => {
self.as_mut_list_state().handle_search_input(key);
- self.update_search_query();
+ self.update_search_status(None);
}
}
return;
@@ -131,7 +131,15 @@ impl<'a> ListView<'a> {
}
UserEvent::Search => {
self.as_mut_list_state().start_search();
- self.update_search_query();
+ self.update_search_status(None);
+ }
+ UserEvent::IgnoreCaseToggle => {
+ let message = self.as_mut_list_state().toggle_ignore_case();
+ self.tx.send(AppEvent::UpdateStatusTransient(message));
+ }
+ UserEvent::FuzzyToggle => {
+ let message = self.as_mut_list_state().toggle_fuzzy();
+ self.tx.send(AppEvent::UpdateStatusTransient(message));
}
UserEvent::UserCommand(n) => {
self.tx.send(AppEvent::OpenUserCommand(n));
@@ -207,16 +215,15 @@ impl<'a> ListView<'a> {
self.as_list_state().graph_image_ids_sorted()
}
- fn update_search_query(&self) {
+ fn update_search_status(&self, transient_message: Option) {
if let SearchState::Searching { .. } = self.as_list_state().search_state() {
let list_state = self.as_list_state();
if let Some(query) = list_state.search_query_string() {
let cursor_pos = list_state.search_query_cursor_position();
- let transient_msg = list_state.transient_message_string();
self.tx.send(AppEvent::UpdateStatusInput(
query,
Some(cursor_pos),
- transient_msg,
+ transient_message,
));
}
}
@@ -265,9 +272,11 @@ impl<'a> ListView<'a> {
selected,
height,
scroll_to_top,
+ search_options,
search_context,
} = list_context;
let list_state = self.as_mut_list_state();
+ list_state.restore_search_options(*search_options);
list_state.reset_height(*height);
if *scroll_to_top {
list_state.select_first();
diff --git a/src/view/views.rs b/src/view/views.rs
index 29ada6c2..6e2add65 100644
--- a/src/view/views.rs
+++ b/src/view/views.rs
@@ -10,7 +10,7 @@ use crate::{
detail::DetailView, help::HelpView, list::ListView, refs::RefsView,
user_command::UserCommandView,
},
- widget::commit_list::{CommitListState, SearchRefreshContext},
+ widget::commit_list::{CommitListState, SearchOptions, SearchRefreshContext},
};
#[derive(Debug, Default)]
@@ -193,6 +193,7 @@ pub struct ListRefreshViewContext {
pub selected: usize,
pub height: usize,
pub scroll_to_top: bool,
+ pub search_options: SearchOptions,
pub search_context: Option,
}
@@ -203,12 +204,14 @@ impl From<&CommitListState<'_>> for ListRefreshViewContext {
// If the selected commit is the top one and there is no offset, it means the list is already scrolled to the top.
// In this case, we set scroll_to_top to true to indicate that the view should be scrolled to the top after refresh.
let scroll_to_top = selected == 0 && offset == 0;
+ let search_options = list_state.search_options();
let search_context = list_state.search_refresh_context();
ListRefreshViewContext {
commit_hash,
selected,
height,
scroll_to_top,
+ search_options,
search_context,
}
}
diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs
index 69186b6e..96bd2a1d 100644
--- a/src/widget/commit_list.rs
+++ b/src/widget/commit_list.rs
@@ -50,23 +50,22 @@ pub enum SearchState {
Searching {
start_index: usize,
match_index: usize,
- ignore_case: bool,
- fuzzy: bool,
- transient_message: TransientMessage,
},
Applied {
match_index: usize,
total_match: usize,
- ignore_case: bool,
- fuzzy: bool,
},
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SearchOptions {
+ pub ignore_case: bool,
+ pub fuzzy: bool,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchRefreshContext {
query: String,
- ignore_case: bool,
- fuzzy: bool,
}
impl SearchState {
@@ -79,15 +78,6 @@ impl SearchState {
}
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum TransientMessage {
- None,
- IgnoreCaseOff,
- IgnoreCaseOn,
- FuzzyOff,
- FuzzyOn,
-}
-
#[derive(Debug, Default, Clone)]
struct SearchMatch {
refs: FxHashMap,
@@ -194,6 +184,7 @@ pub struct CommitListState<'a> {
ref_name_to_commit_index_map: FxHashMap<&'a str, usize>,
search_state: SearchState,
+ search_options: SearchOptions,
search_input: Input,
search_matches: Vec,
@@ -201,9 +192,6 @@ pub struct CommitListState<'a> {
offset: usize,
total: usize,
height: usize,
-
- default_ignore_case: bool,
- default_fuzzy: bool,
}
impl<'a> CommitListState<'a> {
@@ -226,14 +214,16 @@ impl<'a> CommitListState<'a> {
head,
ref_name_to_commit_index_map,
search_state: SearchState::Inactive,
+ search_options: SearchOptions {
+ ignore_case: default_ignore_case,
+ fuzzy: default_fuzzy,
+ },
search_input: Input::default(),
search_matches: vec![SearchMatch::default(); total],
selected: 0,
offset: 0,
total,
height: 0,
- default_ignore_case,
- default_fuzzy,
}
}
@@ -476,14 +466,19 @@ impl<'a> CommitListState<'a> {
self.search_state
}
+ pub fn search_options(&self) -> SearchOptions {
+ self.search_options
+ }
+
+ pub fn restore_search_options(&mut self, options: SearchOptions) {
+ self.search_options = options;
+ }
+
pub fn start_search(&mut self) {
if let SearchState::Inactive | SearchState::Applied { .. } = self.search_state {
self.search_state = SearchState::Searching {
start_index: self.current_selected_index(),
match_index: 0,
- ignore_case: self.default_ignore_case,
- fuzzy: self.default_fuzzy,
- transient_message: TransientMessage::None,
};
self.search_input.reset();
self.clear_search_matches();
@@ -491,34 +486,15 @@ impl<'a> CommitListState<'a> {
}
pub fn handle_search_input(&mut self, key: KeyEvent) {
- if let SearchState::Searching {
- transient_message, ..
- } = &mut self.search_state
- {
- *transient_message = TransientMessage::None;
- }
-
- if let SearchState::Searching {
- start_index,
- ignore_case,
- fuzzy,
- ..
- } = self.search_state
- {
+ if let SearchState::Searching { start_index, .. } = self.search_state {
self.search_input.handle_event(&Event::Key(key));
- self.update_search_matches(ignore_case, fuzzy);
+ self.update_search_matches();
self.select_current_or_next_match_index(start_index);
}
}
pub fn apply_search(&mut self) {
- if let SearchState::Searching {
- match_index,
- ignore_case,
- fuzzy,
- ..
- } = self.search_state
- {
+ if let SearchState::Searching { match_index, .. } = self.search_state {
if self.search_input.value().is_empty() {
self.search_state = SearchState::Inactive;
} else {
@@ -526,22 +502,15 @@ impl<'a> CommitListState<'a> {
self.search_state = SearchState::Applied {
match_index,
total_match,
- ignore_case,
- fuzzy,
};
}
}
}
pub fn search_refresh_context(&self) -> Option {
- if let SearchState::Applied {
- ignore_case, fuzzy, ..
- } = self.search_state
- {
+ if let SearchState::Applied { .. } = self.search_state {
Some(SearchRefreshContext {
query: self.search_input.value().into(),
- ignore_case,
- fuzzy,
})
} else {
None
@@ -550,15 +519,13 @@ impl<'a> CommitListState<'a> {
pub fn restore_search(&mut self, context: &SearchRefreshContext) {
self.search_input = Input::new(context.query.clone());
- self.update_search_matches(context.ignore_case, context.fuzzy);
+ self.update_search_matches();
let total_match = self.search_matches.iter().filter(|m| m.matched()).count();
self.search_state = SearchState::Applied {
// The selected commit may not match after refresh; next/previous updates this value.
match_index: 0,
total_match,
- ignore_case: context.ignore_case,
- fuzzy: context.fuzzy,
};
if total_match > 0 {
@@ -578,58 +545,28 @@ impl<'a> CommitListState<'a> {
}
}
- pub fn toggle_ignore_case(&mut self) {
- if let SearchState::Searching {
- ignore_case,
- transient_message,
- ..
- } = &mut self.search_state
- {
- *ignore_case = !*ignore_case;
- *transient_message = if *ignore_case {
- TransientMessage::IgnoreCaseOn
- } else {
- TransientMessage::IgnoreCaseOff
- };
- }
+ pub fn toggle_ignore_case(&mut self) -> String {
+ self.search_options.ignore_case = !self.search_options.ignore_case;
+ let message = if self.search_options.ignore_case {
+ "Ignore case: ON "
+ } else {
+ "Ignore case: OFF"
+ };
- if let SearchState::Searching {
- start_index,
- ignore_case,
- fuzzy,
- ..
- } = self.search_state
- {
- self.update_search_matches(ignore_case, fuzzy);
- self.select_current_or_next_match_index(start_index);
- }
+ self.update_search_after_options_change();
+ message.into()
}
- pub fn toggle_fuzzy(&mut self) {
- if let SearchState::Searching {
- fuzzy,
- transient_message,
- ..
- } = &mut self.search_state
- {
- *fuzzy = !*fuzzy;
- *transient_message = if *fuzzy {
- TransientMessage::FuzzyOn
- } else {
- TransientMessage::FuzzyOff
- };
- }
+ pub fn toggle_fuzzy(&mut self) -> String {
+ self.search_options.fuzzy = !self.search_options.fuzzy;
+ let message = if self.search_options.fuzzy {
+ "Fuzzy match: ON "
+ } else {
+ "Fuzzy match: OFF"
+ };
- if let SearchState::Searching {
- start_index,
- ignore_case,
- fuzzy,
- ..
- } = self.search_state
- {
- self.update_search_matches(ignore_case, fuzzy);
- self.select_current_or_next_match_index(start_index);
- }
+ self.update_search_after_options_change();
+ message.into()
}
pub fn search_query_string(&self) -> Option {
@@ -665,25 +602,12 @@ impl<'a> CommitListState<'a> {
self.search_input.visual_cursor() as u16 + 1 // add 1 for "/"
}
- pub fn transient_message_string(&self) -> Option {
- if let SearchState::Searching {
- transient_message, ..
- } = self.search_state
- {
- match transient_message {
- TransientMessage::None => None,
- TransientMessage::IgnoreCaseOn => Some("Ignore case: ON ".to_string()),
- TransientMessage::IgnoreCaseOff => Some("Ignore case: OFF".to_string()),
- TransientMessage::FuzzyOn => Some("Fuzzy match: ON ".to_string()),
- TransientMessage::FuzzyOff => Some("Fuzzy match: OFF".to_string()),
- }
- } else {
- None
- }
- }
-
- fn update_search_matches(&mut self, ignore_case: bool, fuzzy: bool) {
- let matcher = SearchMatcher::new(self.search_input.value(), ignore_case, fuzzy);
+ fn update_search_matches(&mut self) {
+ let matcher = SearchMatcher::new(
+ self.search_input.value(),
+ self.search_options.ignore_case,
+ self.search_options.fuzzy,
+ );
let mut match_index = 1;
for (i, commit_info) in self.commits.iter().enumerate() {
let m = &mut self.search_matches[i];
@@ -695,6 +619,28 @@ impl<'a> CommitListState<'a> {
}
}
+ fn update_search_after_options_change(&mut self) {
+ match self.search_state {
+ SearchState::Inactive => {}
+ SearchState::Searching { start_index, .. } => {
+ self.update_search_matches();
+ self.select_current_or_next_match_index(start_index);
+ }
+ SearchState::Applied { .. } => {
+ let current_index = self.current_selected_index();
+ self.update_search_matches();
+ let total_match = self.search_matches.iter().filter(|m| m.matched()).count();
+ self.search_state = SearchState::Applied {
+ match_index: 0,
+ total_match,
+ };
+ if total_match > 0 {
+ self.select_current_or_next_match_index(current_index);
+ }
+ }
+ }
+ }
+
fn clear_search_matches(&mut self) {
self.search_matches.iter_mut().for_each(|m| m.clear());
}
@@ -1289,25 +1235,29 @@ mod tests {
#[test]
fn test_restore_search_recalculates_matches_with_applied_options() {
- let context = with_commit_list_state(&["Fix parser", "other"], |state| {
+ let (context, options) = with_commit_list_state(&["Fix parser", "other"], |state| {
input_search_query(state, "fx");
state.toggle_ignore_case();
state.toggle_fuzzy();
state.apply_search();
- state.search_refresh_context().unwrap()
+ (
+ state.search_refresh_context().unwrap(),
+ state.search_options(),
+ )
});
+ assert_eq!(context, SearchRefreshContext { query: "fx".into() });
assert_eq!(
- context,
- SearchRefreshContext {
- query: "fx".into(),
+ options,
+ SearchOptions {
ignore_case: true,
fuzzy: true,
}
);
with_commit_list_state(&["unrelated", "FIX new", "fix parser"], |state| {
+ state.restore_search_options(options);
state.restore_search(&context);
assert_eq!(state.search_refresh_context(), Some(context.clone()));
@@ -1338,6 +1288,25 @@ mod tests {
});
}
+ #[test]
+ fn test_restore_search_options_without_applied_search() {
+ let options = with_commit_list_state(&["FIX"], |state| {
+ state.toggle_ignore_case();
+ state.search_options()
+ });
+
+ with_commit_list_state(&["FIX"], |state| {
+ state.restore_search_options(options);
+ input_search_query(state, "fix");
+ state.apply_search();
+
+ assert_eq!(
+ state.matched_query_string(),
+ Some(("Match 1 of 1 (query: \"fix\")".into(), true))
+ );
+ });
+ }
+
#[test]
fn test_restore_search_keeps_selected_match_position() {
let context = with_commit_list_state(&["fix"], |state| {
@@ -1362,6 +1331,103 @@ mod tests {
});
}
+ #[test]
+ fn test_search_options_are_reused_for_next_search() {
+ with_commit_list_state(&["FIX"], |state| {
+ input_search_query(state, "fix");
+ state.toggle_ignore_case();
+ state.apply_search();
+ state.cancel_search();
+
+ input_search_query(state, "fix");
+ state.apply_search();
+
+ assert_eq!(
+ state.matched_query_string(),
+ Some(("Match 1 of 1 (query: \"fix\")".into(), true))
+ );
+ });
+ }
+
+ #[test]
+ fn test_search_options_can_be_changed_before_search() {
+ with_commit_list_state(&["FIX"], |state| {
+ state.toggle_ignore_case();
+ input_search_query(state, "fix");
+ state.apply_search();
+
+ assert_eq!(
+ state.matched_query_string(),
+ Some(("Match 1 of 1 (query: \"fix\")".into(), true))
+ );
+ });
+ }
+
+ #[test]
+ fn test_search_option_toggle_messages() {
+ with_commit_list_state(&["fix"], |state| {
+ assert_eq!(state.toggle_ignore_case(), "Ignore case: ON ");
+ assert_eq!(state.toggle_ignore_case(), "Ignore case: OFF");
+ assert_eq!(state.toggle_fuzzy(), "Fuzzy match: ON ");
+ assert_eq!(state.toggle_fuzzy(), "Fuzzy match: OFF");
+ });
+ }
+
+ #[test]
+ fn test_applied_search_options_keep_selected_match() {
+ with_commit_list_state(&["FIX", "fix"], |state| {
+ input_search_query(state, "fix");
+ state.apply_search();
+ state.toggle_ignore_case();
+
+ assert_eq!(
+ state.commits[state.current_selected_index()].commit.subject,
+ "fix"
+ );
+ assert_eq!(
+ state.matched_query_string(),
+ Some(("Match 2 of 2 (query: \"fix\")".into(), true))
+ );
+ });
+ }
+
+ #[test]
+ fn test_applied_search_options_select_next_match() {
+ with_commit_list_state(&["FIX", "fix"], |state| {
+ state.toggle_ignore_case();
+ input_search_query(state, "fix");
+ state.apply_search();
+ state.toggle_ignore_case();
+
+ assert_eq!(
+ state.commits[state.current_selected_index()].commit.subject,
+ "fix"
+ );
+ assert_eq!(
+ state.matched_query_string(),
+ Some(("Match 1 of 1 (query: \"fix\")".into(), true))
+ );
+ });
+ }
+
+ #[test]
+ fn test_applied_search_options_keep_selection_when_no_matches() {
+ with_commit_list_state(&["fix", "other"], |state| {
+ state.toggle_fuzzy();
+ input_search_query(state, "fx");
+ state.apply_search();
+ let selected = state.current_selected_index();
+
+ state.toggle_fuzzy();
+
+ assert_eq!(state.current_selected_index(), selected);
+ assert_eq!(
+ state.matched_query_string(),
+ Some(("No matches found (query: \"fx\")".into(), false))
+ );
+ });
+ }
+
#[test]
fn test_calc_cell_widths_all_columns() {
let area_width = 80;