diff --git a/crates/gpui_term/src/element.rs b/crates/gpui_term/src/element.rs index 4afcdf5..809ac40 100644 --- a/crates/gpui_term/src/element.rs +++ b/crates/gpui_term/src/element.rs @@ -1,6 +1,6 @@ use std::{ cmp::Ordering, - collections::{HashMap, HashSet}, + collections::HashMap, ops::RangeInclusive, panic::Location, rc::Rc, @@ -33,14 +33,7 @@ use crate::{ reserve_left_padding_without_line_numbers, should_relayout_for_mode_change, should_show_line_numbers, }, - scrolling::{ - SCROLLBAR_WIDTH, ScrollbarLayoutState, overlay_scrollbar_layout_state, - paint_overlay_scrollbar, scroll_offset_for_drag_delta, - scroll_offset_for_line_coord_centered, scroll_offset_for_thumb_center_y, - scrollbar_bounds_for_terminal, scrollbar_marker_y_for_line_coord, - scrollbar_track_bounds, search_match_index_for_scrollbar_click, - search_match_index_for_scrollbar_hover, thumb_bounds_for_track, - }, + scrolling::{SCROLLBAR_WIDTH, scrollbar_geometry_for_terminal}, }, }; @@ -137,10 +130,7 @@ pub struct LayoutState { line_number_paint_data: Option, block_below_cursor_element: Option, base_text_style: TextStyle, - scrollbar: ScrollbarLayoutState, - scrollbar_visible: bool, - scrollbar_markers: Vec, - scrollbar_active_marker: Option, + scrollbar_bounds: Bounds, } /// Helper struct for converting backend cursor points to displayed cursor points. @@ -689,73 +679,6 @@ impl TerminalElement { false } - fn handle_scrollbar_left_down( - terminal: &Entity, - terminal_view: &Entity, - e: &MouseDownEvent, - cx: &mut App, - ) -> bool { - // Scrollbar interaction: clicking/dragging on the scrollbar should not start a terminal - // selection. - let term_bounds = terminal.read(cx).last_content().terminal_bounds.bounds; - let sb_width = if TerminalSettings::global(cx).show_scrollbar { - SCROLLBAR_WIDTH - } else { - Pixels::ZERO - }; - let sb_bounds = scrollbar_bounds_for_terminal(term_bounds, sb_width); - if !sb_bounds.contains(&e.position) { - return false; - } - - terminal_view.update(cx, |view: &mut TerminalView, view_cx| { - view.set_scrollbar_hovered(true, view_cx); - view.begin_scrollbar_drag(e.position.y, view_cx); - view.set_mouse_left_down_in_terminal(false); - - let track = scrollbar_track_bounds(sb_bounds); - let total_lines = view.terminal.read(view_cx).total_lines(); - let viewport_lines = view.terminal.read(view_cx).viewport_lines(); - let current_offset = view.terminal.read(view_cx).last_content().display_offset; - - // Determine whether the press is on the thumb; only track presses jump. - let thumb_bounds = - thumb_bounds_for_track(track, total_lines, viewport_lines, current_offset); - - if thumb_bounds.contains(&e.position) { - return; - } - - let marker_hit_radius = px(7.0); - let matches = view.terminal.read(view_cx).matches(); - let best = search_match_index_for_scrollbar_click( - track, - total_lines, - viewport_lines, - matches, - e.position.y, - marker_hit_radius, - ); - - let target_offset = if let Some(match_idx) = best { - let line = matches[match_idx].start().line; - let target_offset = - scroll_offset_for_line_coord_centered(total_lines, viewport_lines, line); - view.terminal.update(view_cx, |term, _| { - term.activate_match(match_idx); - }); - target_offset - } else { - scroll_offset_for_thumb_center_y(track, e.position.y, total_lines, viewport_lines) - }; - - view.apply_scrollbar_target_offset(target_offset, view_cx); - view.set_scrollbar_drag_origin(e.position.y, target_offset); - }); - - true - } - fn begin_terminal_left_drag( terminal: &Entity, terminal_view: &Entity, @@ -766,7 +689,6 @@ impl TerminalElement { // corresponding reset is handled both by the terminal hitbox mouse-up handler and a // window-level mouse-up handler (for releases outside the terminal). terminal_view.update(cx, |view: &mut TerminalView, _| { - view.end_scrollbar_drag(); view.set_mouse_left_down_in_terminal(true); }); @@ -802,61 +724,8 @@ impl TerminalElement { return; } - let total_lines = view.terminal.read(view_cx).total_lines(); - let viewport_lines = view.terminal.read(view_cx).viewport_lines(); - let matches = view.terminal.read(view_cx).matches(); - let marker_hit_radius = px(7.0); - - if let Some(match_idx) = search_match_index_for_scrollbar_hover( - track, - total_lines, - viewport_lines, - matches, - e.position.y, - marker_hit_radius, - ) { - view.set_scrollbar_preview_for_match(match_idx, e.position, view_cx); - } else { - view.clear_scrollbar_preview(view_cx); - } - }); - } - - fn handle_scrollbar_drag_mouse_move( - terminal_view: &Entity, - track: Bounds, - e: &MouseMoveEvent, - cx: &mut App, - ) -> bool { - if !terminal_view.read(cx).scrollbar_dragging() { - return false; - } - - // If we lost the actual MouseUp, clear drag state as soon as possible. - if !e.dragging() { - terminal_view.update(cx, |view: &mut TerminalView, _| { - view.end_scrollbar_drag(); - }); - return true; - } - - terminal_view.update(cx, |view: &mut TerminalView, view_cx| { - let total_lines = view.terminal.read(view_cx).total_lines(); - let viewport_lines = view.terminal.read(view_cx).viewport_lines(); - if let Some((drag_start_y, drag_start_offset)) = view.scrollbar_drag_origin() { - let target_offset = scroll_offset_for_drag_delta( - track, - drag_start_y, - e.position.y, - drag_start_offset, - total_lines, - viewport_lines, - ); - view.apply_scrollbar_target_offset(target_offset, view_cx); - } + view.update_scrollbar_preview_at(track, e.position, view_cx); }); - - true } fn handle_missing_left_up_for_terminal_drag( @@ -988,10 +857,6 @@ impl TerminalElement { return; } - if Self::handle_scrollbar_left_down(&terminal, &terminal_view, e, cx) { - return; - } - Self::begin_terminal_left_drag(&terminal, &terminal_view, e, cx); } }); @@ -1014,14 +879,9 @@ impl TerminalElement { let suggestions_hovered_row = suggestions_overlay_row_at_position(&terminal, &terminal_view, e.position, cx); - let term_bounds = terminal.read(cx).last_content().terminal_bounds.bounds; - let sb_width = if TerminalSettings::global(cx).show_scrollbar { - SCROLLBAR_WIDTH - } else { - Pixels::ZERO - }; - let sb_bounds = scrollbar_bounds_for_terminal(term_bounds, sb_width); - let track = scrollbar_track_bounds(sb_bounds); + let geometry = terminal_view.read(cx).scrollbar_geometry(cx); + let sb_bounds = geometry.bounds; + let track = geometry.track; Self::update_scrollbar_hover_state( &terminal_view, @@ -1032,10 +892,6 @@ impl TerminalElement { cx, ); - if Self::handle_scrollbar_drag_mouse_move(&terminal_view, track, e, cx) { - return; - } - let dragging_from_terminal = terminal_view.read(cx).mouse_left_down_in_terminal(); if Self::handle_missing_left_up_for_terminal_drag( &terminal, @@ -1086,7 +942,6 @@ impl TerminalElement { let was_scrollbar_dragging = terminal_view.read(cx).scrollbar_dragging(); terminal_view.update(cx, |view: &mut TerminalView, _| { view.set_mouse_left_down_in_terminal(false); - view.end_scrollbar_drag(); }); if was_scrollbar_dragging { @@ -1127,7 +982,6 @@ impl TerminalElement { let was_scrollbar_dragging = terminal_view.read(cx).scrollbar_dragging(); terminal_view.update(cx, |view: &mut TerminalView, _| { view.set_mouse_left_down_in_terminal(false); - view.end_scrollbar_drag(); }); if was_scrollbar_dragging { @@ -1238,7 +1092,6 @@ struct SyncedLayout { line_number_width: Pixels, line_number_digits: usize, scrollbar_width: Pixels, - scrollbar_visible: bool, last_hovered_word: Option, } @@ -1253,9 +1106,7 @@ struct PrepaintArtifacts { relative_highlighted_ranges: Vec<(RangeInclusive, Hsla)>, bg_quads: Vec, text_spans: Vec, - scrollbar: ScrollbarLayoutState, - scrollbar_markers: Vec, - scrollbar_active_marker: Option, + scrollbar_bounds: Bounds, } impl TerminalElement { @@ -1334,9 +1185,6 @@ impl TerminalElement { window: &mut Window, cx: &mut App, ) -> SyncedLayout { - let scrollbar_visible = - Self::should_show_scrollbar_overlay(typography.show_scrollbar, terminal_view, cx); - // Use the previous snapshot mode as an early hint; we'll reconcile after sync. let (initial_mode, total_lines_for_digits) = Self::initial_terminal_mode_and_total_lines(terminal, cx); @@ -1417,7 +1265,6 @@ impl TerminalElement { line_number_width, line_number_digits, scrollbar_width, - scrollbar_visible, last_hovered_word, } } @@ -1441,7 +1288,6 @@ impl TerminalElement { line_number_width, line_number_digits, scrollbar_width, - scrollbar_visible, last_hovered_word, } = synced_layout; @@ -1451,7 +1297,6 @@ impl TerminalElement { dimensions, line_number_digits, scrollbar_width, - scrollbar_visible, &typography, last_hovered_word.as_ref(), cx, @@ -1500,10 +1345,7 @@ impl TerminalElement { line_number_paint_data: artifacts.line_number_paint_data, block_below_cursor_element, base_text_style: typography.text_style, - scrollbar: artifacts.scrollbar, - scrollbar_visible, - scrollbar_markers: artifacts.scrollbar_markers, - scrollbar_active_marker: artifacts.scrollbar_active_marker, + scrollbar_bounds: artifacts.scrollbar_bounds, } } @@ -1514,15 +1356,12 @@ impl TerminalElement { dimensions: TerminalBounds, line_number_digits: usize, scrollbar_width: Pixels, - scrollbar_visible: bool, typography: &PrepaintTypography, last_hovered_word: Option<&HoveredWord>, cx: &App, ) -> PrepaintArtifacts { let terminal_read = terminal.read(cx); let search_matches = terminal_read.matches().to_vec(); - let total_lines = terminal_read.total_lines(); - let viewport_lines = terminal_read.viewport_lines(); let line_number_paint_data = (line_number_digits != 0) .then(|| { compute_line_number_paint_data( @@ -1548,18 +1387,8 @@ impl TerminalElement { let terminal_view_read = terminal_view.read(cx); let scroll_top = terminal_view_read.scroll_top(); - let (scrollbar, scrollbar_markers, scrollbar_active_marker) = - Self::compute_scrollbar_layout_and_markers( - scrollbar_visible, - dimensions, - scrollbar_width, - total_lines, - viewport_lines, - display_offset, - terminal_view_read, - &search_matches, - active_match_index, - ); + let scrollbar_bounds = + scrollbar_geometry_for_terminal(dimensions.bounds, scrollbar_width).bounds; let relative_highlighted_ranges = Self::build_relative_highlighted_ranges( &search_matches, @@ -1597,49 +1426,10 @@ impl TerminalElement { relative_highlighted_ranges, bg_quads, text_spans, - scrollbar, - scrollbar_markers, - scrollbar_active_marker, + scrollbar_bounds, } } - #[allow(clippy::too_many_arguments)] - fn compute_scrollbar_layout_and_markers( - scrollbar_visible: bool, - dimensions: TerminalBounds, - scrollbar_width: Pixels, - total_lines: usize, - viewport_lines: usize, - display_offset: usize, - terminal_view: &TerminalView, - search_matches: &[RangeInclusive], - active_match_index: Option, - ) -> (ScrollbarLayoutState, Vec, Option) { - // While the user is dragging the scrollbar, use the view-local "virtual" offset so the - // thumb updates immediately, without waiting for the backend sync. - let display_offset_for_thumb = terminal_view - .scrollbar_virtual_offset() - .unwrap_or(display_offset); - let scrollbar = overlay_scrollbar_layout_state( - dimensions.bounds, - scrollbar_width, - total_lines, - viewport_lines, - display_offset_for_thumb, - ); - - let (scrollbar_markers, scrollbar_active_marker) = Self::compute_scrollbar_markers( - scrollbar_visible, - &scrollbar, - search_matches, - total_lines, - viewport_lines, - active_match_index, - ); - - (scrollbar, scrollbar_markers, scrollbar_active_marker) - } - #[allow(clippy::too_many_arguments)] fn build_bg_quads_and_text_spans( cells: &[crate::IndexedCell], @@ -1697,20 +1487,6 @@ impl TerminalElement { (terminal.last_content().mode, terminal.total_lines()) } - fn should_show_scrollbar_overlay( - show_scrollbar: bool, - terminal_view: &Entity, - cx: &App, - ) -> bool { - show_scrollbar && { - let view = terminal_view.read(cx); - view.scrollbar_dragging() - || view.scrollbar_hovered() - || view.scrollbar_revealed() - || view.is_search_open() - } - } - fn build_terminal_link_style(font_weight: gpui::FontWeight, cx: &App) -> HighlightStyle { HighlightStyle { color: Some(cx.theme().info_hover), @@ -1776,54 +1552,6 @@ impl TerminalElement { }) } - fn compute_scrollbar_markers( - scrollbar_visible: bool, - scrollbar: &ScrollbarLayoutState, - search_matches: &[RangeInclusive], - total_lines: usize, - viewport_lines: usize, - active_match_index: Option, - ) -> (Vec, Option) { - if scrollbar_visible && !search_matches.is_empty() && total_lines > 0 && viewport_lines > 0 - { - let track = scrollbar.track_bounds; - let mut seen_y: HashSet = HashSet::new(); - let mut ys: Vec = Vec::new(); - - for m in search_matches { - if let Some(y) = scrollbar_marker_y_for_line_coord( - track, - total_lines, - viewport_lines, - m.start().line, - ) { - let key = ((y - track.origin.y) / px(1.0)).round() as i32; - if seen_y.insert(key) { - ys.push(y); - if ys.len() >= 4096 { - break; - } - } - } - } - - let active_y = active_match_index - .and_then(|idx| search_matches.get(idx)) - .and_then(|range| { - scrollbar_marker_y_for_line_coord( - track, - total_lines, - viewport_lines, - range.start().line, - ) - }); - - (ys, active_y) - } else { - (Vec::new(), None) - } - } - fn build_relative_highlighted_ranges( search_matches: &[RangeInclusive], active_match_index: Option, @@ -2067,7 +1795,7 @@ impl Element for TerminalElement { self.register_mouse_listeners(layout.mode, &layout.hitbox, window); let mouse_pos = window.mouse_position(); - let should_point = layout.scrollbar.bounds.contains(&mouse_pos) + let should_point = layout.scrollbar_bounds.contains(&mouse_pos) || (window.modifiers().secondary() && layout.dimensions.bounds.contains(&mouse_pos) && terminal_view.read(cx).hover_word.is_some()); @@ -2313,16 +2041,7 @@ fn terminal_element_paint_overlays( element.paint(window, cx); } - // Scrollbar. - if layout.scrollbar_visible && layout.scrollbar.bounds.size.width > Pixels::ZERO { - paint_overlay_scrollbar( - &layout.scrollbar, - &layout.scrollbar_markers, - layout.scrollbar_active_marker, - window, - cx, - ); - } + // Scrollbar thumb/track is rendered by gpui-component in TerminalView overlays. } fn terminal_element_log_paint_stats( diff --git a/crates/gpui_term/src/view/mod.rs b/crates/gpui_term/src/view/mod.rs index bbbce44..c0832a8 100644 --- a/crates/gpui_term/src/view/mod.rs +++ b/crates/gpui_term/src/view/mod.rs @@ -1,9 +1,8 @@ use std::{ops::Range, sync::Arc, time::Duration}; use gpui::{ - Action, AnyElement, App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, KeyContext, KeyDownEvent, Keystroke, MouseButton, - ParentElement, Pixels, PromptLevel, ReadGlobal, Styled, Subscription, Window, div, px, + Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, + KeyDownEvent, Keystroke, Pixels, PromptLevel, ReadGlobal, Styled, Subscription, Window, px, }; use gpui_common::TermuaIcon; use gpui_component::{ @@ -13,14 +12,13 @@ use gpui_component::{ }; use record::{RecordingMenuEntry, recording_context_menu_entry, recording_indicator_label}; use schemars::JsonSchema; -use scrolling::{SCROLLBAR_WIDTH, ScrollState, ScrollbarPreview}; +use scrolling::{ScrollState, TerminalScrollbarHandle}; use serde::Deserialize; use smol::Timer; use crate::{ Copy, DecreaseFontSize, HoveredWord, IncreaseFontSize, ResetFontSize, TerminalContent, TerminalMode, - element::ScrollbarPreviewTextElement, record::render_recording_indicator_label, settings::{CursorShape, TerminalBlink, TerminalSettings}, snippet::{SnippetJump, SnippetJumpDir, SnippetSession}, @@ -36,15 +34,11 @@ mod input; pub(crate) mod line_number; pub(crate) mod record; mod render; +mod scrollbar; pub(crate) mod scrolling; pub(crate) mod search; mod suggestions; -fn format_scrollbar_preview_line_number(one_based: usize, digits: usize) -> String { - let digits = digits.max(1); - format!("{:>width$}\u{00A0}", one_based, width = digits) -} - pub trait ContextMenuProvider: Send + Sync + 'static { fn context_menu( &self, @@ -201,14 +195,6 @@ struct TerminalModeState { has_selection: bool, } -#[derive(Clone, Copy)] -struct ScrollbarPreviewLayoutState { - view_bounds: Bounds, - line_height: Pixels, - cell_width: Pixels, - total_lines: usize, -} - /// A terminal view, maintains the PTY's file handles and communicates with the terminal pub struct TerminalView { pub terminal: Entity, @@ -219,6 +205,7 @@ pub struct TerminalView { blink: BlinkingState, pub hover_word: Option, scroll: ScrollState, + terminal_scrollbar_handle: TerminalScrollbarHandle, pub ime_state: Option, search: SearchState, suggestions: SuggestionsState, @@ -266,17 +253,6 @@ impl TerminalView { } } - fn scrollbar_preview_layout_state(&self, cx: &App) -> ScrollbarPreviewLayoutState { - let terminal = self.terminal.read(cx); - let terminal_bounds = &terminal.last_content().terminal_bounds; - ScrollbarPreviewLayoutState { - view_bounds: terminal_bounds.bounds, - line_height: terminal_bounds.line_height, - cell_width: terminal_bounds.cell_width, - total_lines: terminal.total_lines(), - } - } - fn cast_recording_active(&self, cx: &App) -> bool { self.terminal.read(cx).cast_recording_active() } @@ -334,6 +310,7 @@ impl TerminalView { blink: BlinkingState::default(), hover_word: None, scroll: ScrollState::default(), + terminal_scrollbar_handle: TerminalScrollbarHandle::default(), ime_state: None, search: SearchState::default(), suggestions: SuggestionsState::new(cx), @@ -659,11 +636,6 @@ impl TerminalView { &self.terminal } - pub fn clear_block_below_cursor(&mut self, cx: &mut Context) { - self.scroll.block_below_cursor = None; - cx.notify(); - } - fn next_blink_epoch(&mut self) -> usize { self.blink.epoch = self.blink.epoch.wrapping_add(1); self.blink.epoch @@ -947,6 +919,12 @@ impl TerminalView { if let Some(search) = render_search(self, window, cx) { out.push(search); } + if let Some(scrollbar) = self.render_terminal_scrollbar_overlay(cx) { + out.push(scrollbar); + } + if let Some(markers) = self.render_scrollbar_marker_overlay(cx) { + out.push(markers); + } if let Some(preview) = self.render_scrollbar_preview_overlay(cx) { out.push(preview); } @@ -956,110 +934,6 @@ impl TerminalView { out } - fn render_scrollbar_preview_overlay(&mut self, cx: &mut Context) -> Option { - let preview = self.scrollbar_preview().cloned()?; - let terminal_settings = TerminalSettings::global(cx); - if !terminal_settings.show_scrollbar { - return None; - } - - let ScrollbarPreview { - anchor, - start_line_from_top, - cols, - rows, - cells, - match_range, - .. - } = preview; - - let theme = cx.theme(); - let ScrollbarPreviewLayoutState { - view_bounds, - line_height, - cell_width, - total_lines, - } = self.scrollbar_preview_layout_state(cx); - - let content_h = line_height * (rows.max(1) as f32) + px(16.0); - // `preview.anchor` is stored in window coordinates; convert it to this view's - // local coordinate space so absolute positioning works correctly when the - // terminal view is embedded (i.e. not at window origin). - let anchor_y = anchor.y - view_bounds.origin.y; - let y = scrollbar_preview_overlay_top(anchor_y, view_bounds.size.height, content_h); - - // Use the theme's popover color so the preview reads as an overlay panel, - // clearly distinct from the terminal background. - let panel_bg = theme.popover; - let panel_border = theme.border.opacity(0.9); - let line_no_fg = theme.foreground.opacity(0.40); - let line_no_digits = total_lines.max(1).to_string().len(); - - let mut body = div() - .id("terminal-scrollbar-preview") - .debug_selector(|| "terminal-scrollbar-preview".to_string()) - .absolute() - .left_0() - .top(y) - // Match the terminal's horizontal span, excluding the scrollbar lane so we - // don't steal hover from the markers. - .right(SCROLLBAR_WIDTH) - .bg(panel_bg) - .border_1() - .border_color(panel_border) - .rounded_md() - .shadow_lg() - .py(px(8.0)) - .px(px(10.0)) - .font_family(terminal_settings.font_family.clone()) - .text_size(terminal_settings.font_size) - .font_weight(terminal_settings.font_weight) - .on_mouse_down( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_mouse_down( - MouseButton::Right, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .flex_col(); - - let line_numbers = render_scrollbar_preview_line_numbers( - rows, - start_line_from_top, - line_height, - line_no_fg, - line_no_digits, - ); - - let text = div() - .h(line_height * (rows.max(1) as f32)) - .flex_1() - .overflow_hidden() - .child(ScrollbarPreviewTextElement::new( - cells, - cell_width, - line_height, - cols, - Some(match_range), - )); - - body = body.child( - div() - .flex() - .items_start() - .overflow_hidden() - .child(line_numbers) - .child(text), - ); - - Some(body.into_any_element()) - } - fn render_recording_indicator_overlay(&mut self, cx: &mut Context) -> Option { let recording_active = self.cast_recording_active(cx); let label = recording_indicator_label(recording_active)?; @@ -1188,336 +1062,6 @@ fn handle_terminal_event( } } -fn scrollbar_preview_overlay_top( - anchor_y: Pixels, - view_height: Pixels, - content_h: Pixels, -) -> Pixels { - let mut y = anchor_y - content_h / 2.0; - let top_pad = px(12.0); - // Keep extra breathing room at the bottom so the preview doesn't get - // obscured by footer bars/overlays (e.g. transfer UI). - let bottom_pad = px(56.0); - y = y.clamp(top_pad, (view_height - content_h - bottom_pad).max(top_pad)); - y -} - -fn render_scrollbar_preview_line_numbers( - rows: usize, - start_line_from_top: usize, - line_height: Pixels, - line_no_fg: gpui::Hsla, - line_no_digits: usize, -) -> gpui::Div { - let mut line_numbers = div().flex_col().flex_shrink_0(); - for i in 0..rows { - let line_no = start_line_from_top.saturating_add(i).saturating_add(1); - line_numbers = line_numbers.child( - div() - .h(line_height) - .whitespace_nowrap() - .overflow_hidden() - .text_color(line_no_fg) - .child(format_scrollbar_preview_line_number( - line_no, - line_no_digits, - )), - ); - } - line_numbers -} - -#[cfg(test)] -mod scrollbar_preview_tests { - use std::{borrow::Cow, ops::RangeInclusive, rc::Rc}; - - use gpui::{ - AppContext, Bounds, Context as GpuiContext, Entity, InteractiveElement, Keystroke, - Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, - ScrollWheelEvent, Styled, Window, div, point, px, size, - }; - use gpui_component::Root; - - use super::{TerminalView, format_scrollbar_preview_line_number}; - use crate::{ - Cell, GridPoint, IndexedCell, TerminalBackend, TerminalContent, TerminalShutdownPolicy, - TerminalType, settings::CursorShape, terminal::TerminalBounds, - }; - - #[test] - fn format_scrollbar_preview_line_number_right_aligns() { - assert_eq!(format_scrollbar_preview_line_number(3, 1), "3\u{00A0}"); - assert_eq!(format_scrollbar_preview_line_number(3, 4), " 3\u{00A0}"); - } - - #[test] - fn format_scrollbar_preview_line_number_uses_trailing_space() { - // The preview renderer now uses real terminal cells (with fixed-width positioning), so - // we no longer need to preserve spaces via NBSP substitution. - assert_eq!(format_scrollbar_preview_line_number(1, 1), "1\u{00A0}"); - } - - pub(super) struct PreviewBackend { - content: TerminalContent, - matches: Vec>, - total_lines: usize, - viewport_lines: usize, - preview_cols: usize, - preview_rows: usize, - preview_cells: Vec, - } - - impl PreviewBackend { - pub(super) fn new() -> Self { - // Give the renderer something to work with; actual bounds will be set via `set_size`. - let content = TerminalContent::default(); - - // Make a single match close to the bottom of the buffer. - // With total_lines=100 and viewport_lines=20, line_coord=19 maps to buffer index 99. - let matches = vec![RangeInclusive::new( - GridPoint::new(19, 0), - GridPoint::new(19, 1), - )]; - - let preview_cols = 24; - let preview_rows = 7; - let mut preview_cells = Vec::new(); - for row in 0..preview_rows { - for col in 0..preview_cols { - preview_cells.push(IndexedCell { - point: GridPoint::new(row as i32, col), - cell: Cell { - c: if col == 0 { '>' } else { 'x' }, - ..Default::default() - }, - }); - } - } - - Self { - content, - matches, - total_lines: 100, - viewport_lines: 20, - preview_cols, - preview_rows, - preview_cells, - } - } - } - - impl TerminalBackend for PreviewBackend { - fn backend_name(&self) -> &'static str { - "preview-test" - } - - fn sync(&mut self, _window: &mut Window, _cx: &mut GpuiContext) {} - - fn shutdown( - &mut self, - _policy: TerminalShutdownPolicy, - _cx: &mut GpuiContext, - ) { - } - - fn last_content(&self) -> &TerminalContent { - &self.content - } - - fn matches(&self) -> &[RangeInclusive] { - &self.matches - } - - fn last_clicked_line(&self) -> Option { - None - } - - fn vi_mode_enabled(&self) -> bool { - false - } - - fn mouse_mode(&self, _shift: bool) -> bool { - false - } - - fn selection_started(&self) -> bool { - false - } - - fn set_cursor_shape(&mut self, _cursor_shape: CursorShape) {} - - fn total_lines(&self) -> usize { - self.total_lines - } - - fn viewport_lines(&self) -> usize { - self.viewport_lines - } - - fn activate_match(&mut self, _index: usize) {} - - fn select_matches(&mut self, _matches: &[RangeInclusive]) {} - - fn select_all(&mut self) {} - - fn copy(&mut self, _keep_selection: Option, _cx: &mut GpuiContext) {} - - fn clear(&mut self) {} - - fn scroll_line_up(&mut self) {} - fn scroll_up_by(&mut self, _lines: usize) {} - fn scroll_line_down(&mut self) {} - fn scroll_down_by(&mut self, _lines: usize) {} - fn scroll_page_up(&mut self) {} - fn scroll_page_down(&mut self) {} - fn scroll_to_top(&mut self) {} - fn scroll_to_bottom(&mut self) {} - - fn scrolled_to_top(&self) -> bool { - true - } - - fn scrolled_to_bottom(&self) -> bool { - true - } - - fn set_size(&mut self, new_bounds: TerminalBounds) { - self.content.terminal_bounds = new_bounds; - } - - fn input(&mut self, _input: Cow<'static, [u8]>) {} - - fn paste(&mut self, _text: &str) {} - - fn focus_in(&self) {} - - fn focus_out(&mut self) {} - - fn toggle_vi_mode(&mut self) {} - - fn try_keystroke(&mut self, _keystroke: &Keystroke, _alt_is_meta: bool) -> bool { - false - } - - fn try_modifiers_change( - &mut self, - _modifiers: &Modifiers, - _window: &Window, - _cx: &mut GpuiContext, - ) { - } - - fn mouse_move(&mut self, _e: &MouseMoveEvent, _cx: &mut GpuiContext) {} - - fn select_word_at_event_position(&mut self, _e: &MouseDownEvent) {} - - fn mouse_drag( - &mut self, - _e: &MouseMoveEvent, - _region: Bounds, - _cx: &mut GpuiContext, - ) { - } - - fn mouse_down(&mut self, _e: &MouseDownEvent, _cx: &mut GpuiContext) {} - - fn mouse_up(&mut self, _e: &MouseUpEvent, _cx: &GpuiContext) {} - - fn scroll_wheel(&mut self, _e: &ScrollWheelEvent) {} - - fn get_content(&self) -> String { - String::new() - } - - fn last_n_non_empty_lines(&self, _n: usize) -> Vec { - Vec::new() - } - - fn preview_cells_from_top( - &self, - _start_line: usize, - _count: usize, - ) -> (usize, usize, Vec) { - ( - self.preview_cols, - self.preview_rows, - self.preview_cells.clone(), - ) - } - } - - #[gpui::test] - fn scrollbar_preview_is_not_obscured_by_footer_bar(cx: &mut gpui::TestAppContext) { - cx.update(|app| { - crate::init(app); - }); - - let view_slot: Rc>>> = - Rc::new(std::cell::RefCell::new(None)); - let view_slot_for_window = view_slot.clone(); - - let (root, v) = cx.add_window_view(|window, cx| { - let terminal = cx.new(|_| { - crate::Terminal::new(TerminalType::WezTerm, Box::new(PreviewBackend::new())) - }); - let terminal_view = cx.new(|cx| TerminalView::new(terminal, window, cx)); - *view_slot_for_window.borrow_mut() = Some(terminal_view.clone()); - - terminal_view.update(cx, |this, cx| { - // Anchor the preview near the bottom of the window so the default clamp behavior - // would overlap a bottom footer bar overlay. - this.set_scrollbar_preview_for_match(0, point(px(0.0), px(690.0)), cx); - }); - - Root::new(terminal_view, window, cx) - }); - - v.draw( - point(px(0.0), px(0.0)), - size( - gpui::AvailableSpace::Definite(px(900.0)), - gpui::AvailableSpace::Definite(px(700.0)), - ), - move |_, _| { - div().size_full().relative().child(root).child( - // Simulate a bottom "footer bar" overlay that can obscure the preview - // tooltip when a search marker is near the bottom of the scrollbar. - div() - .debug_selector(|| "test-footerbar".to_string()) - .absolute() - .left_0() - .right_0() - .bottom_0() - .h(px(48.0)), - ) - }, - ); - - v.run_until_parked(); - - let view = view_slot - .borrow() - .clone() - .expect("expected terminal view to be captured"); - let preview_set = v.read_entity(&view, |this, _app| this.scrollbar_preview().is_some()); - assert!(preview_set, "expected scrollbar preview state to be set"); - - let preview_bounds = v - .debug_bounds("terminal-scrollbar-preview") - .expect("scrollbar preview should exist"); - let footer_bounds = v - .debug_bounds("test-footerbar") - .expect("test footer bar should exist"); - - let preview_bottom = preview_bounds.origin.y + preview_bounds.size.height; - assert!( - preview_bottom <= footer_bounds.origin.y, - "expected preview bottom ({preview_bottom:?}) to be above footer bar top ({:?})", - footer_bounds.origin.y - ); - } -} - #[cfg(test)] mod suggestion_selection_tests { use std::rc::Rc; @@ -1525,7 +1069,8 @@ mod suggestion_selection_tests { use gpui::{AppContext, Entity}; use super::{ - SuggestionItem, SuggestionsState, TerminalView, scrollbar_preview_tests::PreviewBackend, + SuggestionItem, SuggestionsState, TerminalView, + scrollbar::scrollbar_preview_tests::PreviewBackend, }; use crate::{GridPoint, TerminalContent}; @@ -2068,7 +1613,7 @@ mod prompt_context_tests { use gpui::AppContext; use gpui_component::Root; - use super::{scrollbar_preview_tests::PreviewBackend, *}; + use super::{scrollbar::scrollbar_preview_tests::PreviewBackend, *}; #[gpui::test] fn prompt_context_snapshots_content_and_cursor_line_id(cx: &mut gpui::TestAppContext) { @@ -2115,7 +1660,7 @@ mod ime_state_tests { use gpui::AppContext; use gpui_component::Root; - use super::{scrollbar_preview_tests::PreviewBackend, *}; + use super::{scrollbar::scrollbar_preview_tests::PreviewBackend, *}; #[gpui::test] fn marked_text_range_defaults_to_utf16_length_when_platform_does_not_supply_range( diff --git a/crates/gpui_term/src/view/render.rs b/crates/gpui_term/src/view/render.rs index 9814f16..e259674 100644 --- a/crates/gpui_term/src/view/render.rs +++ b/crates/gpui_term/src/view/render.rs @@ -12,7 +12,9 @@ impl Render for TerminalView { let terminal_handle = self.terminal.clone(); let terminal_view_handle = cx.entity(); + self.apply_terminal_scrollbar_target(cx); self.sync_scroll_for_render(cx); + self.sync_terminal_scrollbar_handle(cx); let focused = self.focus_handle.is_focused(window); let mut root = self.terminal_view_root_base(cx); diff --git a/crates/gpui_term/src/view/scrollbar.rs b/crates/gpui_term/src/view/scrollbar.rs new file mode 100644 index 0000000..d3d248f --- /dev/null +++ b/crates/gpui_term/src/view/scrollbar.rs @@ -0,0 +1,767 @@ +use std::panic::Location; + +use gpui::{ + AnyElement, App, Bounds, Context, Element, ElementId, GlobalElementId, Hsla, + InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, ParentElement, Pixels, + ReadGlobal, Style, Styled, Window, div, fill, point, px, relative, size, +}; +use gpui_component::{ + ActiveTheme, + scroll::{Scrollbar, ScrollbarShow}, +}; + +use super::TerminalView; +use crate::{ + element::ScrollbarPreviewTextElement, + settings::TerminalSettings, + view::scrolling::{ + SCROLLBAR_ACTIVE_MARKER_SIZE, SCROLLBAR_MARKER_LIMIT, SCROLLBAR_MARKER_SIZE, + SCROLLBAR_WIDTH, ScrollbarMarkerSpec, ScrollbarPreview, + scroll_offset_for_line_coord_centered, scrollbar_marker_specs, + }, +}; + +#[derive(Clone, Copy)] +struct ScrollbarPreviewLayoutState { + view_bounds: Bounds, + line_height: Pixels, + cell_width: Pixels, + total_lines: usize, +} + +fn format_scrollbar_preview_line_number(one_based: usize, digits: usize) -> String { + let digits = digits.max(1); + format!("{:>width$}\u{00A0}", one_based, width = digits) +} + +#[derive(Clone)] +struct ScrollbarMarkersElement { + marker_specs: Vec, + lane_bounds: Bounds, + marker_color: Hsla, + active_marker_color: Hsla, +} + +impl ScrollbarMarkersElement { + fn new( + marker_specs: Vec, + lane_bounds: Bounds, + marker_color: Hsla, + active_marker_color: Hsla, + ) -> Self { + Self { + marker_specs, + lane_bounds, + marker_color, + active_marker_color, + } + } +} + +impl Element for ScrollbarMarkersElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); + (window.request_layout(style, None, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Window, + _: &mut App, + ) -> Self::PrepaintState { + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + _: &mut App, + ) { + for spec in &self.marker_specs { + let local_bounds = scrollbar_marker_local_bounds(*spec, self.lane_bounds); + let marker_bounds = Bounds { + origin: bounds.origin + local_bounds.origin, + size: local_bounds.size, + }; + let color = if spec.active { + self.active_marker_color + } else { + self.marker_color + }; + window.paint_quad(fill(marker_bounds, color)); + } + } +} + +impl IntoElement for ScrollbarMarkersElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +fn scrollbar_marker_size(spec: ScrollbarMarkerSpec) -> Pixels { + if spec.active { + SCROLLBAR_ACTIVE_MARKER_SIZE + } else { + SCROLLBAR_MARKER_SIZE + } +} + +fn scrollbar_marker_local_bounds( + spec: ScrollbarMarkerSpec, + lane_bounds: Bounds, +) -> Bounds { + let marker_size = scrollbar_marker_size(spec); + let top = (spec.y - lane_bounds.origin.y - marker_size / 2.0).clamp( + Pixels::ZERO, + (lane_bounds.size.height - marker_size).max(Pixels::ZERO), + ); + let left = (lane_bounds.size.width - marker_size) / 2.0; + Bounds { + origin: point(left, top), + size: size(marker_size, marker_size), + } +} + +fn scrollbar_marker_window_bounds( + spec: ScrollbarMarkerSpec, + lane_bounds: Bounds, +) -> Bounds { + let local_bounds = scrollbar_marker_local_bounds(spec, lane_bounds); + Bounds { + origin: lane_bounds.origin + local_bounds.origin, + size: local_bounds.size, + } +} + +fn scrollbar_marker_match_index_at_position( + marker_specs: &[ScrollbarMarkerSpec], + lane_bounds: Bounds, + position: gpui::Point, +) -> Option { + marker_specs.iter().find_map(|spec| { + scrollbar_marker_window_bounds(*spec, lane_bounds) + .contains(&position) + .then_some(spec.match_index) + }) +} + +impl TerminalView { + fn scrollbar_preview_layout_state(&self, cx: &App) -> ScrollbarPreviewLayoutState { + let terminal = self.terminal.read(cx); + let terminal_bounds = &terminal.last_content().terminal_bounds; + ScrollbarPreviewLayoutState { + view_bounds: terminal_bounds.bounds, + line_height: terminal_bounds.line_height, + cell_width: terminal_bounds.cell_width, + total_lines: terminal.total_lines(), + } + } + + pub(super) fn render_terminal_scrollbar_overlay( + &mut self, + cx: &mut Context, + ) -> Option { + if !TerminalSettings::global(cx).show_scrollbar { + return None; + } + + Some( + div() + .id("terminal-scrollbar-overlay") + .debug_selector(|| "terminal-scrollbar-overlay".to_string()) + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(SCROLLBAR_WIDTH) + .child( + Scrollbar::vertical(&self.terminal_scrollbar_handle) + .id("terminal-scrollbar") + .scrollbar_show(ScrollbarShow::Hover), + ) + .into_any_element(), + ) + } + + pub(super) fn render_scrollbar_marker_overlay( + &mut self, + cx: &mut Context, + ) -> Option { + let terminal_settings = TerminalSettings::global(cx); + if !terminal_settings.show_scrollbar { + return None; + } + if !(self.scrollbar_dragging() + || self.scrollbar_hovered() + || self.scrollbar_revealed() + || self.is_search_open()) + { + return None; + } + + let terminal = self.terminal.read(cx); + let matches = terminal.matches(); + let total_lines = terminal.total_lines(); + let viewport_lines = terminal.viewport_lines(); + if matches.is_empty() || total_lines == 0 || viewport_lines == 0 { + return None; + } + + let geometry = self.scrollbar_geometry(cx); + let active_match_index = terminal.active_match_index(); + let marker_specs = scrollbar_marker_specs( + geometry.track, + total_lines, + viewport_lines, + matches, + active_match_index, + SCROLLBAR_MARKER_LIMIT, + ); + if marker_specs.is_empty() { + return None; + } + + let marker_color = cx.theme().foreground.opacity(0.30); + let active_marker_color = cx.theme().foreground.opacity(0.70); + let marker_specs_for_click = marker_specs.clone(); + let lane_bounds = geometry.bounds; + + let overlay = div() + .id("terminal-scrollbar-markers") + .debug_selector(|| "terminal-scrollbar-markers".to_string()) + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(SCROLLBAR_WIDTH) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, e: &MouseDownEvent, _, cx| { + let Some(match_idx) = scrollbar_marker_match_index_at_position( + &marker_specs_for_click, + lane_bounds, + e.position, + ) else { + return; + }; + + let (total_lines, viewport_lines, line) = { + let terminal = this.terminal.read(cx); + let Some(search_match) = terminal.matches().get(match_idx) else { + cx.stop_propagation(); + return; + }; + ( + terminal.total_lines(), + terminal.viewport_lines(), + search_match.start().line, + ) + }; + let target_offset = + scroll_offset_for_line_coord_centered(total_lines, viewport_lines, line); + this.terminal.update(cx, |term, _| { + term.activate_match(match_idx); + }); + this.apply_scrollbar_target_offset(target_offset, cx); + cx.stop_propagation(); + }), + ) + .child(ScrollbarMarkersElement::new( + marker_specs, + lane_bounds, + marker_color, + active_marker_color, + )); + + Some(overlay.into_any_element()) + } + + pub(super) fn render_scrollbar_preview_overlay( + &mut self, + cx: &mut Context, + ) -> Option { + let preview = self.scrollbar_preview().cloned()?; + let terminal_settings = TerminalSettings::global(cx); + if !terminal_settings.show_scrollbar { + return None; + } + + let ScrollbarPreview { + anchor, + start_line_from_top, + cols, + rows, + cells, + match_range, + .. + } = preview; + + let theme = cx.theme(); + let ScrollbarPreviewLayoutState { + view_bounds, + line_height, + cell_width, + total_lines, + } = self.scrollbar_preview_layout_state(cx); + + let content_h = line_height * (rows.max(1) as f32) + px(16.0); + let anchor_y = anchor.y - view_bounds.origin.y; + let y = scrollbar_preview_overlay_top(anchor_y, view_bounds.size.height, content_h); + + let panel_bg = theme.popover; + let panel_border = theme.border.opacity(0.9); + let line_no_fg = theme.foreground.opacity(0.40); + let line_no_digits = total_lines.max(1).to_string().len(); + + let mut body = div() + .id("terminal-scrollbar-preview") + .debug_selector(|| "terminal-scrollbar-preview".to_string()) + .absolute() + .left_0() + .top(y) + .right(SCROLLBAR_WIDTH) + .bg(panel_bg) + .border_1() + .border_color(panel_border) + .rounded_md() + .shadow_lg() + .py(px(8.0)) + .px(px(10.0)) + .font_family(terminal_settings.font_family.clone()) + .text_size(terminal_settings.font_size) + .font_weight(terminal_settings.font_weight) + .on_mouse_down( + MouseButton::Left, + cx.listener(|_, _, _, cx| { + cx.stop_propagation(); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|_, _, _, cx| { + cx.stop_propagation(); + }), + ) + .flex_col(); + + let line_numbers = render_scrollbar_preview_line_numbers( + rows, + start_line_from_top, + line_height, + line_no_fg, + line_no_digits, + ); + + let text = div() + .h(line_height * (rows.max(1) as f32)) + .flex_1() + .overflow_hidden() + .child(ScrollbarPreviewTextElement::new( + cells, + cell_width, + line_height, + cols, + Some(match_range), + )); + + body = body.child( + div() + .flex() + .items_start() + .overflow_hidden() + .child(line_numbers) + .child(text), + ); + + Some(body.into_any_element()) + } +} + +fn scrollbar_preview_overlay_top( + anchor_y: Pixels, + view_height: Pixels, + content_h: Pixels, +) -> Pixels { + let mut y = anchor_y - content_h / 2.0; + let top_pad = px(12.0); + let bottom_pad = px(56.0); + y = y.clamp(top_pad, (view_height - content_h - bottom_pad).max(top_pad)); + y +} + +fn render_scrollbar_preview_line_numbers( + rows: usize, + start_line_from_top: usize, + line_height: Pixels, + line_no_fg: gpui::Hsla, + line_no_digits: usize, +) -> gpui::Div { + let mut line_numbers = div().flex_col().flex_shrink_0(); + for i in 0..rows { + let line_no = start_line_from_top.saturating_add(i).saturating_add(1); + line_numbers = line_numbers.child( + div() + .h(line_height) + .whitespace_nowrap() + .overflow_hidden() + .text_color(line_no_fg) + .child(format_scrollbar_preview_line_number( + line_no, + line_no_digits, + )), + ); + } + line_numbers +} + +#[cfg(test)] +pub(super) mod scrollbar_preview_tests { + use std::{borrow::Cow, ops::RangeInclusive, rc::Rc}; + + use gpui::{ + AppContext, Bounds, Context as GpuiContext, Entity, InteractiveElement, Keystroke, + Modifiers, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, + ScrollWheelEvent, Styled, Window, div, point, px, size, + }; + use gpui_component::Root; + + use super::{format_scrollbar_preview_line_number, scrollbar_marker_match_index_at_position}; + use crate::{ + Cell, GridPoint, IndexedCell, TerminalBackend, TerminalContent, TerminalShutdownPolicy, + TerminalType, settings::CursorShape, terminal::TerminalBounds, view::TerminalView, + }; + + #[test] + fn format_scrollbar_preview_line_number_right_aligns() { + assert_eq!(format_scrollbar_preview_line_number(3, 1), "3\u{00A0}"); + assert_eq!(format_scrollbar_preview_line_number(3, 4), " 3\u{00A0}"); + } + + #[test] + fn format_scrollbar_preview_line_number_uses_trailing_space() { + // The preview renderer now uses real terminal cells (with fixed-width positioning), so + // we no longer need to preserve spaces via NBSP substitution. + assert_eq!(format_scrollbar_preview_line_number(1, 1), "1\u{00A0}"); + } + + pub(crate) struct PreviewBackend { + content: TerminalContent, + matches: Vec>, + total_lines: usize, + viewport_lines: usize, + preview_cols: usize, + preview_rows: usize, + preview_cells: Vec, + } + + impl PreviewBackend { + pub(crate) fn new() -> Self { + // Give the renderer something to work with; actual bounds will be set via `set_size`. + let content = TerminalContent::default(); + + // Make a single match close to the bottom of the buffer. + // With total_lines=100 and viewport_lines=20, line_coord=19 maps to buffer index 99. + let matches = vec![RangeInclusive::new( + GridPoint::new(19, 0), + GridPoint::new(19, 1), + )]; + + let preview_cols = 24; + let preview_rows = 7; + let mut preview_cells = Vec::new(); + for row in 0..preview_rows { + for col in 0..preview_cols { + preview_cells.push(IndexedCell { + point: GridPoint::new(row as i32, col), + cell: Cell { + c: if col == 0 { '>' } else { 'x' }, + ..Default::default() + }, + }); + } + } + + Self { + content, + matches, + total_lines: 100, + viewport_lines: 20, + preview_cols, + preview_rows, + preview_cells, + } + } + } + + impl TerminalBackend for PreviewBackend { + fn backend_name(&self) -> &'static str { + "preview-test" + } + + fn sync(&mut self, _window: &mut Window, _cx: &mut GpuiContext) {} + + fn shutdown( + &mut self, + _policy: TerminalShutdownPolicy, + _cx: &mut GpuiContext, + ) { + } + + fn last_content(&self) -> &TerminalContent { + &self.content + } + + fn matches(&self) -> &[RangeInclusive] { + &self.matches + } + + fn last_clicked_line(&self) -> Option { + None + } + + fn vi_mode_enabled(&self) -> bool { + false + } + + fn mouse_mode(&self, _shift: bool) -> bool { + false + } + + fn selection_started(&self) -> bool { + false + } + + fn set_cursor_shape(&mut self, _cursor_shape: CursorShape) {} + + fn total_lines(&self) -> usize { + self.total_lines + } + + fn viewport_lines(&self) -> usize { + self.viewport_lines + } + + fn activate_match(&mut self, _index: usize) {} + + fn select_matches(&mut self, _matches: &[RangeInclusive]) {} + + fn select_all(&mut self) {} + + fn copy(&mut self, _keep_selection: Option, _cx: &mut GpuiContext) {} + + fn clear(&mut self) {} + + fn scroll_line_up(&mut self) {} + fn scroll_up_by(&mut self, _lines: usize) {} + fn scroll_line_down(&mut self) {} + fn scroll_down_by(&mut self, _lines: usize) {} + fn scroll_page_up(&mut self) {} + fn scroll_page_down(&mut self) {} + fn scroll_to_top(&mut self) {} + fn scroll_to_bottom(&mut self) {} + + fn scrolled_to_top(&self) -> bool { + true + } + + fn scrolled_to_bottom(&self) -> bool { + true + } + + fn set_size(&mut self, new_bounds: TerminalBounds) { + self.content.terminal_bounds = new_bounds; + } + + fn input(&mut self, _input: Cow<'static, [u8]>) {} + + fn paste(&mut self, _text: &str) {} + + fn focus_in(&self) {} + + fn focus_out(&mut self) {} + + fn toggle_vi_mode(&mut self) {} + + fn try_keystroke(&mut self, _keystroke: &Keystroke, _alt_is_meta: bool) -> bool { + false + } + + fn try_modifiers_change( + &mut self, + _modifiers: &Modifiers, + _window: &Window, + _cx: &mut GpuiContext, + ) { + } + + fn mouse_move(&mut self, _e: &MouseMoveEvent, _cx: &mut GpuiContext) {} + + fn select_word_at_event_position(&mut self, _e: &MouseDownEvent) {} + + fn mouse_drag( + &mut self, + _e: &MouseMoveEvent, + _region: Bounds, + _cx: &mut GpuiContext, + ) { + } + + fn mouse_down(&mut self, _e: &MouseDownEvent, _cx: &mut GpuiContext) {} + + fn mouse_up(&mut self, _e: &MouseUpEvent, _cx: &GpuiContext) {} + + fn scroll_wheel(&mut self, _e: &ScrollWheelEvent) {} + + fn get_content(&self) -> String { + String::new() + } + + fn last_n_non_empty_lines(&self, _n: usize) -> Vec { + Vec::new() + } + + fn preview_cells_from_top( + &self, + _start_line: usize, + _count: usize, + ) -> (usize, usize, Vec) { + ( + self.preview_cols, + self.preview_rows, + self.preview_cells.clone(), + ) + } + } + + #[gpui::test] + fn scrollbar_preview_is_not_obscured_by_footer_bar(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + crate::init(app); + }); + + let view_slot: Rc>>> = + Rc::new(std::cell::RefCell::new(None)); + let view_slot_for_window = view_slot.clone(); + + let (root, v) = cx.add_window_view(|window, cx| { + let terminal = cx.new(|_| { + crate::Terminal::new(TerminalType::WezTerm, Box::new(PreviewBackend::new())) + }); + let terminal_view = cx.new(|cx| TerminalView::new(terminal, window, cx)); + *view_slot_for_window.borrow_mut() = Some(terminal_view.clone()); + + terminal_view.update(cx, |this, cx| { + // Anchor the preview near the bottom of the window so the default clamp behavior + // would overlap a bottom footer bar overlay. + this.set_scrollbar_preview_for_match(0, point(px(0.0), px(690.0)), cx); + }); + + Root::new(terminal_view, window, cx) + }); + + v.draw( + point(px(0.0), px(0.0)), + size( + gpui::AvailableSpace::Definite(px(900.0)), + gpui::AvailableSpace::Definite(px(700.0)), + ), + move |_, _| { + div().size_full().relative().child(root).child( + // Simulate a bottom "footer bar" overlay that can obscure the preview + // tooltip when a search marker is near the bottom of the scrollbar. + div() + .debug_selector(|| "test-footerbar".to_string()) + .absolute() + .left_0() + .right_0() + .bottom_0() + .h(px(48.0)), + ) + }, + ); + + v.run_until_parked(); + + let view = view_slot + .borrow() + .clone() + .expect("expected terminal view to be captured"); + let preview_set = v.read_entity(&view, |this, _app| this.scrollbar_preview().is_some()); + assert!(preview_set, "expected scrollbar preview state to be set"); + + let preview_bounds = v + .debug_bounds("terminal-scrollbar-preview") + .expect("scrollbar preview should exist"); + let footer_bounds = v + .debug_bounds("test-footerbar") + .expect("test footer bar should exist"); + + let preview_bottom = preview_bounds.origin.y + preview_bounds.size.height; + assert!( + preview_bottom <= footer_bounds.origin.y, + "expected preview bottom ({preview_bottom:?}) to be above footer bar top ({:?})", + footer_bounds.origin.y + ); + } + + #[test] + fn scrollbar_marker_match_index_at_position_hits_marker_rect_only() { + use crate::view::scrolling::ScrollbarMarkerSpec; + + let lane = Bounds { + origin: point(px(100.0), px(20.0)), + size: size(px(14.0), px(100.0)), + }; + let specs = vec![ + ScrollbarMarkerSpec { + match_index: 3, + y: px(40.0), + active: false, + }, + ScrollbarMarkerSpec { + match_index: 7, + y: px(80.0), + active: true, + }, + ]; + + assert_eq!( + scrollbar_marker_match_index_at_position(&specs, lane, point(px(107.0), px(80.0))), + Some(7) + ); + assert_eq!( + scrollbar_marker_match_index_at_position(&specs, lane, point(px(107.0), px(60.0))), + None + ); + } +} diff --git a/crates/gpui_term/src/view/scrolling.rs b/crates/gpui_term/src/view/scrolling.rs index fe3e9b8..5365f45 100644 --- a/crates/gpui_term/src/view/scrolling.rs +++ b/crates/gpui_term/src/view/scrolling.rs @@ -1,10 +1,9 @@ -use std::{cmp, ops::RangeInclusive, rc::Rc, time::Duration}; +use std::{cell::Cell, cmp, collections::HashMap, ops::RangeInclusive, rc::Rc, time::Duration}; use gpui::{ - App, BorderStyle, Bounds, Context, Pixels, Point, ReadGlobal, ScrollWheelEvent, Window, fill, - outline, point, px, size, + App, Bounds, Context, Pixels, Point, ReadGlobal, ScrollWheelEvent, Window, point, px, size, }; -use gpui_component::ActiveTheme; +use gpui_component::scroll::ScrollbarHandle; use smol::Timer; use super::{BlockProperties, TerminalScrollState, TerminalView}; @@ -19,9 +18,109 @@ use crate::{ pub(crate) const SCROLLBAR_WIDTH: Pixels = px(14.0); pub(crate) const SCROLLBAR_PAD: Pixels = px(2.0); -const MIN_THUMB_HEIGHT: Pixels = px(18.0); -const MARKER_SIZE: Pixels = px(4.0); -const ACTIVE_MARKER_SIZE: Pixels = px(6.0); +pub(crate) const SCROLLBAR_MARKER_HIT_RADIUS: Pixels = px(7.0); +pub(crate) const SCROLLBAR_MARKER_LIMIT: usize = 4096; +pub(crate) const SCROLLBAR_MARKER_SIZE: Pixels = px(4.0); +pub(crate) const SCROLLBAR_ACTIVE_MARKER_SIZE: Pixels = px(6.0); + +#[derive(Debug, Clone, Copy)] +struct ScrollbarHandleState { + line_height: Pixels, + total_lines: usize, + viewport_lines: usize, + display_offset: usize, +} + +impl Default for ScrollbarHandleState { + fn default() -> Self { + Self { + line_height: px(1.0), + total_lines: 0, + viewport_lines: 0, + display_offset: 0, + } + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct TerminalScrollbarHandle { + state: Rc>, + target_display_offset: Rc>>, + dragging: Rc>, +} + +impl TerminalScrollbarHandle { + pub(crate) fn update( + &self, + line_height: Pixels, + total_lines: usize, + viewport_lines: usize, + display_offset: usize, + ) { + self.state.set(ScrollbarHandleState { + line_height: line_height.max(px(1.0)), + total_lines, + viewport_lines, + display_offset, + }); + } + + pub(crate) fn take_target_display_offset(&self) -> Option { + self.target_display_offset.take() + } + + pub(crate) fn is_dragging(&self) -> bool { + self.dragging.get() + } +} + +impl ScrollbarHandle for TerminalScrollbarHandle { + fn offset(&self) -> Point { + let state = self.state.get(); + let max_offset = state.total_lines.saturating_sub(state.viewport_lines); + let display_offset = state.display_offset.min(max_offset); + let scroll_offset_from_top = max_offset.saturating_sub(display_offset); + + point( + Pixels::ZERO, + -(scroll_offset_from_top as f32 * state.line_height), + ) + } + + fn set_offset(&self, offset: Point) { + let mut state = self.state.get(); + let max_offset = state.total_lines.saturating_sub(state.viewport_lines); + if max_offset == 0 { + state.display_offset = 0; + self.state.set(state); + self.target_display_offset.set(Some(0)); + return; + } + + let offset_delta = (offset.y / state.line_height).round() as i32; + let display_offset = (max_offset as i32 + offset_delta).clamp(0, max_offset as i32); + state.display_offset = display_offset as usize; + self.state.set(state); + self.target_display_offset + .set(Some(display_offset as usize)); + } + + fn content_size(&self) -> gpui::Size { + let state = self.state.get(); + size( + Pixels::ZERO, + state.total_lines.max(state.viewport_lines) as f32 * state.line_height, + ) + } + + fn start_drag(&self) { + self.dragging.set(true); + } + + fn end_drag(&self) { + self.dragging.set(false); + } +} pub(crate) struct ScrollState { pub(crate) block_below_cursor: Option>, @@ -29,8 +128,6 @@ pub(crate) struct ScrollState { /// True while the primary mouse button is held down after a press inside this terminal view. /// Used to keep selection dragging alive even when the cursor leaves the terminal hitbox. pub(crate) mouse_left_down_in_terminal: bool, - /// True while the primary mouse button is dragging the scrollbar/minimap. - pub(crate) scrollbar_dragging: bool, /// Whether the pointer is currently within the scrollbar lane. /// /// We keep this in the view so the element can render an overlay scrollbar that @@ -39,20 +136,6 @@ pub(crate) struct ScrollState { /// Whether the scrollbar is temporarily revealed due to scroll-wheel/scroll actions. pub(crate) scrollbar_revealed: bool, pub(crate) scrollbar_reveal_epoch: usize, - /// Last scroll target requested by the scrollbar during an active drag/press. - /// Prevents repeated scroll ops when the platform emits multiple move events at the same - /// position (common cause of thumb flicker/jitter). - pub(crate) scrollbar_last_target_offset: Option, - /// View-local scroll position while dragging the scrollbar. - /// - /// `TerminalContent.display_offset` updates only after the backend processes queued scroll - /// ops; keeping a virtual offset makes the scrollbar thumb feel responsive during drags. - pub(crate) scrollbar_virtual_offset: Option, - /// Initial pointer position and scroll offset for a scrollbar drag. - /// - /// We keep the drag mapping delta-based so a press doesn't "jump" the thumb to the pointer. - pub(crate) scrollbar_drag_start_y: Option, - pub(crate) scrollbar_drag_start_offset: Option, /// Whether to auto-follow the live view as output arrives. /// This tracks the "block below cursor" extra scroll space; terminal scrollback is handled /// separately via `TerminalContent.display_offset`. @@ -68,14 +151,9 @@ impl Default for ScrollState { block_below_cursor: None, scroll_top: Pixels::ZERO, mouse_left_down_in_terminal: false, - scrollbar_dragging: false, scrollbar_hovered: false, scrollbar_revealed: false, scrollbar_reveal_epoch: 0, - scrollbar_last_target_offset: None, - scrollbar_virtual_offset: None, - scrollbar_drag_start_y: None, - scrollbar_drag_start_offset: None, stick_to_bottom: true, scrollbar_preview: None, } @@ -95,11 +173,10 @@ pub(crate) struct ScrollbarPreview { pub(crate) match_range: RangeInclusive, } -#[derive(Clone)] -pub(crate) struct ScrollbarLayoutState { +#[derive(Clone, Copy)] +pub(crate) struct ScrollbarGeometry { pub(crate) bounds: Bounds, - pub(crate) track_bounds: Bounds, - pub(crate) thumb_bounds: Bounds, + pub(crate) track: Bounds, } pub(crate) fn scrollbar_track_bounds(sb: Bounds) -> Bounds { @@ -131,57 +208,14 @@ pub(crate) fn scrollbar_bounds_for_terminal( } } -pub(crate) fn overlay_scrollbar_layout_state( +pub(crate) fn scrollbar_geometry_for_terminal( terminal_bounds: Bounds, scrollbar_width: Pixels, - total_lines: usize, - viewport_lines: usize, - display_offset_for_thumb: usize, -) -> ScrollbarLayoutState { +) -> ScrollbarGeometry { let bounds = scrollbar_bounds_for_terminal(terminal_bounds, scrollbar_width); - let track_bounds = scrollbar_track_bounds(bounds); - let thumb_bounds = thumb_bounds_for_track( - track_bounds, - total_lines, - viewport_lines, - display_offset_for_thumb, - ); - ScrollbarLayoutState { + ScrollbarGeometry { bounds, - track_bounds, - thumb_bounds, - } -} - -pub(crate) fn thumb_bounds_for_track( - track_bounds: Bounds, - total_lines: usize, - viewport_lines: usize, - display_offset_for_thumb: usize, -) -> Bounds { - let max_offset = total_lines.saturating_sub(viewport_lines); - - let track_h = track_bounds.size.height.max(Pixels::ZERO); - if total_lines == 0 || max_offset == 0 || track_h <= Pixels::ZERO { - return Bounds { - origin: track_bounds.origin, - size: size(track_bounds.size.width, track_h), - }; - } - - let ratio = (viewport_lines as f32 / total_lines.max(1) as f32).min(1.0); - let thumb_h = (track_h * ratio).max(MIN_THUMB_HEIGHT).min(track_h); - let y_range = (track_h - thumb_h).max(Pixels::ZERO); - - let t = if max_offset == 0 { - 0.0 - } else { - (display_offset_for_thumb as f32 / max_offset as f32).clamp(0.0, 1.0) - }; - let thumb_y = track_bounds.origin.y + (1.0 - t) * y_range; - Bounds { - origin: point(track_bounds.origin.x, thumb_y), - size: size(track_bounds.size.width, thumb_h), + track: scrollbar_track_bounds(bounds), } } @@ -211,6 +245,62 @@ pub(crate) fn scrollbar_marker_y_for_line_coord( Some(track_bounds.origin.y + track_bounds.size.height * t.clamp(0.0, 1.0)) } +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ScrollbarMarkerSpec { + pub(crate) match_index: usize, + pub(crate) y: Pixels, + pub(crate) active: bool, +} + +pub(crate) fn scrollbar_marker_specs( + track_bounds: Bounds, + total_lines: usize, + viewport_lines: usize, + matches: &[RangeInclusive], + active_match_index: Option, + limit: usize, +) -> Vec { + if matches.is_empty() || total_lines == 0 || viewport_lines == 0 || limit == 0 { + return Vec::new(); + } + + let mut spec_indexes_by_y: HashMap = HashMap::new(); + let mut specs: Vec = Vec::new(); + for (match_index, search_match) in matches.iter().enumerate() { + let Some(y) = scrollbar_marker_y_for_line_coord( + track_bounds, + total_lines, + viewport_lines, + search_match.start().line, + ) else { + continue; + }; + let key = ((y - track_bounds.origin.y) / px(1.0)).round() as i32; + let active = active_match_index == Some(match_index); + + if let Some(&spec_index) = spec_indexes_by_y.get(&key) { + let spec = &mut specs[spec_index]; + if active { + spec.match_index = match_index; + spec.active = true; + } + continue; + } + + spec_indexes_by_y.insert(key, specs.len()); + specs.push(ScrollbarMarkerSpec { + match_index, + y, + active, + }); + if specs.len() >= limit { + break; + } + } + + specs +} + pub(crate) fn buffer_index_for_line_coord( total_lines: usize, viewport_lines: usize, @@ -270,6 +360,14 @@ pub(crate) fn search_match_index_for_scrollbar_hover( if h <= Pixels::ZERO { return None; } + + debug_assert!( + matches + .windows(2) + .all(|pair| pair[0].start().line <= pair[1].start().line), + "scrollbar hover lookup expects search matches sorted by start line" + ); + let mut t = (hover_y - track_bounds.origin.y) / h; t = t.clamp(0.0, 1.0); let idx = ((t * total_lines.max(1) as f32).floor() as i64) @@ -331,181 +429,6 @@ pub(crate) fn search_match_index_for_scrollbar_hover( best.map(|(idx, _)| idx) } -pub(crate) fn search_match_index_for_scrollbar_click( - track_bounds: Bounds, - total_lines: usize, - viewport_lines: usize, - matches: &[RangeInclusive], - click_y: Pixels, - hit_radius: Pixels, -) -> Option { - if matches.is_empty() || hit_radius <= Pixels::ZERO { - return None; - } - - let mut best: Option<(usize, Pixels)> = None; - for (idx, m) in matches.iter().enumerate() { - let line = m.start().line; - let Some(y) = - scrollbar_marker_y_for_line_coord(track_bounds, total_lines, viewport_lines, line) - else { - continue; - }; - let dy = (y - click_y).abs(); - if dy <= hit_radius && best.map(|(_, best_dy)| dy < best_dy).unwrap_or(true) { - best = Some((idx, dy)); - if dy <= px(0.5) { - break; - } - } - } - - best.map(|(idx, _)| idx) -} - -pub(crate) fn scroll_offset_for_drag_delta( - track: Bounds, - drag_start_y: Pixels, - current_y: Pixels, - drag_start_offset: usize, - total_lines: usize, - viewport_lines: usize, -) -> usize { - let max_offset = total_lines.saturating_sub(viewport_lines); - if max_offset == 0 || track.size.height <= Pixels::ZERO { - return 0; - } - - // Match the thumb sizing math used during rendering so dragging feels 1:1 with the thumb. - let track_h = track.size.height.max(Pixels::ZERO); - let thumb_h = if total_lines == 0 || track_h <= Pixels::ZERO { - track_h - } else { - let ratio = (viewport_lines as f32 / total_lines.max(1) as f32).min(1.0); - (track_h * ratio).max(MIN_THUMB_HEIGHT).min(track_h) - }; - let y_range = (track_h - thumb_h).max(Pixels::ZERO); - if y_range <= Pixels::ZERO { - return drag_start_offset.min(max_offset); - } - - // Delta-based mapping prevents "jumping" the thumb to the pointer on press. - let dy = current_y - drag_start_y; - let delta_offset = (-(dy / y_range) * max_offset as f32).round(); - let target = (drag_start_offset as f32 + delta_offset).clamp(0.0, max_offset as f32); - (target.round() as usize).min(max_offset) -} - -pub(crate) fn scroll_offset_for_thumb_center_y( - track: Bounds, - y: Pixels, - total_lines: usize, - viewport_lines: usize, -) -> usize { - let max_offset = total_lines.saturating_sub(viewport_lines); - if max_offset == 0 || track.size.height <= Pixels::ZERO { - return 0; - } - - let track_h = track.size.height.max(Pixels::ZERO); - let thumb_h = if total_lines == 0 || track_h <= Pixels::ZERO { - track_h - } else { - let ratio = (viewport_lines as f32 / total_lines.max(1) as f32).min(1.0); - (track_h * ratio).max(MIN_THUMB_HEIGHT).min(track_h) - }; - let y_range = (track_h - thumb_h).max(Pixels::ZERO); - if y_range <= Pixels::ZERO { - return 0; - } - - // Center the thumb at the click point (clamped to track). - let thumb_y = (y - track.origin.y - thumb_h / 2.0).clamp(Pixels::ZERO, y_range); - let t_pos = (thumb_y / y_range).clamp(0.0, 1.0); - (((1.0 - t_pos) * max_offset as f32).round() as usize).min(max_offset) -} - -pub(crate) fn paint_overlay_scrollbar( - sb: &ScrollbarLayoutState, - markers: &[Pixels], - active_marker: Option, - window: &mut Window, - cx: &mut gpui::App, -) { - // Keep the scrollbar lane visually crisp: a subtle track + a clearer thumb. - window.paint_quad(outline( - sb.bounds, - cx.theme().foreground.opacity(0.10), - BorderStyle::Solid, - )); - window.paint_quad(fill(sb.track_bounds, cx.theme().foreground.opacity(0.03))); - - // Thumb: indicates the current viewport. - window.paint_quad(fill(sb.thumb_bounds, cx.theme().selection.opacity(0.8))); - window.paint_quad(outline( - sb.thumb_bounds, - cx.theme().selection.opacity(0.60), - BorderStyle::Solid, - )); - - if markers.is_empty() && active_marker.is_none() { - return; - } - - let track = sb.track_bounds; - let min_y = track.origin.y; - let marker_color = cx.theme().foreground.opacity(0.30); - - for &y in markers { - let w = MARKER_SIZE.min(track.size.width); - let h = MARKER_SIZE.min(track.size.height); - if w <= Pixels::ZERO || h <= Pixels::ZERO { - break; - } - - let x = track.origin.x + (track.size.width - w) / 2.0; - let mut y0 = y - h / 2.0; - let max_y = track.origin.y + (track.size.height - h).max(Pixels::ZERO); - if y0 < min_y { - y0 = min_y; - } else if y0 > max_y { - y0 = max_y; - } - - window.paint_quad(fill( - Bounds { - origin: point(x, y0), - size: size(w, h), - }, - marker_color, - )); - } - - if let Some(y) = active_marker { - let w = ACTIVE_MARKER_SIZE.min(track.size.width); - let h = ACTIVE_MARKER_SIZE.min(track.size.height); - if w > Pixels::ZERO && h > Pixels::ZERO { - let x = track.origin.x + (track.size.width - w) / 2.0; - let min_y = track.origin.y; - let max_y = track.origin.y + (track.size.height - h).max(Pixels::ZERO); - let mut y0 = y - h / 2.0; - if y0 < min_y { - y0 = min_y; - } else if y0 > max_y { - y0 = max_y; - } - - window.paint_quad(fill( - Bounds { - origin: point(x, y0), - size: size(w, h), - }, - cx.theme().foreground.opacity(0.70), - )); - } - } -} - impl TerminalView { pub(super) fn max_scroll_top(&self, cx: &App) -> Pixels { let terminal = self.terminal.read(cx); @@ -615,7 +538,7 @@ impl TerminalView { } pub(crate) fn scrollbar_dragging(&self) -> bool { - self.scroll.scrollbar_dragging + self.terminal_scrollbar_handle.is_dragging() } pub(crate) fn scrollbar_hovered(&self) -> bool { @@ -626,6 +549,36 @@ impl TerminalView { self.scroll.scrollbar_revealed } + pub(crate) fn scrollbar_geometry(&self, cx: &App) -> ScrollbarGeometry { + let terminal = self.terminal.read(cx); + let width = if TerminalSettings::global(cx).show_scrollbar { + SCROLLBAR_WIDTH + } else { + Pixels::ZERO + }; + scrollbar_geometry_for_terminal(terminal.last_content().terminal_bounds.bounds, width) + } + + pub(crate) fn sync_terminal_scrollbar_handle(&self, cx: &App) { + let terminal = self.terminal.read(cx); + let content = terminal.last_content(); + self.terminal_scrollbar_handle.update( + content.terminal_bounds.line_height, + terminal.total_lines(), + terminal.viewport_lines(), + content.display_offset, + ); + } + + pub(crate) fn apply_terminal_scrollbar_target(&mut self, cx: &mut Context) { + let Some(target_offset) = self.terminal_scrollbar_handle.take_target_display_offset() + else { + return; + }; + + self.apply_scrollbar_target_offset(target_offset, cx); + } + pub(crate) fn set_scrollbar_hovered(&mut self, hovered: bool, cx: &mut Context) { if self.scroll.scrollbar_hovered != hovered { self.scroll.scrollbar_hovered = hovered; @@ -663,41 +616,6 @@ impl TerminalView { self.scroll.scroll_top } - pub(crate) fn scrollbar_virtual_offset(&self) -> Option { - self.scroll.scrollbar_virtual_offset - } - - pub(crate) fn scrollbar_drag_origin(&self) -> Option<(Pixels, usize)> { - Some(( - self.scroll.scrollbar_drag_start_y?, - self.scroll.scrollbar_drag_start_offset?, - )) - } - - pub(crate) fn set_scrollbar_drag_origin(&mut self, mouse_y: Pixels, offset: usize) { - self.scroll.scrollbar_drag_start_y = Some(mouse_y); - self.scroll.scrollbar_drag_start_offset = Some(offset); - } - - pub(crate) fn begin_scrollbar_drag(&mut self, mouse_y: Pixels, cx: &mut Context) { - self.scroll.scrollbar_dragging = true; - let current = { - let terminal = self.terminal.read(cx); - terminal.last_content().display_offset - }; - self.scroll.scrollbar_virtual_offset = Some(current); - self.scroll.scrollbar_last_target_offset = Some(current); - self.set_scrollbar_drag_origin(mouse_y, current); - } - - pub(crate) fn end_scrollbar_drag(&mut self) { - self.scroll.scrollbar_dragging = false; - self.scroll.scrollbar_last_target_offset = None; - self.scroll.scrollbar_virtual_offset = None; - self.scroll.scrollbar_drag_start_y = None; - self.scroll.scrollbar_drag_start_offset = None; - } - pub(crate) fn scrollbar_preview(&self) -> Option<&ScrollbarPreview> { self.scroll.scrollbar_preview.as_ref() } @@ -777,21 +695,41 @@ impl TerminalView { cx.notify(); } + pub(crate) fn update_scrollbar_preview_at( + &mut self, + track: Bounds, + position: Point, + cx: &mut Context, + ) { + let match_idx = { + let terminal = self.terminal.read(cx); + search_match_index_for_scrollbar_hover( + track, + terminal.total_lines(), + terminal.viewport_lines(), + terminal.matches(), + position.y, + SCROLLBAR_MARKER_HIT_RADIUS, + ) + }; + + if let Some(match_idx) = match_idx { + self.set_scrollbar_preview_for_match(match_idx, position, cx); + } else { + self.clear_scrollbar_preview(cx); + } + } + pub(crate) fn apply_scrollbar_target_offset( &mut self, target_offset: usize, cx: &mut Context, ) { - if self.scroll.scrollbar_last_target_offset == Some(target_offset) { - return; - } - self.scroll.scrollbar_last_target_offset = Some(target_offset); - let current = self.scroll.scrollbar_virtual_offset.unwrap_or_else(|| { + let current = { let terminal = self.terminal.read(cx); terminal.last_content().display_offset - }); + }; self.scroll_to_display_offset_from_current(current, target_offset, cx); - self.scroll.scrollbar_virtual_offset = Some(target_offset); } fn scroll_to_display_offset_from_current( @@ -1036,15 +974,74 @@ impl TerminalView { #[cfg(test)] mod tests { - use gpui::{Bounds, point, px, size}; + use gpui::{Bounds, Pixels, point, px, size}; + use gpui_component::scroll::ScrollbarHandle as _; use super::{ - buffer_index_for_line_coord, scroll_offset_for_line_coord_centered, - scrollbar_marker_y_for_line_coord, search_match_index_for_scrollbar_click, + SCROLLBAR_MARKER_LIMIT, TerminalScrollbarHandle, buffer_index_for_line_coord, + scroll_offset_for_line_coord_centered, scrollbar_geometry_for_terminal, + scrollbar_marker_specs, scrollbar_marker_y_for_line_coord, search_match_index_for_scrollbar_hover, }; use crate::GridPoint; + #[test] + fn terminal_scrollbar_handle_maps_display_offset_to_component_offset() { + let handle = TerminalScrollbarHandle::default(); + handle.update(px(10.0), 100, 10, 0); + + assert_eq!(handle.content_size().height, px(1000.0)); + assert_eq!(handle.offset().y, px(-900.0)); + + handle.update(px(10.0), 100, 10, 90); + assert_eq!(handle.offset().y, px(0.0)); + } + + #[test] + fn terminal_scrollbar_handle_maps_component_offset_to_display_offset() { + let handle = TerminalScrollbarHandle::default(); + handle.update(px(10.0), 100, 10, 0); + + handle.set_offset(point(Pixels::ZERO, px(-450.0))); + assert_eq!(handle.take_target_display_offset(), Some(45)); + assert_eq!(handle.offset().y, px(-450.0)); + + handle.set_offset(point(Pixels::ZERO, px(100.0))); + assert_eq!(handle.take_target_display_offset(), Some(90)); + assert_eq!(handle.offset().y, Pixels::ZERO); + + handle.set_offset(point(Pixels::ZERO, px(-10_000.0))); + assert_eq!(handle.take_target_display_offset(), Some(0)); + assert_eq!(handle.offset().y, px(-900.0)); + } + + #[test] + fn terminal_scrollbar_handle_handles_unscrollable_content_and_zero_line_height() { + let handle = TerminalScrollbarHandle::default(); + handle.update(Pixels::ZERO, 5, 10, 99); + + assert_eq!(handle.content_size().height, px(10.0)); + assert_eq!(handle.offset().y, Pixels::ZERO); + + handle.set_offset(point(Pixels::ZERO, px(-100.0))); + assert_eq!(handle.take_target_display_offset(), Some(0)); + } + + #[test] + fn scrollbar_geometry_for_terminal_returns_bounds_and_padded_track() { + let terminal_bounds = Bounds { + origin: point(px(10.0), px(20.0)), + size: size(px(80.0), px(100.0)), + }; + + let geometry = scrollbar_geometry_for_terminal(terminal_bounds, px(14.0)); + + assert_eq!(geometry.bounds.origin, point(px(76.0), px(20.0))); + assert_eq!(geometry.bounds.size, size(px(14.0), px(100.0))); + assert_eq!(geometry.track.origin, point(px(78.0), px(22.0))); + assert_eq!(geometry.track.size, size(px(10.0), px(96.0))); + } + #[test] fn scrollbar_marker_y_maps_entire_buffer_top_to_bottom() { let track = Bounds { @@ -1106,45 +1103,84 @@ mod tests { } #[test] - fn search_match_index_for_scrollbar_click_picks_nearest_marker() { + fn search_match_index_for_scrollbar_hover_picks_nearest_marker() { let track = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(10.0), px(100.0)), }; - // total_lines=3, viewport_lines=1 => coords: -2,-1,0 map to y at ~16.7,50,83.3. + // total_lines=3, viewport_lines=1 => marker y ~ 16.7, 50, 83.3 let matches = vec![ GridPoint::new(-2, 0)..=GridPoint::new(-2, 1), GridPoint::new(-1, 0)..=GridPoint::new(-1, 1), GridPoint::new(0, 0)..=GridPoint::new(0, 1), ]; - let hit = search_match_index_for_scrollbar_click(track, 3, 1, &matches, px(51.0), px(4.0)); - assert_eq!(hit, Some(1)); + let hit = search_match_index_for_scrollbar_hover(track, 3, 1, &matches, px(84.0), px(6.0)); + assert_eq!(hit, Some(2)); - let miss = search_match_index_for_scrollbar_click(track, 3, 1, &matches, px(51.0), px(0.5)); + let miss = search_match_index_for_scrollbar_hover(track, 3, 1, &matches, px(84.0), px(0.5)); assert_eq!(miss, None); } #[test] - fn search_match_index_for_scrollbar_hover_picks_nearest_marker() { + fn scrollbar_marker_specs_marks_group_active_when_duplicate_y_contains_active_match() { let track = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(10.0), px(100.0)), }; + let matches = vec![ + GridPoint::new(-1, 0)..=GridPoint::new(-1, 1), + GridPoint::new(-1, 4)..=GridPoint::new(-1, 5), + ]; - // total_lines=3, viewport_lines=1 => marker y ~ 16.7, 50, 83.3 + let specs = scrollbar_marker_specs(track, 3, 1, &matches, Some(1), SCROLLBAR_MARKER_LIMIT); + + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].match_index, 1); + assert!(specs[0].active); + } + + #[test] + fn scrollbar_marker_specs_preserves_first_seen_order_while_grouping_duplicates() { + let track = Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(10.0), px(100.0)), + }; let matches = vec![ GridPoint::new(-2, 0)..=GridPoint::new(-2, 1), GridPoint::new(-1, 0)..=GridPoint::new(-1, 1), + GridPoint::new(-1, 4)..=GridPoint::new(-1, 5), GridPoint::new(0, 0)..=GridPoint::new(0, 1), ]; - let hit = search_match_index_for_scrollbar_hover(track, 3, 1, &matches, px(84.0), px(6.0)); - assert_eq!(hit, Some(2)); + let specs = scrollbar_marker_specs(track, 3, 1, &matches, Some(2), SCROLLBAR_MARKER_LIMIT); - let miss = search_match_index_for_scrollbar_hover(track, 3, 1, &matches, px(84.0), px(0.5)); - assert_eq!(miss, None); + assert_eq!(specs.len(), 3); + assert_eq!(specs[0].match_index, 0); + assert_eq!(specs[1].match_index, 2); + assert_eq!(specs[2].match_index, 3); + assert!(specs[1].active); + } + + #[test] + fn scrollbar_marker_specs_respects_limit() { + let track = Bounds { + origin: point(px(0.0), px(0.0)), + size: size(px(10.0), px(100.0)), + }; + let matches = vec![ + GridPoint::new(-4, 0)..=GridPoint::new(-4, 1), + GridPoint::new(-3, 0)..=GridPoint::new(-3, 1), + GridPoint::new(-2, 0)..=GridPoint::new(-2, 1), + ]; + + let specs = scrollbar_marker_specs(track, 5, 1, &matches, Some(2), 2); + + assert_eq!(specs.len(), 2); + assert_eq!(specs[0].match_index, 0); + assert_eq!(specs[1].match_index, 1); + assert!(!specs.iter().any(|spec| spec.active)); } #[test] diff --git a/crates/gpui_term/src/view/search.rs b/crates/gpui_term/src/view/search.rs index c72fbb2..742d274 100644 --- a/crates/gpui_term/src/view/search.rs +++ b/crates/gpui_term/src/view/search.rs @@ -9,15 +9,7 @@ use gpui_component::ActiveTheme; use smol::Timer; use unicode_segmentation::UnicodeSegmentation; -use super::{ - ImeState, SearchOverlayDelete, SearchOverlayKeyDown, SearchOverlayMove, TerminalView, - scrolling::{ - SCROLLBAR_WIDTH, scroll_offset_for_line_coord_centered, scroll_offset_for_thumb_center_y, - scrollbar_bounds_for_terminal, scrollbar_track_bounds, - search_match_index_for_scrollbar_click, search_match_index_for_scrollbar_hover, - thumb_bounds_for_track, - }, -}; +use super::{ImeState, SearchOverlayDelete, SearchOverlayKeyDown, SearchOverlayMove, TerminalView}; use crate::{ settings::TerminalSettings, terminal::{Search, SearchClose, SearchNext, SearchPaste, SearchPrevious}, @@ -60,69 +52,11 @@ fn on_search_backdrop_left_mouse_down( cx: &mut Context, ) { if TerminalSettings::global(cx).show_scrollbar { - let term_bounds = { - let terminal = this.terminal.read(cx); - terminal.last_content().terminal_bounds.bounds - }; - let sb_bounds = scrollbar_bounds_for_terminal(term_bounds, SCROLLBAR_WIDTH); - if sb_bounds.contains(&e.position) { + let geometry = this.scrollbar_geometry(cx); + if geometry.bounds.contains(&e.position) { // Allow scrollbar interaction while searching; do not dismiss. - let track = scrollbar_track_bounds(sb_bounds); - let (total_lines, viewport_lines, current_offset) = { - let terminal = this.terminal.read(cx); - let content = terminal.last_content(); - ( - terminal.total_lines(), - terminal.viewport_lines(), - content.display_offset, - ) - }; - this.set_scrollbar_hovered(true, cx); - this.begin_scrollbar_drag(e.position.y, cx); this.set_mouse_left_down_in_terminal(false); - - let thumb_bounds = - thumb_bounds_for_track(track, total_lines, viewport_lines, current_offset); - - if !thumb_bounds.contains(&e.position) { - let marker_hit_radius = px(7.0); - let match_idx = { - let terminal = this.terminal.read(cx); - search_match_index_for_scrollbar_click( - track, - total_lines, - viewport_lines, - terminal.matches(), - e.position.y, - marker_hit_radius, - ) - }; - - let target_offset = if let Some(match_idx) = match_idx { - let line = { - let terminal = this.terminal.read(cx); - terminal.matches()[match_idx].start().line - }; - let target_offset = - scroll_offset_for_line_coord_centered(total_lines, viewport_lines, line); - this.terminal.update(cx, |term, _| { - term.activate_match(match_idx); - }); - target_offset - } else { - scroll_offset_for_thumb_center_y( - track, - e.position.y, - total_lines, - viewport_lines, - ) - }; - - this.apply_scrollbar_target_offset(target_offset, cx); - this.set_scrollbar_drag_origin(e.position.y, target_offset); - } - cx.stop_propagation(); return; } @@ -141,29 +75,9 @@ fn on_search_backdrop_mouse_move( let panel_dragging = this.search.search_panel_dragging; if !panel_dragging { if TerminalSettings::global(cx).show_scrollbar { - let term_bounds = { - let terminal = this.terminal.read(cx); - terminal.last_content().terminal_bounds.bounds - }; - let sb_bounds = scrollbar_bounds_for_terminal(term_bounds, SCROLLBAR_WIDTH); - if sb_bounds.contains(&e.position) { - let track = scrollbar_track_bounds(sb_bounds); - let match_idx = { - let terminal = this.terminal.read(cx); - search_match_index_for_scrollbar_hover( - track, - terminal.total_lines(), - terminal.viewport_lines(), - terminal.matches(), - e.position.y, - px(7.0), - ) - }; - if let Some(match_idx) = match_idx { - this.set_scrollbar_preview_for_match(match_idx, e.position, cx); - } else { - this.clear_scrollbar_preview(cx); - } + let geometry = this.scrollbar_geometry(cx); + if geometry.bounds.contains(&e.position) { + this.update_scrollbar_preview_at(geometry.track, e.position, cx); } else { this.clear_scrollbar_preview(cx); } @@ -224,7 +138,6 @@ fn on_search_backdrop_left_mouse_up( search.search_panel_dragging = false; search.search_panel_drag_start_mouse = None; search.search_panel_drag_start_pos = None; - this.end_scrollbar_drag(); cx.stop_propagation(); } @@ -235,12 +148,8 @@ fn on_search_backdrop_right_mouse_down( cx: &mut Context, ) { if TerminalSettings::global(cx).show_scrollbar { - let term_bounds = { - let terminal = this.terminal.read(cx); - terminal.last_content().terminal_bounds.bounds - }; - let sb_bounds = scrollbar_bounds_for_terminal(term_bounds, SCROLLBAR_WIDTH); - if sb_bounds.contains(&e.position) { + let geometry = this.scrollbar_geometry(cx); + if geometry.bounds.contains(&e.position) { // Do not dismiss on scrollbar right-click either. cx.stop_propagation(); return;