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 { pub struct PaneLayout {
hidden: [bool; FocusedPane::ALL.len()], hidden: [bool; FocusedPane::ALL.len()],
pub zoomed: bool, 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 { impl PaneLayout {
@@ -322,6 +330,15 @@ pub struct AppState {
pub searches_pane_area: Option<Rect>, pub searches_pane_area: Option<Rect>,
pub results_pane_area: Option<Rect>, pub results_pane_area: Option<Rect>,
pub downloads_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 /// Where the open popup was last drawn, for its page size. Only one is
/// ever open, so one record serves them all. /// ever open, so one record serves them all.
pub popup_area: Option<Rect>, pub popup_area: Option<Rect>,
@@ -397,6 +414,11 @@ impl AppState {
searches_pane_area: None, searches_pane_area: None,
results_pane_area: None, results_pane_area: None,
downloads_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, popup_area: None,
} }
} }
+127 -16
View File
@@ -1,4 +1,5 @@
use super::MainTui; use super::MainTui;
use super::render::{MIN_PANE, shrink};
use crate::models::{CommandBarMode, FocusedPane, LogView, PaneLayout}; use crate::models::{CommandBarMode, FocusedPane, LogView, PaneLayout};
use crate::ui::page_of; use crate::ui::page_of;
use crate::ui::panes::{ use crate::ui::panes::{
@@ -102,6 +103,9 @@ impl MainTui {
return; return;
} }
KeyCode::Char('W') => { 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(); self.state.layout = PaneLayout::default();
return; return;
} }
@@ -566,24 +570,131 @@ impl MainTui {
} }
pub(super) fn handle_mouse_event(&mut self, mouse: MouseEvent) { pub(super) fn handle_mouse_event(&mut self, mouse: MouseEvent) {
if mouse.kind != MouseEventKind::Down(MouseButton::Left) { match mouse.kind {
return; MouseEventKind::Down(MouseButton::Left) => {
} let at = Position::new(mouse.column, mouse.row);
// A divider wins over the panes it separates; grabbing one
let clicked = Position::new(mouse.column, mouse.row); // starts a drag, and the click alone already resizes.
// The areas are from the last draw. A pane hidden since then, in the if let Some(drag) = self.grabbed_divider(at) {
// same batch of events, still has one, and is not there to click. self.resize_drag = Some(drag);
let hit = FocusedPane::ALL.into_iter().find(|pane| { self.apply_resize(drag, at);
self.state.layout.is_visible(*pane) return;
&& self }
.state // The areas are from the last draw. A pane hidden since then, in the
.pane_area(*pane) // same batch of events, still has one, and is not there to click.
.is_some_and(|area| area.contains(clicked)) let hit = FocusedPane::ALL.into_iter().find(|pane| {
}); self.state.layout.is_visible(*pane)
if let Some(pane) = hit { && self
self.state.focused_pane = pane; .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. /// 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>, config_path: Option<std::path::PathBuf>,
/// Last snapshot written to disk, to skip no-op saves. /// Last snapshot written to disk, to skip no-op saves.
saved_snapshot: Snapshot, saved_snapshot: Snapshot,
/// Which divider the left mouse button is holding, if any.
resize_drag: Option<input::ResizeDrag>,
} }
impl MainTui { impl MainTui {
@@ -50,6 +52,7 @@ impl MainTui {
store, store,
config_path, config_path,
saved_snapshot: Snapshot::default(), saved_snapshot: Snapshot::default(),
resize_drag: None,
}; };
tui.restore_persisted_state(); tui.restore_persisted_state();
tui tui
@@ -1360,6 +1363,27 @@ mod tests {
screen_sized(tui, 160, 40) 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 screen row carrying a pane's legend, `[2] Results` say, without
/// the quotes the test backend wraps each row in. /// the quotes the test backend wraps each row in.
fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str { 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); 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] #[test]
fn the_info_pane_describes_the_highlighted_result() { fn the_info_pane_describes_the_highlighted_result() {
let mut tui = results_tui(3); 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"), ("z", "zoom the focused pane"),
("w", "hide the focused pane"), ("w", "hide the focused pane"),
("Esc", "leave zoom"), ("Esc", "leave zoom"),
("W", "every pane back, zoom off"), ("W", "every pane back, zoom off, sizes reset"),
("click", "focus a pane"), ("click", "focus a pane"),
("drag", "resize panes by their borders"),
], ],
), ),
( (
@@ -170,11 +171,16 @@ impl MainTui {
self.state.searches_pane_area = None; self.state.searches_pane_area = None;
self.state.results_pane_area = None; self.state.results_pane_area = None;
self.state.downloads_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 { if self.state.layout.zoomed {
self.render_pane(frame, area, self.state.focused_pane); self.render_pane(frame, area, self.state.focused_pane);
return; return;
} }
self.state.content_area = Some(area);
// Transfers carry the most columns, so they get the most width. // Transfers carry the most columns, so they get the most width.
let row_panes: Vec<(FocusedPane, u16)> = let row_panes: Vec<(FocusedPane, u16)> =
@@ -187,26 +193,61 @@ impl MainTui {
// With only Info left in the row, the results deserve more of // With only Info left in the row, the results deserve more of
// the height. // the height.
let row_weight = if row_panes.is_empty() { 1 } else { 2 }; let row_weight = if row_panes.is_empty() { 1 } else { 2 };
let [top, bottom] = Layout::vertical([ let top = match self.state.layout.top_height {
Constraint::Fill(3), Some(rows) => Constraint::Length(shrink(rows, area.height)),
Constraint::Fill(row_weight), None => Constraint::Fill(3),
]) };
.areas(area); 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); self.render_pane(frame, top, FocusedPane::Results);
bottom bottom
} else { } else {
area area
}; };
self.state.row_area = Some(row_area);
let mut constraints: Vec<Constraint> = row_panes let mut constraints: Vec<Constraint> = row_panes
.iter() .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(); .collect();
constraints.push(Constraint::Fill(6)); // Info constraints.push(Constraint::Fill(6)); // Info
let chunks = Layout::horizontal(constraints).split(row_area); let chunks = Layout::horizontal(constraints).split(row_area);
for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { 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.render_pane(frame, *chunk, *pane);
} }
self.state.info_pane_area = Some(chunks[row_panes.len()]);
self.render_info_pane(frame, 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. /// Columns between the two halves of the keys list.
const HELP_GAP: u16 = 2; 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. /// The widest key in a column, so the descriptions line up.
fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize { fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize {
sections sections