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:
@@ -1,5 +1,5 @@
|
||||
use super::MainTui;
|
||||
use super::input::{jumped, list_jump};
|
||||
use super::input::{FilterEdit, edit_filter, jumped, list_jump};
|
||||
use crate::models::BrowseStatus;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::{thread, time::Duration};
|
||||
@@ -27,7 +27,9 @@ impl MainTui {
|
||||
self.state.show_browse = false;
|
||||
return;
|
||||
}
|
||||
let jump = list_jump(key, self.popup_page());
|
||||
// The tab bar takes a row once more than one user is open.
|
||||
let chrome = u16::from(self.state.browse.tabs.len() > 1);
|
||||
let jump = list_jump(key, self.popup_page(chrome));
|
||||
// Control combinations are only ever jumps: ctrl-d pages, d downloads.
|
||||
if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return;
|
||||
@@ -173,24 +175,12 @@ impl MainTui {
|
||||
let Some(browse) = self.state.browse.active_tab_mut() else {
|
||||
return false;
|
||||
};
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
browse.filtering = false;
|
||||
browse.set_filter(String::new());
|
||||
}
|
||||
KeyCode::Enter => browse.filtering = false,
|
||||
KeyCode::Char(c) => {
|
||||
let mut filter = browse.filter().to_string();
|
||||
filter.push(c);
|
||||
browse.set_filter(filter);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
let mut filter = browse.filter().to_string();
|
||||
filter.pop();
|
||||
browse.set_filter(filter);
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
let mut filter = browse.filter().to_string();
|
||||
let Some(edit) = edit_filter(&mut filter, key) else {
|
||||
return false;
|
||||
};
|
||||
browse.filtering = edit == FilterEdit::Changed;
|
||||
browse.set_filter(filter);
|
||||
self.sync_browse_selection();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -1738,6 +1738,12 @@ mod tests {
|
||||
assert_eq!(rows(&tui), 3, "music, alpha, a2.flac");
|
||||
let screen = screen_of(&mut tui);
|
||||
assert!(screen.contains("filter: flac_"), "{screen}");
|
||||
ctrl(&mut tui, 'd');
|
||||
assert_eq!(
|
||||
tui.state.browse.active_tab().expect("tab").filter(),
|
||||
"flac",
|
||||
"a control chord pages, it does not type"
|
||||
);
|
||||
press(&mut tui, KeyCode::Enter);
|
||||
press(&mut tui, KeyCode::Char('j'));
|
||||
press(&mut tui, KeyCode::Char('j'));
|
||||
@@ -1775,6 +1781,8 @@ mod tests {
|
||||
assert!(screen.contains("line 010"), "{screen}");
|
||||
assert!(!screen.contains("line 029"), "{screen}");
|
||||
assert!(screen.contains("filter: line 01_"), "{screen}");
|
||||
ctrl(&mut tui, 'u');
|
||||
assert_eq!(tui.state.rooms.log_filter, "line 01", "^u scrolls");
|
||||
press(&mut tui, KeyCode::Enter);
|
||||
press(&mut tui, KeyCode::Esc);
|
||||
assert!(tui.state.rooms.log_filter.is_empty());
|
||||
|
||||
@@ -114,6 +114,17 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
|
||||
),
|
||||
];
|
||||
|
||||
/// What the bar offers while a filter is being typed, wherever that is,
|
||||
/// with the keys that still move around there.
|
||||
fn filter_keys(
|
||||
moves: &[(&'static str, &'static str)],
|
||||
) -> Vec<(&'static str, &'static str)> {
|
||||
let mut keys = vec![("Type", "filter")];
|
||||
keys.extend_from_slice(moves);
|
||||
keys.extend([("Enter", "keep filter"), ("Esc", "clear filter")]);
|
||||
keys
|
||||
}
|
||||
|
||||
impl MainTui {
|
||||
pub(super) fn render(&mut self, frame: &mut Frame) {
|
||||
let shortcuts = self.shortcut_rows(frame.area().width);
|
||||
@@ -497,13 +508,7 @@ impl MainTui {
|
||||
}
|
||||
}
|
||||
RoomsView::Chat if self.state.rooms.filtering.is_some() => {
|
||||
vec![
|
||||
("Type", "filter"),
|
||||
("PgUp/PgDn", "scroll"),
|
||||
("↑↓", "pick user"),
|
||||
("Enter", "keep filter"),
|
||||
("Esc", "clear filter"),
|
||||
]
|
||||
filter_keys(&[("PgUp/PgDn", "scroll"), ("↑↓", "pick user")])
|
||||
}
|
||||
RoomsView::Chat => vec![
|
||||
("Enter", "say"),
|
||||
@@ -565,12 +570,7 @@ impl MainTui {
|
||||
("Esc", "stop typing"),
|
||||
]
|
||||
} else if self.state.chat_filtering {
|
||||
vec![
|
||||
("Type", "filter"),
|
||||
("PgUp/PgDn", "scroll"),
|
||||
("Enter", "keep filter"),
|
||||
("Esc", "clear filter"),
|
||||
]
|
||||
filter_keys(&[("PgUp/PgDn", "scroll")])
|
||||
} else {
|
||||
vec![
|
||||
("Enter", "type"),
|
||||
@@ -586,13 +586,7 @@ impl MainTui {
|
||||
} else if self.state.show_browse {
|
||||
if self.state.browse.active_tab().is_some_and(|b| b.filtering) {
|
||||
// Typing goes to the filter, so no letter keys are on offer.
|
||||
vec![
|
||||
("Type", "filter"),
|
||||
("↑/↓", "navigate"),
|
||||
("PgUp/PgDn", "page"),
|
||||
("Enter", "keep filter"),
|
||||
("Esc", "clear filter"),
|
||||
]
|
||||
filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")])
|
||||
} else {
|
||||
vec![
|
||||
("↑↓", "move"),
|
||||
@@ -632,13 +626,7 @@ impl MainTui {
|
||||
&& 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"),
|
||||
]
|
||||
filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")])
|
||||
} else {
|
||||
let mut keys = match self.state.focused_pane {
|
||||
FocusedPane::Searches => vec![
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::MainTui;
|
||||
use super::input::{jumped, list_jump, scroll_log};
|
||||
use super::input::{FilterEdit, edit_filter, jumped, list_jump, scroll_log};
|
||||
use crate::models::{ChatFilter, RoomsView};
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
@@ -47,7 +47,7 @@ impl MainTui {
|
||||
|
||||
fn handle_rooms_list_input(&mut self, key: KeyEvent) {
|
||||
let len = self.state.rooms.filtered_rooms().len();
|
||||
if let Some(jump) = list_jump(key, self.popup_page()) {
|
||||
if let Some(jump) = list_jump(key, self.popup_page(1)) {
|
||||
self.state.rooms.list_selected =
|
||||
jumped(self.state.rooms.list_selected, len, jump);
|
||||
return;
|
||||
@@ -88,25 +88,18 @@ impl MainTui {
|
||||
}
|
||||
|
||||
fn handle_room_filter_input(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.state.rooms.list_is_filtering = false;
|
||||
self.state.rooms.list_filter.clear();
|
||||
let Some(edit) = edit_filter(&mut self.state.rooms.list_filter, key)
|
||||
else {
|
||||
return self.handle_rooms_list_input(key);
|
||||
};
|
||||
match edit {
|
||||
FilterEdit::Changed | FilterEdit::Cleared => {
|
||||
self.state.rooms.list_selected = 0;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.state.rooms.list_is_filtering = false;
|
||||
self.join_selected_room();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.state.rooms.list_filter.push(c);
|
||||
self.state.rooms.list_selected = 0;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.state.rooms.list_filter.pop();
|
||||
self.state.rooms.list_selected = 0;
|
||||
}
|
||||
_ => {}
|
||||
FilterEdit::Kept => self.join_selected_room(),
|
||||
}
|
||||
if edit != FilterEdit::Changed {
|
||||
self.state.rooms.list_is_filtering = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,33 +156,24 @@ impl MainTui {
|
||||
fn handle_chat_filter_input(&mut self, key: KeyEvent) -> bool {
|
||||
let rooms = &mut self.state.rooms;
|
||||
let over_log = rooms.filtering == Some(ChatFilter::Log);
|
||||
let current = if over_log {
|
||||
let mut filter = if over_log {
|
||||
rooms.log_filter.clone()
|
||||
} else {
|
||||
rooms.user_filter.clone()
|
||||
};
|
||||
let next = match key.code {
|
||||
KeyCode::Esc => String::new(),
|
||||
KeyCode::Enter => current,
|
||||
KeyCode::Char(c) => {
|
||||
let mut next = current;
|
||||
next.push(c);
|
||||
next
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
let mut next = current;
|
||||
next.pop();
|
||||
next
|
||||
}
|
||||
_ => return false,
|
||||
let Some(edit) = edit_filter(&mut filter, key) else {
|
||||
return false;
|
||||
};
|
||||
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
|
||||
if edit != FilterEdit::Changed {
|
||||
rooms.filtering = None;
|
||||
}
|
||||
if over_log {
|
||||
rooms.set_log_filter(next);
|
||||
} else {
|
||||
rooms.set_user_filter(next);
|
||||
// Keeping a filter leaves the view where it is.
|
||||
if edit != FilterEdit::Kept {
|
||||
if over_log {
|
||||
rooms.set_log_filter(filter);
|
||||
} else {
|
||||
rooms.set_user_filter(filter);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user