refactor(tui): fold the keys overlay onto LogView and dedupe the pane lookups

The keys overlay scrolled with its own counter, a fixed page of ten and a
usize::MAX sentinel that the renderer clamped back; it now reads through
the same LogView as the chat and room logs, so the page is the window it
last drew and the half-page keys work there too.

One pane_area lookup and one page_of helper replace the pane-to-area match
that page_size, highlighted_name_end and the mouse hit test each repeated,
and list_jump takes its page as a usize so the callers stop converting.
The pane number keys are one range arm over FocusedPane::ALL, whose index
is the declaration order. The row layout carries each pane's weight beside
it instead of re-matching with an unreachable arm, and the help columns are
measured from the lines already built rather than built again for their
width. The shortcut legend borrows its static keys instead of allocating
three strings per entry per frame.
This commit is contained in:
Michel de Graaf
2026-09-07 10:51:41 +02:00
parent 478d976776
commit d4e86187d2
6 changed files with 138 additions and 166 deletions
+63 -90
View File
@@ -8,12 +8,10 @@ use crate::ui::panes::{
use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::Position;
use ratatui::layout::{Position, Rect};
use ratatui::widgets::TableState;
const NAME_SCROLL_STEP: isize = 8;
/// Rows a page key moves the keys overlay, which has no table to measure.
const HELP_PAGE: usize = 10;
impl MainTui {
pub(super) fn handle_key_event(&mut self, key: KeyEvent) {
@@ -23,22 +21,18 @@ impl MainTui {
}
// The keys list closes on the key that opened it, or the usual two,
// and scrolls like any list for a terminal too short to show it all.
// and scrolls like any log for a terminal too short to show it all.
if self.state.show_help {
let scroll = &mut self.state.help_scroll;
let view = &mut self.state.help_view;
match key.code {
KeyCode::Char('?' | 'q') | KeyCode::Esc => {
self.state.show_help = false;
}
KeyCode::Down | KeyCode::Char('j') => *scroll += 1,
KeyCode::Up | KeyCode::Char('k') => {
*scroll = scroll.saturating_sub(1);
KeyCode::Down | KeyCode::Char('j') => view.scroll(1),
KeyCode::Up | KeyCode::Char('k') => view.scroll(-1),
_ => {
scroll_log(view, key);
}
KeyCode::PageDown => *scroll += HELP_PAGE,
KeyCode::PageUp => *scroll = scroll.saturating_sub(HELP_PAGE),
KeyCode::Home | KeyCode::Char('g') => *scroll = 0,
KeyCode::End | KeyCode::Char('G') => *scroll = usize::MAX,
_ => {}
}
return;
}
@@ -81,19 +75,13 @@ impl MainTui {
}
KeyCode::Char('?') => {
self.state.show_help = true;
self.state.help_scroll = 0;
self.state.help_view.to_oldest();
return;
}
KeyCode::Char('1') => {
self.state.focus_pane(FocusedPane::Searches);
return;
}
KeyCode::Char('2') => {
self.state.focus_pane(FocusedPane::Results);
return;
}
KeyCode::Char('3') => {
self.state.focus_pane(FocusedPane::Downloads);
// A pane's number is its place in the legend order.
KeyCode::Char(digit @ '1'..='3') => {
let index = usize::from(digit as u8 - b'1');
self.state.focus_pane(FocusedPane::ALL[index]);
return;
}
KeyCode::Tab => {
@@ -368,7 +356,7 @@ impl MainTui {
/// The list the focus is on and how far one page moves it.
fn navigate_focused_list(&mut self, key: KeyEvent) -> bool {
let focused = self.state.focused_pane;
let page = self.page_size(focused);
let page = self.page_size();
let (table, len) = match focused {
FocusedPane::Searches => (
&mut self.state.searches_table_state,
@@ -388,24 +376,14 @@ impl MainTui {
/// Rows a popup shows at once: its inner height less a tab bar and a
/// compose line, which is what its list or log has left.
pub(super) fn popup_page(&self) -> isize {
let rows = self.state.popup_area.map_or(1, |area| {
pane_block(false).inner(area).height.saturating_sub(2)
});
isize::try_from(rows.max(1)).unwrap_or(isize::MAX)
pub(super) fn popup_page(&self) -> usize {
page_of(self.state.popup_area, 2)
}
/// Rows a pane shows at once: its inner height less the table header.
fn page_size(&self, pane: FocusedPane) -> isize {
let area = match pane {
FocusedPane::Searches => self.state.searches_pane_area,
FocusedPane::Results => self.state.results_pane_area,
FocusedPane::Downloads => self.state.downloads_pane_area,
};
let rows = area.map_or(1, |area| {
pane_block(false).inner(area).height.saturating_sub(1)
});
isize::try_from(rows.max(1)).unwrap_or(isize::MAX)
/// Rows the focused pane shows at once: its inner height less the table
/// header.
fn page_size(&self) -> usize {
page_of(self.state.pane_area(self.state.focused_pane), 1)
}
fn handle_searches_input(&mut self, key: KeyEvent) {
@@ -518,28 +496,21 @@ impl MainTui {
/// How far the highlighted row's long column can scroll before its end
/// is in view. Zero when nothing is highlighted or the pane is not drawn.
fn highlighted_name_end(&self) -> usize {
match self.state.focused_pane {
FocusedPane::Searches => {
let Some(area) = self.state.searches_pane_area else {
return 0;
};
self.state
.searches_table_state
.selected()
.and_then(|row| self.state.searches.get(row))
.map_or(0, |search| query_end_offset(&search.query, area))
}
FocusedPane::Results => {
let Some(area) = self.state.results_pane_area else {
return 0;
};
self.highlighted_result()
.map_or(0, |file| name_end_offset(&file.filename, area))
}
let pane = self.state.focused_pane;
let Some(area) = self.state.pane_area(pane) else {
return 0;
};
match pane {
FocusedPane::Searches => self
.state
.searches_table_state
.selected()
.and_then(|row| self.state.searches.get(row))
.map_or(0, |search| query_end_offset(&search.query, area)),
FocusedPane::Results => self
.highlighted_result()
.map_or(0, |file| name_end_offset(&file.filename, area)),
FocusedPane::Downloads => {
let Some(area) = self.state.downloads_pane_area else {
return 0;
};
let shown = match selected_transfer(
self.state.downloads_table_state.selected(),
&self.state.downloads,
@@ -589,24 +560,31 @@ impl MainTui {
}
let clicked = Position::new(mouse.column, mouse.row);
let panes = [
(self.state.searches_pane_area, FocusedPane::Searches),
(self.state.results_pane_area, FocusedPane::Results),
(self.state.downloads_pane_area, FocusedPane::Downloads),
];
// 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.
for (area, pane) in panes {
if area.is_some_and(|area| area.contains(clicked))
&& self.state.layout.is_visible(pane)
{
self.state.focused_pane = pane;
return;
}
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;
}
}
}
/// Rows a list in `area` shows at once, less the `chrome` rows above it: a
/// table header, a popup's tab bar and compose line. At least one, so a page
/// key always moves, even before the first draw.
fn page_of(area: Option<Rect>, chrome: u16) -> usize {
area.map_or(0, |area| {
usize::from(pane_block(false).inner(area).height.saturating_sub(chrome))
})
.max(1)
}
/// A move over a whole list at once: to either end, or by a number of rows
/// (negative is up).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -618,8 +596,9 @@ pub(super) enum ListJump {
/// The keys every list and log answers alike for moving further than a row:
/// the two ends, a page, half a page. `page` is how many rows the list shows.
pub(super) fn list_jump(key: KeyEvent, page: isize) -> Option<ListJump> {
pub(super) fn list_jump(key: KeyEvent, page: usize) -> Option<ListJump> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let page = isize::try_from(page).unwrap_or(isize::MAX);
let half = (page / 2).max(1);
Some(match key.code {
KeyCode::Home | KeyCode::Char('g') if !ctrl => ListJump::First,
@@ -637,8 +616,7 @@ pub(super) fn list_jump(key: KeyEvent, page: isize) -> Option<ListJump> {
/// Apply the jump keys to a log, moving by the screenful it last showed.
/// Says whether `key` was one of them.
pub(super) fn scroll_log(view: &mut LogView, key: KeyEvent) -> bool {
let page = isize::try_from(view.page()).unwrap_or(isize::MAX);
match list_jump(key, page) {
match list_jump(key, view.page()) {
Some(ListJump::First) => view.to_oldest(),
Some(ListJump::Last) => view.to_newest(),
Some(ListJump::Rows(rows)) => view.scroll(rows),
@@ -663,19 +641,21 @@ fn navigate_list(
key: KeyEvent,
table: &mut TableState,
len: usize,
page: isize,
page: usize,
) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Up | KeyCode::Char('k') if !ctrl => cycle(table, len, false),
KeyCode::Down | KeyCode::Char('j') if !ctrl => cycle(table, len, true),
_ => match list_jump(key, page) {
Some(jump) => {
_ => {
let Some(jump) = list_jump(key, page) else {
return false;
};
if len > 0 {
let current = table.selected().unwrap_or(0);
select_row(table, len, jumped(current, len, jump));
table.select(Some(jumped(current, len, jump)));
}
None => return false,
},
}
}
true
}
@@ -693,10 +673,3 @@ fn cycle(table: &mut TableState, len: usize, forward: bool) {
};
table.select(Some(next));
}
/// Put the selection on `target`, or the last row when that is past the end.
fn select_row(table: &mut TableState, len: usize, target: usize) {
if len > 0 {
table.select(Some(target.min(len - 1)));
}
}
+26 -37
View File
@@ -6,13 +6,14 @@ use crate::ui::panes::{
render_rooms_pane, render_searches_pane, selected_transfer,
};
use crate::ui::{
SIGNAL, accent_style, body_style, dimmed_style, pack_shortcuts, pane_block,
plain_title, primary_style, render_download_stats, warning_style,
accent_style, body_style, dimmed_style, info_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},
style::Modifier,
text::{Line, Span},
widgets::Paragraph,
};
@@ -147,10 +148,11 @@ impl MainTui {
return;
}
let row_panes: Vec<FocusedPane> =
[FocusedPane::Searches, FocusedPane::Downloads]
// Transfers carry the most columns, so they get the most width.
let row_panes: Vec<(FocusedPane, u16)> =
[(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)]
.into_iter()
.filter(|pane| self.state.layout.is_visible(*pane))
.filter(|(pane, _)| self.state.layout.is_visible(*pane))
.collect();
let row_area = if self.state.layout.is_visible(FocusedPane::Results) {
@@ -168,18 +170,13 @@ impl MainTui {
area
};
// 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"),
})
.map(|(_, weight)| 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) {
for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) {
self.render_pane(frame, *chunk, *pane);
}
self.render_info_pane(frame, chunks[row_panes.len()]);
@@ -295,8 +292,10 @@ impl MainTui {
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 columns = help_columns(screen.width.saturating_sub(frame_width));
let gaps = HELP_GAP * (columns.len() as u16 - 1);
let text_width = columns.iter().map(|lines| widest(lines)).sum::<u16>();
let width = screen.width.min(text_width + gaps + frame_width);
let tallest = columns.iter().map(Vec::len).max().unwrap_or(0);
let height = screen
.height
@@ -314,10 +313,12 @@ impl MainTui {
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);
// needs.
let window = self
.state
.help_view
.window(tallest, usize::from(inner.height));
let scroll = u16::try_from(window.start).unwrap_or(u16::MAX);
let areas =
Layout::horizontal(vec![Constraint::Fill(1); columns.len()])
@@ -734,19 +735,10 @@ fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize {
.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)
/// Cells the widest of `lines` takes.
fn widest(lines: &[Line<'_>]) -> u16 {
let cells = lines.iter().map(Line::width).max().unwrap_or(0);
u16::try_from(cells).unwrap_or(u16::MAX)
}
/// The keys list laid out for `width` cells of text: two columns when both
@@ -754,7 +746,7 @@ fn help_width() -> u16 {
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() {
if width >= widest(&left) + HELP_GAP + widest(&right) {
return vec![left, right];
}
let mut stacked = left;
@@ -776,10 +768,7 @@ fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec<Line<'static>> {
));
for (key, action) in *keys {
lines.push(Line::from(vec![
Span::styled(
format!("{key:<key_width$} "),
Style::default().fg(SIGNAL),
),
Span::styled(format!("{key:<key_width$} "), info_style()),
Span::styled((*action).to_string(), body_style()),
]));
}