use super::*;
use crate::command;
use crate::edit_op::EditOp;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use std::io;
pub(crate) fn emit_image_placements(app: &mut App) {
use crate::image::ImageProtocol;
use std::io::Write;
let protocol = app.image_protocol;
if matches!(protocol, ImageProtocol::None) {
app.image_paint_requests.clear();
app.had_image_pane = false;
return;
}
let pending = std::mem::take(&mut app.image_paint_requests);
let any_now = !pending.is_empty();
let current_paints: Vec<(crate::layout::PaneId, ratatui::layout::Rect)> =
pending.iter().map(|r| (r.pane_id, r.area)).collect();
if any_now && current_paints == app.last_image_paints {
app.had_image_pane = true;
return;
}
let needs_clear = any_now || app.had_image_pane;
let mut out = io::stdout();
if needs_clear && matches!(protocol, ImageProtocol::Kitty) {
let _ = out.write_all(crate::image::kitty::clear_all().as_bytes());
}
for req in pending {
let _ = write!(
out,
"\x1b[{};{}H",
req.area.y.saturating_add(1),
req.area.x.saturating_add(1)
);
match protocol {
ImageProtocol::Kitty => {
if let Ok(esc) = crate::image::kitty::encode_placement(
&req.png_bytes,
req.area.width,
req.area.height,
) {
let _ = out.write_all(esc.as_bytes());
}
}
ImageProtocol::Iterm2 => {
let esc = crate::image::iterm2::encode_placement(
&req.png_bytes,
req.area.width,
req.area.height,
);
let _ = out.write_all(esc.as_bytes());
}
ImageProtocol::Sixel => {
if let Ok(esc) = crate::image::sixel::encode_placement(
&req.png_bytes,
req.area.width,
req.area.height,
) {
let _ = out.write_all(esc.as_bytes());
}
}
ImageProtocol::None => {}
}
}
let _ = out.flush();
app.had_image_pane = any_now;
app.last_image_paints = current_paints;
}
pub(crate) fn record_dot(
app: &mut crate::app::App,
key: KeyEvent,
mode_before: Option<crate::input::EditingMode>,
mode_after: Option<crate::input::EditingMode>,
pending_before: Option<String>,
pending_after: Option<String>,
edited: bool,
) {
use crate::input::EditingMode;
let (Some(before), Some(after)) = (mode_before, mode_after) else {
return;
};
let recording = app.dot_recording.is_some();
if recording {
if let Some(rec) = &mut app.dot_recording {
rec.push(key);
}
if edited {
app.dot_recording_saw_edit = true;
}
let in_flight = after == EditingMode::Insert || pending_after.is_some();
if !in_flight {
if app.dot_recording_saw_edit {
if let Some(rec) = app.dot_recording.take() {
app.dot_keys = rec;
}
} else {
app.dot_recording = None;
}
app.dot_recording_saw_edit = false;
}
return;
}
let in_flight_after = after == EditingMode::Insert || pending_after.is_some();
let started_change =
before == EditingMode::Normal && pending_before.is_none() && in_flight_after;
if started_change {
app.dot_recording = Some(vec![key]);
app.dot_recording_saw_edit = edited;
return;
}
if before.is_visual() && after == EditingMode::Insert {
app.dot_recording = Some(vec![key]);
app.dot_recording_saw_edit = edited;
return;
}
if before == EditingMode::Normal
&& after == EditingMode::Normal
&& pending_before.is_none()
&& pending_after.is_none()
&& edited
{
app.dot_keys = vec![key];
}
if before.is_visual() && after == EditingMode::Normal && edited {
app.dot_keys = vec![key];
}
}
pub(crate) fn is_abbreviation_trigger(c: char) -> bool {
c.is_whitespace()
|| matches!(
c,
'.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\'' | '`'
)
}
pub(crate) fn pane_viewport(app: &App) -> usize {
app.active
.and_then(|cur| {
app.rects
.editor_panes
.iter()
.find(|(_, p)| *p == cur)
.map(|(r, _)| r.height as usize)
})
.unwrap_or(20)
.max(1)
}
pub(crate) fn apply_app_command(app: &mut App, cmd: crate::input::AppCommand) {
use crate::input::AppCommand::*;
match cmd {
Save => {
command::run("file.save", app);
}
ExCommand(s) => {
if app.ex_history.last() != Some(&s) {
app.ex_history.push(s.clone());
if app.ex_history.len() > 100 {
let drop = app.ex_history.len() - 100;
app.ex_history.drain(..drop);
}
}
app.run_ex_command(&s);
}
RunCommand(id) => {
command::run(&id, app);
}
DotRepeat(n) => {
app.pending_dot_count = Some(n);
app.dot_replay();
}
SetMark(c) => app.set_mark_at_cursor(c),
JumpToMarkLine(c) => app.jump_to_mark(c, false),
JumpToMarkExact(c) => app.jump_to_mark(c, true),
MacroRecordInto(c) => {
app.set_pending_macro_register(c);
app.macro_toggle();
}
MacroReplayFrom { reg, count } => {
let n = count.max(1);
let mut prev_snapshot: Option<(usize, usize)> = None;
for _ in 0..n {
let snap_before = app
.active_editor()
.map(|b| (b.editor.text().len(), b.editor.cursor()));
if let Some(prev) = prev_snapshot
&& snap_before == Some(prev)
{
break;
}
app.set_pending_macro_register(reg);
app.macro_replay();
prev_snapshot = snap_before;
}
}
BlockInsertStart { append } => app.block_insert_start(append),
BlockChangeStart => app.block_change_start(),
BlockReplaceWith { ch } => app.block_replace_with(ch),
FilterLinesFromCursor { count } => app.begin_filter_lines_from_cursor(count),
FilterParagraphFromCursor { around } => app.begin_filter_paragraph_from_cursor(around),
OperatorLinewiseTo { op, target } => app.vim_operator_linewise_to(op, target),
CmdlineTabComplete => app.cmdline_tab_complete(),
CmdlinePopupMove(delta) => app.cmdline_popup_move(delta as isize),
CmdlineInsertCursorWord(big) => app.cmdline_insert_cursor_word(big),
CmdlinePasteFromClipboard => app.cmdline_paste_from_clipboard(),
CmdlineEnter(typed) => {
let effective = if app.cmdline_popup_selected > 0
&& let Some(state) = app.cmdline_complete_state.as_ref()
&& let Some(suffix) = state.matches.get(app.cmdline_popup_selected)
{
format!("{}{}", state.head, suffix)
} else {
typed.clone()
};
if app.ex_history.last() != Some(&effective) {
app.ex_history.push(effective.clone());
if app.ex_history.len() > 100 {
let drop = app.ex_history.len() - 100;
app.ex_history.drain(..drop);
}
}
app.run_ex_command(&effective);
}
RepeatInsertStart { count, above } => app.repeat_insert_start(count as usize, above),
FlashStart(a, b) => app.flash_start(a, b),
}
}
pub(crate) fn click_to_file_pos(
b: &crate::buffer::Buffer,
tr: Rect,
wrap: bool,
x: u16,
y: u16,
) -> (usize, usize) {
let visible_row = (y.saturating_sub(tr.y)) as usize;
let click_col = (x.saturating_sub(tr.x)) as usize;
let tw = tr.width as usize;
if wrap && tw > 0 {
let (row, char_start) = b
.wrap_to_file_pos(b.scroll, visible_row, tw)
.unwrap_or((b.scroll, 0));
(row, char_start + click_col)
} else {
let row = b
.visible_to_file_row(b.scroll, visible_row)
.unwrap_or(b.scroll);
(row, b.h_scroll + click_col)
}
}
pub(crate) fn hover_chip_at(app: &App, x: u16, y: u16) -> Option<crate::HoverChip> {
if app.rects.split_dividers.iter().any(|d| {
x >= d.rect.x
&& x < d.rect.x + d.rect.width
&& y >= d.rect.y
&& y < d.rect.y + d.rect.height
}) {
return Some(crate::HoverChip::SplitDivider);
}
if let Some(&(_, pane_id, line_no, kind)) = app
.rects
.gutter_marks
.iter()
.find(|(r, _, _, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::GutterMark {
pane_id,
line_no,
kind,
});
}
if let Some(&(_, _, kind)) = app
.rects
.claude_agents_topbar_chips
.iter()
.find(|(r, _, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::ClaudeAgentsTopbarChip(kind));
}
if let Some(r) = app.rects.statusline_stress_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineStress);
}
if let Some(r) = app.rects.palette_stress_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::PaletteStress);
}
if let Some((idx, _)) = app
.rects
.toast_stack_rects
.iter()
.enumerate()
.find(|(_, r)| contains(**r, x, y))
{
return Some(crate::HoverChip::ToastBox(idx));
}
if let Some(r) = app.rects.statusline_mode_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineMode);
}
if let Some(r) = app.rects.statusline_branch_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineBranch);
}
if let Some(r) = app.rects.statusline_workspace_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineWorkspace);
}
if let Some(r) = app.rects.statusline_clock_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineClock);
}
if let Some(r) = app.rects.statusline_lsp_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineLsp);
}
if let Some(r) = app.rects.statusline_wrap_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineWrap);
}
if let Some(r) = app.rects.statusline_ai_claude_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineAiClaude);
}
if let Some(r) = app.rects.statusline_ai_codex_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineAiCodex);
}
if let Some(r) = app.rects.statusline_autosave_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineAutosave);
}
if let Some(r) = app.rects.statusline_file_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineFile);
}
if let Some(r) = app.rects.statusline_diagnostics_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineDiagnostics);
}
if let Some(r) = app.rects.statusline_symbol_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineSymbol);
}
if let Some(r) = app.rects.statusline_pr_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslinePr);
}
if let Some(r) = app.rects.statusline_language_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineLanguage);
}
if let Some(r) = app.rects.statusline_macro_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineMacroRec);
}
if let Some(r) = app.rects.statusline_find_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineFind);
}
if let Some(r) = app.rects.statusline_sel_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineSel);
}
if let Some(r) = app.rects.statusline_progress_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineProgress);
}
if let Some(r) = app.rects.statusline_bg_tasks_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineBgTasks);
}
if let Some(r) = app.rects.statusline_ai_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineAi);
}
if let Some(r) = app.rects.request_method_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestTopBarChip(
crate::RequestTopBarChip::Method,
));
}
if let Some(r) = app.rects.request_env_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestTopBarChip(
crate::RequestTopBarChip::Env,
));
}
if let Some(r) = app.rects.request_send_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestTopBarChip(
crate::RequestTopBarChip::Send,
));
}
if let Some(r) = app.rects.request_save_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestTopBarChip(
crate::RequestTopBarChip::Save,
));
}
if let Some(r) = app.rects.request_clear_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestTopBarChip(
crate::RequestTopBarChip::Clear,
));
}
if let Some(r) = app.rects.request_code_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestTopBarChip(
crate::RequestTopBarChip::Code,
));
}
if let Some(r) = app.rects.request_split_toggle
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestSplitToggle);
}
if let Some(r) = app.rects.request_edit_split_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestEditSplitChip);
}
if let Some((idx, _)) = app
.rects
.http_panel_section_chips
.iter()
.enumerate()
.find(|(_, (r, _, _))| contains(*r, x, y))
{
return Some(crate::HoverChip::HttpSectionChip(idx));
}
if let Some((idx, _)) = app
.rects
.http_panel_icon_buttons
.iter()
.enumerate()
.find(|(_, (r, _))| contains(*r, x, y))
{
return Some(crate::HoverChip::HttpToolbarChip(idx));
}
if let Some(r) = app.rects.request_edit_split_divider
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestEditSplitDivider);
}
if let Some((idx, _)) = app
.rects
.http_panel_collection_new_request_chips
.iter()
.enumerate()
.find(|(_, (r, _))| contains(*r, x, y))
{
return Some(crate::HoverChip::HttpCollectionAddRequestChip(idx));
}
if let Some((idx, _)) = app
.rects
.request_var_click_rects
.iter()
.enumerate()
.find(|(_, (r, _))| contains(*r, x, y))
{
return Some(crate::HoverChip::RequestVarToken(idx));
}
if let Some(r) = app.rects.request_response_copy_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestResponseCopy);
}
if let Some(r) = app.rects.request_response_wrap_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestResponseWrap);
}
if let Some(r) = app.rects.request_response_ai_prompt_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestResponseAiPrompt);
}
if let Some(r) = app.rects.request_format_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::RequestResponseFormat);
}
if let Some(r) = app.rects.pending_undo_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::PendingUndoChip);
}
if let Some(r) = app.rects.bufferline_new_request_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::BufferlineNewRequest);
}
if app.rects.scrollbars.iter().any(|h| contains(h.area, x, y)) {
return Some(crate::HoverChip::ScrollbarThumb);
}
if let Some(r) = app.rects.right_panel_edge
&& contains(r, x, y)
{
return Some(crate::HoverChip::RightPanelGrip);
}
if let Some(r) = app.rects.tree_edge
&& contains(r, x, y)
{
return Some(crate::HoverChip::TreeRailGrip);
}
if let Some(&(_, idx)) = app
.rects
.menu_bar_words
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::MenuBarWord(idx));
}
if let Some(open) = app.menu_open.as_ref()
&& let Some(&(_, item_idx)) = app
.rects
.menu_bar_items
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::MenuBarItem {
menu_idx: open.menu_idx,
item_idx,
});
}
if let Some(r) = app.rects.statusline_filesize_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineFilesize);
}
if let Some(r) = app.rects.statusline_lncol_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineLnCol);
}
if let Some(idx) = app
.rects
.statusline_segment_hits
.iter()
.position(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::StatuslineSegment(idx));
}
if let Some(&(_, cmd_id)) = app
.rects
.tree_icon_buttons
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::TreeIcon(cmd_id));
}
if let Some(tr) = app.rects.tree_toggle
&& contains(tr, x, y)
{
return Some(crate::HoverChip::WorkspaceHeader);
}
if let Some(&(_, ws_idx)) = app
.rects
.extra_workspace_toggles
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::ExtraWorkspaceHeader(ws_idx));
}
if let Some(&(_, icon_idx)) = app
.rects
.integration_icon_rects
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::IntegrationIcon(icon_idx));
}
if let Some(&(_, section)) = app
.rects
.activity_bar_icons
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::ActivityBarIcon(section));
}
if let Some(r) = app.rects.statusline_mixr_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineNowPlaying);
}
if let Some(&(_, pane_id, commit_idx, lane_idx)) = app
.rects
.git_graph_lane_cells
.iter()
.find(|(r, _, _, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::GitGraphLane {
pane_id,
commit_idx,
lane_idx,
});
}
if let Some(&(_, pane_id, commit_idx)) = app
.rects
.git_graph_subject_cells
.iter()
.find(|(r, _, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::GitGraphCommitMsg {
pane_id,
commit_idx,
});
}
if let Some(r) = app.rects.palette_sidebar_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::PaletteSidebarButton);
}
if let Some(r) = app.rects.palette_right_panel_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::PaletteRightPanelButton);
}
if let Some(r) = app.rects.palette_back_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::PaletteBackButton);
}
if let Some(r) = app.rects.palette_forward_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::PaletteForwardButton);
}
if let Some(r) = app.rects.palette_search_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::PaletteSearchChip);
}
if let Some(r) = app.rects.palette_dropdown_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::PaletteDropdownButton);
}
if let Some(r) = app.rects.palette_add_integration_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::PaletteAddIntegration);
}
if let Some(&(_, leaf_active, tab_pane)) = app
.rects
.split_tab_close
.iter()
.find(|(r, _, _)| contains(*r, x, y))
{
let _ = leaf_active;
return Some(crate::HoverChip::SplitTabClose(tab_pane));
}
if let Some(&(_, leaf_active, tab_pane)) = app
.rects
.split_tab_chips
.iter()
.find(|(r, _, _)| contains(*r, x, y))
{
let _ = leaf_active;
return Some(crate::HoverChip::SplitTabChip(tab_pane));
}
if let Some(&(_, leaf_active)) = app
.rects
.split_tab_plus_buttons
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::SplitTabPlus(leaf_active));
}
if let Some(&(_, tab_idx)) = app
.rects
.right_panel_tabs
.iter()
.find(|(r, _)| contains(*r, x, y))
&& let Some(&pid) = app.right_panel_panes.get(tab_idx)
{
return Some(crate::HoverChip::RightPanelTab(pid));
}
if let Some(r) = app.rects.right_panel_close
&& contains(r, x, y)
{
return Some(crate::HoverChip::RightPanelClose);
}
if let Some(r) = app.rects.agents_panel_new_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::AgentsPanelChip(
crate::AgentsPanelChipKind::NewSession,
));
}
if let Some(r) = app.rects.agents_panel_pr_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::AgentsPanelChip(
crate::AgentsPanelChipKind::FromPr,
));
}
if let Some(r) = app.rects.agents_panel_view_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::AgentsPanelChip(
crate::AgentsPanelChipKind::ViewToggle,
));
}
if let Some(r) = app.rects.cloud_agents_new_run_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::CloudAgentsNewRunButton);
}
if let Some(r) = app.rects.activity_bar_gear
&& contains(r, x, y)
{
return Some(crate::HoverChip::ActivityBarGear);
}
if app
.rects
.split_strip_ai_buttons
.iter()
.any(|(r, _, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::SplitStripAiButton);
}
if let Some(r) = app.rects.statusline_mixr_play_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineMixrPlay);
}
if let Some(r) = app.rects.statusline_mixr_ffwd_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineMixrFfwd);
}
if let Some(r) = app.rects.statusline_sonos_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineSonos);
}
if let Some(r) = app.rects.statusline_sonos_play_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineSonosPlay);
}
if let Some(r) = app.rects.statusline_sonos_next_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineSonosNext);
}
if let Some(r) = app.rects.statusline_sonos_label_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineSonosLabel);
}
if let Some(r) = app.rects.statusline_test_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineTestChip);
}
if app
.rects
.split_strip_term_buttons
.iter()
.any(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::SplitStripTermButton);
}
if app
.rects
.split_strip_maximize_buttons
.iter()
.any(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::SplitStripMaximizeButton);
}
if let Some(&(_, _, dir)) = app
.rects
.split_strip_buttons
.iter()
.find(|(r, _, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::SplitStripButton(dir));
}
if let Some(&(_, action)) = app
.rects
.rail_git_header_buttons
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::RailHeaderChip(action));
}
if let Some(&(_, _, action)) = app
.rects
.git_toolbar_buttons
.iter()
.find(|(r, _, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::GitToolbarChip(action));
}
if let Some(&(_, pid)) = app
.rects
.bufferline_tab_close
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::BufferlineTabClose(pid));
}
if let Some(&(_, pid)) = app
.rects
.bufferline_tabs
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::BufferlineTab(pid));
}
if let Some(&(_, pid)) = app
.rects
.session_tabs
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::SessionsTab(pid));
}
if let Some(r) = app.rects.bufferline_new_tab_button
&& contains(r, x, y)
{
return Some(crate::HoverChip::BufferlineNewTab);
}
if let Some(r) = app.rects.bufferline_tabs_label
&& contains(r, x, y)
{
return Some(crate::HoverChip::BufferlineTabsLabel);
}
if let Some(r) = app.rects.bufferline_theme_toggle
&& contains(r, x, y)
{
return Some(crate::HoverChip::BufferlineThemeToggle);
}
if let Some(r) = app.rects.bufferline_window_close
&& contains(r, x, y)
{
return Some(crate::HoverChip::BufferlineWindowClose);
}
if let Some(&(_, idx)) = app
.rects
.bufferline_tab_page_close
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::BufferlineTabPageClose(idx));
}
if let Some(&(_, idx)) = app
.rects
.bufferline_tab_page_chips
.iter()
.find(|(r, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::BufferlineTabPage(idx));
}
if let Some(r) = app.rects.integrations_tab_installed
&& contains(r, x, y)
{
return Some(crate::HoverChip::IntegrationsTabInstalled);
}
if let Some(r) = app.rects.integrations_tab_marketplace
&& contains(r, x, y)
{
return Some(crate::HoverChip::IntegrationsTabMarketplace);
}
if let Some(r) = app.rects.integrations_tab_refresh
&& contains(r, x, y)
{
return Some(crate::HoverChip::IntegrationsTabRefresh);
}
if let Some(r) = app.rects.integrations_tab_sort
&& contains(r, x, y)
{
return Some(crate::HoverChip::IntegrationsTabSort);
}
if let Some(r) = app.rects.statusline_coverage_chip
&& contains(r, x, y)
{
return Some(crate::HoverChip::StatuslineCoverage);
}
if let Some(&(_, _, action)) = app
.rects
.diff_toolbar_buttons
.iter()
.find(|(r, _, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::DiffToolbar(action));
}
if app
.rects
.fold_chips
.iter()
.any(|(r, _, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::FoldChip);
}
if app
.rects
.code_lens_chips
.iter()
.any(|(r, _, _)| contains(*r, x, y))
{
return Some(crate::HoverChip::CodeLensChip);
}
None
}
const LIST_SCROLL_PER_BATCH_CAP: i32 = 8;
const SCROLL_BUCKET_MAX: f32 = 40.0;
const SCROLL_BUCKET_REFILL: f32 = 60.0;
fn scroll_accel_ceiling(setting: &str) -> f32 {
match setting {
"off" => 1.0,
"gentle" => 1.5,
"fast" => 4.0,
_ => 2.5, }
}
const SCROLL_ACCEL_FULL_RATE: f32 = 120.0;
const SCROLL_ACCEL_FLOOR_RATE: f32 = 45.0;
const SCROLL_GESTURE_GAP_MS: u64 = 250;
fn budgeted_scroll(app: &mut App, delta: i32) -> i32 {
budgeted_scroll_at(app, delta, std::time::Instant::now())
}
fn budgeted_scroll_at(app: &mut App, delta: i32, now: std::time::Instant) -> i32 {
if delta == 0 {
return 0;
}
let ceiling = scroll_accel_ceiling(&app.config.editor.scroll_accel);
let want_raw = delta.unsigned_abs() as f32;
let gap = app
.scroll_last_event_at
.map(|t| now.duration_since(t))
.unwrap_or(std::time::Duration::MAX);
app.scroll_last_event_at = Some(now);
let new_gesture = gap > std::time::Duration::from_millis(SCROLL_GESTURE_GAP_MS);
let rate = if new_gesture {
0.0
} else {
let secs = gap.as_secs_f32().max(0.001);
want_raw / secs
};
if new_gesture {
app.scroll_gesture_peak_rate = 0.0;
app.scroll_rate_decaying = false;
app.scroll_frac_carry = 0.0;
app.scroll_row_accum = 0.0;
}
let cap = SCROLL_BUCKET_MAX * ceiling.max(1.0);
if let Some(prev) = app.scroll_bucket_last_refill {
let elapsed = now.duration_since(prev).as_secs_f32();
app.scroll_bucket = (app.scroll_bucket + elapsed * SCROLL_BUCKET_REFILL).min(cap);
} else {
app.scroll_bucket = cap;
}
app.scroll_bucket_last_refill = Some(now);
if ceiling <= 1.0 {
let spend = want_raw.min(app.scroll_bucket).floor();
app.scroll_bucket -= spend;
app.scroll_last_factor = 1.0;
return delta.signum() * (spend as i32);
}
let peak = app.scroll_gesture_peak_rate;
if rate > peak {
app.scroll_gesture_peak_rate = rate;
} else if peak > 0.0 && rate < peak * 0.5 {
app.scroll_rate_decaying = true;
}
let ramp = ((rate - SCROLL_ACCEL_FLOOR_RATE)
/ (SCROLL_ACCEL_FULL_RATE - SCROLL_ACCEL_FLOOR_RATE))
.clamp(0.0, 1.0);
let factor = if app.scroll_rate_decaying {
1.0
} else {
1.0 + (ceiling - 1.0) * ramp
};
app.scroll_last_factor = factor;
let want = want_raw * factor;
let wanted_with_carry = want + app.scroll_frac_carry;
let spend = wanted_with_carry.min(app.scroll_bucket).floor();
app.scroll_frac_carry = (wanted_with_carry - spend).clamp(0.0, 1.0);
app.scroll_bucket -= spend;
delta.signum() * (spend as i32)
}
fn list_scroll_clamp_scaled(delta: i32, ceiling: f32) -> i32 {
let sign = delta.signum();
let mag = delta.unsigned_abs() as i32;
let cap = ((LIST_SCROLL_PER_BATCH_CAP as f32) * ceiling).round() as i32;
sign * mag.min(cap.max(LIST_SCROLL_PER_BATCH_CAP))
}
pub(crate) fn scroll_under(app: &mut App, x: u16, y: u16, delta: i32) {
let delta = budgeted_scroll(app, delta);
if delta == 0 {
return;
}
let scroll_ceiling = scroll_accel_ceiling(&app.config.editor.scroll_accel);
let accel_on = scroll_ceiling > 1.0;
let on_bufferline_zone = app
.rects
.bufferline_tabs
.iter()
.any(|(r, _)| contains(*r, x, y));
if on_bufferline_zone {
if delta < 0 {
app.prev_buffer();
} else {
app.next_buffer();
}
return;
}
if app.active_section == crate::app::ActivitySection::Sessions
&& let Some(ar) = app.rects.sessions_panel_area
&& contains(ar, x, y)
{
let d = list_scroll_clamp_scaled(delta, scroll_ceiling);
if d < 0 {
app.sessions_panel_scroll = app
.sessions_panel_scroll
.saturating_sub(d.unsigned_abs() as usize);
} else {
app.sessions_panel_scroll = app.sessions_panel_scroll.saturating_add(d as usize);
}
return;
}
if app.active_section == crate::app::ActivitySection::Agents
&& let Some(ar) = app.rects.agents_panel_area
&& contains(ar, x, y)
{
let d = list_scroll_clamp_scaled(delta, scroll_ceiling);
if d < 0 {
app.agents_panel_scroll = app
.agents_panel_scroll
.saturating_sub(d.unsigned_abs() as usize);
} else {
app.agents_panel_scroll = app.agents_panel_scroll.saturating_add(d as usize);
}
return;
}
if app.active_section == crate::app::ActivitySection::Integrations
&& let Some(ar) = app.rects.integrations_panel_area
&& contains(ar, x, y)
{
let d = list_scroll_clamp_scaled(delta, scroll_ceiling);
let step = 3usize;
let target: &mut usize = match app.integrations_panel_tab {
crate::app::IntegrationsPanelTab::Installed => {
&mut app.integrations_panel_scroll_installed
}
crate::app::IntegrationsPanelTab::Marketplace => {
&mut app.integrations_panel_scroll_marketplace
}
crate::app::IntegrationsPanelTab::InDev => &mut app.integrations_panel_scroll_in_dev,
};
if d < 0 {
*target = target.saturating_sub(step * d.unsigned_abs() as usize);
} else {
*target = target.saturating_add(step * d as usize);
}
return;
}
if app.active_section == crate::app::ActivitySection::Http {
let d = list_scroll_clamp_scaled(delta, scroll_ceiling);
let bump = |cur: &mut usize, d: i32| {
if d < 0 {
*cur = cur.saturating_sub(d.unsigned_abs() as usize);
} else {
*cur = cur.saturating_add(d as usize);
}
};
if app
.rects
.http_panel_captured_rows
.iter()
.any(|(r, _)| contains(*r, x, y))
{
bump(&mut app.http_panel_captured_scroll, d);
return;
}
if app
.rects
.http_panel_recent_rows
.iter()
.any(|(r, _)| contains(*r, x, y))
{
bump(&mut app.http_panel_recent_scroll, d);
return;
}
if app
.rects
.http_panel_mock_rows
.iter()
.any(|(r, _)| contains(*r, x, y))
{
bump(&mut app.http_panel_mocks_scroll, d);
return;
}
if app
.rects
.http_panel_chain_rows
.iter()
.any(|(r, _)| contains(*r, x, y))
{
bump(&mut app.http_panel_chains_scroll, d);
return;
}
if app
.rects
.http_panel_collection_folder_rows
.iter()
.any(|(r, _)| contains(*r, x, y))
|| app
.rects
.http_panel_collection_rows
.iter()
.any(|(r, _)| contains(*r, x, y))
{
bump(&mut app.http_panel_collections_scroll, d);
return;
}
}
if let Some(tr) = app.rects.tree
&& contains(tr, x, y)
{
const TREE_NOTCH_WINDOW_MS: u64 = 60;
let step_now = std::time::Instant::now();
let within_same_notch = app
.tree_last_row_step
.map(|t| {
step_now.duration_since(t) < std::time::Duration::from_millis(TREE_NOTCH_WINDOW_MS)
})
.unwrap_or(false);
if within_same_notch && !accel_on {
return;
}
app.tree_last_row_step = Some(step_now);
if accel_on {
app.scroll_row_accum += app.scroll_last_factor.max(1.0);
let rows = app.scroll_row_accum.floor().max(1.0);
app.scroll_row_accum -= rows;
let rows = rows as usize;
let cur = app.tree.cursor();
app.tree.set_cursor(if delta < 0 {
cur.saturating_sub(rows)
} else {
cur.saturating_add(rows)
});
} else if delta < 0 {
app.tree.move_up();
} else {
app.tree.move_down();
}
return;
}
if app.active_section == crate::app::ActivitySection::Git
&& let Some((row_rect, _)) = app.rects.git_palette_rows.first()
{
let bbox_x = row_rect.x;
let bbox_w = row_rect.width;
let bbox_y0 = app
.rects
.git_palette_rows
.iter()
.map(|(r, _)| r.y)
.min()
.unwrap_or(row_rect.y);
let bbox_y1 = app
.rects
.git_palette_rows
.iter()
.map(|(r, _)| r.y)
.max()
.unwrap_or(row_rect.y);
if x >= bbox_x && x < bbox_x + bbox_w && y >= bbox_y0 && y <= bbox_y1 {
let d = list_scroll_clamp_scaled(delta, scroll_ceiling);
if d < 0 {
app.git_palette_scroll = app
.git_palette_scroll
.saturating_sub(d.unsigned_abs() as usize);
} else {
app.git_palette_scroll = app.git_palette_scroll.saturating_add(d as usize);
}
return;
}
}
if let Some(&(_, ws_idx, _)) = app
.rects
.extra_workspace_bodies
.iter()
.find(|(r, _, _)| contains(*r, x, y))
{
if let Some(ws) = app.extra_workspaces.get_mut(ws_idx) {
if delta < 0 {
ws.tree.move_up();
} else {
ws.tree.move_down();
}
}
return;
}
if let Some(hr) = app.rects.git_section_toggle
&& contains(hr, x, y)
&& app.repos.len() > 1
{
app.cycle_active_repo(delta > 0);
return;
}
if app
.rects
.git_rail_rows
.iter()
.any(|(r, _)| contains(*r, x, y))
{
let d = list_scroll_clamp_scaled(delta, scroll_ceiling);
for _ in 0..d.unsigned_abs() {
if d < 0 {
app.git_rail_move_up();
} else {
app.git_rail_move_down();
}
}
return;
}
if let Some(&(_, leaf_pane)) = app
.rects
.split_tab_strip_areas
.iter()
.find(|(r, _)| contains(*r, x, y))
{
let leaf_key = app
.layout()
.leaf_containing(leaf_pane)
.and_then(|tabs| tabs.first().copied())
.unwrap_or(leaf_pane);
let cur = app.leaf_tab_scroll.get(&leaf_key).copied().unwrap_or(0);
let next = if delta < 0 {
cur.saturating_sub(1)
} else {
cur.saturating_add(1)
};
app.leaf_tab_scroll.insert(leaf_key, next);
return;
}
if let Some(&(tr, pid)) = app
.rects
.editor_panes
.iter()
.find(|(r, _)| contains(*r, x, y))
{
let follows_cursor = app.cursor_follows_wheel();
let vp = (tr.height as usize).max(1);
const EDITOR_WHEEL_GAIN: usize = 3;
match app.panes.get_mut(pid) {
Some(Pane::Files(f)) => {
f.move_selection(list_scroll_clamp_scaled(delta, scroll_ceiling) as isize);
}
Some(Pane::Editor(b)) => {
let n = delta.unsigned_abs() as usize * EDITOR_WHEEL_GAIN;
if follows_cursor {
let op = if delta < 0 {
EditOp::MoveUp
} else {
EditOp::MoveDown
};
for _ in 0..n {
b.editor.apply(op.clone(), vp, &mut app.clipboard);
}
} else {
b.scroll = if delta < 0 {
b.scroll.saturating_sub(n)
} else {
let max = b.editor.line_count().saturating_sub(1);
(b.scroll + n).min(max)
};
b.scroll_pinned = true;
}
}
Some(Pane::MdPreview(p)) => {
let n = delta.unsigned_abs() as usize * EDITOR_WHEEL_GAIN;
p.scroll = if delta < 0 {
p.scroll.saturating_sub(n)
} else {
p.scroll + n
};
}
Some(Pane::Diff(d)) => {
let n = delta.unsigned_abs() as usize * EDITOR_WHEEL_GAIN;
d.scroll = if delta < 0 {
d.scroll.saturating_sub(n)
} else {
d.scroll + n
};
}
Some(Pane::Request(rp)) => {
let n = delta.unsigned_abs() as usize;
rp.scroll = if delta < 0 {
rp.scroll.saturating_sub(n)
} else {
rp.scroll + n
};
}
Some(Pane::Pty(s)) => s.scroll_history(if delta < 0 {
delta.unsigned_abs() as isize
} else {
-(delta.unsigned_abs() as isize)
}),
Some(Pane::Ai(a)) => {
let n = delta.unsigned_abs() as usize;
a.scroll = if delta < 0 {
a.scroll.saturating_sub(n)
} else {
a.scroll + n
};
}
Some(Pane::Tests(t)) => {
let n = delta.unsigned_abs() as usize;
t.scroll = if delta < 0 {
t.scroll.saturating_sub(n)
} else {
t.scroll + n
};
}
Some(Pane::GitGraph(g)) => {
if let Some(d) = g.embedded_diff.as_mut() {
let n = delta.unsigned_abs() as usize;
d.scroll = if delta < 0 {
d.scroll.saturating_sub(n)
} else {
d.scroll + n
};
} else {
g.move_selection(if delta < 0 {
-(delta.unsigned_abs() as isize)
} else {
delta.unsigned_abs() as isize
});
}
}
Some(Pane::GitStatus(g)) => {
g.move_selection(if delta < 0 {
-(delta.unsigned_abs() as isize)
} else {
delta.unsigned_abs() as isize
});
}
Some(Pane::Diagnostics(d)) => {
d.move_selection(if delta < 0 {
-(delta.unsigned_abs() as isize)
} else {
delta.unsigned_abs() as isize
});
}
Some(Pane::Grep(g)) => {
g.move_selection(if delta < 0 {
-(delta.unsigned_abs() as isize)
} else {
delta.unsigned_abs() as isize
});
}
Some(Pane::Browser(b)) => {
let step = if delta < 0 {
-(delta.unsigned_abs() as isize)
} else {
delta.unsigned_abs() as isize
};
if b.dom_focus {
b.move_dom_sel(step);
} else if b.net_focus {
b.move_net_sel(step);
} else if b.cookies_focus {
b.move_cookies_sel(step);
} else if b.storage_focus {
b.move_storage_sel(step);
} else {
let n = delta.unsigned_abs() as usize;
b.scroll = if delta < 0 {
b.scroll.saturating_sub(n)
} else {
b.scroll.saturating_add(n)
};
}
}
Some(Pane::Flaky(f)) => {
f.move_selection(if delta < 0 {
-(delta.unsigned_abs() as isize)
} else {
delta.unsigned_abs() as isize
});
}
Some(Pane::Outline(o)) => {
o.move_selection(if delta < 0 {
-(delta.unsigned_abs() as isize)
} else {
delta.unsigned_abs() as isize
});
}
Some(Pane::CmdlineHistory(h)) => {
h.move_selection(if delta < 0 {
-(delta.unsigned_abs() as isize)
} else {
delta.unsigned_abs() as isize
});
}
Some(Pane::Quickfix(g)) => {
g.move_selection(if delta < 0 {
-(delta.unsigned_abs() as isize)
} else {
delta.unsigned_abs() as isize
});
}
Some(Pane::Cheatsheet(c)) => {
if delta < 0 {
c.move_up();
} else {
c.move_down();
}
}
Some(Pane::Debug(p)) => {
let d = delta.signum() as isize;
let n = delta.unsigned_abs() as isize;
let section = p.section;
match section {
crate::pane::DebugSection::Stack => app.debug_pane_move(d * n),
crate::pane::DebugSection::Variables => app.debug_pane_vars_move(d * n),
}
}
Some(Pane::DapRepl(_)) => {
let mag = delta.unsigned_abs() as usize;
if delta < 0 {
if let Some(Pane::DapRepl(p)) = app.panes.get_mut(pid) {
let total = p.history.len();
let cur = if p.scroll == usize::MAX {
total
} else {
p.scroll
};
p.scroll = cur.saturating_sub(mag);
}
} else if let Some(Pane::DapRepl(p)) = app.panes.get_mut(pid) {
let total = p.history.len();
let new = if p.scroll == usize::MAX {
usize::MAX
} else {
let next = p.scroll.saturating_add(mag);
if next >= total { usize::MAX } else { next }
};
p.scroll = new;
}
}
Some(Pane::Image(_)) => {
}
Some(Pane::ClaudeAgents(p)) => {
for _ in 0..delta.unsigned_abs() {
if delta < 0 {
p.move_up();
} else {
p.move_down();
}
}
}
Some(Pane::Websocket(p)) => {
let step = delta.unsigned_abs() as usize;
if delta < 0 {
p.scroll = p.scroll.saturating_add(step);
} else {
p.scroll = p.scroll.saturating_sub(step);
}
}
Some(Pane::SpendReport(p)) => {
let step = delta.unsigned_abs() as usize;
let n = p.snapshot.per_workspace.len();
if n > 0 {
if delta < 0 {
p.selected = p.selected.saturating_sub(step);
} else {
p.selected = (p.selected + step).min(n - 1);
}
}
}
Some(Pane::Mount(m)) => {
m.send_input(mnml_bridge::InputEvent::Scroll {
col: 0,
row: 0,
dy: delta as i16,
});
}
Some(Pane::NewCloudAgentWizard(_)) | Some(Pane::NewCloudRunWizard(_)) => {
}
Some(Pane::IntegrationDetail(p)) => {
if delta < 0 {
p.scroll = p.scroll.saturating_sub(delta.unsigned_abs() as usize);
} else {
p.scroll = p.scroll.saturating_add(delta as usize);
}
}
Some(Pane::ClaudeUsage(p)) => {
if delta < 0 {
p.scroll = p.scroll.saturating_sub(delta.unsigned_abs() as usize);
} else {
p.scroll = p.scroll.saturating_add(delta as usize);
}
}
Some(Pane::CodexUsage(p)) => {
if delta < 0 {
p.scroll = p.scroll.saturating_sub(delta.unsigned_abs() as usize);
} else {
p.scroll = p.scroll.saturating_add(delta as usize);
}
}
Some(Pane::CloudAgentRun(p)) => {
let n = delta.unsigned_abs() as usize;
if delta < 0 {
if p.log_scroll == usize::MAX {
p.log_scroll = p.logs.len().saturating_sub(n);
} else {
p.log_scroll = p.log_scroll.saturating_sub(n);
}
p.log_follow = false;
} else {
let max = p.logs.len();
let new = p.log_scroll.saturating_add(n).min(max);
if new >= max.saturating_sub(1) {
p.log_scroll = usize::MAX;
p.log_follow = true;
} else {
p.log_scroll = new;
}
}
}
None => {}
}
let _ = delta;
let _ = pid;
}
}
pub(crate) fn contains(r: Rect, x: u16, y: u16) -> bool {
x >= r.x && x < r.x.saturating_add(r.width) && y >= r.y && y < r.y.saturating_add(r.height)
}
pub(crate) fn handle_scm_row_click(
app: &mut App,
pane_id: usize,
flat_idx: usize,
is_double_click: bool,
) {
use crate::pane::Pane;
if matches!(app.panes.get(pane_id), Some(Pane::Diagnostics(_))) {
if let Some(Pane::Diagnostics(d)) = app.panes.get_mut(pane_id) {
let n = d.visible_indices().len();
if flat_idx < n {
d.selected = flat_idx;
}
}
if is_double_click {
app.jump_to_selected_diagnostic();
}
return;
}
if matches!(app.panes.get(pane_id), Some(Pane::Outline(_))) {
if let Some(Pane::Outline(o)) = app.panes.get_mut(pane_id) {
let len = o.visible_indices().len();
if flat_idx < len {
o.selected = flat_idx;
}
}
if is_double_click {
app.jump_to_selected_outline();
}
return;
}
if matches!(app.panes.get(pane_id), Some(Pane::Flaky(_))) {
if let Some(Pane::Flaky(f)) = app.panes.get_mut(pane_id)
&& flat_idx < f.items.len()
{
f.selected = flat_idx;
}
if is_double_click {
app.jump_to_selected_flaky();
}
return;
}
if matches!(app.panes.get(pane_id), Some(Pane::Diff(_))) {
if let Some(Pane::Diff(d)) = app.panes.get_mut(pane_id)
&& flat_idx < d.hunks.len()
{
d.cursor = flat_idx;
if d.view_mode == crate::pane::DiffViewMode::Hunk {
if d.hunk_collapsed.contains(&flat_idx) {
d.hunk_collapsed.remove(&flat_idx);
} else {
d.hunk_collapsed.insert(flat_idx);
}
}
}
if is_double_click {
app.jump_to_cursor_hunk();
}
return;
}
if matches!(app.panes.get(pane_id), Some(Pane::GitGraph(_))) {
if let Some(Pane::GitGraph(g)) = app.panes.get_mut(pane_id) {
g.jump_to(flat_idx);
}
if is_double_click {
app.open_selected_commit_diff();
}
return;
}
if matches!(app.panes.get(pane_id), Some(Pane::Cheatsheet(_))) {
if let Some(Pane::Cheatsheet(c)) = app.panes.get_mut(pane_id) {
let n = c.visible_rows_len();
if flat_idx < n {
c.selected = flat_idx;
}
}
if is_double_click {
app.cheatsheet_run_selected();
}
return;
}
if matches!(app.panes.get(pane_id), Some(Pane::CmdlineHistory(_))) {
if let Some(Pane::CmdlineHistory(h)) = app.panes.get_mut(pane_id)
&& flat_idx < h.entries.len()
{
h.selected = flat_idx;
}
if is_double_click {
app.cmdline_history_accept();
}
return;
}
if matches!(app.panes.get(pane_id), Some(Pane::ClaudeAgents(_))) {
if let Some(Pane::ClaudeAgents(p)) = app.panes.get_mut(pane_id) {
let n = p.visible_indices().len();
if flat_idx < n {
p.selected = flat_idx;
p.detail_scroll = 0;
}
}
if is_double_click {
app.claude_agents_action(crate::claude_agents::ClaudeAgentsAction::OpenTranscript);
}
return;
}
if matches!(app.panes.get(pane_id), Some(Pane::Tests(_))) {
if let Some(Pane::Tests(t)) = app.panes.get_mut(pane_id)
&& let crate::playwright::TestsState::Done(r) = &t.state
&& flat_idx < r.tests.len()
{
t.selected = flat_idx;
}
if is_double_click {
app.jump_to_selected_test();
}
return;
}
if matches!(app.panes.get(pane_id), Some(Pane::GitStatus(_))) {
if let Some(Pane::GitStatus(g)) = app.panes.get_mut(pane_id) {
let total = g.unstaged.len() + g.staged.len();
if flat_idx < total {
g.selected = flat_idx;
}
}
if is_double_click {
app.git_status_open_diff();
}
return;
}
if matches!(
app.panes.get(pane_id),
Some(Pane::Grep(_)) | Some(Pane::Quickfix(_))
) {
let len = match app.panes.get(pane_id) {
Some(Pane::Grep(g)) | Some(Pane::Quickfix(g)) => g.hits.len(),
_ => 0,
};
if let Some(pane) = app.panes.get_mut(pane_id) {
let target = match pane {
Pane::Grep(g) | Pane::Quickfix(g) => Some(g),
_ => None,
};
if let Some(g) = target
&& flat_idx < len
{
g.selected = flat_idx;
}
}
if is_double_click {
app.jump_to_selected_grep_hit();
}
return;
}
if matches!(app.panes.get(pane_id), Some(Pane::Browser(_))) {
let net_double_open = {
let Some(Pane::Browser(b)) = app.panes.get_mut(pane_id) else {
return;
};
if b.dom_focus {
let n = b.visible_dom_indices().len();
if flat_idx < n {
b.set_dom_sel(flat_idx);
}
false
} else if b.cookies_focus {
if flat_idx < b.cookies.len() {
b.cookies_sel = flat_idx;
}
false
} else if b.storage_focus {
if flat_idx < b.storage.len() {
b.storage_sel = flat_idx;
}
false
} else if b.net_focus {
let n = b.visible_net_indices().len();
if flat_idx < n {
b.net_sel = flat_idx;
}
is_double_click
} else {
false
}
};
if net_double_open {
app.open_net_entry_as_request();
}
return;
}
let _ = (app, pane_id);
}
pub(crate) fn pty_key_bytes(key: KeyEvent) -> Vec<u8> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let prefix_alt = |b: Vec<u8>| {
if alt {
let mut v = vec![0x1b];
v.extend(b);
v
} else {
b
}
};
match key.code {
KeyCode::Char(c) => {
if ctrl {
let b = match c.to_ascii_lowercase() {
'a'..='z' => Some((c.to_ascii_lowercase() as u8) - b'a' + 1),
' ' | '@' => Some(0),
'[' => Some(0x1b),
'\\' => Some(0x1c),
']' => Some(0x1d),
'^' => Some(0x1e),
'_' | '?' => Some(0x1f),
_ => None,
};
match b {
Some(b) => prefix_alt(vec![b]),
None => prefix_alt(c.to_string().into_bytes()),
}
} else {
prefix_alt(c.to_string().into_bytes())
}
}
KeyCode::Enter => prefix_alt(vec![b'\r']),
KeyCode::Tab => prefix_alt(vec![b'\t']),
KeyCode::BackTab => b"\x1b[Z".to_vec(),
KeyCode::Backspace => prefix_alt(vec![0x7f]),
KeyCode::Esc => vec![0x1b],
KeyCode::Up => b"\x1b[A".to_vec(),
KeyCode::Down => b"\x1b[B".to_vec(),
KeyCode::Right => b"\x1b[C".to_vec(),
KeyCode::Left => b"\x1b[D".to_vec(),
KeyCode::Home => b"\x1b[H".to_vec(),
KeyCode::End => b"\x1b[F".to_vec(),
KeyCode::PageUp => b"\x1b[5~".to_vec(),
KeyCode::PageDown => b"\x1b[6~".to_vec(),
KeyCode::Insert => b"\x1b[2~".to_vec(),
KeyCode::Delete => b"\x1b[3~".to_vec(),
KeyCode::F(n @ 1..=4) => format!("\x1bO{}", (b'P' + (n - 1)) as char).into_bytes(),
KeyCode::F(n) => {
let code = match n {
5 => 15,
6 => 17,
7 => 18,
8 => 19,
9 => 20,
10 => 21,
11 => 23,
12 => 24,
_ => return Vec::new(),
};
format!("\x1b[{code}~").into_bytes()
}
_ => Vec::new(),
}
}
#[cfg(test)]
mod scroll_accel_tests {
use super::{SCROLL_BUCKET_MAX, budgeted_scroll_at, scroll_accel_ceiling};
use crate::app::App;
use crate::config::Config;
fn app_with(accel: &str) -> (tempfile::TempDir, App) {
let d = tempfile::tempdir().unwrap();
let mut cfg = Config::default();
cfg.editor.scroll_accel = accel.to_string();
let app = App::new(d.path().to_path_buf(), cfg).unwrap();
(d, app)
}
#[test]
fn a_fast_spin_travels_further_and_scales_with_the_setting() {
let spin_gap = std::time::Duration::from_millis(8);
let mut out = Vec::new();
for accel in ["off", "gentle", "normal", "fast"] {
let (_d, mut app) = app_with(accel);
let t0 = std::time::Instant::now();
let mut total = 0;
for i in 0..10 {
total += budgeted_scroll_at(&mut app, 1, t0 + spin_gap * i);
}
out.push(total);
}
assert!(
out.windows(2).all(|w| w[1] >= w[0]),
"each setting should scroll at least as far as the one below: {out:?}"
);
assert!(
out[3] > out[0],
"'fast' must beat 'off' on a hard spin: {out:?}"
);
}
#[test]
fn one_notch_is_never_accelerated() {
for accel in ["off", "gentle", "normal", "fast"] {
let (_d, mut app) = app_with(accel);
let t0 = std::time::Instant::now();
for i in 0..4 {
let now = t0 + std::time::Duration::from_millis(400 * i);
assert_eq!(
budgeted_scroll_at(&mut app, 1, now),
1,
"a slow single notch moved more than one line at accel={accel}"
);
}
}
}
#[test]
fn flywheel_inertia_runs_dry_instead_of_coasting() {
for accel in ["off", "gentle", "normal", "fast"] {
let (_d, mut app) = app_with(accel);
let t0 = std::time::Instant::now();
let mut total = 0i32;
let mut last = i32::MAX;
for i in 0..30 {
let now = t0 + std::time::Duration::from_millis(100 * i);
let got = budgeted_scroll_at(&mut app, 10, now);
total += got;
last = got;
}
let ceiling = scroll_accel_ceiling(accel);
let bound = (SCROLL_BUCKET_MAX * ceiling).ceil() as i32 + 3 * 60 + 10;
assert!(
total <= bound,
"at accel={accel}, 3s of inertia moved {total} lines, over the \
{bound} bound — refill is being scaled by acceleration, which \
makes the wheel coast past the hand (the 37074afe regression)"
);
let _ = last;
}
}
}
#[cfg(test)]
mod scroll_clamp_tests {
use super::{LIST_SCROLL_PER_BATCH_CAP, list_scroll_clamp_scaled, scroll_accel_ceiling};
#[test]
fn the_cap_scales_so_accel_is_not_clamped_away() {
let big = 40; let mut out = Vec::new();
for accel in ["off", "gentle", "normal", "fast"] {
out.push(list_scroll_clamp_scaled(big, scroll_accel_ceiling(accel)));
}
assert_eq!(
out[0], LIST_SCROLL_PER_BATCH_CAP,
"'off' must keep the historical cap exactly"
);
assert!(
out.windows(2).all(|w| w[1] >= w[0]),
"cap should widen with the setting: {out:?}"
);
assert!(
out[3] > out[0],
"'fast' must allow more than 'off': {out:?}"
);
}
#[test]
fn a_cap_still_applies_at_every_setting() {
for accel in ["off", "gentle", "normal", "fast"] {
let ceiling = scroll_accel_ceiling(accel);
let got = list_scroll_clamp_scaled(10_000, ceiling);
assert!(
got < 10_000,
"accel={accel} let an absurd magnitude through unclamped ({got})"
);
}
}
#[test]
fn sign_survives_the_clamp() {
let c = scroll_accel_ceiling("fast");
assert!(list_scroll_clamp_scaled(-40, c) < 0);
assert!(list_scroll_clamp_scaled(40, c) > 0);
}
}
#[cfg(test)]
mod scroll_spec_tests {
use super::budgeted_scroll_at;
use crate::app::App;
use crate::config::Config;
use std::time::{Duration, Instant};
fn app_with(accel: &str) -> (tempfile::TempDir, App) {
let d = tempfile::tempdir().unwrap();
let mut cfg = Config::default();
cfg.editor.scroll_accel = accel.to_string();
let app = App::new(d.path().to_path_buf(), cfg).unwrap();
(d, app)
}
fn spin(app: &mut App, t: &mut Instant, gap_ms: u64, notches: usize) -> i32 {
let mut total = 0;
for _ in 0..notches {
*t += Duration::from_millis(gap_ms);
total += budgeted_scroll_at(app, 1, *t);
}
total
}
#[test]
fn slow_wheel_slow_scroll_fast_wheel_fast_scroll() {
for accel in ["gentle", "normal", "fast"] {
let (_d, mut app) = app_with(accel);
let mut t = Instant::now();
let slow = spin(&mut app, &mut t, 120, 10);
let (_d2, mut app2) = app_with(accel);
let mut t2 = Instant::now();
let fast = spin(&mut app2, &mut t2, 8, 10);
assert!(
fast > slow,
"accel={accel}: fast spin moved {fast}, slow moved {slow} — a faster wheel must travel further"
);
assert_eq!(
slow, 10,
"accel={accel}: a slow deliberate scroll must stay 1:1 (got {slow} for 10 notches) so precise positioning still works"
);
}
}
#[test]
fn hardware_that_fires_several_events_per_notch_still_scrolls_one_step() {
for batch in [1, 2, 3, 5] {
let (_d, mut app) = app_with("normal");
let mut t = Instant::now();
let mut factors = Vec::new();
for _ in 0..5 {
t += Duration::from_millis(150);
budgeted_scroll_at(&mut app, batch, t);
factors.push(app.scroll_last_factor);
}
for f in &factors {
assert!(
(*f - 1.0).abs() < 0.01,
"batch={batch}: slow notches produced factor {f}, so a discrete \
surface would jump {} rows for ONE notch",
f.round()
);
}
}
}
#[test]
fn the_shipped_default_does_not_starve_under_sustained_scrolling() {
let default_accel = crate::config::Config::default().editor.scroll_accel;
let (_d, mut app) = app_with(&default_accel);
let mut t = Instant::now();
let mut moved_late = 0;
for i in 0..400 {
t += Duration::from_millis(25);
let got = budgeted_scroll_at(&mut app, 1, t);
if i >= 300 {
moved_late += got;
}
}
assert!(
moved_late >= 50,
"default `{default_accel}`: the last 100 of 400 wheel events moved only \
{moved_late} lines, so sustained scrolling is being throttled to a crawl"
);
}
#[test]
fn the_default_scroll_accel_is_normal() {
assert_eq!(
crate::config::Config::default().editor.scroll_accel,
"normal",
"changing the shipped default changes scrolling for everyone who never \
set it — deliberate flips update this test, accidents fail it"
);
}
#[test]
fn the_off_path_still_scrolls_after_draining_a_full_bucket() {
let (_d, mut app) = app_with("off");
let mut t = Instant::now();
let mut moved_late = 0;
for i in 0..400 {
t += Duration::from_millis(25);
let got = budgeted_scroll_at(&mut app, 1, t);
if i >= 300 {
moved_late += got;
}
}
assert!(
moved_late > 0,
"the last 100 wheel events moved {moved_late} lines — `off` is the \
default, so a bucket that never refills on this path means scrolling \
stops permanently mid-session"
);
}
#[test]
fn when_the_wheel_stops_scrolling_stops() {
for accel in ["gentle", "normal", "fast"] {
let (_d, mut app) = app_with(accel);
let mut t = Instant::now();
spin(&mut app, &mut t, 8, 8);
let gaps = [20, 30, 45, 60, 90, 130, 180];
let mut coasted = 0;
for gap_ms in gaps {
t += Duration::from_millis(gap_ms);
coasted += budgeted_scroll_at(&mut app, 1, t);
}
assert!(
coasted <= gaps.len() as i32,
"accel={accel}: a decaying tail of {} events moved {coasted} lines, so it \
was amplified — it must travel unaccelerated and die with the wheel",
gaps.len()
);
let before = coasted;
t += Duration::from_millis(400);
assert_eq!(
before, coasted,
"accel={accel}: time passing must not move the view on its own"
);
let _ = t;
}
}
#[test]
fn a_new_gesture_after_a_pause_scrolls_again() {
let (_d, mut app) = app_with("fast");
let mut t = Instant::now();
spin(&mut app, &mut t, 8, 6);
for gap_ms in [30, 60, 120] {
t += Duration::from_millis(gap_ms);
budgeted_scroll_at(&mut app, 1, t);
}
t += Duration::from_millis(900); let second = spin(&mut app, &mut t, 8, 6);
assert!(
second > 6,
"the second gesture moved only {second} lines for 6 notches — the stop detector latched instead of resetting per gesture"
);
}
#[test]
fn steady_scrolling_with_jitter_does_not_trip_the_stop_detector() {
let (_d, mut app) = app_with("normal");
let mut t = Instant::now();
let mut total = 0;
for gap_ms in [20, 24, 18, 22, 26, 19, 21, 25, 20, 23] {
t += Duration::from_millis(gap_ms);
total += budgeted_scroll_at(&mut app, 1, t);
}
assert!(
total >= 10,
"steady scrolling moved only {total} lines for 10 notches — jitter is being misread as the wheel stopping"
);
}
}