From 374a81bec150369a6f198d7fb5f7790ec097ffc1 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Wed, 9 Sep 2026 17:20:12 +0200 Subject: [PATCH] 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. --- soulseek-rs/src/models/app_state.rs | 22 ++++ soulseek-rs/src/ui/main_tui/input.rs | 143 +++++++++++++++++++++++--- soulseek-rs/src/ui/main_tui/mod.rs | 106 +++++++++++++++++++ soulseek-rs/src/ui/main_tui/render.rs | 67 ++++++++++-- 4 files changed, 315 insertions(+), 23 deletions(-) diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index aa9c983..5f2c4f6 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -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, + /// 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, + pub downloads_width: Option, } impl PaneLayout { @@ -322,6 +330,15 @@ pub struct AppState { pub searches_pane_area: Option, pub results_pane_area: Option, pub downloads_pane_area: Option, + /// 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, + pub row_area: Option, + pub info_pane_area: Option, + /// 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, + 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, @@ -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, } } diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index abefc80..c2db95d 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -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 { + 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. diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 7623a7e..8b6c087 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -31,6 +31,8 @@ pub struct MainTui { config_path: Option, /// 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, } 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); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index 25221e5..f2b7a5a 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -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 = 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