Skip to content

Commit 1b8ebca

Browse files
feat(search): add smart case search support
1 parent 29b73aa commit 1b8ebca

10 files changed

Lines changed: 210 additions & 35 deletions

File tree

src/core/commands.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ pub enum Command {
6666
SetRunNoOverflow(bool),
6767
#[cfg(feature = "search")]
6868
IncrementalSearchCondition(Box<dyn Fn(&SearchOpts) -> bool + Send + Sync + 'static>),
69+
#[cfg(feature = "search")]
70+
SetSmartCase(bool),
6971

7072
Io(IoCommand),
7173
}
@@ -92,6 +94,8 @@ impl PartialEq for Command {
9294
(Self::RemoveHook(h1, id1), Self::RemoveHook(h2, id2)) => h1 == h2 && id1 == id2,
9395
#[cfg(feature = "search")]
9496
(Self::IncrementalSearchCondition(_), Self::IncrementalSearchCondition(_)) => true,
97+
#[cfg(feature = "search")]
98+
(Self::SetSmartCase(s1), Self::SetSmartCase(s2)) => s1 == s2,
9599
(Self::Io(a), Self::Io(b)) => a == b,
96100
_ => false,
97101
}
@@ -114,6 +118,8 @@ impl Debug for Command {
114118
Self::ShowPrompt(show) => write!(f, "ShowPrompt({show:?})"),
115119
#[cfg(feature = "search")]
116120
Self::IncrementalSearchCondition(_) => write!(f, "IncrementalSearchCondition"),
121+
#[cfg(feature = "search")]
122+
Self::SetSmartCase(sc) => write!(f, "SetSmartCase({sc:?})"),
117123
Self::AddExitCallback(_) => write!(f, "AddExitCallback"),
118124
Self::AddHook(h, id, _) => write!(f, "AddHook({h:?}, {id})"),
119125
Self::RemoveHook(h, id) => write!(f, "RemoveHook({h:?}, {id})"),

src/core/ev_handler.rs

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,18 @@ pub fn handle_event(
196196
command_queue.push_back(Command::Io(IoCommand::FetchSearchQuery));
197197
}
198198
#[cfg(feature = "search")]
199+
Command::UserInput(InputEvent::ToggleSmartCase) => {
200+
p.search_state.smart_case = !p.search_state.smart_case;
201+
if let Some(ref term) = p.search_state.search_term {
202+
let pat = term.as_str();
203+
p.search_state.search_term = search::compile_regex(pat, p.search_state.smart_case);
204+
p.reformat_display();
205+
command_queue.push_back(Command::Io(IoCommand::RedrawDisplay));
206+
}
207+
p.format_prompt();
208+
command_queue.push_back(Command::Io(IoCommand::RedrawPrompt));
209+
}
210+
#[cfg(feature = "search")]
199211
Command::UserInput(InputEvent::NextMatch | InputEvent::MoveToNextMatch(1))
200212
if p.search_state.search_term.is_some() =>
201213
{
@@ -378,6 +390,8 @@ pub fn handle_event(
378390
Command::SetRunNoOverflow(val) => p.run_no_overflow = val,
379391
#[cfg(feature = "search")]
380392
Command::IncrementalSearchCondition(cb) => p.search_state.incremental_search_condition = cb,
393+
#[cfg(feature = "search")]
394+
Command::SetSmartCase(sc) => p.search_state.smart_case = sc,
381395
Command::SetInputClassifier(clf) => p.input_classifier = clf,
382396
#[cfg(feature = "clipboard")]
383397
Command::SetClipboardHandler(handler) => p.clipboard_handler = Some(handler),
@@ -469,12 +483,14 @@ pub fn handle_io_command(
469483
drop(active);
470484
cvar.notify_one();
471485

486+
p.search_state.smart_case = search_result.smart_case;
472487
// If we only have compiled regex cached, use that otherwise compile the original
473488
// string query if its not empty
474489
p.search_state.search_term = if search_result.compiled_regex.is_some() {
475490
search_result.compiled_regex
476491
} else if !search_result.string.is_empty() {
477-
let compiled_regex = regex::Regex::new(&search_result.string).ok();
492+
let compiled_regex =
493+
search::compile_regex(&search_result.string, p.search_state.smart_case);
478494
if compiled_regex.is_none() {
479495
command_queue.push_back(Command::SendMessage(
480496
"Invalid regular expression. Press Enter".to_string(),
@@ -636,7 +652,7 @@ mod tests {
636652
&is_exited,
637653
);
638654
assert!(ps.help_state.is_none());
639-
assert_eq!(is_exited.load(std::sync::atomic::Ordering::SeqCst), false);
655+
assert!(!is_exited.load(std::sync::atomic::Ordering::SeqCst));
640656
assert_eq!(ps.screen.orig_text, "original text\n");
641657
}
642658

@@ -841,6 +857,7 @@ mod tests {
841857

842858
#[test]
843859
#[cfg(feature = "search")]
860+
#[allow(clippy::trivial_regex)]
844861
fn search_navigation_with_no_matches_does_not_panic() {
845862
let mut ps = PagerState::new().unwrap();
846863
ps.search_state.search_term = Some(regex::Regex::new("nonexistent").unwrap());
@@ -849,15 +866,15 @@ mod tests {
849866

850867
// NextMatch with empty search_idx should not panic
851868
handle_event(
852-
Command::UserInput(InputEvent::NextMatch),
869+
Command::UserInput(InputEvent::MoveToNextMatch(1)),
853870
&mut ps,
854871
&mut command_queue,
855872
&Arc::new(AtomicBool::new(false)),
856873
);
857874

858875
// PrevMatch with empty search_idx should not panic
859876
handle_event(
860-
Command::UserInput(InputEvent::PrevMatch),
877+
Command::UserInput(InputEvent::MoveToPrevMatch(1)),
861878
&mut ps,
862879
&mut command_queue,
863880
&Arc::new(AtomicBool::new(false)),
@@ -879,4 +896,39 @@ mod tests {
879896
&Arc::new(AtomicBool::new(false)),
880897
);
881898
}
899+
900+
#[test]
901+
#[cfg(feature = "search")]
902+
fn test_toggle_and_set_smart_case() {
903+
let mut ps = PagerState::new().unwrap();
904+
assert!(!ps.search_state.smart_case);
905+
let mut command_queue = CommandQueue::new_zero();
906+
907+
// Toggle via UserInput
908+
handle_event(
909+
Command::UserInput(InputEvent::ToggleSmartCase),
910+
&mut ps,
911+
&mut command_queue,
912+
&Arc::new(AtomicBool::new(false)),
913+
);
914+
assert!(ps.search_state.smart_case);
915+
916+
// Toggle again
917+
handle_event(
918+
Command::UserInput(InputEvent::ToggleSmartCase),
919+
&mut ps,
920+
&mut command_queue,
921+
&Arc::new(AtomicBool::new(false)),
922+
);
923+
assert!(!ps.search_state.smart_case);
924+
925+
// Explicit set
926+
handle_event(
927+
Command::SetSmartCase(true),
928+
&mut ps,
929+
&mut command_queue,
930+
&Arc::new(AtomicBool::new(false)),
931+
);
932+
assert!(ps.search_state.smart_case);
933+
}
882934
}

src/core/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ impl CommandQueue {
3636
///
3737
/// This is useful when we have to pass this type to [`handle_event`](ev_handler::handle_event)
3838
/// but it is sure that this won't be used.
39-
pub fn new_zero() -> Self {
40-
Self(VecDeque::with_capacity(0))
39+
pub const fn new_zero() -> Self {
40+
Self(VecDeque::new())
4141
}
4242
/// Returns true if the queue is empty.
4343
pub fn is_empty(&self) -> bool {

src/help.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
//! Help text and related definitions for the pager.
22
33
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4+
use std::fmt::Write as _;
45

56
/// Format a [`KeyEvent`] into a human-readable representation (e.g. `"Ctrl-c"`, `"Alt-h"`).
7+
#[must_use]
68
pub fn format_key(ke: &KeyEvent) -> String {
79
let mut s = String::new();
810
if ke.modifiers.contains(KeyModifiers::CONTROL) {
@@ -38,7 +40,9 @@ pub fn format_key(ke: &KeyEvent) -> String {
3840
KeyCode::End => s.push_str("End"),
3941
KeyCode::Delete => s.push_str("Delete"),
4042
KeyCode::Insert => s.push_str("Insert"),
41-
KeyCode::F(n) => s.push_str(&format!("F{n}")),
43+
KeyCode::F(n) => {
44+
let _ = write!(s, "F{n}");
45+
}
4246
KeyCode::Null => s.push_str("Null"),
4347
_ => s.push_str("Unknown"),
4448
}
@@ -48,6 +52,7 @@ pub fn format_key(ke: &KeyEvent) -> String {
4852
/// Format dynamic help table from key event entries and their descriptions.
4953
///
5054
/// Empty descriptions are omitted.
55+
#[must_use]
5156
pub fn format_help_table_from_entries<'a, I>(entries: I) -> String
5257
where
5358
I: IntoIterator<Item = (&'a KeyEvent, &'a str)>,
@@ -79,10 +84,9 @@ where
7984

8085
for (desc, keys) in groups {
8186
let keys_str = keys.join(", ");
82-
out.push_str(&format!(" {:<30} {}\n", keys_str, desc));
87+
let _ = writeln!(out, " {keys_str:<30} {desc}");
8388
}
8489

85-
out.push_str("\n -- Press q, Enter, or Alt-h to return to pager --\n");
8690
out
8791
}
8892

src/input/hashed_event_register.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,7 @@ where
126126

127127
fn format_help(&self) -> Option<String> {
128128
let h = self.format_help();
129-
if h.is_empty() {
130-
None
131-
} else {
132-
Some(h)
133-
}
129+
if h.is_empty() { None } else { Some(h) }
134130
}
135131
}
136132

@@ -142,7 +138,7 @@ where
142138
S: BuildHasher,
143139
{
144140
/// Create a new `HashedEventRegister` with the Hasher `s`
145-
pub fn new(s: S) -> Self {
141+
pub const fn new(s: S) -> Self {
146142
Self(HashMap::with_hasher(s))
147143
}
148144

@@ -305,6 +301,9 @@ where
305301
}
306302

307303
/// Add all elements of `keys` as key bindings with a description that minus should respond to with the callback `cb`, with conflict checking.
304+
///
305+
/// # Panics
306+
/// Panics if a key already exists and `remap` is `false`.
308307
pub fn add_described_key_events_checked(
309308
&mut self,
310309
keys: &[&str],

src/input/mod.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,9 @@ pub enum InputEvent {
265265
/// This is similar to [`Pager::follow_output`](crate::pager::Pager::follow_output) except that
266266
/// this is used to control it from the user's side.
267267
FollowOutput(bool),
268+
#[cfg(feature = "search")]
269+
/// Toggle smart case searching mode.
270+
ToggleSmartCase,
268271
/// Show help message in the prompt area.
269272
ShowHelp,
270273
}
@@ -360,11 +363,20 @@ where
360363
map.add_described_key_events(&["c-l"], "toggle line numbers", |_, ps| {
361364
InputEvent::UpdateLineNumber(!ps.line_numbers)
362365
});
363-
map.add_described_key_events(&["end"], "bottom", |_, _| InputEvent::UpdateUpperMark(usize::MAX - 1));
366+
map.add_described_key_events(&["end"], "bottom", |_, _| {
367+
InputEvent::UpdateUpperMark(usize::MAX - 1)
368+
});
364369
#[cfg(feature = "search")]
365370
{
366-
map.add_described_key_events(&["/"], "search forward", |_, _| InputEvent::Search(SearchMode::Forward));
367-
map.add_described_key_events(&["?"], "search backward", |_, _| InputEvent::Search(SearchMode::Reverse));
371+
map.add_described_key_events(&["/"], "search forward", |_, _| {
372+
InputEvent::Search(SearchMode::Forward)
373+
});
374+
map.add_described_key_events(&["?"], "search backward", |_, _| {
375+
InputEvent::Search(SearchMode::Reverse)
376+
});
377+
map.add_described_key_events(&["m-i"], "toggle smart case", |_, _| {
378+
InputEvent::ToggleSmartCase
379+
});
368380
map.add_described_key_events(&["n"], "next match", |_, ps| {
369381
let position = ps.prefix_num.parse::<usize>().unwrap_or(1);
370382

@@ -695,6 +707,12 @@ impl InputClassifier for DefaultInputClassifier {
695707
Some(InputEvent::MoveToPrevMatch(position))
696708
}
697709
}
710+
#[cfg(feature = "search")]
711+
Event::Key(KeyEvent {
712+
code: KeyCode::Char('i'),
713+
modifiers: KeyModifiers::ALT,
714+
..
715+
}) => Some(InputEvent::ToggleSmartCase),
698716
_ => None,
699717
}
700718
}

src/input/tests.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -502,12 +502,18 @@ fn test_help_key() {
502502
kind: crossterm::event::KeyEventKind::Press,
503503
state: KeyEventState::NONE,
504504
});
505-
assert_eq!(pager.input_classifier.classify_input(alt_h.clone(), &pager), Some(InputEvent::Ignore));
505+
assert_eq!(
506+
pager.input_classifier.classify_input(alt_h.clone(), &pager),
507+
Some(InputEvent::Ignore)
508+
);
506509

507510
// Attach default help key (alt-h / m-h)
508511
let mut reg = HashedEventRegister::default();
509512
reg.add_help_key(&[]);
510-
assert_eq!(reg.classify_input(alt_h.clone(), &pager), Some(InputEvent::ShowHelp));
513+
assert_eq!(
514+
reg.classify_input(alt_h, &pager),
515+
Some(InputEvent::ShowHelp)
516+
);
511517

512518
// Attach custom help key
513519
let mut reg_custom = HashedEventRegister::default();
@@ -518,12 +524,17 @@ fn test_help_key() {
518524
kind: crossterm::event::KeyEventKind::Press,
519525
state: KeyEventState::NONE,
520526
});
521-
assert_eq!(reg_custom.classify_input(f1, &pager), Some(InputEvent::ShowHelp));
527+
assert_eq!(
528+
reg_custom.classify_input(f1, &pager),
529+
Some(InputEvent::ShowHelp)
530+
);
522531

523532
// Dynamic help generation with described keys and omitted empty descriptions
524533
let mut reg_dynamic = HashedEventRegister::with_default_hasher();
525534
reg_dynamic.add_described_key_events(&["q", "c-c"], "quit", |_, _| InputEvent::Exit);
526-
reg_dynamic.add_described_key_events(&["j", "down"], "scroll down", |_, _| InputEvent::UpdateUpperMark(1));
535+
reg_dynamic.add_described_key_events(&["j", "down"], "scroll down", |_, _| {
536+
InputEvent::UpdateUpperMark(1)
537+
});
527538
// Undescribed key (empty description) should not appear in help text
528539
reg_dynamic.add_key_events(&["x"], |_, _| InputEvent::Exit);
529540

src/pager.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,7 @@ impl Pager {
303303
/// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data
304304
/// could not be sent to the receiver
305305
#[cfg(feature = "clipboard")]
306-
pub fn set_clipboard_handler(
307-
&self,
308-
handler: ClipboardHandler,
309-
) -> Result<(), MinusError> {
306+
pub fn set_clipboard_handler(&self, handler: ClipboardHandler) -> Result<(), MinusError> {
310307
Ok(self.tx.send(Command::SetClipboardHandler(handler))?)
311308
}
312309

@@ -386,6 +383,21 @@ impl Pager {
386383
Ok(())
387384
}
388385

386+
/// Enable or disable smart case searching
387+
///
388+
/// When enabled, search queries containing no uppercase characters are case-insensitive,
389+
/// while queries containing uppercase characters remain case-sensitive.
390+
///
391+
/// # Errors
392+
/// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data
393+
/// could not be sent to the receiver end.
394+
#[cfg(feature = "search")]
395+
#[cfg_attr(docsrs, doc(cfg(feature = "search")))]
396+
pub fn set_smart_case(&self, smart_case: bool) -> crate::Result {
397+
self.tx.send(Command::SetSmartCase(smart_case))?;
398+
Ok(())
399+
}
400+
389401
/// Control whether to show the prompt
390402
///
391403
/// Many applications don't want the prompt to be displayed at all. This function can be used to completely turn

0 commit comments

Comments
 (0)