refactor(tui): one page window and one filter editor for every list

Four panes had grown the same dance around ratatui's table: clamp the
selection, take the visible page, hand the table a page-local state,
keep the page start. It is now page_window and render_page in a paging
module beside page_of, and the two popups page by the same count they
draw, tab bar or header included.

Five typed filters had grown the same key handling too. edit_filter now
takes a key for all of them, which also stops a control or alt chord
from typing its letter into a filter, stops Enter, which keeps a filter,
from resetting the view it is over, and lets the results and room-list
filters page like the others. Their titles and the typing shortcuts
come from one place as well.
This commit is contained in:
Michel de Graaf
2026-09-08 11:24:59 +02:00
parent 607532cc44
commit 0cacc8cc10
13 changed files with 293 additions and 299 deletions
+61 -55
View File
@@ -192,30 +192,16 @@ impl MainTui {
// Typing a filter captures the keys that edit it; the rest still
// move around.
if self.state.chat_filtering {
match key.code {
KeyCode::Esc => {
self.state.chat_filtering = false;
self.state.chat_filter.clear();
self.state.chat_view.to_newest();
return;
}
KeyCode::Enter => {
self.state.chat_filtering = false;
return;
}
KeyCode::Char(c) => {
self.state.chat_filter.push(c);
self.state.chat_view.to_newest();
return;
}
KeyCode::Backspace => {
self.state.chat_filter.pop();
self.state.chat_view.to_newest();
return;
}
_ => {}
if self.state.chat_filtering
&& let Some(edit) = edit_filter(&mut self.state.chat_filter, key)
{
if edit != FilterEdit::Changed {
self.state.chat_filtering = false;
}
if edit != FilterEdit::Kept {
self.state.chat_view.to_newest();
}
return;
}
// Paging keys move through the conversation's history.
@@ -364,30 +350,16 @@ impl MainTui {
}
fn handle_filter_input(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.state.results_is_filtering = false;
self.state.results_filter_query.clear();
self.state.results_filtered_items =
self.state.results_items.clone();
self.state.results_filtered_indices =
(0..self.state.results_items.len()).collect();
}
KeyCode::Char(c) => {
self.state.results_filter_query.push(c);
self.apply_filter();
}
KeyCode::Backspace => {
self.state.results_filter_query.pop();
self.apply_filter();
}
// Confirm the filter: leave typing mode but keep the query, so
// the normal Results keys (j/k, space, enter) act on the
// filtered list.
KeyCode::Enter => {
self.state.results_is_filtering = false;
}
_ => self.handle_results_input(key),
let Some(edit) = edit_filter(&mut self.state.results_filter_query, key)
else {
return self.handle_results_input(key);
};
// A kept query stays: the normal Results keys act on the filtered
// list. An empty one shows everything without a rebuild.
if edit == FilterEdit::Changed {
self.apply_filter();
} else {
self.state.results_is_filtering = false;
}
}
@@ -412,10 +384,10 @@ impl MainTui {
navigate_list(key, table, len, page)
}
/// 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) -> usize {
page_of(self.state.popup_area, 2)
/// Rows a popup's list shows at once: its inner height less the `chrome`
/// rows above the list, a tab bar or a header.
pub(super) fn popup_page(&self, chrome: u16) -> usize {
page_of(self.state.popup_area, chrome)
}
/// Rows the focused pane shows at once: its inner height less the table
@@ -478,10 +450,7 @@ impl MainTui {
self.state.results_selected_indices.insert(index);
}
}
KeyCode::Char('/') => {
self.state.results_is_filtering = true;
self.state.results_filter_query.clear();
}
KeyCode::Char('/') => self.state.results_is_filtering = true,
KeyCode::Char('a') => {
let indices: Vec<usize> =
if self.state.results_filter_query.is_empty() {
@@ -613,6 +582,43 @@ impl MainTui {
}
}
/// What a key did to a filter being typed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FilterEdit {
Changed,
Kept,
Cleared,
}
/// Apply `key` to `filter` the way every typed filter takes it: characters
/// and Backspace edit it, Enter keeps it, Esc clears it. A control or alt
/// chord is not typing. `None` when the key was none of those.
pub(super) fn edit_filter(
filter: &mut String,
key: KeyEvent,
) -> Option<FilterEdit> {
match key.code {
KeyCode::Esc => {
filter.clear();
Some(FilterEdit::Cleared)
}
KeyCode::Enter => Some(FilterEdit::Kept),
KeyCode::Backspace => {
filter.pop();
Some(FilterEdit::Changed)
}
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
filter.push(c);
Some(FilterEdit::Changed)
}
_ => None,
}
}
/// 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)]