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
Results run across the top, with the whole width for long file names;
Searches, Downloads and Info share the row underneath. `Tab`/`Shift-Tab` or
`1`–`3` move the focus between panes, `z` zooms the focused pane to the whole
window, and `w` hides it so the others widen (its number brings it back).
Every list takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and
`h`/`l` or `←`/`→` scroll a long file name or query sideways, `0` and `$` to
Results run across the top, each file name in a column of its own with the
folder it came from beside it; Searches, Downloads and Info share the row
underneath. `Tab`/`Shift-Tab` or `1`–`3` move the focus between panes, `z`
zooms the focused pane to the whole window, `w` hides it so the others widen
(its number brings it back), and `W` puts the whole layout back. Every list
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 `?`
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
(`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.
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
+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 chrono::{DateTime, Local};
use ratatui::{layout::Rect, widgets::TableState};
use ratatui::{layout::Rect, text::Line, widgets::TableState};
use soulseek_rs::{DownloadStatus, types::Download};
use std::sync::atomic::AtomicBool;
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandBarMode {
@@ -254,6 +294,11 @@ pub struct AppState {
/// Compose buffer for the active conversation.
pub chat_input: String,
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.
pub chat_view: LogView,
@@ -332,6 +377,9 @@ impl AppState {
chat_peer: None,
chat_input: String::new(),
chat_composing: false,
chat_wrapped: WrappedLog::default(),
chat_filter: String::new(),
chat_filtering: false,
chat_view: LogView::default(),
browse: BrowseTabs::new(),
@@ -501,6 +549,27 @@ mod tests {
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]
fn every_pane_starts_visible_and_unzoomed() {
let state = AppState::new();
+249 -23
View File
@@ -154,23 +154,20 @@ pub struct BrowseRow {
pub size: Option<u64>,
}
/// Flatten the tree into visible rows, descending only into expanded folders.
#[must_use]
pub fn flatten_browse(
nodes: &[BrowseNode],
expanded: &HashSet<String>,
) -> Vec<BrowseRow> {
let mut rows = Vec::new();
flatten_into(nodes, 0, expanded, &mut rows);
rows
}
/// Emit `nodes` as rows. Without a `needle`, every folder and file, a
/// folder's contents only while it is open. With one, only the paths that
/// contain it and the folders on the way there, open ones still deciding
/// whether their contents show. `emit` false only counts. Says whether
/// anything under `nodes` was kept.
fn flatten_into(
nodes: &[BrowseNode],
depth: usize,
expanded: &HashSet<String>,
needle: Option<&str>,
rows: &mut Vec<BrowseRow>,
) {
emit: bool,
) -> bool {
let mut kept = false;
for node in nodes {
match node {
BrowseNode::Folder {
@@ -178,20 +175,53 @@ fn flatten_into(
path,
children,
} => {
let is_expanded = expanded.contains(path);
let open = expanded.contains(path);
let mark = rows.len();
if emit {
rows.push(BrowseRow {
depth,
is_folder: true,
expanded: is_expanded,
expanded: open,
name: name.clone(),
path: path.clone(),
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 } => {
if needle.is_none_or(|needle| path_has(path, needle)) {
kept = true;
if emit {
rows.push(BrowseRow {
depth,
is_folder: false,
@@ -204,10 +234,48 @@ 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.
#[must_use]
pub fn find_node<'a>(
fn find_node<'a>(
nodes: &'a [BrowseNode],
path: &str,
) -> Option<&'a BrowseNode> {
@@ -235,8 +303,7 @@ pub fn find_node<'a>(
/// All file leaves under `node` as `(full_path, size)`, descending the tree
/// structurally (never string-prefix matching).
#[must_use]
pub fn files_under(node: &BrowseNode) -> Vec<(String, u64)> {
fn files_under(node: &BrowseNode) -> Vec<(String, u64)> {
let mut out = Vec::new();
collect_files(node, &mut out);
out
@@ -267,7 +334,17 @@ pub struct BrowseState {
pub username: String,
pub status: BrowseStatus,
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 file_count: usize,
pub folder_count: usize,
@@ -282,6 +359,10 @@ impl BrowseState {
status: BrowseStatus::Loading,
tree: Vec::new(),
expanded: HashSet::new(),
rows: Vec::new(),
filter: String::new(),
kept_expanded: None,
filtering: false,
selected_row: 0,
file_count: 0,
folder_count: 0,
@@ -310,12 +391,105 @@ impl BrowseState {
self.file_count = built.file_count;
self.folder_count = built.folder_count;
self.selected_row = 0;
self.refresh();
}
/// The flattened visible rows for the current expansion state.
#[must_use]
pub fn rows(&self) -> Vec<BrowseRow> {
flatten_browse(&self.tree, &self.expanded)
pub fn rows(&self) -> &[BrowseRow] {
&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 {
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 {
SharedDirectory {
name: name.to_string(),
@@ -579,6 +763,48 @@ mod tests {
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]
fn browse_state_load_auto_expands_top_level() {
let mut state = BrowseState::loading("bob".to_string());
+6 -5
View File
@@ -6,11 +6,12 @@ mod settings;
pub use app_state::{
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView,
MessageDirection, PaneLayout, SearchEntry, SearchStatus,
};
pub use browse::{
BrowseState, BrowseStatus, BrowseTabs, files_under, find_node,
MessageDirection, PaneLayout, SearchEntry, SearchStatus, WrappedLog,
};
pub use browse::{BrowseState, BrowseStatus, BrowseTabs};
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};
+69 -10
View File
@@ -1,4 +1,4 @@
use crate::models::LogView;
use crate::models::{LogView, WrappedLog};
use chrono::{DateTime, Local};
use soulseek_rs::types::{RoomEvent, RoomInfo};
@@ -41,6 +41,8 @@ pub struct OpenRoom {
pub input: String,
/// Where the log is being read.
pub view: LogView,
/// The log wrapped for drawing.
pub wrapped: WrappedLog,
}
impl OpenRoom {
@@ -77,8 +79,22 @@ pub struct RoomsState {
pub view: RoomsView,
/// Whether the compose input for the active room is capturing keys.
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,
/// 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 {
@@ -100,6 +116,31 @@ impl RoomsState {
view: RoomsView::List,
composing: false,
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.
#[must_use]
pub fn selected_user(&self) -> Option<String> {
self.active_room()
.and_then(|r| r.users.get(self.user_selected).cloned())
self.visible_users()
.get(self.user_selected)
.map(|user| (*user).to_string())
}
/// 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) {
if let Some(room) = self.active_room()
&& !room.users.is_empty()
{
self.user_selected =
(self.user_selected + 1).min(room.users.len() - 1);
let len = self.visible_users().len();
if len > 0 {
self.user_selected = (self.user_selected + 1).min(len - 1);
}
}
@@ -305,7 +345,7 @@ impl RoomsState {
/// Keep `user_selected` within the active room's member list.
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));
}
@@ -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)]
mod tests {
use super::*;
+70 -34
View File
@@ -1,6 +1,6 @@
use super::MainTui;
use super::input::{jumped, list_jump};
use crate::models::{BrowseStatus, files_under, find_node};
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};
@@ -9,11 +9,27 @@ const BROWSE_TIMEOUT: Duration = Duration::from_secs(20);
impl MainTui {
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')) {
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;
@@ -48,8 +64,8 @@ impl MainTui {
_ => {}
}
// Snapshot the flattened rows + current selection, then drop the borrow.
let (rows, sel, row) = {
// The highlighted row and where it sits, then drop the borrow.
let (len, sel, row) = {
let Some(browse) = self.state.browse.active_tab() else {
self.state.show_browse = false;
return;
@@ -62,13 +78,12 @@ impl MainTui {
return;
}
let sel = browse.selected_row.min(rows.len() - 1);
let row = rows[sel].clone();
(rows, sel, row)
(rows.len(), sel, rows[sel].clone())
};
if let Some(jump) = jump {
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();
return;
@@ -85,7 +100,11 @@ impl MainTui {
}
KeyCode::Char('d') => {
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 {
vec![(row.path.clone(), row.size.unwrap_or(0))]
};
@@ -102,59 +121,76 @@ impl MainTui {
browse.selected_row = sel.saturating_sub(1);
}
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') => {
if row.is_folder && !row.expanded {
browse.expanded.insert(row.path.clone());
browse.set_expanded(&row.path, true);
} 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') => {
if row.is_folder && row.expanded {
browse.expanded.remove(&row.path);
} else if let Some(parent) =
(0..sel).rev().find(|&i| rows[i].depth < row.depth)
browse.set_expanded(&row.path, false);
} else if let Some(parent) = (0..sel)
.rev()
.find(|&i| browse.rows()[i].depth < row.depth)
{
browse.selected_row = parent;
}
}
KeyCode::Enter => {
// Folder toggle (files handled above).
if row.expanded {
browse.expanded.remove(&row.path);
} else {
browse.expanded.insert(row.path.clone());
}
browse.set_expanded(&row.path, !row.expanded);
}
_ => {}
}
// 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();
}
/// 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.
fn sync_browse_selection(&mut self) {
let selected = self.state.browse.active_tab().map(|b| b.selected_row);
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.
fn queue_browse_files(&mut self, files: Vec<(String, u64)>) {
let Some(username) =
+75 -41
View File
@@ -1,6 +1,6 @@
use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, LogView};
use crate::ui::pane_block;
use crate::models::{CommandBarMode, FocusedPane, LogView, PaneLayout};
use crate::ui::page_of;
use crate::ui::panes::{
InfoSubject, name_end_offset, query_end_offset, selected_transfer,
transfer_name_end_offset, upload_display_name,
@@ -8,7 +8,7 @@ use crate::ui::panes::{
use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::{Position, Rect};
use ratatui::layout::Position;
use ratatui::widgets::TableState;
const NAME_SCROLL_STEP: isize = 8;
@@ -100,6 +100,10 @@ impl MainTui {
self.state.hide_focused_pane();
return;
}
KeyCode::Char('W') => {
self.state.layout = PaneLayout::default();
return;
}
KeyCode::Esc if self.state.layout.zoomed => {
self.state.layout.zoomed = false;
return;
@@ -186,6 +190,20 @@ impl MainTui {
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.
if scroll_log(&mut self.state.chat_view, key) {
return;
@@ -195,9 +213,15 @@ impl MainTui {
}
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 => {
self.state.show_messages = false;
}
KeyCode::Char('/') => self.state.chat_filtering = true,
// Move through the conversation list on the right.
KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => {
self.state.cycle_chat_peer(true);
@@ -326,31 +350,17 @@ 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);
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();
}
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 => {
} else {
self.state.results_is_filtering = false;
}
_ => self.handle_results_input(key),
}
}
/// 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)
}
/// 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
@@ -440,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() {
@@ -575,14 +582,41 @@ impl MainTui {
}
}
/// Rows a list in `area` shows at once, less the `chrome` rows above it: a
/// table header, a popup's tab bar and compose line. At least one, so a page
/// key always moves, even before the first draw.
fn page_of(area: Option<Rect>, chrome: u16) -> usize {
area.map_or(0, |area| {
usize::from(pane_block(false).inner(area).height.saturating_sub(chrome))
})
.max(1)
/// 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
+174 -2
View File
@@ -265,7 +265,9 @@ pub fn launch_main_tui(
mod tests {
use super::*;
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};
/// 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());
tui.state.results_items = results(n);
tui.state.focused_pane = FocusedPane::Results;
// 88 cells wide gives the name column 11 cells and the folder 7.
tui.state.results_pane_area =
Some(ratatui::layout::Rect::new(0, 0, 80, 13));
Some(ratatui::layout::Rect::new(0, 0, 88, 13));
tui
}
@@ -1675,6 +1678,147 @@ mod tests {
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) {
tui.state.rooms.apply_event(
soulseek_rs::RoomEvent::Joined {
@@ -1809,6 +1953,34 @@ mod tests {
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]
fn page_keys_work_in_the_transfers_and_searches_lists_too() {
let mut tui = furnished_tui();
+53 -13
View File
@@ -1,5 +1,5 @@
use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, RoomsView};
use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView};
use crate::ui::panes::{
InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_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"),
("w", "hide the focused pane"),
("Esc", "leave zoom"),
("W", "every pane back, zoom off"),
("click", "focus a pane"),
],
),
@@ -95,10 +96,35 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
("^u ^d", "half a page of them"),
("Home End / g G", "oldest / newest, or first / last row"),
("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 {
pub(super) fn render(&mut self, frame: &mut Frame) {
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![
("Enter", "say"),
("PgUp/PgDn", "scroll"),
("↑↓", "pick user"),
("/", "find in chat"),
("u", "find user"),
("b", "browse user"),
("m", "message user"),
("Tab", "switch room"),
@@ -497,7 +528,7 @@ impl MainTui {
/// The keys every pane shares for moving between and resizing panes,
/// ending the bar the same way wherever the focus sits.
fn pane_shortcuts(&self) -> Vec<(&'static str, &'static str)> {
vec![
let mut keys = vec![
("Tab/1-3", "pane"),
(
"z",
@@ -508,9 +539,13 @@ impl MainTui {
},
),
("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
@@ -534,11 +569,14 @@ impl MainTui {
("Enter", "send"),
("Esc", "stop typing"),
]
} else if self.state.chat_filtering {
filter_keys(&[("PgUp/PgDn", "scroll")])
} else {
vec![
("Enter", "type"),
("PgUp/PgDn", "scroll"),
("↑↓/Tab", "switch chat"),
("/", "find"),
("m", "new chat"),
("i/Esc", "close"),
]
@@ -546,10 +584,16 @@ impl MainTui {
} else if self.state.show_rooms {
self.rooms_shortcuts()
} 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![
("↑↓", "move"),
("PgUp/PgDn", "page"),
("J/K", "next/prev folder"),
("→←", "expand/collapse"),
("H/L", "collapse/expand all"),
("/", "filter"),
("Enter", "open/download"),
("d", "download folder"),
("Tab", "switch user"),
@@ -557,6 +601,7 @@ impl MainTui {
("w", "close tab"),
("Esc", "hide"),
]
}
} else if self.state.command_bar_active {
match self.state.command_bar_mode {
CommandBarMode::Search => vec![
@@ -581,19 +626,14 @@ 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![
("s", "search"),
("Enter", "results"),
("d", "remove"),
("C", "clear all"),
("m", "message"),
("i", "inbox"),
("c", "chat"),
+62 -21
View File
@@ -1,6 +1,6 @@
use super::MainTui;
use super::input::{jumped, list_jump, scroll_log};
use crate::models::RoomsView;
use super::input::{FilterEdit, edit_filter, jumped, list_jump, scroll_log};
use crate::models::{ChatFilter, RoomsView};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
impl MainTui {
@@ -30,6 +30,14 @@ impl MainTui {
self.handle_room_filter_input(key);
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 {
RoomsView::List => self.handle_rooms_list_input(key),
@@ -39,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;
@@ -80,25 +88,18 @@ impl MainTui {
}
fn handle_room_filter_input(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
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;
}
FilterEdit::Kept => self.join_selected_room(),
}
if edit != FilterEdit::Changed {
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) {
return;
}
let filtered = !self.state.rooms.log_filter.is_empty()
|| !self.state.rooms.user_filter.is_empty();
match key.code {
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') => {
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::BackTab => self.state.rooms.prev_tab(),
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
/// the rooms popup so the browse tree is the sole overlay.
fn browse_selected_room_user(&mut self) {
+2
View File
@@ -1,11 +1,13 @@
mod downloads;
pub mod login;
mod main_tui;
mod paging;
mod panes;
mod styles;
mod utils;
pub use downloads::render_download_stats;
pub use main_tui::launch_main_tui;
pub use paging::*;
pub use styles::*;
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::ui::{
HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, format_bytes,
get_spinner_char, highlight_style, pane_block, primary_style,
row_highlight_style, warning_style,
HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, filter_title,
format_bytes, get_spinner_char, highlight_style, page_window, pane_block,
primary_style, render_page, row_highlight_style, warning_style,
};
use ratatui::{
Frame,
@@ -62,10 +62,22 @@ fn render_browse_one(
spinner_state: usize,
) {
let title = match browse.status {
BrowseStatus::Loaded => format!(
" Browse {} — {} files, {} folders (Enter/d: download, Tab: user, w: close, Esc: hide) ",
BrowseStatus::Loaded => {
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
),
)
}
_ => format!(" Browse {} ", browse.username),
};
let block = pane_block(true).title(title);
@@ -105,9 +117,21 @@ fn render_browse_one(
];
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 => {
let rows: Vec<Row> = browse
.rows()
let all = browse.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()
.map(|row| {
let indent = " ".repeat(row.depth);
@@ -142,8 +166,7 @@ fn render_browse_one(
.highlight_spacing(HighlightSpacing::Always)
.block(block);
table_state.select(Some(browse.selected_row));
frame.render_stateful_widget(table, area, table_state);
render_page(frame, table, area, table_state, start);
}
}
}
+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::{
accent_style, border_style, dimmed_style, highlight_style, info_style,
primary_style, wrap_chat_line,
accent_style, border_style, dimmed_style, filter_title, highlight_style,
info_style, primary_style, wrap_chat_line,
};
use ratatui::{
Frame,
@@ -24,7 +24,16 @@ pub fn render_chat_pane(
let peer = peer.as_deref();
let title = peer.map_or_else(
|| " 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()
.borders(Borders::ALL)
@@ -52,14 +61,7 @@ pub fn render_chat_pane(
return;
};
render_messages(
frame,
body[0],
&state.messages,
&mut state.chat_view,
peer,
own_username,
);
render_messages(frame, body[0], state, peer, own_username);
render_compose(frame, chunks[1], state);
}
@@ -102,34 +104,37 @@ fn render_peers(
fn render_messages(
frame: &mut Frame,
area: Rect,
messages: &[ChatMessage],
view: &mut LogView,
state: &mut AppState,
peer: &str,
own_username: &str,
) {
let AppState {
messages,
chat_view: view,
chat_wrapped: wrapped,
chat_filter: filter,
..
} = state;
let width = area.width as usize;
let lines: Vec<Line> = messages
.iter()
.filter(|m| m.peer == peer)
.flat_map(|m| {
let key = format!("{peer}\n{filter}");
let lines = wrapped.rows(width, &key, messages, |m| {
if m.peer != peer || !contains_filter(&m.text, filter) {
return Vec::new();
}
let (sender, sender_style) = match m.direction {
MessageDirection::Incoming => (peer, info_style()),
MessageDirection::Outgoing => (own_username, accent_style()),
};
wrap_chat_line(
vec![
Span::styled(
m.at.format("%H:%M ").to_string(),
dimmed_style(),
),
Span::styled(m.at.format("%H:%M ").to_string(), dimmed_style()),
Span::styled(format!("<{sender}> "), sender_style),
],
&m.text,
primary_style(),
width,
)
})
.collect();
});
// The newest rows, unless the reader is holding a place in the history.
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,
accent_style, body_style, dimmed_style, download_status_glyph, error_style,
format_bytes, format_speed, header_style, inactive_style, info_style,
pane_block, pane_title, primary_style, row_highlight_style, success_style,
warning_style,
page_window, pane_block, pane_title, primary_style, render_page,
row_highlight_style, success_style, warning_style,
};
use ratatui::{
Frame,
@@ -81,7 +81,15 @@ pub fn render_downloads_pane(
.height(1);
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()
.map(|download_entry| {
let download = &download_entry.download;
@@ -153,7 +161,7 @@ pub fn render_downloads_pane(
})
.collect();
rows.extend(uploads.iter().map(|upload| {
rows.extend(shown_uploads.iter().map(|upload| {
let (status_icon, status_style) = match &upload.status {
UploadStatus::Queued(_) => (GLYPH_QUEUED, warning_style()),
UploadStatus::InProgress => (GLYPH_ACTIVE, accent_style()),
@@ -213,5 +221,5 @@ pub fn render_downloads_pane(
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::ui::{
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
header_style, info_style, pane_block, pane_title, row_highlight_style,
success_style, warning_style,
header_style, info_style, page_window, pane_block, pane_title, render_page,
row_highlight_style, success_style, warning_style,
};
use ratatui::{
Frame,
@@ -15,9 +15,13 @@ use std::collections::HashSet;
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::Fill(3),
Constraint::Fill(2),
Constraint::Length(12),
Constraint::Length(15),
Constraint::Length(10),
@@ -39,12 +43,28 @@ pub struct ResultsPaneParams<'a> {
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 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]
pub fn name_end_offset(name: &str, area: Rect) -> usize {
end_offset(name, area, &WIDTHS, NAME_COLUMN)
pub fn name_end_offset(path: &str, area: Rect) -> usize {
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
@@ -118,6 +138,7 @@ pub fn render_results_pane(
let header = Row::new(vec![
Cell::from("✓").style(header_style()),
Cell::from("Filename").style(header_style()),
Cell::from("Folder").style(header_style()),
Cell::from("Size").style(header_style()),
Cell::from("User").style(header_style()),
Cell::from("Bitrate").style(header_style()),
@@ -127,10 +148,13 @@ pub fn render_results_pane(
.height(1);
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
let rows: Vec<Row> = items
.iter()
.enumerate()
let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN);
let window = page_window(table_state, items.len(), area, 1);
let start = window.start;
let rows: Vec<Row> = (start..)
.zip(&items[window])
.map(|(idx, file)| {
let (folder, name) = split_path(&file.filename);
let checkbox =
if row_is_selected(idx, original_indices, selected_indices) {
"[✓]"
@@ -159,12 +183,10 @@ pub fn render_results_pane(
Row::new(vec![
Cell::from(checkbox).style(checkbox_style),
Cell::from(scroll_text(
&file.filename,
name_offset,
name_width,
))
Cell::from(scroll_text(name, name_offset, name_width))
.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(file.username.clone()).style(info_style()),
Cell::from(bitrate_str).style(dimmed_style()),
@@ -189,7 +211,7 @@ pub fn render_results_pane(
.highlight_spacing(HighlightSpacing::Always)
.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)]
@@ -202,8 +224,18 @@ mod tests {
fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String {
let mut state = TableState::default();
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 =
Terminal::new(TestBackend::new(80, 6)).expect("backend");
Terminal::new(TestBackend::new(88, 6)).expect("backend");
terminal
.draw(|frame| {
render_results_pane(
@@ -211,7 +243,7 @@ mod tests {
frame.area(),
ResultsPaneParams {
items,
table_state: &mut state,
table_state: state,
selected_indices: &HashSet::new(),
original_indices: None,
filter_query: "",
@@ -267,6 +299,34 @@ mod tests {
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]
fn an_unscrolled_name_starts_at_its_beginning() {
let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")];
@@ -275,6 +335,20 @@ mod tests {
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]
fn identity_mapping_used_when_no_filter() {
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::{
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style,
highlight_style, info_style, pane_block, plain_title, primary_style,
row_highlight_style, wrap_chat_line,
HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, filter_title,
highlight_style, info_style, page_window, pane_block, plain_title,
primary_style, render_page, row_highlight_style, wrap_chat_line,
};
use ratatui::{
Frame,
@@ -61,7 +64,10 @@ fn render_list(
Cell::from("room").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()
.map(|r| {
let open = rooms.open_index(&r.name).is_some();
@@ -90,14 +96,18 @@ fn render_list(
.highlight_spacing(HighlightSpacing::Always)
.block(block);
table_state.select(Some(rooms.list_selected.min(filtered.len() - 1)));
frame.render_stateful_widget(table, area, table_state);
render_page(frame, table, area, table_state, start);
}
fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
let block = pane_block(true).title(
" Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ",
let typing_log = rooms.filtering == Some(ChatFilter::Log);
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);
frame.render_widget(block, area);
@@ -115,6 +125,9 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) {
active: active_index,
user_selected,
composing,
log_filter,
user_filter,
filtering,
..
} = rooms;
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)])
.split(chunks[1]);
render_messages(frame, body[0], active.lines.as_slice(), &mut active.view);
render_users(frame, body[1], &active.users, *user_selected);
let OpenRoom {
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.
if *composing {
@@ -179,33 +212,32 @@ fn render_tab_bar(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
fn render_messages(
frame: &mut Frame,
area: Rect,
lines: &[crate::models::RoomLine],
lines: &[RoomLine],
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.
let width = area.width as usize;
let rendered: Vec<Line> = lines
.iter()
.flat_map(|l| {
let rendered = wrapped.rows(width, filter, lines, |l| {
let sender = l.username.as_deref().unwrap_or_default();
if !contains_filter(&l.text, filter) && !contains_filter(sender, filter)
{
return Vec::new();
}
let time =
Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style());
match &l.username {
Some(user) => wrap_chat_line(
vec![
time,
Span::styled(format!("<{user}> "), info_style()),
],
vec![time, Span::styled(format!("<{user}> "), info_style())],
&l.text,
primary_style(),
width,
),
None => {
wrap_chat_line(vec![time], &l.text, dimmed_style(), width)
None => wrap_chat_line(vec![time], &l.text, dimmed_style(), width),
}
}
})
.collect();
});
let window = view.window(rendered.len(), usize::from(area.height));
frame.render_widget(Paragraph::new(rendered[window].to_vec()), area);
}
@@ -213,14 +245,15 @@ fn render_messages(
fn render_users(
frame: &mut Frame,
area: Rect,
users: &[String],
users: &[&str],
selected: usize,
title: String,
) {
let block = Block::default()
.borders(Borders::LEFT)
.border_style(dimmed_style())
.padding(PANE_PADDING)
.title(plain_title(format!("Users ({})", users.len()), false));
.title(plain_title(title, false));
let inner = block.inner(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.
pub fn pane_block(focused: bool) -> Block<'static> {
Block::default()
+3 -2
View File
@@ -37,8 +37,9 @@ export const HeroTerminal = () => {
>
<StatusLine counts={demo.counts} pct={demo.pct} />
{/* Results across the top with the whole width for long names;
Searches, Transfers and Info share the row underneath. */}
{/* Results across the top, file names in a column of their own
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="relative min-h-0 overflow-hidden">
<ResultsPane
+7 -2
View File
@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'
import { TuiPane } from './chrome'
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'
interface SearchesPaneProps {
@@ -134,6 +134,7 @@ export const ResultsPane = ({
>
<span>✓</span>
<span>Filename</span>
<span>Folder</span>
<span>Size</span>
<span>User</span>
<span>Bitrate</span>
@@ -143,6 +144,7 @@ export const ResultsPane = ({
<div className="flex-1 overflow-y-auto">
{rows.map((row, i) => {
const { glyph, tone } = resultGlyph(row)
const parts = row.name.split('\\')
const isSelected = selected === i
return (
<button
@@ -167,7 +169,10 @@ export const ResultsPane = ({
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 className="text-right text-[var(--color-tape)]">{row.size}</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'],
['z', 'zoom the focused pane to the whole window'],
['w', 'hide the focused pane; the others widen'],
['W', 'every pane back on screen, zoom off'],
['↑ ↓ · j k', 'move a row in any list'],
['Home End · g G', 'first / last row'],
['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])[] = [
@@ -46,6 +47,8 @@ const ROOM_KEYS: readonly (readonly [string, string])[] = [
['x', 'leave the active room'],
['l', 'back to the room list'],
['↑ ↓', 'select a member'],
['/', 'search the chat log; in the inbox, the conversation'],
['u', 'filter the member list'],
['b', "browse the member's files"],
['m', 'message the member'],
]
@@ -54,7 +57,7 @@ const PANES: readonly { num?: number; title: string; body: string }[] = [
{
num: 2,
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,