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
+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.