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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 61 additions & 27 deletions crates/fresh-editor/src/app/mouse_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use crate::input::keybindings::Action;
use crate::model::event::{ContainerId, CursorId, LeafId, SplitDirection};
use crate::services::plugins::hooks::HookArgs;
use crate::view::popup_mouse::{popup_areas_to_layout_info, PopupHitTester};
use crate::view::prompt::PromptType;
use crate::view::prompt::{PromptType, MAX_VISIBLE_SUGGESTIONS};
use crate::view::ui::tabs::TabHit;
use anyhow::Result as AnyhowResult;
use ratatui::layout::Rect;
Expand All @@ -23,6 +23,33 @@ fn in_rect(col: u16, row: u16, rect: Rect) -> bool {
col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
}

/// Map a screen row on a suggestion list's scrollbar track to the prompt
/// scroll offset that puts the thumb's top on exactly that row.
///
/// Shared by the press and the drag-follow-up so the thumb tracks the cursor
/// identically in both. `ScrollbarState::click_to_offset` is deliberately
/// *not* used here: it divides by the whole track height instead of by the
/// thumb's actual travel, so it lands the thumb a row above the row the user
/// pointed at and can never reach the bottom of the track.
/// [`ScrollbarState::offset_for_thumb_top`] is the real inverse of the thumb
/// geometry the renderer draws.
///
/// Rows above/below the track clamp to its ends rather than being rejected,
/// so a fast drag doesn't drop the thumb.
fn prompt_scrollbar_offset_for_row(
total: usize,
visible: usize,
scroll_offset: usize,
sb_rect: Rect,
row: u16,
) -> usize {
use crate::view::ui::scrollbar::ScrollbarState;
let clamped_row = row.clamp(sb_rect.y, sb_rect.y + sb_rect.height.saturating_sub(1));
let track_row = clamped_row.saturating_sub(sb_rect.y) as usize;
ScrollbarState::new(total, visible, scroll_offset)
.offset_for_thumb_top(sb_rect.height as usize, track_row)
}

