aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-10 04:22:10 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-10 04:22:10 +0300
commitc8850a2e3e284033142db0f8117fa5998c68bd59 (patch)
tree487530d94265c096ceeb3ded7c60a94f14a6f7fd /src/ui
parent0de503ccd9a8fedeeb6cbcc1446ab2bc644fdbb6 (diff)
feat: add copy mode search and tmux word motions
refs #1230
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/menus.rs67
-rw-r--r--src/ui/panes.rs112
2 files changed, 160 insertions, 19 deletions
diff --git a/src/ui/menus.rs b/src/ui/menus.rs
index 42b4ec70..763c0ea8 100644
--- a/src/ui/menus.rs
+++ b/src/ui/menus.rs
@@ -70,26 +70,57 @@ pub(super) fn render_copy_mode_overlay(app: &AppState, frame: &mut Frame, area:
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD);
- let select = if app
- .copy_mode
- .is_some_and(|copy_mode| copy_mode.selection.is_some())
- {
- "selecting"
+ let Some(copy_mode) = app.copy_mode.as_ref() else {
+ return;
+ };
+ let line = if let Some(prompt) = copy_mode.search.prompt.as_ref() {
+ let marker = match prompt.direction {
+ crate::app::state::CopyModeSearchDirection::Forward => "/",
+ crate::app::state::CopyModeSearchDirection::Backward => "?",
+ };
+ Line::from(vec![
+ Span::styled(" COPY ", mode_style),
+ Span::raw(" "),
+ Span::styled(marker, key),
+ Span::styled(prompt.query.clone(), Style::default().fg(app.palette.text)),
+ Span::styled("█", key),
+ Span::styled(" enter search esc cancel", dim),
+ ])
} else {
- "select"
+ let select = if copy_mode.selection.is_some() {
+ "selecting"
+ } else {
+ "select"
+ };
+ let match_status = copy_mode
+ .search
+ .current
+ .map(|current| format!(" {}/{}", current + 1, copy_mode.search.matches.len()))
+ .or_else(|| (!copy_mode.search.query.is_empty()).then(|| " 0/0".to_string()))
+ .unwrap_or_default();
+ let (exit_keys, exit_label) =
+ if copy_mode.search.query.is_empty() && copy_mode.selection.is_none() {
+ ("q/esc", " exit")
+ } else {
+ ("esc", " clear q exit")
+ };
+ Line::from(vec![
+ Span::styled(" COPY ", mode_style),
+ Span::raw(" "),
+ Span::styled("h/j/k/l w/b/e { }", key),
+ Span::styled(" move ", dim),
+ Span::styled("/ ?", key),
+ Span::styled(" search ", dim),
+ Span::styled("n/N", key),
+ Span::styled(format!(" repeat{match_status} "), dim),
+ Span::styled("v/space", key),
+ Span::styled(format!(" {select} "), dim),
+ Span::styled("y/enter", key),
+ Span::styled(" copy ", dim),
+ Span::styled(exit_keys, key),
+ Span::styled(exit_label, dim),
+ ])
};
- let line = Line::from(vec![
- Span::styled(" COPY ", mode_style),
- Span::raw(" "),
- Span::styled("h/j/k/l w/b/e { }", key),
- Span::styled(" move ", dim),
- Span::styled("v/space", key),
- Span::styled(format!(" {select} "), dim),
- Span::styled("y/enter", key),
- Span::styled(" copy ", dim),
- Span::styled("q/esc", key),
- Span::styled(" exit", dim),
- ]);
let overlay_y = area.y + area.height.saturating_sub(1);
let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index c071a010..3241de12 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -331,6 +331,17 @@ pub(super) fn render_panes(
}
}
+ let (copy_search_top, copy_search_bottom, copy_search_matches) =
+ validated_copy_mode_search_matches(app, info, rt);
+ render_copy_mode_search_highlights(
+ app,
+ frame,
+ info,
+ copy_search_top,
+ copy_search_bottom,
+ &copy_search_matches,
+ false,
+ );
render_selection_highlight(
&app.selection,
frame,
@@ -340,6 +351,15 @@ pub(super) fn render_panes(
&app.palette,
app.host_terminal_theme,
);
+ render_copy_mode_search_highlights(
+ app,
+ frame,
+ info,
+ copy_search_top,
+ copy_search_bottom,
+ &copy_search_matches,
+ true,
+ );
render_copy_mode_cursor(app, frame, info);
}
}
@@ -598,7 +618,7 @@ fn render_copy_mode_cursor(app: &AppState, frame: &mut Frame, info: &PaneInfo) {
if app.mode != Mode::Copy {
return;
}
- let Some(copy_mode) = app.copy_mode else {
+ let Some(copy_mode) = app.copy_mode.as_ref() else {
return;
};
if copy_mode.pane_id != info.id
@@ -619,6 +639,96 @@ fn render_copy_mode_cursor(app: &AppState, frame: &mut Frame, info: &PaneInfo) {
);
}
+fn validated_copy_mode_search_matches(
+ app: &AppState,
+ info: &PaneInfo,
+ rt: &crate::terminal::TerminalRuntime,
+) -> (u32, u32, Vec<(usize, crate::pane::TerminalTextMatch)>) {
+ let Some(copy_mode) = app.copy_mode.as_ref() else {
+ return (0, 0, Vec::new());
+ };
+ if copy_mode.pane_id != info.id {
+ return (0, 0, Vec::new());
+ }
+ let Some(metrics) = rt.scroll_metrics() else {
+ return (0, 0, Vec::new());
+ };
+ let top = metrics
+ .max_offset_from_bottom
+ .saturating_sub(metrics.offset_from_bottom)
+ .min(u32::MAX as usize) as u32;
+ let bottom = top.saturating_add(u32::from(info.inner_rect.height.saturating_sub(1)));
+ let first_visible = copy_mode
+ .search
+ .matches
+ .partition_point(|text_match| text_match.end.row < top);
+ let visible = &copy_mode.search.matches[first_visible..];
+ let visible_len = visible.partition_point(|text_match| text_match.start.row <= bottom);
+ let candidates = visible[..visible_len].to_vec();
+ let validity = rt.text_matches_are_current(&candidates);
+
+ let matches = candidates
+ .into_iter()
+ .zip(validity)
+ .enumerate()
+ .filter_map(|(offset, (text_match, is_current))| {
+ is_current.then_some((first_visible + offset, text_match))
+ })
+ .collect();
+ (top, bottom, matches)
+}
+
+fn render_copy_mode_search_highlights(
+ app: &AppState,
+ frame: &mut Frame,
+ info: &PaneInfo,
+ top: u32,
+ bottom: u32,
+ matches: &[(usize, crate::pane::TerminalTextMatch)],
+ current_only: bool,
+) {
+ let Some(copy_mode) = app.copy_mode.as_ref() else {
+ return;
+ };
+ let current = copy_mode.search.current;
+ let style = if current_only {
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface1)
+ };
+
+ for &(index, text_match) in matches {
+ if (current == Some(index)) != current_only {
+ continue;
+ }
+ let start_row = text_match.start.row.max(top);
+ let end_row = text_match.end.row.min(bottom);
+ for absolute_row in start_row..=end_row {
+ let viewport_row = absolute_row.saturating_sub(top) as u16;
+ let start_col = if absolute_row == text_match.start.row {
+ text_match.start.col
+ } else {
+ 0
+ };
+ let end_col = if absolute_row == text_match.end.row {
+ text_match.end.col
+ } else {
+ info.inner_rect.width.saturating_sub(1)
+ };
+ for col in start_col..=end_col.min(info.inner_rect.width.saturating_sub(1)) {
+ let x = info.inner_rect.x.saturating_add(col);
+ let y = info.inner_rect.y.saturating_add(viewport_row);
+ frame.buffer_mut()[(x, y)].set_style(style);
+ }
+ }
+ }
+}
+
fn render_selection_highlight(
selection: &Option<crate::selection::Selection>,
frame: &mut Frame,