diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-10 04:22:10 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-10 04:22:10 +0300 |
| commit | c8850a2e3e284033142db0f8117fa5998c68bd59 (patch) | |
| tree | 487530d94265c096ceeb3ded7c60a94f14a6f7fd /src/ui | |
| parent | 0de503ccd9a8fedeeb6cbcc1446ab2bc644fdbb6 (diff) | |
feat: add copy mode search and tmux word motions
refs #1230
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/menus.rs | 67 | ||||
| -rw-r--r-- | src/ui/panes.rs | 112 |
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, + ©_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, + ©_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 = ©_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, |
