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;