feat(tui): results across the top, with panes you can hide, zoom and Tab through

The results pane now has the whole width, where a long file name needs it,
and Searches, Downloads and Info share the row underneath. Tab and Shift-Tab
walk the panes, z zooms the focused one to the window, w hides it so the
others widen, and its number brings it back. Every list takes the same
navigation keys, the shortcuts bar wraps instead of cutting keys off, and ?
lists every key by pane, scrolling on a short terminal.

Follows up on #55.
This commit is contained in:
Michel de Graaf
2026-09-07 07:49:32 +00:00
parent c91b97de7c
commit da8ab45303
15 changed files with 1209 additions and 232 deletions
+371 -97
View File
@@ -6,19 +6,92 @@ use crate::ui::panes::{
render_rooms_pane, render_searches_pane, selected_transfer,
};
use crate::ui::{
accent_style, dimmed_style, format_shortcuts_styled, pane_block,
SIGNAL, accent_style, body_style, dimmed_style, pack_shortcuts, pane_block,
plain_title, primary_style, render_download_stats, warning_style,
};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Position, Rect},
style::Modifier,
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
/// The keys overlay, grouped by where a key applies. Two columns, because a
/// single list runs longer than most terminals are tall.
const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[
(
"Panes",
&[
("Tab / Shift-Tab", "next / previous pane"),
("1 2 3", "focus a pane, hidden or not"),
("z", "zoom the focused pane"),
("w", "hide the focused pane"),
("Esc", "leave zoom"),
("click", "focus a pane"),
],
),
(
"Anywhere",
&[
("s", "search the network"),
("b", "browse a user's files"),
("m", "compose a private message"),
("i", "inbox"),
("o", "settings"),
("?", "this list"),
("q", "quit"),
],
),
(
"Any list",
&[
("↑ ↓ / k j", "move one row"),
("Home End / g G", "first / last row"),
("PgUp PgDn / ^b ^f", "one page"),
("^u ^d", "half a page"),
],
),
];
const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
(
"Searches",
&[
("Enter", "show its results"),
("d", "remove the search"),
("C", "clear every search"),
("c", "chat rooms"),
],
),
(
"Results",
&[
("Space", "select / deselect"),
("a / A", "select all / none"),
("Enter", "download the selection"),
("/", "filter, Enter keeps it, Esc clears"),
("h l / ← →", "scroll the file name"),
("0 / $", "start / end of the file name"),
("b", "browse the owner"),
("c", "chat rooms"),
],
),
(
"Downloads / Uploads",
&[
("p", "pause / resume"),
("x", "cancel"),
("r", "retry a failed one"),
("d", "delete a queued or finished one"),
("c", "clear every finished one"),
],
),
];
impl MainTui {
pub(super) fn render(&mut self, frame: &mut Frame) {
let shortcuts = self.shortcut_rows(frame.area().width);
let mut constraints = vec![
Constraint::Length(3), // Status bar
Constraint::Fill(1), // Main content
@@ -26,7 +99,7 @@ impl MainTui {
if self.state.command_bar_active {
constraints.push(Constraint::Length(3)); // Command bar
}
constraints.push(Constraint::Length(3)); // Shortcuts
constraints.push(Constraint::Length(shortcuts.len() as u16 + 2));
let main_chunks = Layout::vertical(constraints).split(frame.area());
let daemon = self.client.daemon_endpoint();
@@ -43,55 +116,111 @@ impl MainTui {
// Render command bar if active (vim-style, above shortcuts)
if self.state.command_bar_active {
self.render_command_bar(frame, main_chunks[2]);
self.render_shortcuts(frame, main_chunks[3]);
self.render_shortcuts(frame, main_chunks[3], shortcuts);
} else {
self.render_shortcuts(frame, main_chunks[2]);
self.render_shortcuts(frame, main_chunks[2], shortcuts);
}
self.render_overlays(frame);
}
/// Results across the top, where a long file name has the whole width;
/// Searches, Downloads and Info in a row underneath. A hidden pane drops
/// out of its row and the rest widen; a zoomed one has the area alone.
fn render_content(&mut self, frame: &mut Frame, area: Rect) {
// Split main content area
let content_chunks = Layout::horizontal([
Constraint::Percentage(30), // Searches pane
Constraint::Percentage(70), // Results + Downloads
])
.split(area);
// Only what is drawn can be clicked.
self.state.searches_pane_area = None;
self.state.results_pane_area = None;
self.state.downloads_pane_area = None;
// Store searches pane area
self.state.searches_pane_area = Some(content_chunks[0]);
if self.state.layout.zoomed {
self.render_pane(frame, area, self.state.focused_pane);
return;
}
// Render Searches pane (left)
render_searches_pane(
frame,
content_chunks[0],
&self.state.searches,
&mut self.state.searches_table_state,
self.state.focused_pane == FocusedPane::Searches,
);
let row_panes: Vec<FocusedPane> =
[FocusedPane::Searches, FocusedPane::Downloads]
.into_iter()
.filter(|pane| self.state.layout.is_visible(*pane))
.collect();
// Split right side into Results (top) and Downloads (bottom)
let right_chunks = Layout::vertical([
Constraint::Percentage(60), // Results
Constraint::Percentage(40), // Downloads
])
.split(content_chunks[1]);
let row_area = if self.state.layout.is_visible(FocusedPane::Results) {
// 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);
self.render_pane(frame, top, FocusedPane::Results);
bottom
} else {
area
};
// Split bottom-right into Downloads list and Info pane
let downloads_chunks = Layout::horizontal([
Constraint::Percentage(60), // Downloads list
Constraint::Percentage(40), // Info pane
])
.split(right_chunks[1]);
// Transfers carry the most columns, so they get the most width.
let mut constraints: Vec<Constraint> = row_panes
.iter()
.map(|pane| match pane {
FocusedPane::Searches => Constraint::Fill(5),
FocusedPane::Downloads => Constraint::Fill(10),
FocusedPane::Results => unreachable!("not a row pane"),
})
.collect();
constraints.push(Constraint::Fill(6)); // Info
let chunks = Layout::horizontal(constraints).split(row_area);
for (chunk, pane) in chunks.iter().zip(&row_panes) {
self.render_pane(frame, *chunk, *pane);
}
self.render_info_pane(frame, chunks[row_panes.len()]);
}
// Store results and downloads pane areas
self.state.results_pane_area = Some(right_chunks[0]);
self.state.downloads_pane_area = Some(downloads_chunks[0]);
fn render_pane(
&mut self,
frame: &mut Frame,
area: Rect,
pane: FocusedPane,
) {
let focused = self.state.focused_pane == pane;
match pane {
FocusedPane::Searches => {
self.state.searches_pane_area = Some(area);
render_searches_pane(
frame,
area,
&self.state.searches,
&mut self.state.searches_table_state,
focused,
);
}
FocusedPane::Results => {
self.state.results_pane_area = Some(area);
self.render_results_pane(frame, area, focused);
}
FocusedPane::Downloads => {
self.state.downloads_pane_area = Some(area);
render_downloads_pane(
frame,
area,
&self.state.downloads,
&self.state.uploads,
&mut self.state.downloads_table_state,
focused,
);
}
}
}
// Render Results pane. When a filter is active the rendered rows are a
// subset, so the pane also needs the mapping back to unfiltered indices
// to render the selection checkboxes correctly.
fn render_results_pane(
&mut self,
frame: &mut Frame,
area: Rect,
focused: bool,
) {
// When a filter is active the rendered rows are a subset, so the pane
// also needs the mapping back to unfiltered indices to render the
// selection checkboxes correctly.
let (results_items, results_original_indices) =
if self.state.results_filter_query.is_empty() {
(&self.state.results_items, None)
@@ -110,7 +239,7 @@ impl MainTui {
render_results_pane(
frame,
right_chunks[0],
area,
ResultsPaneParams {
items: results_items,
table_state: &mut self.state.results_table_state,
@@ -118,21 +247,16 @@ impl MainTui {
original_indices: results_original_indices,
filter_query: &self.state.results_filter_query,
is_filtering: self.state.results_is_filtering,
focused: self.state.focused_pane == FocusedPane::Results,
focused,
active_search_query,
name_offset: self.state.results_name_offset,
},
);
}
render_downloads_pane(
frame,
downloads_chunks[0],
&self.state.downloads,
&self.state.uploads,
&mut self.state.downloads_table_state,
self.state.focused_pane == FocusedPane::Downloads,
);
/// The Info pane follows the focus: the highlighted result, or the
/// highlighted transfer.
fn render_info_pane(&self, frame: &mut Frame, area: Rect) {
let selected = if self.state.focused_pane == FocusedPane::Results {
self.highlighted_result().map(InfoSubject::Result)
} else {
@@ -144,7 +268,7 @@ impl MainTui {
};
render_download_info_pane(
frame,
downloads_chunks[1],
area,
selected,
matches!(
self.state.focused_pane,
@@ -153,6 +277,49 @@ impl MainTui {
);
}
/// The keys list, two columns side by side where the terminal is wide
/// enough and one on top of the other where it is not, scrolling when
/// even that is taller than the window.
fn render_help_popup(&mut self, frame: &mut Frame) {
let screen = frame.area();
// Borders and padding on either side of the text.
let frame_width = 4;
let width = screen.width.min(help_width() + frame_width);
let columns = help_columns(width.saturating_sub(frame_width));
let tallest = columns.iter().map(Vec::len).max().unwrap_or(0);
let height = screen
.height
.min(u16::try_from(tallest + 2).unwrap_or(u16::MAX));
let area = Rect::new(
screen.x + (screen.width - width) / 2,
screen.y + (screen.height - height) / 2,
width,
height,
);
frame.render_widget(ratatui::widgets::Clear, area);
let block = pane_block(true).title(plain_title("Keys", true));
let inner = block.inner(area);
frame.render_widget(block, area);
// Both columns scroll together, and no further than the taller one
// needs; the clamp is written back so the keys cannot run past it.
let furthest = tallest.saturating_sub(usize::from(inner.height));
self.state.help_scroll = self.state.help_scroll.min(furthest);
let scroll = u16::try_from(self.state.help_scroll).unwrap_or(u16::MAX);
let areas =
Layout::horizontal(vec![Constraint::Fill(1); columns.len()])
.spacing(HELP_GAP)
.split(inner);
for (area, lines) in areas.iter().zip(columns) {
frame.render_widget(
Paragraph::new(lines).scroll((scroll, 0)),
*area,
);
}
}
fn render_overlays(&mut self, frame: &mut Frame) {
// Messages inbox overlays everything when open.
if self.state.show_messages {
@@ -188,6 +355,12 @@ impl MainTui {
&mut self.state.rooms_list_table_state,
);
}
// The keys list only opens from the main view, so it is on top of
// nothing; drawn last so that stays true if that ever changes.
if self.state.show_help {
self.render_help_popup(frame);
}
}
fn render_settings_popup(&self, frame: &mut Frame) {
@@ -304,10 +477,31 @@ impl MainTui {
}
}
/// The keys every pane shares for moving between and resizing panes,
/// ending the bar the same way wherever the focus sits.
fn pane_shortcuts(&self) -> Vec<(&'static str, &'static str)> {
vec![
("Tab/1-3", "pane"),
(
"z",
if self.state.layout.zoomed {
"unzoom"
} else {
"zoom"
},
),
("w", "hide"),
("?", "keys"),
("q", "quit"),
]
}
/// Which shortcuts the bar offers, which is purely a question of what is
/// open and where the focus sits. Sibling of [`Self::rooms_shortcuts`].
fn shortcuts(&self) -> Vec<(&'static str, &'static str)> {
if self.state.settings.is_some() {
if self.state.show_help {
vec![("?/Esc", "close")]
} else if self.state.settings.is_some() {
vec![
("↑↓", "move"),
("Enter/e", "edit download dir"),
@@ -364,29 +558,28 @@ impl MainTui {
("Esc", "cancel"),
],
}
} else if self.state.results_is_filtering
&& self.state.focused_pane == FocusedPane::Results
{
// Typing goes to the filter, so no letter keys are on offer here.
vec![
("Type", "filter"),
("↑/↓", "navigate"),
("PgUp/PgDn", "page"),
("Enter", "keep filter"),
("Esc", "clear filter"),
]
} else {
match self.state.focused_pane {
let mut keys = match self.state.focused_pane {
FocusedPane::Searches => vec![
("s", "search"),
("Enter", "results"),
("d", "remove"),
("m", "message"),
("i", "inbox"),
("c", "chat"),
("b", "browse user"),
("1-3", "focus pane"),
("↑↓", "navigate"),
("Enter", "results"),
("q", "quit"),
],
FocusedPane::Results if self.state.results_is_filtering => {
vec![
("Type", "filter"),
("↑/↓", "navigate"),
("Enter", "confirm"),
("Esc", "clear filter"),
("1-3", "focus pane"),
("q", "quit"),
]
}
FocusedPane::Results => vec![
("Space", "select"),
("Enter", "download"),
@@ -394,29 +587,50 @@ impl MainTui {
("c", "chat"),
("/", "filter"),
("a/A", "select all/none"),
("h/l", "scroll"),
("h/l", "scroll name"),
("g/G", "top/end"),
("1-3", "focus pane"),
("q", "quit"),
],
FocusedPane::Downloads => {
vec![
("p", "pause/resume"),
("x", "cancel"),
("r", "retry failed"),
("d", "delete queued/done"),
("c", "clear finished"),
("b", "browse user"),
("1-3", "focus pane"),
("q", "quit"),
]
}
}
FocusedPane::Downloads => vec![
("p", "pause/resume"),
("x", "cancel"),
("r", "retry failed"),
("d", "delete queued/done"),
("c", "clear finished"),
("b", "browse user"),
],
};
keys.extend(self.pane_shortcuts());
keys
}
}
fn render_shortcuts(&self, frame: &mut Frame, area: Rect) {
let shortcuts_line = format_shortcuts_styled(&self.shortcuts());
/// The legend packed into rows for a window `width` wide, so a narrow
/// terminal grows the bar instead of cutting keys off its right edge.
fn shortcut_rows(&self, width: u16) -> Vec<Line<'static>> {
// Borders and padding, and the unread badge's column when it shows.
let badge = self.unread_badge().width() as u16;
let reserved = 4 + if badge > 0 { badge + 1 } else { 0 };
pack_shortcuts(&self.shortcuts(), width.saturating_sub(reserved))
}
/// A prominent unread badge for the shortcuts bar. It rides in the
/// always-visible bar, so unread mail/chat shows in every pane, not just
/// some. The spinner tick (0..10, ~100ms each) drives a ~1s blink: bright
/// for the first half of the cycle, dim for the second.
fn unread_badge(&self) -> Line<'static> {
unread_indicator(
self.state.unread_messages,
self.state.rooms.total_unread(),
self.spinner_state < 5,
)
}
fn render_shortcuts(
&self,
frame: &mut Frame,
area: Rect,
rows: Vec<Line<'static>>,
) {
// Surface our own sharing status in the block title.
let shared = self.client.shared_directories();
let sharing = match shared.as_slice() {
@@ -431,26 +645,20 @@ impl MainTui {
let inner = block.inner(area);
frame.render_widget(block, area);
// A prominent unread badge, right-aligned so the context shortcuts on
// the left never crowd it out. It rides in the always-visible shortcuts
// bar, so unread mail/chat shows in every pane, not just some. The
// spinner tick (0..10, ~100ms each) drives a ~1s blink: bright for the
// first half of the cycle, dim for the second.
let unread = unread_indicator(
self.state.unread_messages,
self.state.rooms.total_unread(),
self.spinner_state < 5,
);
// The unread badge is right-aligned so the context shortcuts on the
// left never crowd it out.
let unread = self.unread_badge();
if unread.spans.is_empty() {
frame.render_widget(Paragraph::new(shortcuts_line), inner);
frame.render_widget(Paragraph::new(rows), inner);
} else {
let badge_width = unread.width() as u16;
let cols = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(badge_width),
])
.spacing(1)
.split(inner);
frame.render_widget(Paragraph::new(shortcuts_line), cols[0]);
frame.render_widget(Paragraph::new(rows), cols[0]);
frame.render_widget(
Paragraph::new(unread).alignment(Alignment::Right),
cols[1],
@@ -493,6 +701,72 @@ impl MainTui {
}
}
/// Columns between the two halves of the keys list.
const HELP_GAP: u16 = 2;
/// The widest key in a column, so the descriptions line up.
fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize {
sections
.iter()
.flat_map(|(_, keys)| keys.iter())
.map(|(key, _)| Span::raw(*key).width())
.max()
.unwrap_or(0)
}
/// Cells a column of the keys list needs to show every line whole.
fn help_column_width(sections: &[(&str, &[(&str, &str)])]) -> u16 {
let widest = help_lines(sections)
.iter()
.map(Line::width)
.max()
.unwrap_or(0);
u16::try_from(widest).unwrap_or(u16::MAX)
}
/// The text width of the keys list with both halves side by side.
fn help_width() -> u16 {
help_column_width(HELP_LEFT) + HELP_GAP + help_column_width(HELP_RIGHT)
}
/// The keys list laid out for `width` cells of text: two columns when both
/// fit beside each other, otherwise the right half under the left.
fn help_columns(width: u16) -> Vec<Vec<Line<'static>>> {
let left = help_lines(HELP_LEFT);
let right = help_lines(HELP_RIGHT);
if width >= help_width() {
return vec![left, right];
}
let mut stacked = left;
stacked.push(Line::from(""));
stacked.extend(right);
vec![stacked]
}
fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec<Line<'static>> {
let key_width = key_column_width(sections);
let mut lines = Vec::new();
for (index, (heading, keys)) in sections.iter().enumerate() {
if index > 0 {
lines.push(Line::from(""));
}
lines.push(Line::styled(
(*heading).to_string(),
accent_style().add_modifier(Modifier::BOLD),
));
for (key, action) in *keys {
lines.push(Line::from(vec![
Span::styled(
format!("{key:<key_width$} "),
Style::default().fg(SIGNAL),
),
Span::styled((*action).to_string(), body_style()),
]));
}
}
lines
}
/// A `Rect` centered within `area`, sized to the given percentages.
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([