Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Added
- add Alt+C in search to toggle case-insensitive matching
- add `--maximized` and `--fullscreen` flags to start the window in that mode
- persist and restore window size, maximized, and fullscreen state per session
- REP (`CSI Ps b`): repeat the last printed character `Ps` times
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ visual_bell = true # default: false
| `Enter` | Next match |
| `↑` / `↓` | Navigate search history |
| `Ctrl+C` | Copy current match |
| `Alt+C` | Toggle case-insensitive matching |
| `n` / `N` | Next / previous match (Normal mode) |
| `Escape` | Exit search |

Expand Down
1 change: 1 addition & 0 deletions doc/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ Screenshot capture is a two-step flow: region selection followed by a name promp
- `Enter` — navigate to the next match (wraps around).
- `Escape` — return to Normal mode; matches remain visible for `n`/`N` navigation.
- `Backspace` — delete the last character of the query.
- `Alt+C` — toggle case-insensitive matching; matches recompute live and the status bar shows `[i]` while active.
- `n` (Normal mode) — next match.
- `N` (Normal mode) — previous match.
- The view scrolls automatically to center the current match.
Expand Down
10 changes: 9 additions & 1 deletion src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ impl App {
};
self.update_search_matches();
}
Key::Character(s)
if self.modifiers.state().alt_key() && s.eq_ignore_ascii_case("c") =>
{
// Alt+C toggles case-insensitive matching and recomputes live.
self.state.search_case_insensitive = !self.state.search_case_insensitive;
self.update_search_matches();
}
Key::Character(s) if s == "\x03" || s == "c" => {
self.copy_current_match();
}
Expand Down Expand Up @@ -234,7 +241,8 @@ impl App {
if let Some(entry) = self.state.tabs[tab_idx].panes.get(&active)
&& let Some(grid) = entry.pane.grid_read()
{
self.state.search_matches = search::compute_search_matches(&grid, &query);
self.state.search_matches =
search::compute_search_matches(&grid, &query, self.state.search_case_insensitive);
}

if !self.state.search_matches.is_empty() {
Expand Down
6 changes: 5 additions & 1 deletion src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ pub struct AppState {
pub search_current: usize,
pub search_history: Vec<String>,
pub search_before_history: String,
/// When true, scrollback search matching ignores case (toggled with Alt+C).
pub search_case_insensitive: bool,
pub hovered_url: Option<String>,
pub swallow_next_tab: bool,
pub theme: ResolvedTheme,
Expand Down Expand Up @@ -129,6 +131,7 @@ impl AppState {
search_current: 0,
search_history: Vec::new(),
search_before_history: String::new(),
search_case_insensitive: false,
hovered_url: None,
swallow_next_tab: false,
theme,
Expand Down Expand Up @@ -244,7 +247,8 @@ impl AppState {
if let Some(entry) = self.tabs[tab_idx].panes.get(&active)
&& let Some(grid) = entry.pane.grid_read()
{
self.search_matches = crate::search::compute_search_matches(&grid, &query);
self.search_matches =
crate::search::compute_search_matches(&grid, &query, self.search_case_insensitive);
}
if !self.search_matches.is_empty() {
self.scroll_to_match(0);
Expand Down
26 changes: 26 additions & 0 deletions src/app_state_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,32 @@ fn update_search_matches_finds_content() {
assert!(!s.search_matches.is_empty());
}

#[test]
fn toggling_case_insensitive_changes_match_count() {
let mut s = make_state_with_pane();
let active = s.tab().active;
if let Some(e) = s.tab_mut().panes.get_mut(&active) {
for c in "Needle needle NEEDLE".chars() {
e.pane.grid.write().unwrap().write_char(c);
}
}
s.tab_mut().mode = InputMode::Search {
query: "needle".to_string(),
history_pos: None,
};

// Case-sensitive (default): only the exact lowercase "needle" matches.
s.update_search_matches();
let sensitive = s.search_matches.len();
assert_eq!(sensitive, 1);

// Flip the flag and recompute: all three case variants match.
s.search_case_insensitive = true;
s.update_search_matches();
assert!(s.search_matches.len() > sensitive);
assert_eq!(s.search_matches.len(), 3);
}

/// Poison the active pane's grid lock (simulating a parser thread that panicked
/// while holding the write lock) and confirm the main-thread read paths degrade
/// instead of panicking in cascade. Before the poison-tolerant `grid_read`
Expand Down
1 change: 1 addition & 0 deletions src/renderer/render_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ impl App {
&tab_titles,
self.state.search_matches.len(),
self.state.search_current,
self.state.search_case_insensitive,
right_text.as_deref(),
pane_title,
self.state.config.window.inactive_dim,
Expand Down
12 changes: 9 additions & 3 deletions src/renderer/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ impl Renderer {
tab_titles: &[(String, bool, bool)],
search_total: usize,
search_current: usize,
search_case_insensitive: bool,
right_text: Option<&str>,
pane_title: Option<&str>,
inactive_dim: f32,
Expand Down Expand Up @@ -221,6 +222,7 @@ impl Renderer {
passthrough,
search_total,
search_current,
search_case_insensitive,
right_text,
pane_title,
bell_flash_intensity.is_some(),
Expand Down Expand Up @@ -715,6 +717,7 @@ impl Renderer {
passthrough: bool,
search_total: usize,
search_current: usize,
search_case_insensitive: bool,
right_text: Option<&str>,
pane_title: Option<&str>,
bell_active: bool,
Expand Down Expand Up @@ -768,6 +771,7 @@ impl Renderer {
mode,
search_total,
search_current,
search_case_insensitive,
badge_x + badge_w + 10,
badge_y + 2,
px,
Expand Down Expand Up @@ -898,18 +902,20 @@ impl Renderer {
mode: &InputMode,
search_total: usize,
search_current: usize,
case_insensitive: bool,
x: u32,
y: u32,
px: f32,
color: u32,
) {
if let InputMode::Search { query, history_pos } = mode {
let ci = if case_insensitive { " [i]" } else { "" };
let base = if query.is_empty() {
"/".to_string()
format!("/{ci}")
} else if search_total == 0 {
format!("/{query} [no matches]")
format!("/{query} [no matches]{ci}")
} else {
format!("/{query} [{}/{}]", search_current + 1, search_total)
format!("/{query} [{}/{}]{ci}", search_current + 1, search_total)
};
let info = if let Some((pos, len)) = history_pos {
format!("{base} [hist {}/{}]", pos + 1, len)
Expand Down
17 changes: 17 additions & 0 deletions src/renderer/text_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ fn draw_empty_buffer_does_not_panic() {
&[],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -225,6 +226,7 @@ fn draw_pane_fills_background_color() {
&[("shell".to_string(), true, false)],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -258,6 +260,7 @@ fn draw_tab_bar_renders_without_panic() {
],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -290,6 +293,7 @@ fn draw_status_bar_renders_without_panic() {
&[],
3,
1,
false,
Some("/home/user"),
None,
0.55,
Expand Down Expand Up @@ -320,6 +324,7 @@ fn draw_status_bar_shell_indicators_render_without_panic() {
&[],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -353,6 +358,7 @@ fn draw_status_bar_pane_title_centered() {
&[],
0,
0,
false,
None,
Some("nvim src/main.rs"),
0.55,
Expand All @@ -377,6 +383,7 @@ fn draw_status_bar_pane_title_centered() {
&[],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -416,6 +423,7 @@ fn draw_status_bar_pane_title_suppressed_in_search() {
&[],
0,
0,
false,
None,
Some("nvim src/main.rs"),
0.55,
Expand Down Expand Up @@ -443,6 +451,7 @@ fn draw_status_bar_pane_title_suppressed_in_search() {
&[],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -504,6 +513,7 @@ fn draw_with_bell_flash_does_not_panic() {
&[],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -534,6 +544,7 @@ fn draw_with_separator_does_not_panic() {
&[],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -633,6 +644,7 @@ fn do_draw(r: &mut Renderer, panes: &[PaneView<'_>], mode: &InputMode) {
&[("t".to_string(), true, false)],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -803,6 +815,7 @@ fn draw_pane_osc8_link_paints_underline_without_hover() {
&[("t".to_string(), true, false)],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -897,6 +910,7 @@ fn draw_pane_reverse_video_swaps_background_to_fg_color() {
&[("t".to_string(), true, false)],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -1126,6 +1140,7 @@ fn draw_status_bar_search_empty_query_shows_slash() {
&[],
0,
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -1159,6 +1174,7 @@ fn draw_status_bar_search_no_matches_shows_label() {
&[],
0, // search_total = 0
0,
false,
None,
None,
0.55,
Expand Down Expand Up @@ -1337,6 +1353,7 @@ fn pane_padding_leaves_top_left_corner_as_background() {
&[("t".to_string(), true, false)],
0,
0,
false,
None,
None,
0.55,
Expand Down
12 changes: 10 additions & 2 deletions src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ use crate::terminal::grid::Grid;

/// Scans `grid` (scrollback + live rows) for all matches of `query` (treated as a regex).
/// Returns `(abs_row, start_col, match_len)` tuples sorted by ascending `abs_row`.
/// When `case_insensitive` is true, matching ignores case.
/// Returns an empty vec when `query` is empty or not a valid regex.
pub fn compute_search_matches(grid: &Grid, query: &str) -> Vec<(usize, usize, usize)> {
pub fn compute_search_matches(
grid: &Grid,
query: &str,
case_insensitive: bool,
) -> Vec<(usize, usize, usize)> {
if query.is_empty() {
return Vec::new();
}
let re = match regex::Regex::new(query) {
let re = match regex::RegexBuilder::new(query)
.case_insensitive(case_insensitive)
.build()
{
Ok(r) => r,
Err(_) => return Vec::new(),
};
Expand Down
Loading