feat(tui): resize panes by dragging pane borders

Press and drag the border under Results or the seam at a row pane's
right edge; the panes resize live and release ends the drag. Dragged
sizes are cell counts on PaneLayout, so W resets panes, zoom and sizes
in one key, and the reset-layout hint shows once anything is dragged.
Undragged panes keep their default Fill proportions.
This commit is contained in:
Michel de Graaf
2026-09-09 17:20:12 +02:00
parent 926c1c6697
commit 374a81bec1
4 changed files with 315 additions and 23 deletions
+60 -7
View File
@@ -29,8 +29,9 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[
("z", "zoom the focused pane"),
("w", "hide the focused pane"),
("Esc", "leave zoom"),
("W", "every pane back, zoom off"),
("W", "every pane back, zoom off, sizes reset"),
("click", "focus a pane"),
("drag", "resize panes by their borders"),
],
),
(
@@ -170,11 +171,16 @@ impl MainTui {
self.state.searches_pane_area = None;
self.state.results_pane_area = None;
self.state.downloads_pane_area = None;
self.state.info_pane_area = None;
self.state.hsplit_divider = None;
self.state.vsplit_dividers.clear();
self.state.row_area = None;
if self.state.layout.zoomed {
self.render_pane(frame, area, self.state.focused_pane);
return;
}
self.state.content_area = Some(area);
// Transfers carry the most columns, so they get the most width.
let row_panes: Vec<(FocusedPane, u16)> =
@@ -187,26 +193,61 @@ impl MainTui {
// With only Info left in the row, the results deserve more of
// the height.
let row_weight = if row_panes.is_empty() { 1 } else { 2 };
let [top, bottom] = Layout::vertical([
Constraint::Fill(3),
Constraint::Fill(row_weight),
])
.areas(area);
let top = match self.state.layout.top_height {
Some(rows) => Constraint::Length(shrink(rows, area.height)),
None => Constraint::Fill(3),
};
let [top, bottom] =
Layout::vertical([top, Constraint::Fill(row_weight)])
.areas(area);
// The two touching border rows between the halves; either can
// be dragged.
self.state.hsplit_divider = Some(Rect::new(
area.x,
top.y.saturating_add(top.height).saturating_sub(1),
area.width,
2,
));
self.render_pane(frame, top, FocusedPane::Results);
bottom
} else {
area
};
self.state.row_area = Some(row_area);
let mut constraints: Vec<Constraint> = row_panes
.iter()
.map(|(_, weight)| Constraint::Fill(*weight))
.map(|(pane, weight)| {
let dragged = match *pane {
FocusedPane::Searches => self.state.layout.searches_width,
FocusedPane::Downloads => self.state.layout.downloads_width,
FocusedPane::Results => None,
};
match dragged {
Some(cols) => {
Constraint::Length(shrink(cols, row_area.width))
}
None => Constraint::Fill(*weight),
}
})
.collect();
constraints.push(Constraint::Fill(6)); // Info
let chunks = Layout::horizontal(constraints).split(row_area);
for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) {
// The seam at this pane's right edge: its border column and the
// next pane's, both draggable.
self.state.vsplit_dividers.push((
*pane,
Rect::new(
chunk.x.saturating_add(chunk.width).saturating_sub(1),
row_area.y,
2,
row_area.height,
),
));
self.render_pane(frame, *chunk, *pane);
}
self.state.info_pane_area = Some(chunks[row_panes.len()]);
self.render_info_pane(frame, chunks[row_panes.len()]);
}
@@ -768,6 +809,18 @@ impl MainTui {
/// Columns between the two halves of the keys list.
const HELP_GAP: u16 = 2;
/// A dragged pane size kept inside the space the terminal now offers: never
/// below [`MIN_PANE`], and never so big that what shares the split with it
/// drops below `MIN_PANE` too.
pub(super) fn shrink(dragged: u16, total: u16) -> u16 {
let max = total.saturating_sub(MIN_PANE).max(MIN_PANE);
dragged.max(MIN_PANE).min(max)
}
/// The smallest pane a drag can leave anyone: a border and a row or column
/// of content each side.
pub(super) const MIN_PANE: u16 = 3;
/// The widest key in a column, so the descriptions line up.
fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize {
sections