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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user