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
+22
View File
@@ -57,6 +57,14 @@ impl FocusedPane {
pub struct PaneLayout {
hidden: [bool; FocusedPane::ALL.len()],
pub zoomed: bool,
/// Cell height dragged onto the divider between Results and the row
/// underneath. `None` keeps the default proportions.
pub top_height: Option<u16>,
/// Cell widths dragged onto the dividers in the bottom row. `None` keeps
/// the default proportions; Info, which has no divider of its own, takes
/// whatever the row has left over.
pub searches_width: Option<u16>,
pub downloads_width: Option<u16>,
}
impl PaneLayout {
@@ -322,6 +330,15 @@ pub struct AppState {
pub searches_pane_area: Option<Rect>,
pub results_pane_area: Option<Rect>,
pub downloads_pane_area: Option<Rect>,
/// The whole area the panes share, and the bottom row's part of it,
/// recorded at each draw so a divider drag maps to cells.
pub content_area: Option<Rect>,
pub row_area: Option<Rect>,
pub info_pane_area: Option<Rect>,
/// The border rows a mouse can grab to resize: the one Results sits on
/// and the seam at the right of each row pane.
pub hsplit_divider: Option<Rect>,
pub vsplit_dividers: Vec<(FocusedPane, Rect)>,
/// Where the open popup was last drawn, for its page size. Only one is
/// ever open, so one record serves them all.
pub popup_area: Option<Rect>,
@@ -397,6 +414,11 @@ impl AppState {
searches_pane_area: None,
results_pane_area: None,
downloads_pane_area: None,
content_area: None,
row_area: None,
info_pane_area: None,
hsplit_divider: None,
vsplit_dividers: Vec::new(),
popup_area: None,
}
}
+127 -16
View File
@@ -1,4 +1,5 @@
use super::MainTui;
use super::render::{MIN_PANE, shrink};
use crate::models::{CommandBarMode, FocusedPane, LogView, PaneLayout};
use crate::ui::page_of;
use crate::ui::panes::{
@@ -102,6 +103,9 @@ impl MainTui {
return;
}
KeyCode::Char('W') => {
// The universal undo for the window: every pane back, zoom
// off, and any mouse-dragged pane sizes back to the default
// proportions — they all live in the layout.
self.state.layout = PaneLayout::default();
return;
}
@@ -566,24 +570,131 @@ impl MainTui {
}
pub(super) fn handle_mouse_event(&mut self, mouse: MouseEvent) {
if mouse.kind != MouseEventKind::Down(MouseButton::Left) {
return;
}
let clicked = Position::new(mouse.column, mouse.row);
// The areas are from the last draw. A pane hidden since then, in the
// same batch of events, still has one, and is not there to click.
let hit = FocusedPane::ALL.into_iter().find(|pane| {
self.state.layout.is_visible(*pane)
&& self
.state
.pane_area(*pane)
.is_some_and(|area| area.contains(clicked))
});
if let Some(pane) = hit {
self.state.focused_pane = pane;
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
let at = Position::new(mouse.column, mouse.row);
// A divider wins over the panes it separates; grabbing one
// starts a drag, and the click alone already resizes.
if let Some(drag) = self.grabbed_divider(at) {
self.resize_drag = Some(drag);
self.apply_resize(drag, at);
return;
}
// The areas are from the last draw. A pane hidden since then, in the
// same batch of events, still has one, and is not there to click.
let hit = FocusedPane::ALL.into_iter().find(|pane| {
self.state.layout.is_visible(*pane)
&& self
.state
.pane_area(*pane)
.is_some_and(|area| area.contains(at))
});
if let Some(pane) = hit {
self.state.focused_pane = pane;
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(drag) = self.resize_drag {
self.apply_resize(
drag,
Position::new(mouse.column, mouse.row),
);
}
}
MouseEventKind::Up(MouseButton::Left) => {
self.resize_drag = None;
}
_ => {}
}
}
/// Which dragged divider, if any, the pointer is on. Nothing is grabbable
/// while a popup or a zoom covers the dividers.
fn grabbed_divider(&self, at: Position) -> Option<ResizeDrag> {
if self.state.layout.zoomed
|| self.state.show_help
|| self.state.show_messages
|| self.state.show_browse
|| self.state.show_rooms
|| self.state.settings.is_some()
{
return None;
}
if self
.state
.hsplit_divider
.is_some_and(|rect| rect.contains(at))
{
return Some(ResizeDrag::Rows);
}
self.state
.vsplit_dividers
.iter()
.find(|(_, rect)| rect.contains(at))
.map(|(pane, _)| ResizeDrag::Columns(*pane))
}
/// Move the divider under the pointer to where the pointer is, keeping
/// every pane at least [`MIN_PANE`] cells.
fn apply_resize(&mut self, drag: ResizeDrag, at: Position) {
match drag {
ResizeDrag::Rows => {
let Some(area) = self.state.content_area else {
return;
};
// The dragged row is the pane's last row, so the height is
// everything from the top of the content down to it.
let height = at.y.saturating_sub(area.y).saturating_add(1);
self.state.layout.top_height =
Some(shrink(height, area.height));
}
ResizeDrag::Columns(pane) => {
let Some(row) = self.state.row_area else {
return;
};
let left = if pane == FocusedPane::Downloads
&& self.state.layout.is_visible(FocusedPane::Searches)
{
// Downloads starts where Searches ends.
self.state
.searches_pane_area
.map_or(row.x, |area| area.x.saturating_add(area.width))
} else {
row.x
};
let width = at.x.saturating_sub(left).saturating_add(1);
// Info shares the row, so the drag may not crowd it (and any
// other visible pane) out of `MIN_PANE` cells.
let others = MIN_PANE
* u16::from(
self.state.layout.is_visible(FocusedPane::Searches)
&& pane == FocusedPane::Downloads,
)
+ MIN_PANE;
let max = row.width.saturating_sub(others).max(MIN_PANE);
let width = width.max(MIN_PANE).min(max);
match pane {
FocusedPane::Searches => {
self.state.layout.searches_width = Some(width);
}
FocusedPane::Downloads => {
self.state.layout.downloads_width = Some(width);
}
FocusedPane::Results => {}
}
}
}
}
}
/// What a divider does while the left button is held on it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ResizeDrag {
/// The border between Results and the row underneath: sets that row's height.
Rows,
/// The seam at the right of the named row pane: gives it that width and
/// takes it from the panes to its right.
Columns(FocusedPane),
}
/// What a key did to a filter being typed.
+106
View File
@@ -31,6 +31,8 @@ pub struct MainTui {
config_path: Option<std::path::PathBuf>,
/// Last snapshot written to disk, to skip no-op saves.
saved_snapshot: Snapshot,
/// Which divider the left mouse button is holding, if any.
resize_drag: Option<input::ResizeDrag>,
}
impl MainTui {
@@ -50,6 +52,7 @@ impl MainTui {
store,
config_path,
saved_snapshot: Snapshot::default(),
resize_drag: None,
};
tui.restore_persisted_state();
tui
@@ -1360,6 +1363,27 @@ mod tests {
screen_sized(tui, 160, 40)
}
fn mouse(
tui: &mut MainTui,
kind: ratatui::crossterm::event::MouseEventKind,
column: u16,
row: u16,
) {
tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
});
}
fn mouse_drag_row(tui: &mut MainTui, from: (u16, u16), to: (u16, u16)) {
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
mouse(tui, MouseEventKind::Down(MouseButton::Left), from.0, from.1);
mouse(tui, MouseEventKind::Drag(MouseButton::Left), to.0, to.1);
mouse(tui, MouseEventKind::Up(MouseButton::Left), to.0, to.1);
}
/// The screen row carrying a pane's legend, `[2] Results` say, without
/// the quotes the test backend wraps each row in.
fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str {
@@ -2044,6 +2068,88 @@ mod tests {
assert_eq!(tui.state.focused_pane, FocusedPane::Downloads);
}
#[test]
fn dragging_the_border_under_results_resizes_its_row() {
let mut tui = furnished_tui();
let _ = screen_of(&mut tui);
let results = tui.state.results_pane_area.expect("laid out");
let divider = tui.state.hsplit_divider.expect("a divider");
let before = results.height;
let focused = tui.state.focused_pane;
// Two drags: the row shrinks by the rows the pointer moved up.
mouse_drag_row(
&mut tui,
(results.x + 1, divider.y),
(results.x + 1, divider.y - 2),
);
assert_eq!(tui.state.layout.top_height, Some(before - 2));
assert_eq!(
tui.state.focused_pane, focused,
"a grab is not a focus click"
);
let _ = screen_of(&mut tui);
assert_eq!(
tui.state.results_pane_area.expect("laid out").height,
before - 2
);
// A drag far past the row stops at the smallest pane, not zero.
mouse_drag_row(
&mut tui,
(results.x + 1, divider.y - 2),
(results.x + 1, 0),
);
assert!(tui.state.layout.top_height.expect("resized") >= 3);
press(&mut tui, KeyCode::Char('W'));
assert_eq!(tui.state.layout.top_height, None, "W resets the layout");
}
#[test]
fn dragging_a_row_panes_right_edge_gives_it_width() {
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
let mut tui = furnished_tui();
let _ = screen_of(&mut tui);
let searches = tui.state.searches_pane_area.expect("laid out");
let seam = tui
.state
.vsplit_dividers
.iter()
.find(|(pane, _)| *pane == FocusedPane::Searches)
.map(|(_, rect)| *rect)
.expect("a seam beside Searches");
tui.state.focused_pane = FocusedPane::Downloads;
mouse_drag_row(
&mut tui,
(seam.x, searches.y + 1),
(seam.x + 4, searches.y + 1),
);
assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4));
assert_eq!(tui.state.focused_pane, FocusedPane::Downloads);
// The widths are applied, and Info narrows to whatever is left.
let _ = screen_of(&mut tui);
assert_eq!(
tui.state.searches_pane_area.expect("laid out").width,
searches.width + 4
);
assert!(tui.state.info_pane_area.expect("laid out").width >= 3);
// Releasing the button ends the drag: later motions just move the
// pointer.
mouse(
&mut tui,
MouseEventKind::Drag(MouseButton::Left),
seam.x + 40,
searches.y + 1,
);
assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4));
}
#[test]
fn the_info_pane_describes_the_highlighted_result() {
let mut tui = results_tui(3);
+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