resizable viewport (#1732)

Proof of concept for a resizable viewport.

The general approach here is to duplicate the `Terminal` struct from
ratatui, but with our own logic. This is a "light fork" in that we are
still using all the base ratatui functions (`Buffer`, `Widget` and so
on), but we're doing our own bookkeeping at the top level to determine
where to draw everything.

This approach could use improvement—e.g, when the window is resized to a
smaller size, if the UI wraps, we don't correctly clear out the
artifacts from wrapping. This is possible with a little work (i.e.
tracking what parts of our UI would have been wrapped), but this
behavior is at least at par with the existing behavior.


https://github.com/user-attachments/assets/4eb17689-09fd-4daa-8315-c7ebc654986d


cc @joshka who might have Thoughts™
This commit is contained in:
Jeremy Rose
2025-07-30 17:06:55 -07:00
committed by GitHub
parent 221ebfcccc
commit f2134f6633
11 changed files with 668 additions and 23 deletions

View File

@@ -71,6 +71,15 @@ impl ChatComposer<'_> {
this
}
pub fn desired_height(&self) -> u16 {
2 + self.textarea.lines().len() as u16
+ match &self.active_popup {
ActivePopup::None => 0u16,
ActivePopup::Command(c) => c.calculate_required_height(),
ActivePopup::File(c) => c.calculate_required_height(),
}
}
/// Returns true if the composer currently contains no user input.
pub(crate) fn is_empty(&self) -> bool {
self.textarea.is_empty()
@@ -651,7 +660,7 @@ impl WidgetRef for &ChatComposer<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
match &self.active_popup {
ActivePopup::Command(popup) => {
let popup_height = popup.calculate_required_height(&area);
let popup_height = popup.calculate_required_height();
// Split the provided rect so that the popup is rendered at the
// *top* and the textarea occupies the remaining space below.
@@ -673,7 +682,7 @@ impl WidgetRef for &ChatComposer<'_> {
self.textarea.render(textarea_rect, buf);
}
ActivePopup::File(popup) => {
let popup_height = popup.calculate_required_height(&area);
let popup_height = popup.calculate_required_height();
let popup_rect = Rect {
x: area.x,

View File

@@ -71,7 +71,7 @@ impl CommandPopup {
/// Determine the preferred height of the popup. This is the number of
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
/// table/border overhead (one line at the top and one at the bottom).
pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
pub(crate) fn calculate_required_height(&self) -> u16 {
let matches = self.filtered_commands();
let row_count = matches.len().clamp(1, MAX_POPUP_ROWS) as u16;
// Account for the border added by the Block that wraps the table.

View File

@@ -109,7 +109,7 @@ impl FileSearchPopup {
}
/// Preferred height (rows) including border.
pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
pub(crate) fn calculate_required_height(&self) -> u16 {
// Row count depends on whether we already have matches. If no matches
// yet (e.g. initial search or query with no results) reserve a single
// row so the popup is still visible. When matches are present we show

View File

@@ -64,6 +64,10 @@ impl BottomPane<'_> {
}
}
pub fn desired_height(&self) -> u16 {
self.composer.desired_height()
}
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
if let Some(mut view) = self.active_view.take() {