Files
galaxy/app/src/terminal/block_list_viewport.rs
T
rkw6086 2d581f4c01 Fix tool output clipping and live-edge scroll following
Preserve full terminal-grid width with horizontal scrolling in tool panes. Keep hidden command control changes from unpinning the viewport, and re-pin scrolling at the live edge in normal and inverted layouts.

Validation: formatting, test-layout checks, both presubmit Clippy commands, and 141 relevant tests passed. Ten other terminal-view tests failed identically on the untouched baseline (keyboard, cloud-agent, input status, and footer behavior).
2026-09-10 00:30:57 -05:00

2064 lines
90 KiB
Rust

use std::ops::Range;
use std::rc::Rc;
use std::sync::MutexGuard;
use galaxy_core::features::FeatureFlag;
use galaxyui::elements::ClippedScrollStateHandle;
use galaxyui::units::{IntoLines, IntoPixels, Lines, Pixels};
use galaxyui::{AppContext, ModelHandle};
use pathfinder_geometry::vector::Vector2F;
use serde::{Deserialize, Serialize};
use sum_tree::{Cursor, SeekBias};
use super::block_list_element::{
GridType, SnackbarHeader, SnackbarHeaderState, SnackbarPoint, VisibleItem,
};
use super::model::block::{Block, BlockSection};
use super::model::blocks::{
BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, BlockListPoint, SelectionRange,
TotalIndex,
};
use super::model::selection::SelectionPoint;
use super::model::terminal_model::{BlockIndex, BlockSortDirection, WithinBlock};
use super::view::BlockVisibilityMode;
use super::{
height_in_range_approx, heights_approx_gt, heights_approx_gte, heights_approx_lt,
heights_approx_lte, SizeInfo, HEIGHT_FUDGE_FACTOR_LINES,
};
use crate::ai::blocklist::agent_view::{AgentViewDisplayMode, AgentViewState};
use crate::terminal::input::inline_menu::InlineMenuPositioner;
use crate::terminal::model::blocks::RichContentItem;
use crate::terminal::model::index::Point as IndexPoint;
/// Wraps a scroll position for the purposes of centralizing update logic.
pub struct ScrollState {
position: ScrollPosition,
}
impl ScrollState {
pub fn new(position: ScrollPosition) -> Self {
Self { position }
}
pub fn position(&self) -> ScrollPosition {
self.position
}
/// Possibly updates the scroll position, returning whether it was actually changed.
pub fn update(
&mut self,
viewport: ViewportState,
update: ScrollPositionUpdate,
app: &AppContext,
) -> bool {
let next_position = viewport.next_scroll_position(update, app);
if next_position != self.position {
log::debug!(
"updating scroll position from {:?} to {:?} for update {:?}",
self.position,
next_position,
update
);
self.position = next_position;
return true;
}
false
}
}
/// Defines whether we are tracking scroll position from the top or bottom of the
/// blocklist. In modes where the input is below the blocklist (PinnedToBottom and
/// Waterfall), we track scroll position from the top, so that when there is a long
/// running command in those modes we can maintain scroll position. For modes where
/// the block list is inverted (PinnedToTop), we track scroll position from the bottom
/// for the same reason.
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum ScrollLines {
/// Scroll position is measured in lines from the top of the block list
/// e.g. ScrollTop(0) == the top of the block list.
ScrollTop(Lines),
/// Scroll position is measured in lines from the bottom of the block list
/// e.g. ScrollBottom(0) == the bottom of the block list.
ScrollBottom(Lines),
}
impl ScrollLines {
/// Convert from scroll top to ScrollLines, taking into account the input mode.
fn from_scroll_top(
scroll_top: Lines,
input_mode: InputMode,
block_list: &BlockList,
content_element_height: Lines,
) -> Self {
match input_mode {
InputMode::PinnedToBottom | InputMode::Waterfall => ScrollLines::ScrollTop(scroll_top),
InputMode::PinnedToTop => {
let active_block = block_list.active_block();
let is_long_running = active_block.is_active_and_long_running();
// The behavior for pinned to top is somewhat subtle...
//
// If you are scrolled into a long-running block, we want to maintain
// your scroll position as the block grows downward, so we need to anchor
// using scroll top.
//
// Otherwise we want to use scroll bottom so that the block that is growing
// above you doesn't make you lose your position when you are scrolled down.
if is_long_running
&& scroll_top
< active_block
.height(block_list.agent_view_state())
.into_lines()
{
ScrollLines::ScrollTop(scroll_top)
} else {
ScrollLines::ScrollBottom(
block_list.block_heights().summary().height
- content_element_height
- scroll_top,
)
}
}
}
}
/// Get the scroll top from the scroll lines, taking into account the input mode.
fn scroll_top(&self, block_list: &BlockList, content_element_height: Lines) -> Lines {
match *self {
ScrollLines::ScrollTop(scroll_top) => scroll_top,
ScrollLines::ScrollBottom(scroll_bottom) => {
block_list.block_heights().summary().height - content_element_height - scroll_bottom
}
}
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum ScrollPosition {
/// Follow newly inserted content at the top of an inverted block list.
FollowsTopOfMostRecentBlock,
/// The scrolling follows the bottom of the most recently executed block.
/// In terms of scroll_top, this implies scrolling stays locked to max_scroll_top.
FollowsBottomOfMostRecentBlock,
/// The scrolling follows the bottom of the most recently executed block,
/// similar to FollowsBottomOfMostRecentBlock, but because there can be a gap
/// below the most recent block, the scroll_top is not necessarily max_scroll_top.
/// We store the scroll top and use that.
WaterfallGapFollowsBottomOfMostRecentBlock { scroll_top_in_lines: Lines },
/// Scrolling is set to a particular scroll_top (offset in lines from the top
/// of the block list)
FixedAtPosition { scroll_lines: ScrollLines },
/// The scrolling follows an offset within a long-running block and
/// adjusts for output grid truncation.
///
/// In other words, this allows a user to lock their scroll position at
/// a particular line and have the scroll follow that line even while the
/// output grid is being truncated at head (until the line itself is truncated).
FixedWithinLongRunningBlock {
/// The absolute scroll offset.
/// This is equivalent to [`ScrollPosition::FixedAtPosition::scroll_lines`].
scroll_lines: ScrollLines,
/// The number of lines truncated from the output grid
/// at the time that the scroll position was set.
num_output_lines_truncated: u64,
},
}
/// Represents the location of a find match to be used for calculating scroll position.
#[derive(Debug, Clone, Copy)]
pub enum FindMatchScrollLocation {
/// For matches occurring in command blocks.
Block {
block_index: BlockIndex,
/// `BlockSection` describing the relative position of the match within the block.
section: BlockSection,
},
/// For matches occurring in rich content views.
RichContent {
/// The total index of the rich content item in the blocklist sumtree.
index: TotalIndex,
},
}
/// An enum of all possible scroll position updates, useful for centralizing
/// scroll update logic.
#[derive(Debug, Clone, Copy)]
pub enum ScrollPositionUpdate {
AfterCommandExecutionStarted,
AfterRichBlockInserted,
AfterRichBlockUpdated,
AfterKeydownOnTerminal,
AfterTypedCharacters,
AfterWriteUserBytesToPty,
AfterScrollEvent {
scroll_delta: Lines,
},
AfterResize,
AfterClear,
AfterPageUp,
AfterPageDown,
AfterHome,
AfterEnd,
AfterFilter {
block_index: BlockIndex,
prev_top_of_viewport: Lines,
prev_bottom_of_block: Lines,
prev_first_visible_original_row: Option<usize>,
},
AfterFilterClear {
block_index: BlockIndex,
offset_from_block_top: Lines,
},
ScrollToTopOfBlock {
block_index: BlockIndex,
},
ScrollToBottomOfBlock {
block_index: BlockIndex,
},
ScrollMostRecentBlockIntoView,
ScrollToBlocklistRowIfNotVisible {
row: Lines,
},
ScrollToFindMatchIfNotVisible(FindMatchScrollLocation),
ScrollToTopOfBlockWithBuffer {
block_index: BlockIndex,
buffer_lines: Lines,
},
ScrollToTopOfRichContent {
index: TotalIndex,
},
AfterEnterAgentView,
AfterExitAgentView {
saved_position: ScrollPosition,
},
}
/// The direction that blocks flow in the viewport.
#[derive(
Default,
Debug,
Clone,
Copy,
PartialEq,
Serialize,
Deserialize,
schemars::JsonSchema,
settings_value::SettingsValue,
)]
#[schemars(
description = "Direction that blocks flow in the terminal viewport.",
rename_all = "snake_case"
)]
pub enum InputMode {
/// The most recent blocks are at the bottom of the screen and new blocks
/// are added at the bottom as the blocklist grows
#[default]
PinnedToBottom,
/// The most recent blocks are at the top of the screen and new blocks
/// are added at the top as the blocklist grows
PinnedToTop,
/// The input starts at the top and gets pushed down by commands above it.
Waterfall,
}
impl InputMode {
pub fn is_inverted_blocklist(&self) -> bool {
self.is_pinned_to_top()
}
pub fn is_pinned_to_top(&self) -> bool {
matches!(self, InputMode::PinnedToTop)
}
pub fn block_sort_direction(&self) -> BlockSortDirection {
if self.is_inverted_blocklist() {
BlockSortDirection::MostRecentFirst
} else {
BlockSortDirection::MostRecentLast
}
}
}
pub enum ClampingMode {
// If the point is within the block, clamp to the nearest grid point. For
// example, if the point is within the padding to the left of the command
// grid clamp to the nearest point within the command grid.
ClampToGridIfWithinBlock,
// Regardless of whether the point is in a block, clamp to the nearest grid point.
ClampToGrid,
// If the point isn't contained within the grid, return none.
ReturnNoneIfNotInGrid,
}
/// A block that is partially scrolled off the bottom of the screen.
#[derive(Debug, Clone, Copy)]
pub struct OverhangingBlock {
visible_block_height_px: Pixels,
block_index: BlockIndex,
is_most_recent_block: bool,
}
impl OverhangingBlock {
/// The height of the block that is scrolled into view.
pub fn visible_block_height_px(&self) -> Pixels {
self.visible_block_height_px
}
/// The index of the block.
pub fn block_index(&self) -> BlockIndex {
self.block_index
}
/// Whether this is the most recently executed block.
pub fn is_most_recent_block(&self) -> bool {
self.is_most_recent_block
}
}
/// An iterator over the block list items in the current viewport
/// that takes into account whether the block list is inverted, always returning
/// blocks from screen top to to screen bottom. Note that the indices of all
/// items returned by this iterator are always in model coordinates, so that most
/// recently executed blocks have higher indices than older blocks.
///
/// E.g. If you are iterating over a blocklist that is in "non-inverted mode"
/// the blocks will be returned from top to bottom visually, but the indices will
/// be lower with the topmost blocks and higher with the bottommost because the
/// bottommost are the most recent.
///
/// In "inverted "mode, the topmost blocks will have the highest model indices because
/// they are most recent.
pub struct ViewportIter<'a> {
/// The underlying cursor into the block heights that we are iterating over
/// For `BlockDirection::MostRecentOnBottom` this is a forward iterator.
/// For `BlockDirection::MostRecentOnTop` this is a reverse iterator.
block_heights_iter: Box<dyn Iterator<Item = &'a BlockHeightItem> + 'a>,
/// The current input mode.
input_mode: InputMode,
agent_view_state: &'a AgentViewState,
/// The y-offset of the current block in lines from the viewport origin.
top_of_current_block: Lines,
/// The y-offset of the bottom of the viewport in lines from the viewport origin.
bottom_offset: Lines,
/// The index of the first block being iterated over.
start_block_index: BlockIndex,
/// The index of the current block being iterated over.
curr_block_index: BlockIndex,
/// The index of the last item (block or otherwise) we have iterated over.
curr_entry_index: TotalIndex,
}
impl ViewportIter<'_> {
/// Returns the visible block range iterated over so far.
pub fn visible_block_range(&self) -> Range<BlockIndex> {
match self.input_mode {
InputMode::PinnedToBottom | InputMode::Waterfall => {
self.start_block_index..(self.curr_block_index + 1.into())
}
InputMode::PinnedToTop => self.curr_block_index..(self.start_block_index + 1.into()),
}
}
}
/// An item in the viewport, along with the indices it's at. These are indices
/// into the block list, not indices representing the order the blocks are
/// rendered.
#[derive(Debug)]
pub struct ViewportIterItem {
/// An optional block index, only defined if this is a block.
pub block_index: Option<BlockIndex>,
/// The index into the entire block heights list. Defined for all items.
pub entry_index: TotalIndex,
/// The height of the current item.
pub block_height_item: BlockHeightItem,
/// The top of the current block in terms of lines from the top of the first block
/// in the viewport. Always starts at zero for the first iterator item.
pub top_of_current_block: Lines,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AutoscrollBehavior {
/// Always autoscroll when an action completes.
Always,
/// When an action completes, only autoscroll if the scroll position is at the end of
/// the blocklist.
WhenScrolledToEnd,
}
/// This struct encapsulates all the data we need to do a viewport calculation.
/// Callers should not hold onto this state for long periods (and it will be
/// hard to in any case because of the lifetime params). Specifically the usage
/// pattern is to create a viewport for a paritcular blocklist state, do some
/// calculations and then discard it.
///
/// To do viewport calculations you need a reference to the blocklist (which is of
/// limited lifetime because of how model locking works), plus all
/// the info related to scroll position and screen size.
pub struct ViewportState<'a> {
block_list: &'a BlockList,
snackbar_header_state: SnackbarHeaderState,
input_mode: InputMode,
size_info: SizeInfo,
scroll_position: ScrollPosition,
horizontal_clipped_scroll_state: ClippedScrollStateHandle,
/// Cached visible items to use in place of re-iterating to generate them
visible_items: Option<Rc<Vec<VisibleItem>>>,
/// The size of the BlocklistElement.
blocklist_element_size: Vector2F,
/// The size of the terminal input view, presumably from the last layout.
input_size: Vector2F,
/// Autoscroll behavior for rich content blocks.
rich_block_autoscroll_behavior: AutoscrollBehavior,
inline_menu_positioner: ModelHandle<InlineMenuPositioner>,
}
impl<'a> ViewportState<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
block_list: &'a BlockList,
snackbar_header_state: SnackbarHeaderState,
input_mode: InputMode,
size_info: SizeInfo,
scroll_position: ScrollPosition,
visible_items: Option<Rc<Vec<VisibleItem>>>,
horizontal_clipped_scroll_state: ClippedScrollStateHandle,
blocklist_element_size: Vector2F,
input_size: Vector2F,
rich_block_autoscroll_behavior: AutoscrollBehavior,
inline_menu_positioner: ModelHandle<InlineMenuPositioner>,
) -> Self {
Self {
block_list,
snackbar_header_state,
input_mode,
size_info,
scroll_position,
horizontal_clipped_scroll_state,
visible_items,
blocklist_element_size,
input_size,
rich_block_autoscroll_behavior,
inline_menu_positioner,
}
}
fn snackbar_header_state(&self) -> MutexGuard<'_, SnackbarHeader> {
self.snackbar_header_state
.state_handle
.lock()
.expect("locking snackbar header state")
}
fn content_element_height_lines(&self) -> Lines {
Pixels::new(self.blocklist_element_size.y()).to_lines(self.size_info.cell_height_px)
}
/// Returns a new iterator over the viewport that respects the viewport's
/// block direction. See the ViewportIter struct docs for more info.
pub fn iter(&self) -> ViewportIter<'a> {
self.iter_from(self.scroll_top_in_lines())
}
/// Returns a new iterator over the viewport that respects the viewport's
/// block direction. See the ViewportIter struct docs for more info.
///
/// This will include items that are below the bottom of the viewport by the given
/// bottom_overhang amount.
pub fn iter_with_bottom_overhang(&self, bottom_overhang: Lines) -> ViewportIter<'a> {
let bottom_offset =
self.scroll_top_in_lines() + self.content_element_height_lines() + bottom_overhang;
self.iter_range(self.scroll_top_in_lines(), bottom_offset)
}
// Returns a new iterator starting from the given scroll position in lines.
fn iter_from(&self, top_offset: Lines) -> ViewportIter<'a> {
let bottom_offset = self.scroll_top_in_lines() + self.content_element_height_lines();
self.iter_range(top_offset, bottom_offset)
}
// Returns a new iterator that spans the given line range.
fn iter_range(&self, top_offset: Lines, bottom_offset: Lines) -> ViewportIter<'a> {
let cursor = self.block_height_cursor(top_offset);
match self.input_mode {
InputMode::PinnedToBottom | InputMode::Waterfall => {
let block_index = BlockIndex::from(cursor.start().block_count);
let curr_entry_index = cursor.start().total_count;
let top_of_block = cursor.start().height;
ViewportIter {
block_heights_iter: Box::new(cursor),
input_mode: self.input_mode,
agent_view_state: self.block_list.agent_view_state(),
top_of_current_block: top_of_block,
bottom_offset,
start_block_index: block_index,
curr_block_index: block_index,
curr_entry_index: curr_entry_index.into(),
}
}
InputMode::PinnedToTop => {
let total_block_height = self.block_list.block_heights().summary().height;
// Note that we use cursor.end() in the backwards case because
// when you are iterating in reverse, the end gives you the correct first
// element.
let backwards_end = cursor.end();
let block_index = BlockIndex::from(backwards_end.block_count);
let curr_entry_index = backwards_end.total_count;
let top_of_block = total_block_height - backwards_end.height;
ViewportIter {
block_heights_iter: Box::new(cursor.rev()),
input_mode: self.input_mode,
agent_view_state: self.block_list.agent_view_state(),
top_of_current_block: top_of_block,
bottom_offset,
start_block_index: block_index,
curr_block_index: block_index,
curr_entry_index: curr_entry_index.into(),
}
}
}
}
fn block_height_cursor(
&self,
top_offset: Lines,
) -> Cursor<'a, BlockHeightItem, BlockHeight, BlockHeightSummary> {
match self.input_mode {
InputMode::PinnedToBottom | InputMode::Waterfall => {
let mut forward_cursor = self
.block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
forward_cursor.seek_clamped(&BlockHeight::from(top_offset), SeekBias::Right);
forward_cursor
}
InputMode::PinnedToTop => {
let total_block_height = self.block_list.block_heights().summary().height;
let mut backwards_cursor = self
.block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
// Note that we seek_clamped here because accumulated floating point errors
// in adjusting block list heights make it possible that the total_block_height
// we receive from the summary actually very slightly exceeds what the
// cursor thinks is the max point to seek to. In the case with a zero scroll top
// this can make seeks fail without the clamp. We want to cap the seek at the
// end, not have the seek fail in this case.
let seek_to = (total_block_height - top_offset).max(Lines::zero());
backwards_cursor.seek_clamped(&BlockHeight::from(seek_to), SeekBias::Left);
backwards_cursor
}
}
}
/// Returns where the first block starts relative to the grid origin in pixels
/// If there is no top block, returns a zero offset
pub fn offset_to_top_of_first_block(&self, app: &AppContext) -> Pixels {
let total_block_height = self.block_list.block_heights().summary().height;
let top_of_current_block = if let Some(visible_items) = &self.visible_items {
let mut block_heights_cursor = self
.block_list
.block_heights()
.cursor::<TotalIndex, BlockHeightSummary>();
let first_index = visible_items
.first()
.map(|item| item.index())
.unwrap_or_else(|| 0.into());
block_heights_cursor.seek(&first_index, SeekBias::Right);
match self.input_mode {
InputMode::PinnedToBottom | InputMode::Waterfall => {
block_heights_cursor.start().height
}
InputMode::PinnedToTop => total_block_height - block_heights_cursor.end().height,
}
} else {
let mut viewport_iter = self.iter();
let top_item = viewport_iter.next().expect("should be a top item to paint");
top_item.top_of_current_block
};
let content_element_lines = self.content_element_height_lines();
let top_offset = self.scroll_top_in_lines();
// Move the grid origin upwards to start at the top of the current block
let mut adjustment =
-(top_offset - top_of_current_block).to_pixels(self.size_info.cell_height_px());
if matches!(self.input_mode, InputMode::PinnedToBottom) {
// Take into account the case where the blocks don't fill up the entire grid
if content_element_lines > total_block_height {
adjustment += (content_element_lines - total_block_height)
.to_pixels(self.size_info.cell_height_px());
}
}
if self.block_list.active_gap().is_some() && !self.is_input_rendered_at_bottom_of_pane(app)
{
// Apply a paint-time translation to the blocklist that accounts for inline menu
// visibility/positioning, effectively "sliding" the blocklist contents upwards to
// preserve the current input position.
//
// See doc comments on blocklist_top_inset_when_in_waterfall_mode for context.
if let Some(blocklist_inset) = self
.inline_menu_positioner
.as_ref(app)
.blocklist_top_inset_when_in_waterfall_mode(app)
{
adjustment -= blocklist_inset;
}
}
adjustment
}
fn scroll_lines_from_scroll_top(&self, scroll_top: Lines) -> ScrollLines {
ScrollLines::from_scroll_top(
scroll_top,
self.input_mode,
self.block_list,
self.content_element_height_lines(),
)
}
/// How far the view is scrolled from the top of all blocks in lines.
pub fn scroll_top_in_lines(&self) -> Lines {
match (self.input_mode, self.scroll_position) {
(_, ScrollPosition::FollowsTopOfMostRecentBlock) => Lines::zero(),
(
InputMode::PinnedToBottom,
ScrollPosition::FollowsBottomOfMostRecentBlock
| ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. },
) => self.max_scroll_top_in_lines(),
(InputMode::Waterfall, ScrollPosition::FollowsBottomOfMostRecentBlock) => {
self.max_scroll_top_in_lines()
}
(
InputMode::Waterfall,
ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock {
scroll_top_in_lines,
},
) => {
if self.block_list.active_gap().is_some() {
let max_scroll_top_lines = self.max_scroll_top_in_lines();
if max_scroll_top_lines - scroll_top_in_lines
> self.content_element_height_lines()
{
// If you are scrolled completely above the viewport, then set the scroll
// position so that the bottom of the latest command is directly above
// the input.
max_scroll_top_lines - self.content_element_height_lines()
} else {
// Otherwise, if you are partially scrolled into the viewport, maintain
// the current scroll position.
scroll_top_in_lines
}
} else {
self.max_scroll_top_in_lines()
}
}
(
InputMode::PinnedToTop,
ScrollPosition::FollowsBottomOfMostRecentBlock
| ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. },
) => {
let index = self.block_list.last_non_hidden_block_by_index();
// If there is a visible rich content block after the last non hidden block, return the height
// of it. Otherwise, return the height of the last non hidden block.
let height = match self
.block_list
.last_non_hidden_rich_content_block_after_block(index)
.map(|(_, content)| content.last_laid_out_height)
{
Some(height) => Some(height.into_lines()),
None => index.and_then(|last_index| {
self.block_list.block_at(last_index).map(|block| {
block
.height(self.block_list.agent_view_state())
.into_lines()
})
}),
};
height
.map(|height| height - self.content_element_height_lines())
.unwrap_or(Lines::zero())
}
(_, ScrollPosition::FixedAtPosition { scroll_lines }) => {
scroll_lines.scroll_top(self.block_list, self.content_element_height_lines())
}
(
_,
ScrollPosition::FixedWithinLongRunningBlock {
scroll_lines,
num_output_lines_truncated,
},
) => {
// Adjust the scroll-top by the number of lines
// truncated since we set our scroll position.
let adjustment = self
.block_list
.active_block()
.output_grid()
.grid_handler()
.num_lines_truncated()
.saturating_sub(num_output_lines_truncated)
.into_lines();
let unadjusted_scroll_top =
scroll_lines.scroll_top(self.block_list, self.content_element_height_lines());
let adjusted_scroll_top = unadjusted_scroll_top - adjustment;
// We only want to adjust the scroll position as far up as the block goes.
let top_of_block = self.top_of_block_in_lines(self.block_list.active_block_index());
std::cmp::max(adjusted_scroll_top, top_of_block)
}
}
.max(Lines::zero())
.min(self.max_scroll_top_in_lines())
}
/// How far the view is scrolled from the top of all blocks in pixels
pub fn scroll_top_in_pixels(&self) -> Pixels {
self.scroll_top_in_lines()
.to_pixels(self.size_info.cell_height_px())
}
/// Returns the index of the topmost visible block in the viewport
#[cfg(test)]
pub fn topmost_visible_block(&self) -> Option<BlockIndex> {
self.iter().next().and_then(|item| item.block_index)
}
pub fn next_scroll_position(
&self,
update: ScrollPositionUpdate,
app: &AppContext,
) -> ScrollPosition {
match update {
ScrollPositionUpdate::AfterCommandExecutionStarted => {
self.scroll_position_after_command_execution(app)
}
// When a rich block is inserted, we make the same scroll change
// as we would when a command is executed (i.e. scroll to end).
ScrollPositionUpdate::AfterRichBlockInserted
| ScrollPositionUpdate::AfterRichBlockUpdated => {
// If the user has scrolled to some fixed position, check the autoscroll behavior
// before overriding it and scrolling to the end.
if self.rich_block_autoscroll_behavior != AutoscrollBehavior::Always
&& matches!(
self.scroll_position,
ScrollPosition::FixedAtPosition { .. }
| ScrollPosition::FixedWithinLongRunningBlock { .. }
)
{
return self.scroll_position;
}
// Keep an established live-edge pin across tool/response insertion.
// Recomputing a waterfall gap offset here can turn it into a fixed offset.
match self.scroll_position {
ScrollPosition::FollowsTopOfMostRecentBlock
| ScrollPosition::FollowsBottomOfMostRecentBlock => self.scroll_position,
ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. }
| ScrollPosition::FixedAtPosition { .. }
| ScrollPosition::FixedWithinLongRunningBlock { .. } => {
self.scroll_position_after_command_execution(app)
}
}
}
ScrollPositionUpdate::AfterKeydownOnTerminal
| ScrollPositionUpdate::AfterTypedCharacters
| ScrollPositionUpdate::AfterWriteUserBytesToPty => {
// keydown actually has the same semantics for adjusting scroll position
// as executing a command.
self.scroll_position_after_command_execution(app)
}
ScrollPositionUpdate::AfterScrollEvent { scroll_delta } => {
self.scroll_position_for_delta(scroll_delta)
}
ScrollPositionUpdate::AfterResize => {
let max_scroll_top = self.max_scroll_top_in_lines();
// When resizing, the number of rows might "shrink" as the wrapped-around lines
// are rendered in one line. This changes the value of maximum scroll top and could
// make the previous scroll position invalid. Thus we add an additional check here
// to change the scroll position to stick to the bottom if previous scroll top is invalid.
if let ScrollPosition::FixedAtPosition { scroll_lines } = self.scroll_position {
if scroll_lines.scroll_top(self.block_list, self.content_element_height_lines())
> max_scroll_top
{
return ScrollPosition::FollowsBottomOfMostRecentBlock;
}
}
self.scroll_position
}
ScrollPositionUpdate::AfterClear => self.scroll_position_after_clear(),
ScrollPositionUpdate::ScrollToTopOfBlock { block_index } => {
self.scroll_position_at_top_of_block(block_index)
}
ScrollPositionUpdate::ScrollToBottomOfBlock { block_index } => {
self.scroll_position_at_bottom_of_block(block_index)
}
ScrollPositionUpdate::ScrollMostRecentBlockIntoView => {
if self.input_mode.is_inverted_blocklist() {
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(Lines::zero()),
}
} else if let Some(block_index) = self.block_list.last_non_hidden_block_by_index() {
self.scroll_position_at_bottom_of_block(block_index)
} else {
self.scroll_position
}
}
ScrollPositionUpdate::AfterPageUp => self
.scroll_position_for_delta(self.content_element_height_lines() - 1.0.into_lines()),
ScrollPositionUpdate::AfterPageDown => self
.scroll_position_for_delta(1.0.into_lines() - self.content_element_height_lines()),
ScrollPositionUpdate::AfterHome => self.scroll_position_for_offset(Lines::zero()),
ScrollPositionUpdate::AfterEnterAgentView => {
if self.input_mode.is_inverted_blocklist() {
ScrollPosition::FollowsTopOfMostRecentBlock
} else {
ScrollPosition::FollowsBottomOfMostRecentBlock
}
}
ScrollPositionUpdate::AfterEnd => {
if matches!(
self.input_mode,
InputMode::PinnedToBottom | InputMode::Waterfall
) {
ScrollPosition::FollowsBottomOfMostRecentBlock
} else {
ScrollPosition::FixedAtPosition {
scroll_lines: self
.scroll_lines_from_scroll_top(self.max_scroll_top_in_lines()),
}
}
}
ScrollPositionUpdate::ScrollToBlocklistRowIfNotVisible { row } => {
self.scroll_to_blocklist_row_if_not_visible(row)
}
ScrollPositionUpdate::ScrollToFindMatchIfNotVisible(find_match_position) => {
self.scroll_to_match_if_not_visible(find_match_position)
}
ScrollPositionUpdate::AfterFilter {
block_index,
prev_top_of_viewport,
prev_bottom_of_block,
prev_first_visible_original_row,
} => self.adjust_scroll_after_filter(
block_index,
prev_top_of_viewport,
prev_bottom_of_block,
prev_first_visible_original_row,
),
ScrollPositionUpdate::AfterFilterClear {
block_index,
offset_from_block_top,
} => self.adjust_scroll_after_filter_clear(block_index, offset_from_block_top),
ScrollPositionUpdate::ScrollToTopOfBlockWithBuffer {
block_index,
buffer_lines,
} => self.scroll_position_at_top_of_block_with_buffer(block_index, buffer_lines),
ScrollPositionUpdate::ScrollToTopOfRichContent { index } => {
let (top, _) = self.rich_content_scroll_bounds(index);
let scroll_top = top.max(Lines::zero()).min(self.max_scroll_top_in_lines());
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(scroll_top),
}
}
ScrollPositionUpdate::AfterExitAgentView { saved_position } => saved_position,
}
}
fn adjust_scroll_after_filter_clear(
&self,
block_index: BlockIndex,
offset_from_block_top: Lines,
) -> ScrollPosition {
if matches!(
self.scroll_position,
ScrollPosition::FollowsTopOfMostRecentBlock
| ScrollPosition::FollowsBottomOfMostRecentBlock
| ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. }
) {
return self.scroll_position;
}
let block_top = self.top_of_block_in_lines(block_index);
let new_scroll_top = block_top + offset_from_block_top;
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(new_scroll_top),
}
}
fn anchor_block_bottom_after_filtering(
&self,
block_index: BlockIndex,
prev_top_of_viewport: Lines,
prev_bottom_of_block: Lines,
) -> ScrollPosition {
let curr_bottom_of_block = self.bottom_of_block_in_lines(block_index);
let height_delta = prev_bottom_of_block - curr_bottom_of_block;
let new_scroll_top = prev_top_of_viewport - height_delta;
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(new_scroll_top),
}
}
/// Attempt to anchor to the top row in the viewport before the filter was
/// applied. The algorithm is as follows:
/// 1. If the top row in the viewport is not filtered out, scroll so it
/// remains the top row in the viewport.
/// 2. If the top row in the viewport was filtered out, look for the next
/// closest row and scroll so that becomes the top row in the viewport.
/// e.g. Suppose the user is looking at:
/// --- top of viewport ---
/// | row 4 |
/// | row 5 |
/// | row 6 |
/// --- bottom of viewport ---
/// If rows 4 and 5 get filtered out, then row 6 is the next closest
/// row and so we scroll so that becomes the top row in the viewport.
/// --- top of viewport ---
/// | row 6 |
/// | ... |
/// | ... |
/// --- bottom of viewport ---
fn anchor_top_row_after_filtering(
&self,
block: &Block,
top_of_block: Lines,
new_first_displayed_row: usize,
) -> ScrollPosition {
let header_height = self.snackbar_header_height();
let new_scroll_top = top_of_block
+ block.output_grid_offset().into_lines()
+ (new_first_displayed_row as f32).into_lines()
- header_height;
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(new_scroll_top),
}
}
fn adjust_scroll_after_filter(
&self,
block_index: BlockIndex,
prev_top_of_viewport: Lines,
prev_bottom_of_block: Lines,
prev_first_visible_original_row: Option<usize>,
) -> ScrollPosition {
if matches!(
self.scroll_position,
ScrollPosition::FollowsTopOfMostRecentBlock
| ScrollPosition::FollowsBottomOfMostRecentBlock
| ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. }
) {
return self.scroll_position;
}
let Some(block) = self.block_list.block_at(block_index) else {
log::warn!("Could not find block when adjusting scroll after filter");
return self.scroll_position;
};
// The top of block is always in the same position before and after filtering.
let top_of_block = self.top_of_block_in_lines(block_index);
let top_of_output_grid = top_of_block + block.output_grid_offset().into_lines();
let prev_bottom_of_viewport = prev_top_of_viewport + self.content_element_height_lines();
// Scroll to the top/bottom of the block if it was not in the viewport at all.
let was_viewport_before_block = heights_approx_lte(prev_bottom_of_viewport, top_of_block);
if was_viewport_before_block {
return self.scroll_position_at_top_of_block(block_index);
}
let was_viewport_after_block = heights_approx_lte(
// Adjust so that the bottom padding counts as "after" the block. The
// end result here is if the top of the viewport is in the padding,
// we will scroll to the bottom of the block.
prev_bottom_of_block - block.padding_bottom().into_lines(),
prev_top_of_viewport,
);
if was_viewport_after_block {
return self.scroll_position_at_bottom_of_block(block_index);
}
let showing_snackbar = self.snackbar_header_state().header_position.is_some();
// If the prompt or command are visible (not via the snackbar), the
// top of the block will be anchored.
let was_top_of_block_visible = height_in_range_approx(
if showing_snackbar {
top_of_block
} else {
top_of_output_grid
},
prev_top_of_viewport,
prev_bottom_of_viewport,
);
let was_bottom_of_block_visible = height_in_range_approx(
prev_bottom_of_block,
prev_top_of_viewport,
prev_bottom_of_viewport,
);
if was_top_of_block_visible {
// Keep the top of the block in the same position. This is a no-op
// when the input mode is not `PinnedToTop`.
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(prev_top_of_viewport),
}
} else {
// The top of the viewport was somewhere in the output grid pre-filter.
let Some(prev_first_visible_original_row) = prev_first_visible_original_row else {
log::warn!("No previous row in viewport found from before filtering");
return self.scroll_position;
};
if let Some(new_first_displayed_row) = block
.output_grid()
.grid_handler()
.get_exact_or_next_displayed_row(prev_first_visible_original_row)
{
self.anchor_top_row_after_filtering(block, top_of_block, new_first_displayed_row)
} else if was_bottom_of_block_visible {
self.anchor_block_bottom_after_filtering(
block_index,
prev_top_of_viewport,
prev_bottom_of_block,
)
} else {
self.scroll_position_at_bottom_of_block(block_index)
}
}
}
/// Returns the optimal scroll position for the given `find_match_location`.
fn scroll_to_match_if_not_visible(
&self,
find_match_location: FindMatchScrollLocation,
) -> ScrollPosition {
let current_scroll_top = self.scroll_top_in_lines();
let viewport_height = self.content_element_height_lines();
let current_scroll_bottom = current_scroll_top + viewport_height;
let mut new_scroll_top = match find_match_location {
FindMatchScrollLocation::Block {
block_index,
section,
} => {
let block_section_offset = self
.block_list
.block_at(block_index)
.map(|block| block.block_section_offset_from_top(section))
.unwrap_or(Lines::zero());
let match_position =
self.top_of_block_in_lines(block_index) + block_section_offset.into_lines();
let buffer = {
// This is used as a row offset from the top/bottom of the view in order to ensure that
// the match is fully visible, not hidden by the find bar or snackbar.
const SCROLL_OFFSET: Lines = Lines::new(5.0);
if SCROLL_OFFSET < viewport_height {
SCROLL_OFFSET
} else {
Lines::zero()
}
};
// If the match position is above the viewport, scroll up until the match
// is at the bottom of the viewport (with some buffer from the bottom).
if match_position < current_scroll_top + buffer {
match_position - viewport_height + buffer
} else if match_position > current_scroll_bottom - buffer {
// Otherwise, if the match position is below the viewport, scroll down until the
// match is at the top of the viewport (with some buffer from the top).
// We calculate the bottom of the viewport by adding the height of the viewport to
// the current top scroll position, subtracting a buffer to ensure the match is fully visible.
match_position - buffer
} else {
// The match is in view so just set the current scroll position to unmovable.
// A new finished block will set scroll_position to FixedToBottom, scrolling away from the focused match.
// An active, running block doesn't change scroll_position, so the focused match will always remain in view
// if it is in an active, running block.
return ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(current_scroll_top),
};
}
}
FindMatchScrollLocation::RichContent { index } => {
// Scrolls to the rich content block containing the match, but does not yet support
// scrolling to the exact match location since rich content UI is not laid out along
// sum tree `Line`s.
//
// As a result, we determine a scroll position relative to the top or bottom of the rich
// content block depending on which direction we need to scroll to make the match visible.
let (rich_content_top, rich_content_bottom) =
self.rich_content_scroll_bounds(index);
if (rich_content_top >= current_scroll_top
&& rich_content_bottom <= current_scroll_bottom)
|| (rich_content_top <= current_scroll_top
&& rich_content_bottom >= current_scroll_bottom)
{
// If the AI block is either completely in view or is larger than the viewport
// and spans the entire viewport, fix the scroll position.
return ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(current_scroll_top),
};
} else if rich_content_top < current_scroll_top {
// If the AI block is above the viewport, scroll such that the block is at the
// bottom of the viewport.
rich_content_bottom - viewport_height
} else {
// Else, the block must be below the viewport, scroll such that the block is at
// the top of the viewport.
rich_content_top
}
}
};
new_scroll_top = new_scroll_top
.max(Lines::zero())
.min(self.max_scroll_top_in_lines());
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(new_scroll_top),
}
}
fn snackbar_header_height(&self) -> Lines {
let row_height = self.size_info.cell_height_px();
self.snackbar_header_state()
.header_position
.map_or(0., |header| header.rect.height() / row_height.as_f32())
.into_lines()
}
fn scroll_to_blocklist_row_if_not_visible(&self, row: Lines) -> ScrollPosition {
let row = self.block_list_y_to_scroll_y(row);
let total_block_heights = self.block_list.block_heights().summary().height;
let visible_rows = self.content_element_height_lines();
let max_scroll_position = (total_block_heights - visible_rows).max(Lines::zero());
let current_scroll_position = self.scroll_top_in_lines();
let header_height = self.snackbar_header_height();
// The visible screen in which there can be a text selection is bounded as such:
//
// -----current_scroll_position + header_height -----------
// | |
// | |
// | |
// | |
// -------(current_scroll_position + visible_rows)--------
//
// If the point lies above the top boundary, we scroll upwards to reveal the line, making adjustments
// based on the snackbar of the block where the new point lies. If the point lies below the boundary
// boundary (or in a partial bottom row), we scroll such that the desired row is fully revealed as the
// bottommost visible row. If the point lies in the boundaries, there's no scroll adjustment.
let new_scroll_position = if row < current_scroll_position + header_height {
// Scrolling up: place the row at the very top of the viewport
row - header_height
} else if row > current_scroll_position + (visible_rows - 1.0.into_lines()) {
// Scrolling down: place the row as the last visible row in the viewport
row - (visible_rows - 1.0.into_lines())
} else {
// If the row lies in the viewport, no adjustments are needed
current_scroll_position
};
let new_top = new_scroll_position
.max(Lines::zero())
.min(max_scroll_position);
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(new_top),
}
}
fn scroll_position_after_clear(&self) -> ScrollPosition {
match self.input_mode {
InputMode::PinnedToTop => ScrollPosition::FollowsTopOfMostRecentBlock,
InputMode::PinnedToBottom => ScrollPosition::FollowsBottomOfMostRecentBlock,
InputMode::Waterfall => ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock {
scroll_top_in_lines: self.max_scroll_top_in_lines(),
},
}
}
// Returns whether the input is rendered exactly at the bottom of its pane.
fn is_input_rendered_at_bottom_of_pane(&self, app: &AppContext) -> bool {
match self.input_mode {
InputMode::Waterfall => {
let current_scroll_top_px = self.scroll_top_in_pixels();
let total_block_height_px = self
.block_list
.block_heights()
.summary()
.height
.to_pixels(self.size_info.cell_height_px());
let (gap_height_px, blocklist_inset_px) =
if let Some(gap) = self.block_list.active_gap() {
(
Some(gap.height().to_pixels(self.size_info.cell_height_px())),
// See doc comment on `blocklist_top_inset_when_in_waterfall_mode`
// for context.
self.inline_menu_positioner
.as_ref(app)
.blocklist_top_inset_when_in_waterfall_mode(app),
)
} else {
(None, None)
};
let total_block_height_without_gap_px =
total_block_height_px - gap_height_px.unwrap_or_default();
let visible_block_height_px = total_block_height_without_gap_px
- current_scroll_top_px
- blocklist_inset_px.unwrap_or_default();
let input_height_px = Pixels::new(self.input_size.y());
let max_blocklist_element_height =
self.size_info.pane_height_px() - input_height_px;
visible_block_height_px >= max_blocklist_element_height
}
InputMode::PinnedToBottom => true,
InputMode::PinnedToTop => false,
}
}
// Returns the scroll position to set after a command has finished executing
fn scroll_position_after_command_execution(&self, app: &AppContext) -> ScrollPosition {
match (self.input_mode, self.block_list.active_gap()) {
(InputMode::PinnedToTop, _) => ScrollPosition::FollowsTopOfMostRecentBlock,
(InputMode::Waterfall, Some(gap)) => {
// In gap waterfall mode, the logic is somewhat complex for how to adjust scroll position
// after a command is executed. The basic result we want is:
// 1. If the gap is scrolled into view don't change the scroll position and just add the command
// above the input box while moving the input box down. Because the gap is shrinking by the size
// of the new command you don't actually need to change the scroll position in this case.
// 2. If the gap is scrolled below the input (so that the input appears sticky at the bottom) then
// we need to scroll so that the most recent command is just above the input and the input stays
// at the bottom, possibly leaving whatever is left of the gap below it. This is what the math
// below is figuring out.
let total_block_height_px = self
.block_list
.block_heights()
.summary()
.height
.to_pixels(self.size_info.cell_height_px());
let gap_height_px = gap.height().to_pixels(self.size_info.cell_height_px());
let block_heights_without_gap_px = total_block_height_px - gap_height_px;
if self.is_input_rendered_at_bottom_of_pane(app) {
let new_position_in_lines = (block_heights_without_gap_px
- Pixels::new(self.blocklist_element_size.y()))
.to_lines(self.size_info.cell_height_px());
ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock {
scroll_top_in_lines: new_position_in_lines
.max(Lines::zero())
.min(self.max_scroll_top_in_lines()),
}
} else {
ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock {
scroll_top_in_lines: self.scroll_top_in_lines(),
}
}
}
(_, _) => ScrollPosition::FollowsBottomOfMostRecentBlock,
}
}
/// Calculates the next scroll position for the given viewport state and scroll delta.
fn scroll_position_for_delta(&self, delta: Lines) -> ScrollPosition {
let max_scroll_top = self.max_scroll_top_in_lines();
let current_top = self.scroll_top_in_lines();
let new_top = (current_top - delta).max(Lines::zero()).min(max_scroll_top);
// Horizontal wheel gestures and no-op events must not change follow state.
if delta == Lines::zero() {
return self.scroll_position;
}
self.scroll_position_for_offset(new_top)
}
fn scroll_position_for_offset(&self, new_top: Lines) -> ScrollPosition {
let tolerance = Pixels::new(0.5).to_lines(self.size_info.cell_height_px());
if self.input_mode.is_inverted_blocklist() && new_top <= tolerance {
ScrollPosition::FollowsTopOfMostRecentBlock
} else if !self.input_mode.is_inverted_blocklist()
&& new_top + tolerance >= self.max_scroll_top_in_lines()
{
ScrollPosition::FollowsBottomOfMostRecentBlock
} else if self.block_list.active_block().is_active_and_long_running()
&& self.does_block_exceed_viewport(self.block_list.active_block_index(), new_top)
{
// We only want to account for truncation if the active block is fully spanning
// the viewport. If there are other block items in the viewport, we don't want
// truncation to affect the scroll position because the user might want to have
// their scroll position fixed between different blocks.
ScrollPosition::FixedWithinLongRunningBlock {
scroll_lines: self.scroll_lines_from_scroll_top(new_top),
num_output_lines_truncated: self
.block_list
.active_block()
.output_grid()
.grid_handler()
.num_lines_truncated(),
}
} else {
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(new_top),
}
}
}
/// Returns the block index at the given block list point, or None if
/// there is no block at that point in the blocklist.
pub fn block_index_from_point(&self, point: BlockListPoint) -> Option<BlockIndex> {
let mut block_heights_cursor = self
.block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
block_heights_cursor.seek(&BlockHeight::from(point.row), SeekBias::Right);
block_heights_cursor.item().and_then(|item| match item {
BlockHeightItem::Block(_) => Some(block_heights_cursor.start().block_count.into()),
BlockHeightItem::Gap(_)
| BlockHeightItem::RestoredBlockSeparator { .. }
| BlockHeightItem::InlineBanner { .. }
| BlockHeightItem::SubshellSeparator { .. }
| BlockHeightItem::RichContent { .. } => None,
})
}
pub fn block_height_item_from_point(&self, point: BlockListPoint) -> Option<&BlockHeightItem> {
let mut block_heights_cursor = self
.block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
block_heights_cursor.seek(&BlockHeight::from(point.row), SeekBias::Right);
block_heights_cursor.item()
}
/// Returns whether the last non-hidden block is visible according to the given block visibility mode
/// Note that "in view" here means that the block is scrolled at least partially into view
/// and has a non-zero height.
pub fn is_most_recent_block_in_view(&self, block_visibility_mode: BlockVisibilityMode) -> bool {
self.block_list
.last_non_hidden_block_by_index()
.is_some_and(|block_index| self.is_block_in_view(block_index, block_visibility_mode))
}
/// Returns whether a block is visible according to the given block visibility mode
/// Note that "in view" here means that the block is scrolled at least partially into view
/// and has a non-zero height.
pub fn is_block_in_view(
&self,
block_index: BlockIndex,
block_visibility: BlockVisibilityMode,
) -> bool {
let top_of_viewport_in_lines = self.scroll_top_in_lines();
let bottom_of_viewport_in_lines =
top_of_viewport_in_lines + self.content_element_height_lines();
match block_visibility {
BlockVisibilityMode::TopOfBlockVisible => {
let top_of_block_in_lines = self.top_of_block_in_lines(block_index);
heights_approx_gte(top_of_block_in_lines, top_of_viewport_in_lines)
&& heights_approx_lte(top_of_block_in_lines, bottom_of_viewport_in_lines)
}
BlockVisibilityMode::BottomOfBlockVisible => {
let bottom_of_block_in_lines = self.bottom_of_block_in_lines(block_index);
heights_approx_gte(bottom_of_block_in_lines, top_of_viewport_in_lines)
&& heights_approx_lte(bottom_of_block_in_lines, bottom_of_viewport_in_lines)
}
}
}
/// Returns true iff the block at `block_index` starts before the viewport
/// (starting at `new_scroll_top`) and ends after it.
fn does_block_exceed_viewport(&self, block_index: BlockIndex, new_scroll_top: Lines) -> bool {
let top_of_viewport_in_lines = new_scroll_top;
let bottom_of_viewport_in_lines =
top_of_viewport_in_lines + self.content_element_height_lines();
let top_of_block_in_lines = self.top_of_block_in_lines(block_index);
let bottom_of_block_in_lines = self.bottom_of_block_in_lines(block_index);
heights_approx_lt(top_of_block_in_lines, top_of_viewport_in_lines)
&& heights_approx_gt(bottom_of_block_in_lines, bottom_of_viewport_in_lines)
}
pub fn max_scroll_top_px(&self) -> Pixels {
self.max_scroll_top_in_lines()
.to_pixels(self.size_info.cell_height_px)
}
/// Returns the max possible value in lines for scroll_top (how far from the top of the
/// blocklist it's possible to scroll down)
pub fn max_scroll_top_in_lines(&self) -> Lines {
match (self.input_mode, self.block_list.active_gap()) {
(InputMode::Waterfall, Some(gap)) => {
// In waterfall mode with a gap, the max scroll top is always right
// at the top of the gap.
//
// This diagram of the positions in the blocklist model (not view) shows why.
//
// | Block list up to gap |
// | -- top of viewport -- | <-- max_scroll_top
// | ------- GAP --------- |
// | commands after gap -- |
// | ------ input -------- |
// | bottom of viewport |
//
// The invariant is that the GAP + the height of the blocks after the gap
// always equals the height of the viewport.
//
// Note that in waterfall mode, the actual layout in the view looks like this:
//
// | Block list up to gap |
// | -- top of viewport -- | <-- max_scroll_top
// | commands after gap -- |
// | ------ input -------- |
// | ------- GAP --------- |
// | bottom of viewport |
//
// but the re-ordering of the items in the view doesn't change max scroll top.
let mut cursor = self
.block_list
.block_heights()
.cursor::<TotalIndex, BlockHeightSummary>();
cursor.seek(&gap.index(), SeekBias::Right);
cursor.start().height
}
(_, _) => {
let total_block_height = self.block_list.block_heights().summary().height;
(total_block_height - self.content_element_height_lines()).max(Lines::zero())
}
}
}
/// Returns the number of lines the top of this block is offset from zero
/// scroll top.
pub fn top_of_block_in_lines(&self, block_index: BlockIndex) -> Lines {
let mut cursor = self
.block_list
.block_heights()
.cursor::<BlockIndex, BlockHeightSummary>();
cursor.seek(&block_index, SeekBias::Right);
let block_list_height = self.block_list.block_heights().summary().height;
let num_visible_lines = self.content_element_height_lines();
match (self.input_mode, self.block_list.active_gap()) {
(InputMode::PinnedToBottom, _) | (InputMode::Waterfall, None) => {
let mut top = cursor.start().height;
// Adjust the top for the case where the blocks don't fill the viewport
if block_list_height < num_visible_lines {
top += num_visible_lines - block_list_height;
}
top.max(Lines::zero())
}
(InputMode::PinnedToTop, _) => {
(block_list_height - cursor.end().height).max(Lines::zero())
}
(InputMode::Waterfall, Some(gap)) => {
// If there is a gap in waterfall mode, we need to take into account whether
// the block is above or below the gap.
let top = if cursor.start().total_count < gap.index().0 {
cursor.start().height
} else {
cursor.start().height - gap.height()
};
top.max(Lines::zero())
}
}
}
/// Returns the (top, bottom) scroll bounds in lines for the rich content at the given TotalIndex.
fn rich_content_scroll_bounds(&self, index: TotalIndex) -> (Lines, Lines) {
let mut cursor = self
.block_list
.block_heights()
.cursor::<TotalIndex, BlockHeightSummary>();
cursor.seek(&index, SeekBias::Right);
if let InputMode::PinnedToTop = self.input_mode {
// With `PinnedToTop`, we have to "invert" the scroll position calculation
// since the block list is inverted.
let block_list_height = self.block_list.block_heights().summary().height;
(
block_list_height - cursor.end().height,
block_list_height - cursor.start().height,
)
} else {
(cursor.start().height, cursor.end().height)
}
}
/// Returns the scroll position for navigating the top of the block at the
/// given index into view
fn scroll_position_at_top_of_block(&self, block_index: BlockIndex) -> ScrollPosition {
self.scroll_position_at_top_of_block_with_buffer(block_index, Lines::zero())
}
/// Returns the scroll position for navigating the top of the block at the
/// given index into view, with a buffer of space above the top of the block.
fn scroll_position_at_top_of_block_with_buffer(
&self,
block_index: BlockIndex,
buffer_lines: Lines,
) -> ScrollPosition {
let top_of_block_with_buffer =
(self.top_of_block_in_lines(block_index) - buffer_lines).max(Lines::zero());
match self.input_mode {
InputMode::PinnedToBottom | InputMode::Waterfall => {
if top_of_block_with_buffer >= self.max_scroll_top_in_lines() {
ScrollPosition::FollowsBottomOfMostRecentBlock
} else {
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(top_of_block_with_buffer),
}
}
}
InputMode::PinnedToTop => ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(
top_of_block_with_buffer.min(self.max_scroll_top_in_lines()),
),
},
}
}
/// Returns the number of lines the bottom of this block is offset from zero
/// scroll top.
pub fn bottom_of_block_in_lines(&self, block_index: BlockIndex) -> Lines {
let top_of_block = self.top_of_block_in_lines(block_index);
let block_height = self
.block_list
.block_at(block_index)
.map_or(Lines::zero(), |b| {
b.height(self.block_list.agent_view_state())
});
top_of_block + block_height
}
/// Returns the scroll position for navigating the bottom of the block at the
/// given index into view
pub fn scroll_position_at_bottom_of_block(&self, block_index: BlockIndex) -> ScrollPosition {
let bottom_of_block = self.bottom_of_block_in_lines(block_index);
let scroll_top = (bottom_of_block - self.content_element_height_lines())
.max(Lines::zero())
.min(self.max_scroll_top_in_lines());
// Check if this command block is the most recent block. This means there is no command or rich content block that is after
// the block at given index.
let is_most_recent_visible_block = self
.block_list
.last_non_hidden_block_by_index()
.is_some_and(|index| {
index == block_index
&& self
.block_list
.last_non_hidden_rich_content_block_after_block(Some(index))
.is_none()
});
match self.input_mode {
InputMode::PinnedToBottom | InputMode::PinnedToTop => {
if is_most_recent_visible_block {
ScrollPosition::FollowsBottomOfMostRecentBlock
} else {
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(scroll_top),
}
}
}
InputMode::Waterfall => {
if is_most_recent_visible_block {
if self.block_list.active_gap().is_some() {
ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock {
scroll_top_in_lines: scroll_top,
}
} else {
ScrollPosition::FollowsBottomOfMostRecentBlock
}
} else {
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(scroll_top),
}
}
}
}
}
/// Returns whether we are in waterfall mode with an active gap.
pub fn is_waterfall_gap_mode(&self) -> bool {
matches!(self.input_mode, InputMode::Waterfall) && self.block_list.active_gap().is_some()
}
/// Returns the scroll position of any "overhanging" bottom block - this
/// is a block whose bottom part is scrolled off the bottom of the screen
pub fn overhanging_bottom_block(&self, app: &AppContext) -> Option<OverhangingBlock> {
if matches!(self.input_mode, InputMode::Waterfall)
&& !self.is_input_rendered_at_bottom_of_pane(app)
{
// For waterfall mode the input needs to be at the bottom of the pane
// for there to be an overhang.
return None;
}
let bottom_of_view = self.scroll_top_in_lines() + self.content_element_height_lines();
let last_block = self.iter_from(bottom_of_view).next()?;
let block_index = last_block.block_index?;
let top_of_block = self.top_of_block_in_lines(block_index);
let bottom_of_block = self.bottom_of_block_in_lines(block_index);
let is_most_recent_block =
self.block_list.last_non_hidden_block_by_index() == Some(block_index);
// A block is ovehanging when top_of_block < bottom_of_view < bottom_of_block
(bottom_of_block - bottom_of_view > HEIGHT_FUDGE_FACTOR_LINES).then_some(OverhangingBlock {
visible_block_height_px: (bottom_of_view - top_of_block)
.max((0.).into_lines())
.to_pixels(self.size_info.cell_height_px()),
block_index,
is_most_recent_block,
})
}
/// Converts a pixel coordinate to a point in the `BlockList` coordinate space
pub fn screen_coord_to_blocklist_point(
&self,
viewport_origin: Vector2F,
snackbar_point: SnackbarPoint,
clamping_mode: ClampingMode,
) -> Option<BlockListPoint> {
let relative_coord = snackbar_point.coord - viewport_origin;
let total_block_height = self.block_list.block_heights().summary().height;
let size = self.size_info;
let content_element_lines = self.content_element_height_lines();
let mut coord_in_lines = relative_coord
.y()
.into_pixels()
.to_lines(size.cell_height_px());
let (is_coord_above_blocks, is_coord_below_blocks) = match self.input_mode {
InputMode::PinnedToBottom => {
let screen_taller_than_blocks = content_element_lines > total_block_height;
let coord_above_bottom_blocks =
coord_in_lines < content_element_lines - total_block_height;
(
screen_taller_than_blocks && coord_above_bottom_blocks,
relative_coord.y() > self.blocklist_element_size.y(),
)
}
InputMode::Waterfall => (
relative_coord.y() < 0.,
relative_coord.y() > self.blocklist_element_size.y(),
),
InputMode::PinnedToTop => {
let screen_taller_than_blocks = content_element_lines > total_block_height;
let coord_below_top_blocks = coord_in_lines > total_block_height;
(
relative_coord.y() < 0.,
screen_taller_than_blocks && coord_below_top_blocks,
)
}
};
match clamping_mode {
ClampingMode::ClampToGridIfWithinBlock => {
if total_block_height == Lines::zero()
|| relative_coord.x() < 0.
|| relative_coord.x()
- self.horizontal_clipped_scroll_state.scroll_start().as_f32()
> self.size_info.pane_width_px().as_f32()
|| is_coord_above_blocks
|| is_coord_below_blocks
{
return None;
}
}
ClampingMode::ReturnNoneIfNotInGrid => {
let min_x = size.padding_x_px;
let max_x =
size.padding_x_px + ((size.columns as f32).into_pixels() * size.cell_width_px);
if total_block_height == Lines::zero()
|| is_coord_above_blocks
|| is_coord_below_blocks
|| relative_coord.x() < min_x.as_f32()
|| relative_coord.x() > max_x.as_f32()
{
return None;
}
}
ClampingMode::ClampToGrid => {
if total_block_height == Lines::zero() {
return None;
}
}
}
// Translate the coordinate position upwards if it's contained inside
// the snackbar header.
coord_in_lines -= self
.snackbar_header_state()
.header_translation_for_coord(size, snackbar_point);
let scroll_top = self.scroll_top_in_lines();
let offset = (content_element_lines - total_block_height).max(Lines::zero());
let column = ((relative_coord.x() - size.padding_x_px.as_f32()).max(0.)
/ size.cell_width_px().as_f32()) as usize;
let row = match self.input_mode {
InputMode::PinnedToBottom => coord_in_lines + scroll_top - offset,
InputMode::Waterfall => {
match self.block_list.active_gap() {
Some(gap) => {
let mut row = coord_in_lines + scroll_top;
let mut cursor = self
.block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
cursor.seek(&BlockHeight::from(row), SeekBias::Right);
// In waterfall gap mode if the row is after the gap, we need
// to adjust the row to account for the gap because we are moving from
// screen to model cooredinates here.
if cursor.start().total_count >= gap.index().0 {
row += gap.height();
}
row
}
None => {
// With no gap the logic is the same as pinned to bottom, except
// the offset doesn't apply because you can never have any space
// between the top of the blocks and the top of the screen
coord_in_lines + scroll_top
}
}
}
InputMode::PinnedToTop => {
// The logic here is somewhat tricky. In inverted mode
// the blocklist has the most recent blocks at the top, so we
// generally can translate a screen coord by subtracting its
// current row from the total block height. This will correctly
// find the block at the given row, but will actually find the wrong line
// within it because within a single block, the direction is always
// top to bottom.
//
// To illustrate, assume you had a blocklist with 3 blocks,
// rendered in terms of recency order, from 3, the most recent, to 1,
// the least recent, with no scroll. Assume the screen coord is
// at line 3 in the second block.
//
// | Block 3 = 5 lines long|
// | Block 2 = 10 lines long| <-- screen coord at line 3 of block 2
// | Block 1 = 15 lines long|
//
// In this case the total_block_height = 30, and coord_in_lines = 8 (5 lines
// from block 3 and 3 lines from block 2).
// This implies that total_block_height - coord_in_lines = 22.
//
// If we treated 22 as the BlockListPoint, then we would actually be pointing
// at row 7 in Block 2 (because 22 - 15 = 7). That's because the blocklist
// heights in the model are stored like this:
//
// | Block 1 = 15 lines long|
// | Block 2 = 10 lines long|
// | Block 3 = 5 lines long|
//
// So instead, we need to calculate how many lines into Block 2 the screen coord
// is, and add that directly to the total height before Block 2 in the blocklist.
// This yields 3 + 15 = 18, which is the correct BlockListPoint.
// We use the inverted_row to identify the block the coord is in.
let inverted_row =
(total_block_height - (coord_in_lines + scroll_top)).max(Lines::zero());
let mut cursor = self
.block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
cursor.seek(&BlockHeight::from(inverted_row), SeekBias::Right);
// The "overhang" is how many rows past the start of the block the row is
// when looking at the block list non-inverted.
let overhang = inverted_row - cursor.start().height;
cursor.item().map_or(inverted_row, |item| {
// We need to actually subtract the overhang from the block height
// to get how many rows past the start of the block we are at,
// and then add that to the height.
let rows_past_block_start = item.height().into_lines() - overhang;
cursor.start().height + rows_past_block_start
})
}
};
// Use saturating subtraction to compute the max valid row index, ensuring it
// doesn't go negative when total_block_height is less than 1 line.
let max_row = (total_block_height - 1.0.into_lines()).max(Lines::zero());
Some(BlockListPoint::new(
row.max(Lines::zero()).min(max_row),
column.min(self.size_info.columns() - 1),
))
}
// Take a point in the block list space and returns the point within a grid in the blocklist, if
// contained with a block.
pub fn block_list_point_to_grid_point(
&self,
point: BlockListPoint,
) -> Option<WithinBlock<IndexPoint>> {
let mut block_heights_cursor = self
.block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
block_heights_cursor.seek(&BlockHeight::from(point.row), SeekBias::Right);
let block_index = match block_heights_cursor.item() {
Some(BlockHeightItem::Block(_)) => {
BlockIndex::from(block_heights_cursor.start().block_count)
}
_ => return None,
};
let block = self.block_list.block_at(block_index)?;
let row_within_grid = point.row - block_heights_cursor.start().height;
// Determine whether it is in the command grid, prompt/command grid, output grid or none of them.
let (point, grid_type) = match block.find(row_within_grid) {
BlockSection::PromptAndCommandGrid(row) => (
IndexPoint {
row: row.as_f64() as usize,
col: point.column,
},
GridType::PromptAndCommand,
),
BlockSection::OutputGrid(row) => (
IndexPoint {
row: row.as_f64() as usize,
col: point.column,
},
GridType::Output,
),
_ => return None,
};
Some(WithinBlock::new(point, block_index, grid_type))
}
// Translates a row in the BlockListPoint space into a row in
// the scroll space of the viewport (how many rows from the top-left
// block are we), taking into account block direction.
pub fn block_list_y_to_scroll_y(&self, block_list_y_lines: Lines) -> Lines {
match self.input_mode {
InputMode::PinnedToBottom => block_list_y_lines,
InputMode::Waterfall => {
let Some(gap) = self.block_list.active_gap() else {
return block_list_y_lines;
};
let mut cursor = self
.block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
cursor.seek(&BlockHeight::from(block_list_y_lines), SeekBias::Right);
// If there's a gap we need to potentially adjust the position based
// on whether we are before or after it.
if cursor.start().total_count < gap.index().0 {
block_list_y_lines
} else {
block_list_y_lines - gap.height()
}
}
InputMode::PinnedToTop => {
let total_block_height = self.block_list.block_heights().summary().height;
let mut cursor = self
.block_list
.block_heights()
.cursor::<BlockHeight, BlockHeightSummary>();
cursor.seek(&BlockHeight::from(block_list_y_lines), SeekBias::Right);
let overhang = block_list_y_lines - cursor.start().height;
let block_start = total_block_height - cursor.end().height;
block_start + overhang
}
}
}
/// Returns the index (in the original grid) of the output row at the top of
/// the viewport in the given block index. Returns None if the row at the top
/// of the viewport is not in the output grid of the given block.
pub fn get_first_visible_output_row(&self, block_index: BlockIndex) -> Option<usize> {
let header_height = self.snackbar_header_height();
let top_of_viewport = self.scroll_top_in_lines();
let top_of_block = self.top_of_block_in_lines(block_index);
let block = self.block_list.block_at(block_index)?;
let viewport_offset_into_block = (top_of_viewport + header_height) - top_of_block;
let viewport_offset_into_output =
viewport_offset_into_block - block.output_grid_offset().into_lines();
if !heights_approx_gte(viewport_offset_into_output, 0.0_f32.into_lines()) {
// The viewport is before the block output.
return None;
}
let output_offset_row = viewport_offset_into_output.as_f64().round() as usize;
if output_offset_row >= block.output_grid().len_displayed() {
// The viewport is after the block output.
return None;
}
let original_row = block
.output_grid()
.grid_handler()
.maybe_translate_row_from_displayed_to_original(output_offset_row);
Some(original_row)
}
/// Convert a selection range in block list coordinate space to points in viewport coordinate space,
/// which accounts for input mode.
pub fn selection_as_viewport_points(
&self,
range: &SelectionRange,
) -> (SelectionPoint, SelectionPoint) {
let start = SelectionPoint {
row: self.block_list_y_to_scroll_y(range.start.row),
col: range.start.column,
};
let end = SelectionPoint {
row: self.block_list_y_to_scroll_y(range.end.row),
col: range.end.column,
};
(start, end)
}
/// Returns true if the start point <= end point after converting the selection from
/// block list coordinate space to viewport coordinate space, which accounts for input mode.
pub fn is_range_in_order_in_viewport(&self, range: &SelectionRange) -> bool {
let (start, end) = self.selection_as_viewport_points(range);
start <= end
}
}
impl Iterator for ViewportIter<'_> {
type Item = ViewportIterItem;
fn next(&mut self) -> Option<Self::Item> {
loop {
// If the block is not in the viewport at all, stop rendering. The blocks are ordered,
// so this means the rest of the blocks also aren't in the viewport.
//
// Add a 'fudge' factor to ensure that an item with zero height right at the bottom of
// the viewport may still get relaid out, if this condition would otherwise fail due to
// floating point imprecision.
if self.top_of_current_block.as_f64() > self.bottom_offset.as_f64() + f64::EPSILON * 5.
{
return None;
}
let item = self.block_heights_iter.next()?;
// If there is a gap in waterfall mode, it needs to be handled
// outside of the blocklist viewport because the gap is rendered below the input
// box, so skip it.
if matches!(item, BlockHeightItem::Gap(_))
&& matches!(self.input_mode, InputMode::Waterfall)
{
continue;
}
let is_block = matches!(item, BlockHeightItem::Block(_));
// Note that in inverted case we need to return the updated
// index in the ViewportIterItem, whereas in the non-inverted
// case, we update the index after returning the item. This is just
// an artifact of the way the indexing works.
if self.input_mode.is_inverted_blocklist() {
if self.curr_entry_index.0 > 0 {
self.curr_entry_index = (self.curr_entry_index.0 - 1).into();
}
if is_block && self.curr_block_index > 0.into() {
self.curr_block_index -= 1.into();
}
}
let block_index = if is_block {
Some(self.curr_block_index)
} else {
None
};
let next = Some(ViewportIterItem {
block_index,
entry_index: self.curr_entry_index,
block_height_item: *item,
top_of_current_block: self.top_of_current_block,
});
let block_height = item.height();
self.top_of_current_block += block_height.into_lines();
if !self.input_mode.is_inverted_blocklist() {
self.curr_entry_index = (self.curr_entry_index.0 + 1).into();
if is_block {
self.curr_block_index += 1.into();
}
}
match item {
BlockHeightItem::RichContent(RichContentItem {
agent_view_conversation_id: fullscreen_agent_view_conversation_id,
..
}) => match self.agent_view_state {
AgentViewState::Active {
conversation_id,
display_mode: AgentViewDisplayMode::FullScreen,
..
} => {
// If currently in a fullscreen agent view, only return this item if its
// conversation id matches that of the active agent view.
if fullscreen_agent_view_conversation_id
.is_some_and(|id| id == *conversation_id)
{
return next;
}
}
AgentViewState::Active {
display_mode: AgentViewDisplayMode::Inline,
..
}
| AgentViewState::Inactive => {
// If not in a fullscreen agent view, return the item only if it 'belongs'
// to the terminal mode (represented as no `ai_conversation_id`).
if fullscreen_agent_view_conversation_id.is_none() {
return next;
}
}
},
_ => {
if !FeatureFlag::AgentView.is_enabled() || block_height.as_f64() > 0. {
return next;
}
}
}
}
}
}