Merge pull request #91 from michel/tui/results-folder-column

feat(tui): folder column, paged long lists, and filters in every popup
This commit is contained in:
Michel de Graaf
2026-09-08 12:32:08 +02:00
committed by GitHub
22 changed files with 1256 additions and 296 deletions
+14 -7
View File
@@ -99,12 +99,13 @@ Everything else is a one-shot command that runs headless.
### The TUI ### The TUI
Results run across the top, with the whole width for long file names; Results run across the top, each file name in a column of its own with the
Searches, Downloads and Info share the row underneath. `Tab`/`Shift-Tab` or folder it came from beside it; Searches, Downloads and Info share the row
`1`–`3` move the focus between panes, `z` zooms the focused pane to the whole underneath. `Tab`/`Shift-Tab` or `1`–`3` move the focus between panes, `z`
window, and `w` hides it so the others widen (its number brings it back). zooms the focused pane to the whole window, `w` hides it so the others widen
Every list takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and (its number brings it back), and `W` puts the whole layout back. Every list
`h`/`l` or `←`/`→` scroll a long file name or query sideways, `0` and `$` to takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and `h`/`l` or
`←`/`→` scroll a long file name, folder or query sideways, `0` and `$` to
either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?` either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?`
shows every key for where you are. shows every key for where you are.
@@ -572,7 +573,13 @@ unread counter. `c` opens the chat-rooms popup: a `/`-filterable room list
busiest first, `Enter` to join, several rooms open at once as tabs busiest first, `Enter` to join, several rooms open at once as tabs
(`Tab`/`Shift-Tab` to switch, `x` to leave, `l` back to the list), and `↑`/`↓` (`Tab`/`Shift-Tab` to switch, `x` to leave, `l` back to the list), and `↑`/`↓`
through the member list with `b` to browse someone or `m` to message them. through the member list with `b` to browse someone or `m` to message them.
Unread counts appear on the tabs and on the `c chat (n)` shortcut. `/` searches the open room's log and `u` its member list; the inbox takes `/`
too. Unread counts appear on the tabs and on the `c chat (n)` shortcut.
`b` browses a user's shared files as a tree: `←`/`→` close and open a folder,
`J`/`K` jump between folders, `H`/`L` close or open every folder, `/` filters
the whole tree by path, and `Enter` or `d` downloads a file or a folder's
files (under a filter, only the ones it shows).
### Connectivity ### Connectivity
+1 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 930 KiB

After

Width:  |  Height:  |  Size: 926 KiB