/// Where a screen cell lands inside a floating widget panel. See
/// [`Editor::probe_floating_widget`].
struct FloatingWidgetProbe {
Expand Down Expand Up @@ -544,7 +571,9 @@ impl Editor {
// under the pointer — the preview when over it, otherwise the
// result list (without moving the selection). See issue #2119.
} else if self.handle_prompt_scroll(delta) {
// bottom-anchored prompt consumed the scroll (moves selection)
// bottom-anchored prompt dropdown consumed the scroll: it scrolls
// its list without moving the selection, like every other wheel
// surface in the editor.
} else if self.is_file_open_active()
&& self.is_mouse_over_file_browser(col, row)
&& self.handle_file_open_scroll(delta)
Expand Down Expand Up @@ -598,7 +627,8 @@ impl Editor {
/// result list *without* moving the selection.
///
/// Bottom-anchored prompts (command palette, file finder) are left to
/// `handle_prompt_scroll`, which keeps their wheel-moves-selection UX.
/// `handle_prompt_scroll`, which scrolls their dropdown the same
/// selection-preserving way.
fn handle_overlay_prompt_scroll(&mut self, col: u16, row: u16, delta: i32) -> bool {
if !self.overlay_prompt_active() {
return false;
Expand Down Expand Up @@ -1953,13 +1983,10 @@ impl Editor {
Some(self.handle_action(Action::PromptConfirm))
}

/// Click/drag on the floating-overlay prompt's scrollbar
/// (issue #1796). Reuses
/// `view::ui::scrollbar::ScrollbarState::click_to_offset` for
/// the same math the popup-scrollbar handler uses, so thumb
/// behaviour is consistent across the editor.
/// Click/drag on a suggestion-list scrollbar: the floating-overlay
/// prompt's (issue #1796) and the bottom-anchored dropdown's
/// (issues #623 / #1593), which share `suggestions_scrollbar_rect`.
fn handle_click_prompt_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
use crate::view::ui::scrollbar::ScrollbarState;
let sb_rect = self.active_chrome().suggestions_scrollbar_rect?;
if col < sb_rect.x
|| col >= sb_rect.x + sb_rect.width
Expand All @@ -1981,12 +2008,19 @@ impl Editor {
.windows
.get_mut(&active_window_id)
.and_then(|w| w.prompt.as_mut())?;
let visible = suggestions_area_visible.unwrap_or(prompt.suggestions.len().min(10));
let total = prompt.suggestions.len();
let track_height = sb_rect.height as usize;
let click_row = row.saturating_sub(sb_rect.y) as usize;
let state = ScrollbarState::new(total, visible, prompt.scroll_offset);
prompt.scroll_offset = state.click_to_offset(track_height, click_row);
let visible = suggestions_area_visible
.unwrap_or_else(|| prompt.suggestions.len().min(MAX_VISIBLE_SUGGESTIONS));
prompt.scroll_offset = prompt_scrollbar_offset_for_row(
prompt.suggestions.len(),
visible,
prompt.scroll_offset,
sb_rect,
row,
);
// Latch manual scroll so the renderer's keep-selection-visible
// pass doesn't immediately yank the offset back to the selection
// (same latch the wheel uses; released when the selection moves).
prompt.manual_scroll = true;
// Hand off to the drag follow-up so subsequent mouse moves
// keep tracking the thumb.
self.active_window_mut()
Expand Down Expand Up @@ -2976,7 +3010,6 @@ impl Editor {
.mouse_state
.dragging_prompt_scrollbar
{
use crate::view::ui::scrollbar::ScrollbarState;
// Snapshot chrome rects up front so the prompt borrow on
// active_window_mut() doesn't conflict.
let sb_rect = self.active_chrome().suggestions_scrollbar_rect;
Expand All @@ -2989,17 +3022,18 @@ impl Editor {
.get_mut(&active_window_id)
.and_then(|w| w.prompt.as_mut()),
) {
let visible = suggestions_area_visible.unwrap_or(prompt.suggestions.len().min(10));
let total = prompt.suggestions.len();
let track_height = sb_rect.height as usize;
// Allow dragging slightly past the top/bottom; clamp
// here rather than rejecting so the thumb keeps up
// with a fast mouse.
let clamped_row =
row.clamp(sb_rect.y, sb_rect.y + sb_rect.height.saturating_sub(1));
let click_row = clamped_row.saturating_sub(sb_rect.y) as usize;
let state = ScrollbarState::new(total, visible, prompt.scroll_offset);
prompt.scroll_offset = state.click_to_offset(track_height, click_row);
let visible = suggestions_area_visible
.unwrap_or_else(|| prompt.suggestions.len().min(MAX_VISIBLE_SUGGESTIONS));
prompt.scroll_offset = prompt_scrollbar_offset_for_row(
prompt.suggestions.len(),
visible,
prompt.scroll_offset,
sb_rect,
row,
);
// Keep the manual-scroll latch through the drag so the
// renderer doesn't pull the offset back to the selection.
prompt.manual_scroll = true;
}
return Ok(());
}
Expand Down
49 changes: 23 additions & 26 deletions crates/fresh-editor/src/app/prompt_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::input::keybindings::KeyContext;
use crate::input::quick_open::{BufferInfo, QuickOpenContext};
use crate::services::async_bridge::AsyncMessage;
use crate::services::plugins::PluginManager;
use crate::view::prompt::{Prompt, PromptType};
use crate::view::prompt::{Prompt, PromptType, MAX_VISIBLE_SUGGESTIONS};

use super::file_open;
use super::window::Window;
Expand Down Expand Up @@ -967,37 +967,34 @@ impl Editor {
}
}

/// Handle mouse wheel scroll in prompt with suggestions.
/// Handle mouse wheel scroll over a prompt's suggestion list (command
/// palette, Select Locale, quick open, every other bottom-anchored
/// dropdown).
///
/// The wheel scrolls the **view only** — it never moves the selection.
/// That is the editor-wide rule (and what VS Code does): the highlighted
/// entry may scroll out of sight, and pressing Enter still commits it.
/// Wheeling used to walk `selected_suggestion` instead, which also
/// rewrote the prompt input under the user and — once the scrollbar
/// could latch the offset — made the list jump, because the wheel
/// released the latch and the renderer snapped the view back to a
/// selection that had never visibly moved.
///
/// Returns true if scroll was handled, false if no prompt is active or has no suggestions.
pub fn handle_prompt_scroll(&mut self, delta: i32) -> bool {
// Scroll by what the renderer actually drew (`suggestions_area` is
// `(inner_rect, scroll_start_idx, visible_count, total_count)`), so
// the offset can't run past the end of the list. Read before
// borrowing the prompt: `active_window_mut()` is a method call, so
// the compiler can't see the two are disjoint sub-fields.
let visible_rows = self.active_chrome().suggestions_area.map(|(_, _, v, _)| v);
if let Some(ref mut prompt) = self.active_window_mut().prompt {
if prompt.suggestions.is_empty() {
return false;
}

let current = prompt.selected_suggestion.unwrap_or(0);
let len = prompt.suggestions.len();

// Calculate new position based on scroll direction
// delta < 0 = scroll up, delta > 0 = scroll down
let new_selected = if delta < 0 {
// Scroll up - move selection up (decrease index)
current.saturating_sub((-delta) as usize)
} else {
// Scroll down - move selection down (increase index)
(current + delta as usize).min(len.saturating_sub(1))
};

prompt.selected_suggestion = Some(new_selected);

// Update input to match selected suggestion for non-plugin prompts
if !matches!(prompt.prompt_type, PromptType::Plugin { .. }) {
if let Some(suggestion) = prompt.suggestions.get(new_selected) {
prompt.input = suggestion.get_value().to_string();
prompt.cursor_pos = prompt.input.len();
}
}

let visible = visible_rows
.unwrap_or_else(|| prompt.suggestions.len().min(MAX_VISIBLE_SUGGESTIONS));
prompt.scroll_results(delta, visible);
return true;
}
false
Expand Down
28 changes: 25 additions & 3 deletions crates/fresh-editor/src/app/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@ impl Editor {
// Initialize popup/suggestion layout state (rendered after status bar below)
self.active_chrome_mut().suggestions_area = None;
self.active_chrome_mut().suggestions_outer_area = None;
self.active_chrome_mut().suggestions_scrollbar_rect = None;
self.active_chrome_mut().prompt_results_area = None;
self.active_chrome_mut().prompt_preview_area = None;
self.active_window_mut().file_browser_layout = None;
Expand Down Expand Up @@ -2453,9 +2454,12 @@ impl Editor {
return;
}

let suggestion_count = prompt.suggestions.len().min(10);
let is_quick_open = prompt.prompt_type == crate::view::prompt::PromptType::QuickOpen;
let hints_height: u16 = if is_quick_open { 1 } else { 0 };
let suggestion_count = prompt
.suggestions
.len()
.min(crate::view::prompt::MAX_VISIBLE_SUGGESTIONS);
let height = suggestion_count as u16 + 2 + hints_height;

let suggestions_area = ratatui::layout::Rect {
Expand All @@ -2478,9 +2482,14 @@ impl Editor {
}

// Adjust the prompt's scroll position to keep the selected item
// visible, scrolling the minimum amount required.
// visible, scrolling the minimum amount required — unless the user
// has scrolled the list with the scrollbar, in which case pulling
// the offset back would undo their scroll (same reasoning as the
// overlay prompt, issue #2119).
if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
prompt.ensure_selected_visible();
if !prompt.manual_scroll {
prompt.ensure_selected_visible_within(suggestion_count);
}
}
let Some(prompt) = &self.active_window().prompt else {
return;
Expand All @@ -2500,6 +2509,19 @@ impl Editor {
if chrome.suggestions_area.is_some() {
chrome.suggestions_outer_area = Some(suggestions_area);
}
// When the list overflows, the renderer drew a scrollbar over the
// popup's right border; record its rect so the shared prompt-
// scrollbar mouse handlers (click-to-jump, thumb drag) work here
// exactly like in the overlay prompt (issue #623 / #1593).
chrome.suggestions_scrollbar_rect =
new_suggestions_area.and_then(|(inner, _, visible, total)| {
(total > visible).then_some(ratatui::layout::Rect {
x: inner.x + inner.width,
y: inner.y,
width: 1,
height: inner.height,
})
});

// The quick-open hints row is chrome drawn into cells; the web renders
// no hints, so in `suppress_chrome_cells` mode we skip it entirely
Expand Down
23 changes: 9 additions & 14 deletions crates/fresh-editor/src/view/popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,8 +586,15 @@ impl Popup {
}
}

/// Scroll by a delta amount (positive = down, negative = up)
/// Used for mouse wheel scrolling
/// Scroll by a delta amount (positive = down, negative = up).
///
/// Used for mouse-wheel scrolling and for the scrollbar handlers, so it
/// moves the **view only**: a `List` popup's selection stays on whatever
/// entry it was on, even when that entry scrolls off-screen. Dragging the
/// selection along with the viewport made the wheel silently retarget
/// what Enter would commit; the keyboard paths (`select_next`,
/// `page_down`, …) are the ones that move the selection, and they scroll
/// the view to follow it.
pub fn scroll_by(&mut self, delta: i32) {
let content_len = self.wrapped_item_count();
let visible = self.visible_height();
Expand All @@ -600,18 +607,6 @@ impl Popup {
// Scroll down
self.scroll_offset = (self.scroll_offset + delta as usize).min(max_scroll);
}

// For list popups, adjust selection to stay visible
if let PopupContent::List { items, selected } = &mut self.content {
let visible_start = self.scroll_offset;
let visible_end = (self.scroll_offset + visible).min(items.len());

if *selected < visible_start {
*selected = visible_start;
} else if *selected >= visible_end {
*selected = visible_end.saturating_sub(1);
}
}
}

/// Get the total number of items/lines in the popup
Expand Down
45 changes: 20 additions & 25 deletions crates/fresh-editor/src/view/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,17 @@ pub struct Prompt {
pub status: String,
}

/// Maximum number of suggestion rows shown at once. Mirrors the cap used by
/// `SuggestionsRenderer` so `Prompt::ensure_selected_visible` can compute the
/// viewport size without inspecting render state.
/// Maximum number of suggestion rows a bottom-anchored dropdown shows at once.
/// The palette is a transient overlay drawn over the document, so it stays
/// deliberately short rather than growing with the terminal — on an 80x24
/// terminal a height-sized dropdown would cover all but one line of the
/// buffer, and on a split layout it would hide the other pane entirely.
/// Overflow is communicated by the scrollbar the renderer draws over the
/// popup's right border (issues #623 / #1593) instead of by more rows.
///
/// Renderers with their own geometry (the floating Live Grep overlay, which
/// can be 30+ rows tall) pass their actual height to
/// [`Prompt::ensure_selected_visible_within`] rather than using this cap.
pub const MAX_VISIBLE_SUGGESTIONS: usize = 10;

impl Prompt {
Expand Down Expand Up @@ -674,28 +682,15 @@ impl Prompt {
self.manual_scroll = false;
}

/// Adjust `scroll_offset` so that `selected_suggestion` is inside the
/// viewport, scrolling the minimum amount required. A selection that's
/// already on-screen leaves the viewport untouched — this is what stops
/// a click on a near-bottom item from snapping the list upward and
/// recentering under the cursor (issue #1660).
///
/// Uses the bottom-popup default cap (`MAX_VISIBLE_SUGGESTIONS`).
/// Callers rendering into a different-sized area (e.g. the
/// floating Live Grep overlay, where the suggestion list can be
/// 30+ rows tall) should call
/// [`ensure_selected_visible_within`] with the actual height
/// instead — otherwise the scroll moves prematurely once the
/// selection passes the 10th row even though the rest of the
/// list is still visible on-screen.
pub fn ensure_selected_visible(&mut self) {
self.ensure_selected_visible_within(MAX_VISIBLE_SUGGESTIONS);
}

/// Like [`ensure_selected_visible`] but with an explicit
/// `visible_count` argument, so renderers in differently-sized
/// frames don't all share the bottom-popup `MAX_VISIBLE_SUGGESTIONS`
/// assumption.
/// Adjust `scroll_offset` so that `selected_suggestion` is inside a
/// viewport of `visible_count` rows, scrolling the minimum amount
/// required. A selection that's already on-screen leaves the viewport
/// untouched — this is what stops a click on a near-bottom item from
/// snapping the list upward and recentering under the cursor (issue
/// #1660). Callers pass the actual rendered height of their list
/// (bottom-anchored popup and floating Live Grep overlay alike), so
/// the scroll only moves when the selection genuinely leaves the
/// visible window.
pub fn ensure_selected_visible_within(&mut self, visible_count: usize) {
let total = self.suggestions.len();
let visible = total.min(visible_count.max(1));
Expand Down
Loading