+70 -1
View File
@@ -1,6 +1,6 @@
use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState}; use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState};
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use ratatui::{layout::Rect, widgets::TableState}; use ratatui::{layout::Rect, text::Line, widgets::TableState};
use soulseek_rs::{DownloadStatus, types::Download}; use soulseek_rs::{DownloadStatus, types::Download};
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::{Arc, mpsc::Receiver, mpsc::Sender}; use std::sync::{Arc, mpsc::Receiver, mpsc::Sender};
@@ -174,6 +174,46 @@ impl LogView {
} }
} }
/// A log wrapped to a width, kept between frames.
///
/// Wrapping every line of a long log on every draw is what made a busy room
/// lag. Lines only ever arrive at the end, so a draw wraps the new ones and
/// starts over only when the width or `key` (what is being shown, a filter
/// say) changes.
#[derive(Debug, Clone, Default)]
pub struct WrappedLog {
width: usize,
key: String,
/// How many source lines the rows cover.
wrapped: usize,
rows: Vec<Line<'static>>,
}
impl WrappedLog {
/// The rows for `lines`, `wrap` turning one line into its rows (none,
/// for a line a filter drops).
pub fn rows<T>(
&mut self,
width: usize,
key: &str,
lines: &[T],
wrap: impl Fn(&T) -> Vec<Line<'static>>,
) -> &[Line<'static>] {
if width != self.width || key != self.key || lines.len() < self.wrapped
{
self.rows.clear();
self.wrapped = 0;
self.width = width;
self.key = key.to_string();
}
for line in &lines[self.wrapped..] {
self.rows.extend(wrap(line));
}
self.wrapped = lines.len();
&self.rows
}
}
/// What the shared command bar is currently capturing input for. /// What the shared command bar is currently capturing input for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandBarMode { pub enum CommandBarMode {
@@ -254,6 +294,11 @@ pub struct AppState {
/// Compose buffer for the active conversation. /// Compose buffer for the active conversation.
pub chat_input: String, pub chat_input: String,
pub chat_composing: bool, pub chat_composing: bool,
/// The open conversation wrapped for drawing.
pub chat_wrapped: WrappedLog,
/// Narrows the open conversation to messages mentioning it.
pub chat_filter: String,
pub chat_filtering: bool,
/// Where the conversation is being read. /// Where the conversation is being read.
pub chat_view: LogView, pub chat_view: LogView,
@@ -332,6 +377,9 @@ impl AppState {
chat_peer: None, chat_peer: None,
chat_input: String::new(), chat_input: String::new(),
chat_composing: false, chat_composing: false,
chat_wrapped: WrappedLog::default(),
chat_filter: String::new(),
chat_filtering: false,
chat_view: LogView::default(), chat_view: LogView::default(),
browse: BrowseTabs::new(), browse: BrowseTabs::new(),
@@ -501,6 +549,27 @@ mod tests {
layout.visible().collect() layout.visible().collect()
} }
#[test]
fn a_wrapped_log_only_wraps_what_arrived_and_starts_over_on_a_change() {
let wrap = |line: &String| vec![Line::raw(line.clone())];
let mut log = WrappedLog::default();
let mut lines = vec!["a".to_string(), "b".to_string()];
assert_eq!(log.rows(40, "", &lines, wrap).len(), 2);
lines.push("c".to_string());
assert_eq!(log.rows(40, "", &lines, wrap).len(), 3);
// A narrower pane or another filter rebuilds; fewer lines do too.
let only_c = |line: &String| {
(line == "c")
.then(|| Line::raw(line.clone()))
.into_iter()
.collect()
};
assert_eq!(log.rows(40, "c", &lines, only_c).len(), 1);
assert_eq!(log.rows(30, "c", &lines, wrap).len(), 3);
lines.truncate(1);
assert_eq!(log.rows(30, "c", &lines, wrap).len(), 1);
}
#[test] #[test]
fn every_pane_starts_visible_and_unzoomed() { fn every_pane_starts_visible_and_unzoomed() {
let state = AppState::new(); let state = AppState::new();
+249 -23
View File
@@ -154,23 +154,20 @@ pub struct BrowseRow {
pub size: Option<u64>, pub size: Option<u64>,
} }
/// Flatten the tree into visible rows, descending only into expanded folders. /// Emit `nodes` as rows. Without a `needle`, every folder and file, a
#[must_use] /// folder's contents only while it is open. With one, only the paths that
pub fn flatten_browse( /// contain it and the folders on the way there, open ones still deciding
nodes: &[BrowseNode], /// whether their contents show. `emit` false only counts. Says whether
expanded: &HashSet<String>, /// anything under `nodes` was kept.
) -> Vec<BrowseRow> {
let mut rows = Vec::new();
flatten_into(nodes, 0, expanded, &mut rows);
rows
}
fn flatten_into( fn flatten_into(
nodes: &[BrowseNode], nodes: &[BrowseNode],
depth: usize, depth: usize,
expanded: &HashSet<String>, expanded: &HashSet<String>,
needle: Option<&str>,
rows: &mut Vec<BrowseRow>, rows: &mut Vec<BrowseRow>,
) { emit: bool,
) -> bool {
let mut kept = false;
for node in nodes { for node in nodes {
match node { match node {
BrowseNode::Folder { BrowseNode::Folder {
@@ -178,20 +175,53 @@ fn flatten_into(
path, path,
children, children,
} => { } => {
let is_expanded = expanded.contains(path); let open = expanded.contains(path);
let mark = rows.len();
if emit {
rows.push(BrowseRow { rows.push(BrowseRow {
depth, depth,
is_folder: true, is_folder: true,
expanded: is_expanded, expanded: open,
name: name.clone(), name: name.clone(),
path: path.clone(), path: path.clone(),
size: None, size: None,
}); });
if is_expanded { }
flatten_into(children, depth + 1, expanded, rows); let below = match needle {
None => {
if open {
flatten_into(
children,
depth + 1,
expanded,
None,
rows,
emit,
);
}
true
}
Some(needle) => {
flatten_into(
children,
depth + 1,
expanded,
Some(needle),
rows,
emit && open,
) || path_has(path, needle)
}
};
if below {
kept = true;
} else {
rows.truncate(mark);
} }
} }
BrowseNode::File { name, path, size } => { BrowseNode::File { name, path, size } => {
if needle.is_none_or(|needle| path_has(path, needle)) {
kept = true;
if emit {
rows.push(BrowseRow { rows.push(BrowseRow {
depth, depth,
is_folder: false, is_folder: false,
@@ -203,11 +233,49 @@ fn flatten_into(
} }
} }
} }
}
}
kept
}
/// Whether `path` contains `needle`, which is already ASCII-lowercase. Case
/// is folded for ASCII only, without allocating: a filter keystroke runs
/// this over every path in a share. A letter beyond ASCII matches as typed.
fn path_has(path: &str, needle: &str) -> bool {
needle.is_empty()
|| path
.as_bytes()
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
}
/// Open every folder with a path containing `needle` somewhere under it,
/// so a filter shows all it matched. Says whether anything under `nodes`
/// matched.
fn open_matching(
nodes: &[BrowseNode],
needle: &str,
expanded: &mut HashSet<String>,
) -> bool {
let mut any = false;
for node in nodes {
match node {
BrowseNode::Folder { path, children, .. } => {
if open_matching(children, needle, expanded)
|| path_has(path, needle)
{
expanded.insert(path.clone());
any = true;
}
}
BrowseNode::File { path, .. } => any |= path_has(path, needle),
}
}
any
} }
/// Find a node by its full path. /// Find a node by its full path.
#[must_use] fn find_node<'a>(
pub fn find_node<'a>(
nodes: &'a [BrowseNode], nodes: &'a [BrowseNode],
path: &str, path: &str,
) -> Option<&'a BrowseNode> { ) -> Option<&'a BrowseNode> {
@@ -235,8 +303,7 @@ pub fn find_node<'a>(
/// All file leaves under `node` as `(full_path, size)`, descending the tree /// All file leaves under `node` as `(full_path, size)`, descending the tree
/// structurally (never string-prefix matching). /// structurally (never string-prefix matching).
#[must_use] fn files_under(node: &BrowseNode) -> Vec<(String, u64)> {
pub fn files_under(node: &BrowseNode) -> Vec<(String, u64)> {
let mut out = Vec::new(); let mut out = Vec::new();
collect_files(node, &mut out); collect_files(node, &mut out);
out out
@@ -267,7 +334,17 @@ pub struct BrowseState {
pub username: String, pub username: String,
pub status: BrowseStatus, pub status: BrowseStatus,
pub tree: Vec<BrowseNode>, pub tree: Vec<BrowseNode>,
pub expanded: HashSet<String>, expanded: HashSet<String>,
/// The tree flattened for the current expansion, kept between frames:
/// a heavy sharer's tree runs to tens of thousands of rows.
rows: Vec<BrowseRow>,
/// Narrows the rows to paths containing it, case-insensitively. Setting
/// it opens every folder with a match; clearing it puts the folders back
/// the way they were, kept in `kept_expanded` meanwhile.
filter: String,
kept_expanded: Option<HashSet<String>>,
/// Whether typing goes to the filter.
pub filtering: bool,
pub selected_row: usize, pub selected_row: usize,
pub file_count: usize, pub file_count: usize,
pub folder_count: usize, pub folder_count: usize,
@@ -282,6 +359,10 @@ impl BrowseState {
status: BrowseStatus::Loading, status: BrowseStatus::Loading,
tree: Vec::new(), tree: Vec::new(),
expanded: HashSet::new(), expanded: HashSet::new(),
rows: Vec::new(),
filter: String::new(),
kept_expanded: None,
filtering: false,
selected_row: 0, selected_row: 0,
file_count: 0, file_count: 0,
folder_count: 0, folder_count: 0,
@@ -310,12 +391,105 @@ impl BrowseState {
self.file_count = built.file_count; self.file_count = built.file_count;
self.folder_count = built.folder_count; self.folder_count = built.folder_count;
self.selected_row = 0; self.selected_row = 0;
self.refresh();
} }
/// The flattened visible rows for the current expansion state. /// The flattened visible rows for the current expansion state.
#[must_use] #[must_use]
pub fn rows(&self) -> Vec<BrowseRow> { pub fn rows(&self) -> &[BrowseRow] {
flatten_browse(&self.tree, &self.expanded) &self.rows
}
/// Open or close the folder at `path`.
pub fn set_expanded(&mut self, path: &str, expanded: bool) {
if expanded {
self.expanded.insert(path.to_string());
} else {
self.expanded.remove(path);
}
self.refresh();
}
pub fn expand_all(&mut self) {
folder_paths(&self.tree, &mut self.expanded);
self.refresh();
}
pub fn collapse_all(&mut self) {
self.expanded.clear();
self.refresh();
}
#[must_use]
pub fn filter(&self) -> &str {
&self.filter
}
/// Narrow the rows to `filter`, starting over from the top, with every
/// folder holding a match open. An emptied filter puts the folders back
/// as they were before it.
pub fn set_filter(&mut self, filter: String) {
if filter == self.filter {
return;
}
if filter.is_empty() {
if let Some(kept) = self.kept_expanded.take() {
self.expanded = kept;
}
} else {
if self.kept_expanded.is_none() {
self.kept_expanded = Some(self.expanded.clone());
}
self.expanded.clear();
open_matching(
&self.tree,
&filter.to_ascii_lowercase(),
&mut self.expanded,
);
}
self.filter = filter;
self.selected_row = 0;
self.refresh();
}
/// The files under the folder at `path`, only those the filter lets
/// through: what `d` on a filtered folder downloads is what it shows.
#[must_use]
pub fn folder_files(&self, path: &str) -> Vec<(String, u64)> {
let needle = self.filter.to_ascii_lowercase();
find_node(&self.tree, path)
.map(files_under)
.unwrap_or_default()
.into_iter()
.filter(|(path, _)| path_has(path, &needle))
.collect()
}
/// Re-flatten after the tree, the expansion or the filter changed under
/// `rows`.
fn refresh(&mut self) {
let needle =
(!self.filter.is_empty()).then(|| self.filter.to_ascii_lowercase());
self.rows.clear();
flatten_into(
&self.tree,
0,
&self.expanded,
needle.as_deref(),
&mut self.rows,
true,
);
self.selected_row =
self.selected_row.min(self.rows.len().saturating_sub(1));
}
}
fn folder_paths(nodes: &[BrowseNode], out: &mut HashSet<String>) {
for node in nodes {
if let BrowseNode::Folder { path, children, .. } = node {
out.insert(path.clone());
folder_paths(children, out);
}
} }
} }
@@ -413,6 +587,16 @@ impl BrowseTabs {
mod tests { mod tests {
use super::*; use super::*;
/// The tree flattened for `expanded`, unfiltered.
fn flatten_browse(
nodes: &[BrowseNode],
expanded: &HashSet<String>,
) -> Vec<BrowseRow> {
let mut rows = Vec::new();
flatten_into(nodes, 0, expanded, None, &mut rows, true);
rows
}
fn dir(name: &str, files: &[(&str, u64)]) -> SharedDirectory { fn dir(name: &str, files: &[(&str, u64)]) -> SharedDirectory {
SharedDirectory { SharedDirectory {
name: name.to_string(), name: name.to_string(),
@@ -579,6 +763,48 @@ mod tests {
assert_eq!(tabs.retry_active(), None); assert_eq!(tabs.retry_active(), None);
} }
#[test]
fn expanding_and_collapsing_refresh_the_rows_and_keep_the_selection() {
let mut state = BrowseState::loading("bob".to_string());
state.load(&[dir("music\\album", &[("a.mp3", 1), ("b.mp3", 2)])]);
assert_eq!(state.rows().len(), 2, "music open, album closed");
state.set_expanded("music\\album", true);
assert_eq!(state.rows().len(), 4);
state.selected_row = 3;
state.set_expanded("music\\album", false);
assert_eq!(state.rows().len(), 2);
assert_eq!(state.selected_row, 1, "pulled back onto the last row");
}
#[test]
fn a_filter_opens_every_folder_with_a_match_and_folds_still_work() {
let mut state = BrowseState::loading("bob".to_string());
state.load(&[
dir("music\\alpha", &[("a1.mp3", 1), ("a2.flac", 2)]),
dir("music\\beta", &[("b1.mp3", 3)]),
]);
let names = |state: &BrowseState| -> Vec<String> {
state.rows().iter().map(|row| row.name.clone()).collect()
};
state.set_filter("beta".to_string());
assert_eq!(names(&state), ["music", "beta", "b1.mp3"]);
state.set_filter("FLAC".to_string());
assert_eq!(names(&state), ["music", "alpha", "a2.flac"], "any case");
assert_eq!(
state.folder_files("music"),
[("music\\alpha\\a2.flac".to_string(), 2)],
"a folder downloads what it shows"
);
state.set_expanded("music\\alpha", false);
assert_eq!(names(&state), ["music", "alpha"], "folded under a filter");
state.set_filter(String::new());
assert_eq!(state.rows().len(), 3, "the folders as they were before");
state.expand_all();
assert_eq!(state.rows().len(), 6);
state.collapse_all();
assert_eq!(state.rows().len(), 1);
}
#[test] #[test]
fn browse_state_load_auto_expands_top_level() { fn browse_state_load_auto_expands_top_level() {
let mut state = BrowseState::loading("bob".to_string()); let mut state = BrowseState::loading("bob".to_string());
+6 -5
View File
@@ -6,11 +6,12 @@ mod settings;
pub use app_state::{ pub use app_state::{
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView, AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView,
MessageDirection, PaneLayout, SearchEntry, SearchStatus, MessageDirection, PaneLayout, SearchEntry, SearchStatus, WrappedLog,
};
pub use browse::{
BrowseState, BrowseStatus, BrowseTabs, files_under, find_node,
}; };
pub use browse::{BrowseState, BrowseStatus, BrowseTabs};
pub use file_display_data::FileDisplayData; pub use file_display_data::FileDisplayData;
pub use rooms::{RoomLine, RoomsState, RoomsView}; pub use rooms::{
ChatFilter, OpenRoom, RoomLine, RoomsState, RoomsView, contains_filter,
matching_users,
};
pub use settings::{SettingsAction, SettingsMode, SettingsState}; pub use settings::{SettingsAction, SettingsMode, SettingsState};
+69 -10
View File
@@ -1,4 +1,4 @@
use crate::models::LogView; use crate::models::{LogView, WrappedLog};
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use soulseek_rs::types::{RoomEvent, RoomInfo}; use soulseek_rs::types::{RoomEvent, RoomInfo};
@@ -41,6 +41,8 @@ pub struct OpenRoom {
pub input: String, pub input: String,
/// Where the log is being read. /// Where the log is being read.
pub view: LogView, pub view: LogView,
/// The log wrapped for drawing.
pub wrapped: WrappedLog,
} }
impl OpenRoom { impl OpenRoom {
@@ -77,8 +79,22 @@ pub struct RoomsState {
pub view: RoomsView, pub view: RoomsView,
/// Whether the compose input for the active room is capturing keys. /// Whether the compose input for the active room is capturing keys.
pub composing: bool, pub composing: bool,
/// Selected index into the active room's member list (for browse/PM). /// Selected index into the active room's member list (for browse/PM),
/// as narrowed by `user_filter`.
pub user_selected: usize, pub user_selected: usize,
/// Narrows the active room's log to lines mentioning it.
pub log_filter: String,
/// Narrows the active room's member list to names containing it.
pub user_filter: String,
/// Which of the two, if either, typing goes to.
pub filtering: Option<ChatFilter>,
}
/// A room's two filterable lists.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatFilter {
Log,
Users,
} }
impl Default for RoomsState { impl Default for RoomsState {
@@ -100,6 +116,31 @@ impl RoomsState {
view: RoomsView::List, view: RoomsView::List,
composing: false, composing: false,
user_selected: 0, user_selected: 0,
log_filter: String::new(),
user_filter: String::new(),
filtering: None,
}
}
/// The active room's members the filter lets through.
#[must_use]
pub fn visible_users(&self) -> Vec<&str> {
self.active_room().map_or_else(Vec::new, |room| {
matching_users(&room.users, &self.user_filter)
})
}
/// Narrow the member list, starting over from its top.
pub fn set_user_filter(&mut self, filter: String) {
self.user_filter = filter;
self.user_selected = 0;
}
/// Narrow the log, back at its newest matching line.
pub fn set_log_filter(&mut self, filter: String) {
self.log_filter = filter;
if let Some(view) = self.active_view_mut() {
view.to_newest();
} }
} }
@@ -159,8 +200,9 @@ impl RoomsState {
/// The username highlighted in the active room's member list, if any. /// The username highlighted in the active room's member list, if any.
#[must_use] #[must_use]
pub fn selected_user(&self) -> Option<String> { pub fn selected_user(&self) -> Option<String> {
self.active_room() self.visible_users()
.and_then(|r| r.users.get(self.user_selected).cloned()) .get(self.user_selected)
.map(|user| (*user).to_string())
} }
/// Where the active room's log is being read, to move it. /// Where the active room's log is being read, to move it.
@@ -173,11 +215,9 @@ impl RoomsState {
} }
pub fn select_user_down(&mut self) { pub fn select_user_down(&mut self) {
if let Some(room) = self.active_room() let len = self.visible_users().len();
&& !room.users.is_empty() if len > 0 {
{ self.user_selected = (self.user_selected + 1).min(len - 1);
self.user_selected =
(self.user_selected + 1).min(room.users.len() - 1);
} }
} }
@@ -305,7 +345,7 @@ impl RoomsState {
/// Keep `user_selected` within the active room's member list. /// Keep `user_selected` within the active room's member list.
fn clamp_user_selected(&mut self) { fn clamp_user_selected(&mut self) {
let len = self.active_room().map_or(0, |r| r.users.len()); let len = self.visible_users().len();
self.user_selected = self.user_selected.min(len.saturating_sub(1)); self.user_selected = self.user_selected.min(len.saturating_sub(1));
} }
@@ -320,6 +360,25 @@ impl RoomsState {
} }
} }
/// Whether `text` contains `filter`, ignoring case; an empty filter matches.
#[must_use]
pub fn contains_filter(text: &str, filter: &str) -> bool {
filter.is_empty() || text.to_lowercase().contains(&filter.to_lowercase())
}
/// The names in `users` that contain `filter`, in their own order.
#[must_use]
pub fn matching_users<'a>(users: &'a [String], filter: &str) -> Vec<&'a str> {
let needle = filter.to_lowercase();
users
.iter()
.filter(|user| {
needle.is_empty() || user.to_lowercase().contains(&needle)
})
.map(String::as_str)
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+70 -34
View File
@@ -1,6 +1,6 @@
use super::MainTui; use super::MainTui;
use super::input::{jumped, list_jump}; use super::input::{FilterEdit, edit_filter, jumped, list_jump};
use crate::models::{BrowseStatus, files_under, find_node}; use crate::models::BrowseStatus;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::{thread, time::Duration}; use std::{thread, time::Duration};
@@ -9,11 +9,27 @@ const BROWSE_TIMEOUT: Duration = Duration::from_secs(20);
impl MainTui { impl MainTui {
pub(super) fn handle_browse_input(&mut self, key: KeyEvent) { pub(super) fn handle_browse_input(&mut self, key: KeyEvent) {
// Typing goes to the filter; the other keys still move around.
if self.state.browse.active_tab().is_some_and(|b| b.filtering)
&& self.handle_browse_filter_input(key)
{
return;
}
// Esc peels back one level: a filter first, then the popup.
if key.code == KeyCode::Esc
&& let Some(browse) = self.state.browse.active_tab_mut()
&& !browse.filter().is_empty()
{
browse.set_filter(String::new());
return;
}
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
self.state.show_browse = false; self.state.show_browse = false;
return; 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. // Control combinations are only ever jumps: ctrl-d pages, d downloads.
if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) { if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
return; return;
@@ -48,8 +64,8 @@ impl MainTui {
_ => {} _ => {}
} }
// Snapshot the flattened rows + current selection, then drop the borrow. // The highlighted row and where it sits, then drop the borrow.
let (rows, sel, row) = { let (len, sel, row) = {
let Some(browse) = self.state.browse.active_tab() else { let Some(browse) = self.state.browse.active_tab() else {
self.state.show_browse = false; self.state.show_browse = false;
return; return;
@@ -62,13 +78,12 @@ impl MainTui {
return; return;
} }
let sel = browse.selected_row.min(rows.len() - 1); let sel = browse.selected_row.min(rows.len() - 1);
let row = rows[sel].clone(); (rows.len(), sel, rows[sel].clone())
(rows, sel, row)
}; };
if let Some(jump) = jump { if let Some(jump) = jump {
if let Some(browse) = self.state.browse.active_tab_mut() { if let Some(browse) = self.state.browse.active_tab_mut() {
browse.selected_row = jumped(sel, rows.len(), jump); browse.selected_row = jumped(sel, len, jump);
} }
self.sync_browse_selection(); self.sync_browse_selection();
return; return;
@@ -85,7 +100,11 @@ impl MainTui {
} }
KeyCode::Char('d') => { KeyCode::Char('d') => {
let files = if row.is_folder { let files = if row.is_folder {
self.browse_folder_files(&row.path) self.state
.browse
.active_tab()
.map(|b| b.folder_files(&row.path))
.unwrap_or_default()
} else { } else {
vec![(row.path.clone(), row.size.unwrap_or(0))] vec![(row.path.clone(), row.size.unwrap_or(0))]
}; };
@@ -102,59 +121,76 @@ impl MainTui {
browse.selected_row = sel.saturating_sub(1); browse.selected_row = sel.saturating_sub(1);
} }
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
browse.selected_row = (sel + 1).min(rows.len() - 1); browse.selected_row = (sel + 1).min(len - 1);
} }
// The folder-sized steps: the next or previous folder row.
KeyCode::Char('J') => {
if let Some(next) =
(sel + 1..len).find(|&i| browse.rows()[i].is_folder)
{
browse.selected_row = next;
}
}
KeyCode::Char('K') => {
if let Some(previous) =
(0..sel).rev().find(|&i| browse.rows()[i].is_folder)
{
browse.selected_row = previous;
}
}
KeyCode::Char('H') => browse.collapse_all(),
KeyCode::Char('L') => browse.expand_all(),
KeyCode::Char('/') => browse.filtering = true,
KeyCode::Right | KeyCode::Char('l') => { KeyCode::Right | KeyCode::Char('l') => {
if row.is_folder && !row.expanded { if row.is_folder && !row.expanded {
browse.expanded.insert(row.path.clone()); browse.set_expanded(&row.path, true);
} else if row.is_folder { } else if row.is_folder {
browse.selected_row = (sel + 1).min(rows.len() - 1); browse.selected_row = (sel + 1).min(len - 1);
} }
} }
KeyCode::Left | KeyCode::Char('h') => { KeyCode::Left | KeyCode::Char('h') => {
if row.is_folder && row.expanded { if row.is_folder && row.expanded {
browse.expanded.remove(&row.path); browse.set_expanded(&row.path, false);
} else if let Some(parent) = } else if let Some(parent) = (0..sel)
(0..sel).rev().find(|&i| rows[i].depth < row.depth) .rev()
.find(|&i| browse.rows()[i].depth < row.depth)
{ {
browse.selected_row = parent; browse.selected_row = parent;
} }
} }
KeyCode::Enter => { KeyCode::Enter => {
// Folder toggle (files handled above). // Folder toggle (files handled above).
if row.expanded { browse.set_expanded(&row.path, !row.expanded);
browse.expanded.remove(&row.path);
} else {
browse.expanded.insert(row.path.clone());
}
} }
_ => {} _ => {}
} }
// Re-clamp against the new flattened length.
let new_len = browse.rows().len();
browse.selected_row =
browse.selected_row.min(new_len.saturating_sub(1));
} }
self.sync_browse_selection(); self.sync_browse_selection();
} }
/// The keys that edit the filter while it is being typed. Says whether
/// `key` was one of them; the rest move around the narrowed rows.
fn handle_browse_filter_input(&mut self, key: KeyEvent) -> bool {
let Some(browse) = self.state.browse.active_tab_mut() else {
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
}
/// Point the browse table cursor at the active tab's selected row. /// Point the browse table cursor at the active tab's selected row.
fn sync_browse_selection(&mut self) { fn sync_browse_selection(&mut self) {
let selected = self.state.browse.active_tab().map(|b| b.selected_row); let selected = self.state.browse.active_tab().map(|b| b.selected_row);
self.state.browse_table_state.select(selected); self.state.browse_table_state.select(selected);
} }
/// Files (`path`, `size`) under the active browse tab's folder at `path`.
fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> {
self.state
.browse
.active_tab()
.and_then(|b| find_node(&b.tree, path))
.map(files_under)
.unwrap_or_default()
}
/// Queue downloads of `files` (path, size) from the active browse tab's user. /// Queue downloads of `files` (path, size) from the active browse tab's user.
fn queue_browse_files(&mut self, files: Vec<(String, u64)>) { fn queue_browse_files(&mut self, files: Vec<(String, u64)>) {
let Some(username) = let Some(username) =
+75 -41
View File
@@ -1,6 +1,6 @@
use super::MainTui; use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, LogView}; use crate::models::{CommandBarMode, FocusedPane, LogView, PaneLayout};
use crate::ui::pane_block; use crate::ui::page_of;
use crate::ui::panes::{ use crate::ui::panes::{
InfoSubject, name_end_offset, query_end_offset, selected_transfer, InfoSubject, name_end_offset, query_end_offset, selected_transfer,
transfer_name_end_offset, upload_display_name, transfer_name_end_offset, upload_display_name,
@@ -8,7 +8,7 @@ use crate::ui::panes::{
use ratatui::crossterm::event::{ use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
}; };
use ratatui::layout::{Position, Rect}; use ratatui::layout::Position;
use ratatui::widgets::TableState; use ratatui::widgets::TableState;
const NAME_SCROLL_STEP: isize = 8; const NAME_SCROLL_STEP: isize = 8;
@@ -100,6 +100,10 @@ impl MainTui {
self.state.hide_focused_pane(); self.state.hide_focused_pane();
return; return;
} }
KeyCode::Char('W') => {
self.state.layout = PaneLayout::default();
return;
}
KeyCode::Esc if self.state.layout.zoomed => { KeyCode::Esc if self.state.layout.zoomed => {
self.state.layout.zoomed = false; self.state.layout.zoomed = false;
return; return;
@@ -186,6 +190,20 @@ impl MainTui {
return; return;
} }
// Typing a filter captures the keys that edit it; the rest still
// move around.
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. // Paging keys move through the conversation's history.
if scroll_log(&mut self.state.chat_view, key) { if scroll_log(&mut self.state.chat_view, key) {
return; return;
@@ -195,9 +213,15 @@ impl MainTui {
} }
match key.code { match key.code {
// Esc peels back one level: a filter first, then the popup.
KeyCode::Esc if !self.state.chat_filter.is_empty() => {
self.state.chat_filter.clear();
self.state.chat_view.to_newest();
}
KeyCode::Char('i' | 'q') | KeyCode::Esc => { KeyCode::Char('i' | 'q') | KeyCode::Esc => {
self.state.show_messages = false; self.state.show_messages = false;
} }
KeyCode::Char('/') => self.state.chat_filtering = true,
// Move through the conversation list on the right. // Move through the conversation list on the right.
KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => { KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => {
self.state.cycle_chat_peer(true); self.state.cycle_chat_peer(true);
@@ -326,31 +350,17 @@ impl MainTui {
} }
fn handle_filter_input(&mut self, key: KeyEvent) { fn handle_filter_input(&mut self, key: KeyEvent) {
match key.code { let Some(edit) = edit_filter(&mut self.state.results_filter_query, key)
KeyCode::Esc => { else {
self.state.results_is_filtering = false; return self.handle_results_input(key);
self.state.results_filter_query.clear(); };
self.state.results_filtered_items = // A kept query stays: the normal Results keys act on the filtered
self.state.results_items.clone(); // list. An empty one shows everything without a rebuild.
self.state.results_filtered_indices = if edit == FilterEdit::Changed {
(0..self.state.results_items.len()).collect();
}
KeyCode::Char(c) => {
self.state.results_filter_query.push(c);
self.apply_filter(); self.apply_filter();
} } else {
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.state.results_is_filtering = false;
} }
_ => self.handle_results_input(key),
}
} }
/// The list the focus is on and how far one page moves it. /// The list the focus is on and how far one page moves it.
@@ -374,10 +384,10 @@ impl MainTui {
navigate_list(key, table, len, page) navigate_list(key, table, len, page)
} }
/// Rows a popup shows at once: its inner height less a tab bar and a /// Rows a popup's list shows at once: its inner height less the `chrome`
/// compose line, which is what its list or log has left. /// rows above the list, a tab bar or a header.
pub(super) fn popup_page(&self) -> usize { pub(super) fn popup_page(&self, chrome: u16) -> usize {
page_of(self.state.popup_area, 2) page_of(self.state.popup_area, chrome)
} }
/// Rows the focused pane shows at once: its inner height less the table /// Rows the focused pane shows at once: its inner height less the table
@@ -440,10 +450,7 @@ impl MainTui {
self.state.results_selected_indices.insert(index); self.state.results_selected_indices.insert(index);
} }
} }
KeyCode::Char('/') => { KeyCode::Char('/') => self.state.results_is_filtering = true,
self.state.results_is_filtering = true;
self.state.results_filter_query.clear();
}
KeyCode::Char('a') => { KeyCode::Char('a') => {
let indices: Vec<usize> = let indices: Vec<usize> =
if self.state.results_filter_query.is_empty() { if self.state.results_filter_query.is_empty() {
@@ -575,14 +582,41 @@ impl MainTui {
} }
} }
/// Rows a list in `area` shows at once, less the `chrome` rows above it: a /// What a key did to a filter being typed.
/// table header, a popup's tab bar and compose line. At least one, so a page #[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// key always moves, even before the first draw. pub(super) enum FilterEdit {
fn page_of(area: Option<Rect>, chrome: u16) -> usize { Changed,
area.map_or(0, |area| { Kept,
usize::from(pane_block(false).inner(area).height.saturating_sub(chrome)) Cleared,
}) }
.max(1)
/// 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 /// A move over a whole list at once: to either end, or by a number of rows
+174 -2
View File
@@ -265,7 +265,9 @@ pub fn launch_main_tui(
mod tests { mod tests {
use super::*; use super::*;
use crate::daemon::proto::ChatMessageDto; use crate::daemon::proto::ChatMessageDto;
use crate::models::{FocusedPane, MessageDirection, SearchStatus}; use crate::models::{
FocusedPane, MessageDirection, RoomsView, SearchStatus,
};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
/// A stand-in for a daemon: it already holds a conversation, a transfer /// A stand-in for a daemon: it already holds a conversation, a transfer
@@ -1158,8 +1160,9 @@ mod tests {
let mut tui = with_session(TalkativeSession::default()); let mut tui = with_session(TalkativeSession::default());
tui.state.results_items = results(n); tui.state.results_items = results(n);
tui.state.focused_pane = FocusedPane::Results; tui.state.focused_pane = FocusedPane::Results;
// 88 cells wide gives the name column 11 cells and the folder 7.
tui.state.results_pane_area = tui.state.results_pane_area =
Some(ratatui::layout::Rect::new(0, 0, 80, 13)); Some(ratatui::layout::Rect::new(0, 0, 88, 13));
tui tui
} }
@@ -1675,6 +1678,147 @@ mod tests {
assert!(tui.state.show_browse, "still open"); assert!(tui.state.show_browse, "still open");
} }
#[test]
fn folder_keys_and_the_filter_move_through_a_browsed_tree() {
let mut tui = attach(Arc::new(TalkativeSession::default()));
tui.state.browse.open("bob");
let listing = vec![
soulseek_rs::SharedDirectory {
name: "music\\alpha".to_string(),
files: vec![
soulseek_rs::SharedFileEntry {
name: "a1.mp3".to_string(),
size: 1,
attributes: Vec::new(),
},
soulseek_rs::SharedFileEntry {
name: "a2.flac".to_string(),
size: 2,
attributes: Vec::new(),
},
],
},
soulseek_rs::SharedDirectory {
name: "music\\beta".to_string(),
files: vec![soulseek_rs::SharedFileEntry {
name: "b1.mp3".to_string(),
size: 3,
attributes: Vec::new(),
}],
},
];
tui.state
.browse
.active_tab_mut()
.expect("tab")
.load(&listing);
tui.state.show_browse = true;
let _ = screen_of(&mut tui);
let selected = |tui: &MainTui| {
tui.state.browse.active_tab().expect("tab").selected_row
};
let rows = |tui: &MainTui| {
tui.state.browse.active_tab().expect("tab").rows().len()
};
press(&mut tui, KeyCode::Char('L'));
assert_eq!(rows(&tui), 6, "every folder open");
press(&mut tui, KeyCode::Char('J'));
press(&mut tui, KeyCode::Char('J'));
assert_eq!(selected(&tui), 4, "beta, skipping alpha's files");
press(&mut tui, KeyCode::Char('K'));
assert_eq!(selected(&tui), 1, "back to alpha");
press(&mut tui, KeyCode::Char('H'));
assert_eq!(rows(&tui), 1, "just music");
press(&mut tui, KeyCode::Char('/'));
for c in "flac".chars() {
press(&mut tui, KeyCode::Char(c));
}
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'));
assert_eq!(selected(&tui), 2, "j moves again once the filter is kept");
press(&mut tui, KeyCode::Esc);
assert_eq!(rows(&tui), 1, "Esc clears the filter first");
assert!(tui.state.show_browse);
press(&mut tui, KeyCode::Esc);
assert!(!tui.state.show_browse, "then hides the popup");
}
#[test]
fn slash_and_u_filter_a_rooms_log_and_its_members() {
let mut tui = attach(Arc::new(TalkativeSession::default()));
in_a_room(&mut tui, 30);
press(&mut tui, KeyCode::Char('u'));
for c in "user2".chars() {
press(&mut tui, KeyCode::Char(c));
}
press(&mut tui, KeyCode::Enter);
press(&mut tui, KeyCode::Char('j'));
assert_eq!(
tui.state.rooms.selected_user().as_deref(),
Some("user21"),
"j moves within the matching members"
);
let screen = screen_of(&mut tui);
assert!(screen.contains("Users 10/30"), "{screen}");
press(&mut tui, KeyCode::Char('/'));
for c in "line 01".chars() {
press(&mut tui, KeyCode::Char(c));
}
let screen = screen_of(&mut tui);
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());
assert!(tui.state.rooms.user_filter.is_empty());
assert_eq!(tui.state.rooms.view, RoomsView::Chat, "still in the room");
press(&mut tui, KeyCode::Esc);
assert_eq!(tui.state.rooms.view, RoomsView::List, "then the list");
}
#[test]
fn slash_filters_the_open_conversation() {
let mut tui = attach(Arc::new(TalkativeSession::default()));
for text in ["hello there", "see you", "hello again"] {
tui.state.messages.push(crate::models::ChatMessage {
direction: MessageDirection::Incoming,
peer: "bob".to_string(),
text: text.to_string(),
at: chrono::Local::now(),
});
}
tui.state.chat_peer = Some("bob".to_string());
tui.state.show_messages = true;
press(&mut tui, KeyCode::Char('/'));
for c in "hello".chars() {
press(&mut tui, KeyCode::Char(c));
}
let screen = screen_of(&mut tui);
assert!(screen.contains("hello again"), "{screen}");
assert!(!screen.contains("see you"), "{screen}");
press(&mut tui, KeyCode::Enter);
press(&mut tui, KeyCode::Esc);
assert!(tui.state.chat_filter.is_empty() && tui.state.show_messages);
press(&mut tui, KeyCode::Esc);
assert!(!tui.state.show_messages);
}
fn in_a_room(tui: &mut MainTui, messages: usize) { fn in_a_room(tui: &mut MainTui, messages: usize) {
tui.state.rooms.apply_event( tui.state.rooms.apply_event(
soulseek_rs::RoomEvent::Joined { soulseek_rs::RoomEvent::Joined {
@@ -1809,6 +1953,34 @@ mod tests {
assert_eq!(bar_rows, 1, "{wide}"); assert_eq!(bar_rows, 1, "{wide}");
} }
#[test]
fn the_searches_pane_lists_its_clear_all_key() {
let mut tui = furnished_tui();
tui.state.focused_pane = FocusedPane::Searches;
let screen = screen_sized(&mut tui, 300, 40);
assert!(screen.contains("[C → clear all]"), "{screen}");
}
#[test]
fn shift_w_brings_every_pane_back_and_leaves_zoom() {
let mut tui = furnished_tui();
tui.state.focused_pane = FocusedPane::Searches;
assert!(!screen_sized(&mut tui, 300, 40).contains("[W → "));
press(&mut tui, KeyCode::Char('w'));
press(&mut tui, KeyCode::Char('z'));
assert!(tui.state.layout.zoomed);
assert!(!tui.state.layout.is_visible(FocusedPane::Searches));
let screen = screen_sized(&mut tui, 300, 40);
assert!(screen.contains("[W → reset layout]"), "{screen}");
press(&mut tui, KeyCode::Char('W'));
assert!(!tui.state.layout.zoomed);
assert!(
FocusedPane::ALL
.iter()
.all(|p| tui.state.layout.is_visible(*p))
);
}
#[test] #[test]
fn page_keys_work_in_the_transfers_and_searches_lists_too() { fn page_keys_work_in_the_transfers_and_searches_lists_too() {
let mut tui = furnished_tui(); let mut tui = furnished_tui();
+53 -13
View File
@@ -1,5 +1,5 @@
use super::MainTui; use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, RoomsView}; use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView};
use crate::ui::panes::{ use crate::ui::panes::{
InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane, InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane,
render_download_info_pane, render_downloads_pane, render_results_pane, render_download_info_pane, render_downloads_pane, render_results_pane,
@@ -29,6 +29,7 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[
("z", "zoom the focused pane"), ("z", "zoom the focused pane"),
("w", "hide the focused pane"), ("w", "hide the focused pane"),
("Esc", "leave zoom"), ("Esc", "leave zoom"),
("W", "every pane back, zoom off"),
("click", "focus a pane"), ("click", "focus a pane"),
], ],
), ),
@@ -95,10 +96,35 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
("^u ^d", "half a page of them"), ("^u ^d", "half a page of them"),
("Home End / g G", "oldest / newest, or first / last row"), ("Home End / g G", "oldest / newest, or first / last row"),
("Tab / Shift-Tab", "next room, chat or user"), ("Tab / Shift-Tab", "next room, chat or user"),
("/", "filter the messages, the room list or the tree"),
("u", "filter a room's member list"),
],
),
(
"Browse popup",
&[
("← → / h l", "close / open a folder, or step out / in"),
("J K", "next / previous folder"),
("H L", "close / open every folder"),
("/", "filter by path, Enter keeps it, Esc clears"),
("Enter", "open a folder, or download a file"),
("d", "download a file, or a folder's files"),
("r", "ask again after a timeout"),
], ],
), ),
]; ];
/// 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 { impl MainTui {
pub(super) fn render(&mut self, frame: &mut Frame) { pub(super) fn render(&mut self, frame: &mut Frame) {
let shortcuts = self.shortcut_rows(frame.area().width); let shortcuts = self.shortcut_rows(frame.area().width);
@@ -481,10 +507,15 @@ impl MainTui {
] ]
} }
} }
RoomsView::Chat if self.state.rooms.filtering.is_some() => {
filter_keys(&[("PgUp/PgDn", "scroll"), ("↑↓", "pick user")])
}
RoomsView::Chat => vec![ RoomsView::Chat => vec![
("Enter", "say"), ("Enter", "say"),
("PgUp/PgDn", "scroll"), ("PgUp/PgDn", "scroll"),
("↑↓", "pick user"), ("↑↓", "pick user"),
("/", "find in chat"),
("u", "find user"),
("b", "browse user"), ("b", "browse user"),
("m", "message user"), ("m", "message user"),
("Tab", "switch room"), ("Tab", "switch room"),
@@ -497,7 +528,7 @@ impl MainTui {
/// The keys every pane shares for moving between and resizing panes, /// The keys every pane shares for moving between and resizing panes,
/// ending the bar the same way wherever the focus sits. /// ending the bar the same way wherever the focus sits.
fn pane_shortcuts(&self) -> Vec<(&'static str, &'static str)> { fn pane_shortcuts(&self) -> Vec<(&'static str, &'static str)> {
vec![ let mut keys = vec![
("Tab/1-3", "pane"), ("Tab/1-3", "pane"),
( (
"z", "z",
@@ -508,9 +539,13 @@ impl MainTui {
}, },
), ),
("w", "hide"), ("w", "hide"),
("?", "keys"), ];
("q", "quit"), // Only once a pane is hidden or zoomed is there a layout to reset.
] if self.state.layout != PaneLayout::default() {
keys.push(("W", "reset layout"));
}
keys.extend([("?", "keys"), ("q", "quit")]);
keys
} }
/// Which shortcuts the bar offers, which is purely a question of what is /// Which shortcuts the bar offers, which is purely a question of what is
@@ -534,11 +569,14 @@ impl MainTui {
("Enter", "send"), ("Enter", "send"),
("Esc", "stop typing"), ("Esc", "stop typing"),
] ]
} else if self.state.chat_filtering {
filter_keys(&[("PgUp/PgDn", "scroll")])
} else { } else {
vec![ vec![
("Enter", "type"), ("Enter", "type"),
("PgUp/PgDn", "scroll"), ("PgUp/PgDn", "scroll"),
("↑↓/Tab", "switch chat"), ("↑↓/Tab", "switch chat"),
("/", "find"),
("m", "new chat"), ("m", "new chat"),
("i/Esc", "close"), ("i/Esc", "close"),
] ]
@@ -546,10 +584,16 @@ impl MainTui {
} else if self.state.show_rooms { } else if self.state.show_rooms {
self.rooms_shortcuts() self.rooms_shortcuts()
} else if self.state.show_browse { } 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.
filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")])
} else {
vec![ vec![
("↑↓", "move"), ("↑↓", "move"),
("PgUp/PgDn", "page"), ("J/K", "next/prev folder"),
("→←", "expand/collapse"), ("→←", "expand/collapse"),
("H/L", "collapse/expand all"),
("/", "filter"),
("Enter", "open/download"), ("Enter", "open/download"),
("d", "download folder"), ("d", "download folder"),
("Tab", "switch user"), ("Tab", "switch user"),
@@ -557,6 +601,7 @@ impl MainTui {
("w", "close tab"), ("w", "close tab"),
("Esc", "hide"), ("Esc", "hide"),
] ]
}
} else if self.state.command_bar_active { } else if self.state.command_bar_active {
match self.state.command_bar_mode { match self.state.command_bar_mode {
CommandBarMode::Search => vec![ CommandBarMode::Search => vec![
@@ -581,19 +626,14 @@ impl MainTui {
&& self.state.focused_pane == FocusedPane::Results && self.state.focused_pane == FocusedPane::Results
{ {
// Typing goes to the filter, so no letter keys are on offer here. // Typing goes to the filter, so no letter keys are on offer here.
vec![ filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")])
("Type", "filter"),
("↑/↓", "navigate"),
("PgUp/PgDn", "page"),
("Enter", "keep filter"),
("Esc", "clear filter"),
]
} else { } else {
let mut keys = match self.state.focused_pane { let mut keys = match self.state.focused_pane {
FocusedPane::Searches => vec![ FocusedPane::Searches => vec![
("s", "search"), ("s", "search"),
("Enter", "results"), ("Enter", "results"),
("d", "remove"), ("d", "remove"),
("C", "clear all"),
("m", "message"), ("m", "message"),
("i", "inbox"), ("i", "inbox"),
("c", "chat"), ("c", "chat"),
+62 -21
View File
@@ -1,6 +1,6 @@
use super::MainTui; 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::RoomsView; use crate::models::{ChatFilter, RoomsView};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
impl MainTui { impl MainTui {
@@ -30,6 +30,14 @@ impl MainTui {
self.handle_room_filter_input(key); self.handle_room_filter_input(key);
return; return;
} }
// So does typing a filter over the log or the member list; the
// other keys still move around.
if self.state.rooms.view == RoomsView::Chat
&& self.state.rooms.filtering.is_some()
&& self.handle_chat_filter_input(key)
{
return;
}
match self.state.rooms.view { match self.state.rooms.view {
RoomsView::List => self.handle_rooms_list_input(key), RoomsView::List => self.handle_rooms_list_input(key),
@@ -39,7 +47,7 @@ impl MainTui {
fn handle_rooms_list_input(&mut self, key: KeyEvent) { fn handle_rooms_list_input(&mut self, key: KeyEvent) {
let len = self.state.rooms.filtered_rooms().len(); 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 = self.state.rooms.list_selected =
jumped(self.state.rooms.list_selected, len, jump); jumped(self.state.rooms.list_selected, len, jump);
return; return;
@@ -80,25 +88,18 @@ impl MainTui {
} }
fn handle_room_filter_input(&mut self, key: KeyEvent) { fn handle_room_filter_input(&mut self, key: KeyEvent) {
match key.code { let Some(edit) = edit_filter(&mut self.state.rooms.list_filter, key)
KeyCode::Esc => { else {
return self.handle_rooms_list_input(key);
};
match edit {
FilterEdit::Changed | FilterEdit::Cleared => {
self.state.rooms.list_selected = 0;
}
FilterEdit::Kept => self.join_selected_room(),
}
if edit != FilterEdit::Changed {
self.state.rooms.list_is_filtering = false; self.state.rooms.list_is_filtering = false;
self.state.rooms.list_filter.clear();
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;
}
_ => {}
} }
} }
@@ -113,11 +114,24 @@ impl MainTui {
if key.modifiers.contains(KeyModifiers::CONTROL) { if key.modifiers.contains(KeyModifiers::CONTROL) {
return; return;
} }
let filtered = !self.state.rooms.log_filter.is_empty()
|| !self.state.rooms.user_filter.is_empty();
match key.code { match key.code {
KeyCode::Char('q') => self.state.show_rooms = false, KeyCode::Char('q') => self.state.show_rooms = false,
// Esc peels back one level: the filters first, then the list.
KeyCode::Esc if filtered => {
self.state.rooms.set_log_filter(String::new());
self.state.rooms.set_user_filter(String::new());
}
KeyCode::Esc | KeyCode::Char('l') => { KeyCode::Esc | KeyCode::Char('l') => {
self.state.rooms.view = RoomsView::List; self.state.rooms.view = RoomsView::List;
} }
KeyCode::Char('/') => {
self.state.rooms.filtering = Some(ChatFilter::Log);
}
KeyCode::Char('u') => {
self.state.rooms.filtering = Some(ChatFilter::Users);
}
KeyCode::Tab => self.state.rooms.next_tab(), KeyCode::Tab => self.state.rooms.next_tab(),
KeyCode::BackTab => self.state.rooms.prev_tab(), KeyCode::BackTab => self.state.rooms.prev_tab(),
KeyCode::Char('x') => self.leave_active_room(), KeyCode::Char('x') => self.leave_active_room(),
@@ -137,6 +151,33 @@ impl MainTui {
} }
} }
/// The keys that edit whichever filter is being typed, the log's or the
/// member list's. Says whether `key` was one of them.
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 mut filter = if over_log {
rooms.log_filter.clone()
} else {
rooms.user_filter.clone()
};
let Some(edit) = edit_filter(&mut filter, key) else {
return false;
};
if edit != FilterEdit::Changed {
rooms.filtering = None;
}
// 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
}
/// Browse the shares of the member highlighted in the active room. Closes /// Browse the shares of the member highlighted in the active room. Closes
/// the rooms popup so the browse tree is the sole overlay. /// the rooms popup so the browse tree is the sole overlay.
fn browse_selected_room_user(&mut self) { fn browse_selected_room_user(&mut self) {
+2
View File
@@ -1,11 +1,13 @@
mod downloads; mod downloads;
pub mod login; pub mod login;
mod main_tui; mod main_tui;
mod paging;
mod panes; mod panes;
mod styles; mod styles;
mod utils; mod utils;
pub use downloads::render_download_stats; pub use downloads::render_download_stats;
pub use main_tui::launch_main_tui; pub use main_tui::launch_main_tui;
pub use paging::*;
pub use styles::*; pub use styles::*;
pub use utils::*; pub use utils::*;
+104
View File
@@ -0,0 +1,104 @@
//! How a pane's table shows one page of a list that may run to tens of
//! thousands of rows: which rows those are, and how ratatui is handed just
//! those. Paging and drawing both count rows here, so they agree on what a
//! page is.
use crate::ui::pane_block;
use ratatui::{
Frame,
layout::Rect,
widgets::{Table, TableState},
};
use std::ops::Range;
/// Rows a list in `area` shows at once, less the `chrome` rows above it: a
/// table header, a popup's tab bar. At least one, so a page key always
/// moves, even before the first draw.
#[must_use]
pub 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)
}
/// The page a table over `area` shows of `len` rows, `chrome` rows of the
/// area being above them: where the last frame left off, moved only as far
/// as keeps the selection in view. A selection past the end lands on the
/// last row, as the table would have put it.
pub fn page_window(
state: &mut TableState,
len: usize,
area: Rect,
chrome: u16,
) -> Range<usize> {
if let Some(selected) = state.selected_mut() {
*selected = (*selected).min(len.saturating_sub(1));
}
visible_range(
state.offset(),
state.selected(),
len,
page_of(Some(area), chrome),
)
}
/// Draw `table`, holding the rows of the page that starts at `start`, with
/// `state` shifted onto that page. The page start is kept for the next
/// frame.
pub fn render_page(
frame: &mut Frame,
table: Table,
area: Rect,
state: &mut TableState,
start: usize,
) {
let mut page_state = TableState::default()
.with_selected(state.selected().map(|selected| selected - start));
frame.render_stateful_widget(table, area, &mut page_state);
*state.offset_mut() = start;
}
/// The page a `height`-tall table shows, by ratatui's rule (its
/// `visible_rows` is private): the offset, moved only as far as keeps the
/// selection in view. A selection past the end counts as the last row.
fn visible_range(
offset: usize,
selected: Option<usize>,
len: usize,
height: usize,
) -> Range<usize> {
let height = height.max(1);
let last = len.saturating_sub(1);
let start = match selected.map(|selected| selected.min(last)) {
Some(selected) => {
offset.clamp((selected + 1).saturating_sub(height), selected)
}
None => offset.min(last),
};
start..(start + height).min(len)
}
#[cfg(test)]
mod tests {
use super::{page_window, visible_range};
use ratatui::{layout::Rect, widgets::TableState};
#[test]
fn the_page_follows_the_selection_and_is_never_longer_than_the_pane() {
assert_eq!(visible_range(500, Some(505), 10_000, 10), 500..510);
assert_eq!(visible_range(0, Some(9_999), 10_000, 10), 9_990..10_000);
assert_eq!(visible_range(500, Some(499), 10_000, 10), 499..509);
assert_eq!(visible_range(9_995, None, 10_000, 10), 9_995..10_000);
assert_eq!(visible_range(0, Some(9), 3, 2), 1..3);
}
#[test]
fn a_window_pulls_a_selection_past_the_end_onto_the_last_row() {
// 6 rows tall: 2 border, 1 header, 3 of the list.
let area = Rect::new(0, 0, 40, 6);
let mut state = TableState::default().with_selected(Some(9));
assert_eq!(page_window(&mut state, 5, area, 1), 2..5);
assert_eq!(state.selected(), Some(4));
}
}
+32 -9
View File
@@ -1,8 +1,8 @@
use crate::models::{BrowseState, BrowseStatus, BrowseTabs}; use crate::models::{BrowseState, BrowseStatus, BrowseTabs};
use crate::ui::{ use crate::ui::{
HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, format_bytes, HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, filter_title,
get_spinner_char, highlight_style, pane_block, primary_style, format_bytes, get_spinner_char, highlight_style, page_window, pane_block,
row_highlight_style, warning_style, primary_style, render_page, row_highlight_style, warning_style,
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
@@ -62,10 +62,22 @@ fn render_browse_one(
spinner_state: usize, spinner_state: usize,
) { ) {
let title = match browse.status { let title = match browse.status {
BrowseStatus::Loaded => format!( BrowseStatus::Loaded => {
" Browse {} — {} files, {} folders (Enter/d: download, Tab: user, w: close, Esc: hide) ", let shown =
browse.rows().iter().filter(|row| !row.is_folder).count();
filter_title(
&format!(
"Browse {} — {shown}/{} files",
browse.username, browse.file_count
),
browse.filter(),
browse.filtering,
&format!(
" Browse {} — {} files, {} folders (Enter/d: download, /: filter, Tab: user, w: close, Esc: hide) ",
browse.username, browse.file_count, browse.folder_count browse.username, browse.file_count, browse.folder_count
), ),
)
}
_ => format!(" Browse {} ", browse.username), _ => format!(" Browse {} ", browse.username),
}; };
let block = pane_block(true).title(title); let block = pane_block(true).title(title);
@@ -105,9 +117,21 @@ fn render_browse_one(
]; ];
frame.render_widget(Paragraph::new(text).block(block), area); frame.render_widget(Paragraph::new(text).block(block), area);
} }
BrowseStatus::Loaded
if browse.rows().is_empty() && !browse.filter().is_empty() =>
{
let text = format!("Nothing here matches '{}'.", browse.filter());
frame.render_widget(
Paragraph::new(text).style(dimmed_style()).block(block),
area,
);
}
BrowseStatus::Loaded => { BrowseStatus::Loaded => {
let rows: Vec<Row> = browse let all = browse.rows();
.rows() table_state.select(Some(browse.selected_row));
let window = page_window(table_state, all.len(), area, 0);
let start = window.start;
let rows: Vec<Row> = all[window]
.iter() .iter()
.map(|row| { .map(|row| {
let indent = " ".repeat(row.depth); let indent = " ".repeat(row.depth);
@@ -142,8 +166,7 @@ fn render_browse_one(
.highlight_spacing(HighlightSpacing::Always) .highlight_spacing(HighlightSpacing::Always)
.block(block); .block(block);
table_state.select(Some(browse.selected_row)); render_page(frame, table, area, table_state, start);
frame.render_stateful_widget(table, area, table_state);
} }
} }
} }
+29 -24
View File
@@ -1,7 +1,7 @@
use crate::models::{AppState, ChatMessage, LogView, MessageDirection}; use crate::models::{AppState, MessageDirection, contains_filter};
use crate::ui::{ use crate::ui::{
accent_style, border_style, dimmed_style, highlight_style, info_style, accent_style, border_style, dimmed_style, filter_title, highlight_style,
primary_style, wrap_chat_line, info_style, primary_style, wrap_chat_line,
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
@@ -24,7 +24,16 @@ pub fn render_chat_pane(
let peer = peer.as_deref(); let peer = peer.as_deref();
let title = peer.map_or_else( let title = peer.map_or_else(
|| " Messages (m: compose, i/Esc: close) ".to_string(), || " Messages (m: compose, i/Esc: close) ".to_string(),
|peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "), |peer| {
filter_title(
peer,
&state.chat_filter,
state.chat_filtering,
&format!(
" {peer} (↑↓/Tab: switch, /: find, m: to…, i/Esc: close) "
),
)
},
); );
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
@@ -52,14 +61,7 @@ pub fn render_chat_pane(
return; return;
}; };
render_messages( render_messages(frame, body[0], state, peer, own_username);
frame,
body[0],
&state.messages,
&mut state.chat_view,
peer,
own_username,
);
render_compose(frame, chunks[1], state); render_compose(frame, chunks[1], state);
} }
@@ -102,34 +104,37 @@ fn render_peers(
fn render_messages( fn render_messages(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
messages: &[ChatMessage], state: &mut AppState,
view: &mut LogView,
peer: &str, peer: &str,
own_username: &str, own_username: &str,
) { ) {
let AppState {
messages,
chat_view: view,
chat_wrapped: wrapped,
chat_filter: filter,
..
} = state;
let width = area.width as usize; let width = area.width as usize;
let lines: Vec<Line> = messages let key = format!("{peer}\n{filter}");
.iter() let lines = wrapped.rows(width, &key, messages, |m| {
.filter(|m| m.peer == peer) if m.peer != peer || !contains_filter(&m.text, filter) {
.flat_map(|m| { return Vec::new();
}
let (sender, sender_style) = match m.direction { let (sender, sender_style) = match m.direction {
MessageDirection::Incoming => (peer, info_style()), MessageDirection::Incoming => (peer, info_style()),
MessageDirection::Outgoing => (own_username, accent_style()), MessageDirection::Outgoing => (own_username, accent_style()),
}; };
wrap_chat_line( wrap_chat_line(
vec![ vec![
Span::styled( Span::styled(m.at.format("%H:%M ").to_string(), dimmed_style()),
m.at.format("%H:%M ").to_string(),
dimmed_style(),
),
Span::styled(format!("<{sender}> "), sender_style), Span::styled(format!("<{sender}> "), sender_style),
], ],
&m.text, &m.text,
primary_style(), primary_style(),
width, width,
) )
}) });
.collect();
// The newest rows, unless the reader is holding a place in the history. // The newest rows, unless the reader is holding a place in the history.
let window = view.window(lines.len(), usize::from(area.height)); let window = view.window(lines.len(), usize::from(area.height));
+13 -5
View File
@@ -4,8 +4,8 @@ use crate::ui::{
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL, GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
accent_style, body_style, dimmed_style, download_status_glyph, error_style, accent_style, body_style, dimmed_style, download_status_glyph, error_style,
format_bytes, format_speed, header_style, inactive_style, info_style, format_bytes, format_speed, header_style, inactive_style, info_style,
pane_block, pane_title, primary_style, row_highlight_style, success_style, page_window, pane_block, pane_title, primary_style, render_page,
warning_style, row_highlight_style, success_style, warning_style,
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
@@ -81,7 +81,15 @@ pub fn render_downloads_pane(
.height(1); .height(1);
let name_width = column_width(area, &WIDTHS, NAME_COLUMN); let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
let mut rows: Vec<Row> = downloads // Downloads come first, uploads after; one page of the two together.
let total = downloads.len() + uploads.len();
let window = page_window(table_state, total, area, 1);
let start = window.start;
let split = downloads.len();
let shown_downloads = &downloads[start.min(split)..window.end.min(split)];
let shown_uploads =
&uploads[start.saturating_sub(split)..window.end.saturating_sub(split)];
let mut rows: Vec<Row> = shown_downloads
.iter() .iter()
.map(|download_entry| { .map(|download_entry| {
let download = &download_entry.download; let download = &download_entry.download;
@@ -153,7 +161,7 @@ pub fn render_downloads_pane(
}) })
.collect(); .collect();
rows.extend(uploads.iter().map(|upload| { rows.extend(shown_uploads.iter().map(|upload| {
let (status_icon, status_style) = match &upload.status { let (status_icon, status_style) = match &upload.status {
UploadStatus::Queued(_) => (GLYPH_QUEUED, warning_style()), UploadStatus::Queued(_) => (GLYPH_QUEUED, warning_style()),
UploadStatus::InProgress => (GLYPH_ACTIVE, accent_style()), UploadStatus::InProgress => (GLYPH_ACTIVE, accent_style()),
@@ -213,5 +221,5 @@ pub fn render_downloads_pane(
focused, focused,
))); )));
frame.render_stateful_widget(table, area, table_state); render_page(frame, table, area, table_state, start);
} }
+91 -17
View File
@@ -2,8 +2,8 @@ use super::name_scroll::{column_width, end_offset, scroll_text};
use crate::models::FileDisplayData; use crate::models::FileDisplayData;
use crate::ui::{ use crate::ui::{
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
header_style, info_style, pane_block, pane_title, row_highlight_style, header_style, info_style, page_window, pane_block, pane_title, render_page,
success_style, warning_style, row_highlight_style, success_style, warning_style,
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
@@ -15,9 +15,13 @@ use std::collections::HashSet;
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
const WIDTHS: [Constraint; 7] = [ // The file name and its folder share whatever width the fixed columns leave,
// the name getting the larger part: it is what a search is after, and a
// folder's tail is a scroll away.
const WIDTHS: [Constraint; 8] = [
Constraint::Length(3), Constraint::Length(3),
Constraint::Fill(3), Constraint::Fill(3),
Constraint::Fill(2),
Constraint::Length(12), Constraint::Length(12),
Constraint::Length(15), Constraint::Length(15),
Constraint::Length(10), Constraint::Length(10),
@@ -39,12 +43,28 @@ pub struct ResultsPaneParams<'a> {
pub name_offset: usize, pub name_offset: usize,
} }
/// Filename is the second column. /// The file name is the second column, the folder it sits in the third.
const NAME_COLUMN: usize = 1; const NAME_COLUMN: usize = 1;
const FOLDER_COLUMN: usize = 2;
/// A shared path as `(folder, name)`. Peers send backslashes, but a slash
/// counts too.
fn split_path(path: &str) -> (&str, &str) {
path.rsplit_once(['\\', '/']).unwrap_or(("", path))
}
/// The offset that brings the end of a result's longer column into view.
/// One offset scrolls the name and the folder together, so it runs until
/// both tails are showing.
#[must_use] #[must_use]
pub fn name_end_offset(name: &str, area: Rect) -> usize { pub fn name_end_offset(path: &str, area: Rect) -> usize {
end_offset(name, area, &WIDTHS, NAME_COLUMN) let (folder, name) = split_path(path);
end_offset(name, area, &WIDTHS, NAME_COLUMN).max(end_offset(
folder,
area,
&WIDTHS,
FOLDER_COLUMN,
))
} }
/// Whether the rendered row `display_idx` is selected. `selected_indices` holds /// Whether the rendered row `display_idx` is selected. `selected_indices` holds
@@ -118,6 +138,7 @@ pub fn render_results_pane(
let header = Row::new(vec![ let header = Row::new(vec![
Cell::from("✓").style(header_style()), Cell::from("✓").style(header_style()),
Cell::from("Filename").style(header_style()), Cell::from("Filename").style(header_style()),
Cell::from("Folder").style(header_style()),
Cell::from("Size").style(header_style()), Cell::from("Size").style(header_style()),
Cell::from("User").style(header_style()), Cell::from("User").style(header_style()),
Cell::from("Bitrate").style(header_style()), Cell::from("Bitrate").style(header_style()),
@@ -127,10 +148,13 @@ pub fn render_results_pane(
.height(1); .height(1);
let name_width = column_width(area, &WIDTHS, NAME_COLUMN); let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
let rows: Vec<Row> = items let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN);
.iter() let window = page_window(table_state, items.len(), area, 1);
.enumerate() let start = window.start;
let rows: Vec<Row> = (start..)
.zip(&items[window])
.map(|(idx, file)| { .map(|(idx, file)| {
let (folder, name) = split_path(&file.filename);
let checkbox = let checkbox =
if row_is_selected(idx, original_indices, selected_indices) { if row_is_selected(idx, original_indices, selected_indices) {
"[✓]" "[✓]"
@@ -159,12 +183,10 @@ pub fn render_results_pane(
Row::new(vec![ Row::new(vec![
Cell::from(checkbox).style(checkbox_style), Cell::from(checkbox).style(checkbox_style),
Cell::from(scroll_text( Cell::from(scroll_text(name, name_offset, name_width))
&file.filename,
name_offset,
name_width,
))
.style(body_style()), .style(body_style()),
Cell::from(scroll_text(folder, name_offset, folder_width))
.style(dimmed_style()),
Cell::from(format_bytes(file.size)).style(warning_style()), Cell::from(format_bytes(file.size)).style(warning_style()),
Cell::from(file.username.clone()).style(info_style()), Cell::from(file.username.clone()).style(info_style()),
Cell::from(bitrate_str).style(dimmed_style()), Cell::from(bitrate_str).style(dimmed_style()),
@@ -189,7 +211,7 @@ pub fn render_results_pane(
.highlight_spacing(HighlightSpacing::Always) .highlight_spacing(HighlightSpacing::Always)
.block(pane_block(focused).title(pane_title("2", &title, focused))); .block(pane_block(focused).title(pane_title("2", &title, focused)));
frame.render_stateful_widget(table, area, table_state); render_page(frame, table, area, table_state, start);
} }
#[cfg(test)] #[cfg(test)]
@@ -202,8 +224,18 @@ mod tests {
fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String { fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String {
let mut state = TableState::default(); let mut state = TableState::default();
state.select(Some(0)); state.select(Some(0));
render_with(items, &mut state, name_offset)
}
fn render_with(
items: &[FileDisplayData],
state: &mut TableState,
name_offset: usize,
) -> String {
// Wide enough for the name column to hold 11 cells, with 7 for the
// folder beside it, and tall enough for three rows.
let mut terminal = let mut terminal =
Terminal::new(TestBackend::new(80, 6)).expect("backend"); Terminal::new(TestBackend::new(88, 6)).expect("backend");
terminal terminal
.draw(|frame| { .draw(|frame| {
render_results_pane( render_results_pane(
@@ -211,7 +243,7 @@ mod tests {
frame.area(), frame.area(),
ResultsPaneParams { ResultsPaneParams {
items, items,
table_state: &mut state, table_state: state,
selected_indices: &HashSet::new(), selected_indices: &HashSet::new(),
original_indices: None, original_indices: None,
filter_query: "", filter_query: "",
@@ -267,6 +299,34 @@ mod tests {
assert!(!screen.contains("…\u{301}"), "{screen}"); assert!(!screen.contains("…\u{301}"), "{screen}");
} }
#[test]
fn a_path_shows_its_name_before_its_folder() {
let items = [file("@@abc\\Music\\Album\\01.flac"), file("cover.jpg")];
let screen = render_rows(&items, 0);
assert!(screen.contains("01.flac @@abc\\M"), "{screen}");
assert!(screen.contains("cover.jpg "), "{screen}");
}
#[test]
fn one_offset_scrolls_the_name_and_the_folder_together() {
let items = [file("@@abc\\Music\\Album\\abcdefghijklmnopqrstuvwxyz")];
let screen = render_rows(&items, 4);
assert!(screen.contains("…efghijklmn …c\\Musi"), "{screen}");
}
#[test]
fn the_end_offset_is_the_longer_columns() {
// 88 cells wide: 11 for the name, 7 for the folder.
let area = ratatui::layout::Rect::new(0, 0, 88, 6);
let folder = "f".repeat(20);
assert_eq!(super::name_end_offset(&format!("{folder}\\a"), area), 14);
let name = "x".repeat(40);
assert_eq!(
super::name_end_offset(&format!("{folder}\\{name}"), area),
30
);
}
#[test] #[test]
fn an_unscrolled_name_starts_at_its_beginning() { fn an_unscrolled_name_starts_at_its_beginning() {
let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")]; let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")];
@@ -275,6 +335,20 @@ mod tests {
assert!(!screen.contains("…"), "{screen}"); assert!(!screen.contains("…"), "{screen}");
} }
#[test]
fn a_far_selection_is_drawn_from_its_own_page() {
let items: Vec<FileDisplayData> =
(0..10_000).map(|i| file(&format!("{i}.mp3"))).collect();
let mut state = TableState::default();
state.select(Some(9_999));
let screen = render_with(&items, &mut state, 0);
assert!(screen.contains("›[ ] 9999.mp3"), "{screen}");
assert!(screen.contains(" [ ] 9997.mp3"), "{screen}");
assert!(!screen.contains(" [ ] 0.mp3"), "{screen}");
assert_eq!(state.offset(), 9_997, "the next frame starts here");
assert_eq!(state.selected(), Some(9_999));
}
#[test] #[test]
fn identity_mapping_used_when_no_filter() { fn identity_mapping_used_when_no_filter() {
let selected: HashSet<usize> = std::iter::once(1).collect(); let selected: HashSet<usize> = std::iter::once(1).collect();
+60 -27
View File
@@ -1,8 +1,11 @@
use crate::models::{LogView, RoomsState, RoomsView}; use crate::models::{
ChatFilter, LogView, OpenRoom, RoomLine, RoomsState, RoomsView, WrappedLog,
contains_filter, matching_users,
};
use crate::ui::{ use crate::ui::{
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, filter_title,
highlight_style, info_style, pane_block, plain_title, primary_style, highlight_style, info_style, page_window, pane_block, plain_title,
row_highlight_style, wrap_chat_line, primary_style, render_page, row_highlight_style, wrap_chat_line,
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
@@ -61,7 +64,10 @@ fn render_list(
Cell::from("room").style(dimmed_style()), Cell::from("room").style(dimmed_style()),
Cell::from("users").style(dimmed_style()), Cell::from("users").style(dimmed_style()),
]); ]);
let table_rows: Vec<Row> = filtered table_state.select(Some(rooms.list_selected));
let window = page_window(table_state, filtered.len(), area, 1);
let start = window.start;
let table_rows: Vec<Row> = filtered[window]
.iter() .iter()
.map(|r| { .map(|r| {
let open = rooms.open_index(&r.name).is_some(); let open = rooms.open_index(&r.name).is_some();
@@ -90,14 +96,18 @@ fn render_list(
.highlight_spacing(HighlightSpacing::Always) .highlight_spacing(HighlightSpacing::Always)
.block(block); .block(block);
table_state.select(Some(rooms.list_selected.min(filtered.len() - 1))); render_page(frame, table, area, table_state, start);
frame.render_stateful_widget(table, area, table_state);
} }
fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
let block = pane_block(true).title( let typing_log = rooms.filtering == Some(ChatFilter::Log);
" Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ", let title = filter_title(
"Chat rooms",
&rooms.log_filter,
typing_log,
" Chat rooms (Tab: switch, /: find, u: find user, l: room list, x: leave, Esc: back) ",
); );
let block = pane_block(true).title(title);
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
@@ -115,6 +125,9 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
active: active_index, active: active_index,
user_selected, user_selected,
composing, composing,
log_filter,
user_filter,
filtering,
.. ..
} = rooms; } = rooms;
let Some(active) = open.get_mut(*active_index) else { let Some(active) = open.get_mut(*active_index) else {
@@ -131,8 +144,28 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]) Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)])
.split(chunks[1]); .split(chunks[1]);
render_messages(frame, body[0], active.lines.as_slice(), &mut active.view); let OpenRoom {
render_users(frame, body[1], &active.users, *user_selected); lines,
view,
wrapped,
users,
..
} = active;
render_messages(frame, body[0], lines, view, wrapped, log_filter);
let shown = matching_users(users, user_filter);
let typing_users = *filtering == Some(ChatFilter::Users);
let members = if typing_users || !user_filter.is_empty() {
format!(
"Users {}/{} · {}{}",
shown.len(),
users.len(),
user_filter,
if typing_users { "_" } else { "" }
)
} else {
format!("Users ({})", users.len())
};
render_users(frame, body[1], &shown, *user_selected, members);
// Compose line or hint. // Compose line or hint.
if *composing { if *composing {
@@ -179,33 +212,32 @@ fn render_tab_bar(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
fn render_messages( fn render_messages(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
lines: &[crate::models::RoomLine], lines: &[RoomLine],
view: &mut LogView, view: &mut LogView,
wrapped: &mut WrappedLog,
filter: &str,
) { ) {
// Wrap each message to the pane width, then show the rows the reader is // Each message wrapped to the pane width, then the rows the reader is
// at: the newest ones, unless they are holding a place in the history. // at: the newest ones, unless they are holding a place in the history.
let width = area.width as usize; let width = area.width as usize;
let rendered: Vec<Line> = lines let rendered = wrapped.rows(width, filter, lines, |l| {
.iter() let sender = l.username.as_deref().unwrap_or_default();
.flat_map(|l| { if !contains_filter(&l.text, filter) && !contains_filter(sender, filter)
{
return Vec::new();
}
let time = let time =
Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style()); Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style());
match &l.username { match &l.username {
Some(user) => wrap_chat_line( Some(user) => wrap_chat_line(
vec![ vec![time, Span::styled(format!("<{user}> "), info_style())],
time,
Span::styled(format!("<{user}> "), info_style()),
],
&l.text, &l.text,
primary_style(), primary_style(),
width, width,
), ),
None => { None => wrap_chat_line(vec![time], &l.text, dimmed_style(), width),
wrap_chat_line(vec![time], &l.text, dimmed_style(), width)
} }
} });
})
.collect();
let window = view.window(rendered.len(), usize::from(area.height)); let window = view.window(rendered.len(), usize::from(area.height));
frame.render_widget(Paragraph::new(rendered[window].to_vec()), area); frame.render_widget(Paragraph::new(rendered[window].to_vec()), area);
} }
@@ -213,14 +245,15 @@ fn render_messages(
fn render_users( fn render_users(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
users: &[String], users: &[&str],
selected: usize, selected: usize,
title: String,
) { ) {
let block = Block::default() let block = Block::default()
.borders(Borders::LEFT) .borders(Borders::LEFT)
.border_style(dimmed_style()) .border_style(dimmed_style())
.padding(PANE_PADDING) .padding(PANE_PADDING)
.title(plain_title(format!("Users ({})", users.len()), false)); .title(plain_title(title, false));
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
+17
View File
@@ -138,6 +138,23 @@ pub fn plain_title(title: impl Into<String>, focused: bool) -> Line<'static> {
)) ))
} }
/// A popup's title: `idle` until its list is filtered, then what it is
/// filtered by, a caret after it while that is still being typed, and how
/// the filter ends.
#[must_use]
pub fn filter_title(
head: &str,
filter: &str,
typing: bool,
idle: &str,
) -> String {
if !typing && filter.is_empty() {
return idle.to_string();
}
let caret = if typing { "_" } else { "" };
format!(" {head} · filter: {filter}{caret} (Enter: keep, Esc: clear) ")
}
/// The standard bordered pane. Chain `.title(..)` for a legend. /// The standard bordered pane. Chain `.title(..)` for a legend.
pub fn pane_block(focused: bool) -> Block<'static> { pub fn pane_block(focused: bool) -> Block<'static> {
Block::default() Block::default()
+3 -2
View File
@@ -37,8 +37,9 @@ export const HeroTerminal = () => {
> >
<StatusLine counts={demo.counts} pct={demo.pct} /> <StatusLine counts={demo.counts} pct={demo.pct} />
{/* Results across the top with the whole width for long names; {/* Results across the top, file names in a column of their own
Searches, Transfers and Info share the row underneath. */} with the folder beside them; Searches, Transfers and Info
share the row underneath. */}
<div className="mt-3 grid gap-2.5" style={{ gridTemplateRows: '330px 300px' }}> <div className="mt-3 grid gap-2.5" style={{ gridTemplateRows: '330px 300px' }}>
<div className="relative min-h-0 overflow-hidden"> <div className="relative min-h-0 overflow-hidden">
<ResultsPane <ResultsPane
+7 -2
View File
@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'
import { TuiPane } from './chrome' import { TuiPane } from './chrome'
import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data' import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data'
const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px' const RESULT_COLS = '30px 3fr 2fr 74px 92px 78px 74px 42px'
const TRANSFER_COLS = '48px 1fr 96px 168px 74px' const TRANSFER_COLS = '48px 1fr 96px 168px 74px'
interface SearchesPaneProps { interface SearchesPaneProps {
@@ -134,6 +134,7 @@ export const ResultsPane = ({
> >
<span>✓</span> <span>✓</span>
<span>Filename</span> <span>Filename</span>
<span>Folder</span>
<span>Size</span> <span>Size</span>
<span>User</span> <span>User</span>
<span>Bitrate</span> <span>Bitrate</span>
@@ -143,6 +144,7 @@ export const ResultsPane = ({
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{rows.map((row, i) => { {rows.map((row, i) => {
const { glyph, tone } = resultGlyph(row) const { glyph, tone } = resultGlyph(row)
const parts = row.name.split('\\')
const isSelected = selected === i const isSelected = selected === i
return ( return (
<button <button
@@ -167,7 +169,10 @@ export const ResultsPane = ({
isSelected ? 'text-[var(--color-paper)]' : 'text-[var(--color-paper-dim)]', isSelected ? 'text-[var(--color-paper)]' : 'text-[var(--color-paper-dim)]',
)} )}
> >
{row.name} {parts.at(-1)}
</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-[var(--color-dust)]">
{parts.slice(0, -1).join('\\')}
</span> </span>
<span className="text-right text-[var(--color-tape)]">{row.size}</span> <span className="text-right text-[var(--color-tape)]">{row.size}</span>
<span className="text-[var(--color-signal)]">{row.user}</span> <span className="text-[var(--color-signal)]">{row.user}</span>
+5 -2
View File
@@ -34,10 +34,11 @@ const PANE_KEYS: readonly (readonly [string, string])[] = [
['1–3', 'focus a pane, and bring it back if hidden'], ['1–3', 'focus a pane, and bring it back if hidden'],
['z', 'zoom the focused pane to the whole window'], ['z', 'zoom the focused pane to the whole window'],
['w', 'hide the focused pane; the others widen'], ['w', 'hide the focused pane; the others widen'],
['W', 'every pane back on screen, zoom off'],
['↑ ↓ · j k', 'move a row in any list'], ['↑ ↓ · j k', 'move a row in any list'],
['Home End · g G', 'first / last row'], ['Home End · g G', 'first / last row'],
['PgUp PgDn · ^u ^d', 'a page, or half a page; in a chat, back through the log'], ['PgUp PgDn · ^u ^d', 'a page, or half a page; in a chat, back through the log'],
['h l · ← →', 'scroll a long name or query sideways, 0 and $ to either end'], ['h l · ← →', 'scroll a long name, folder or query sideways, 0 and $ to either end'],
] ]
const ROOM_KEYS: readonly (readonly [string, string])[] = [ const ROOM_KEYS: readonly (readonly [string, string])[] = [
@@ -46,6 +47,8 @@ const ROOM_KEYS: readonly (readonly [string, string])[] = [
['x', 'leave the active room'], ['x', 'leave the active room'],
['l', 'back to the room list'], ['l', 'back to the room list'],
['↑ ↓', 'select a member'], ['↑ ↓', 'select a member'],
['/', 'search the chat log; in the inbox, the conversation'],
['u', 'filter the member list'],
['b', "browse the member's files"], ['b', "browse the member's files"],
['m', 'message the member'], ['m', 'message the member'],
] ]
@@ -54,7 +57,7 @@ const PANES: readonly { num?: number; title: string; body: string }[] = [
{ {
num: 2, num: 2,
title: 'Results', title: 'Results',
body: 'Files from the network across the whole width: size, user, bitrate, speed, free slots. Space selects, Enter queues a download. h/l or ←/→ scroll a long name, 0 and $ jump to either end.', body: 'Files from the network across the whole width, each name in a column of its own with its folder beside it, then size, user, bitrate, speed, free slots. Space selects, Enter queues a download. h/l or ←/→ scroll a long name and folder, 0 and $ jump to either end.',
}, },
{ {
num: 1, num: 1,