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:
@@ -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
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 930 KiB After Width: | Height: | Size: 926 KiB |
@@ -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();
|
||||
|
||||
@@ -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,36 +175,107 @@ fn flatten_into(
|
||||
path,
|
||||
children,
|
||||
} => {
|
||||
let is_expanded = expanded.contains(path);
|
||||
rows.push(BrowseRow {
|
||||
depth,
|
||||
is_folder: true,
|
||||
expanded: is_expanded,
|
||||
name: name.clone(),
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
});
|
||||
if is_expanded {
|
||||
flatten_into(children, depth + 1, expanded, rows);
|
||||
let open = expanded.contains(path);
|
||||
let mark = rows.len();
|
||||
if emit {
|
||||
rows.push(BrowseRow {
|
||||
depth,
|
||||
is_folder: true,
|
||||
expanded: open,
|
||||
name: name.clone(),
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
});
|
||||
}
|
||||
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 } => {
|
||||
rows.push(BrowseRow {
|
||||
depth,
|
||||
is_folder: false,
|
||||
expanded: false,
|
||||
name: name.clone(),
|
||||
path: path.clone(),
|
||||
size: Some(*size),
|
||||
});
|
||||
if needle.is_none_or(|needle| path_has(path, needle)) {
|
||||
kept = true;
|
||||
if emit {
|
||||
rows.push(BrowseRow {
|
||||
depth,
|
||||
is_folder: false,
|
||||
expanded: false,
|
||||
name: name.clone(),
|
||||
path: path.clone(),
|
||||
size: Some(*size),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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,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};
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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,30 +350,16 @@ impl MainTui {
|
||||
}
|
||||
|
||||
fn handle_filter_input(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.state.results_is_filtering = false;
|
||||
self.state.results_filter_query.clear();
|
||||
self.state.results_filtered_items =
|
||||
self.state.results_items.clone();
|
||||
self.state.results_filtered_indices =
|
||||
(0..self.state.results_items.len()).collect();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.state.results_filter_query.push(c);
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.state.results_filter_query.pop();
|
||||
self.apply_filter();
|
||||
}
|
||||
// Confirm the filter: leave typing mode but keep the query, so
|
||||
// the normal Results keys (j/k, space, enter) act on the
|
||||
// filtered list.
|
||||
KeyCode::Enter => {
|
||||
self.state.results_is_filtering = false;
|
||||
}
|
||||
_ => self.handle_results_input(key),
|
||||
let Some(edit) = edit_filter(&mut self.state.results_filter_query, key)
|
||||
else {
|
||||
return self.handle_results_input(key);
|
||||
};
|
||||
// A kept query stays: the normal Results keys act on the filtered
|
||||
// list. An empty one shows everything without a rebuild.
|
||||
if edit == FilterEdit::Changed {
|
||||
self.apply_filter();
|
||||
} else {
|
||||
self.state.results_is_filtering = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,17 +584,24 @@ impl MainTui {
|
||||
} else if self.state.show_rooms {
|
||||
self.rooms_shortcuts()
|
||||
} else if self.state.show_browse {
|
||||
vec![
|
||||
("↑↓", "move"),
|
||||
("PgUp/PgDn", "page"),
|
||||
("→←", "expand/collapse"),
|
||||
("Enter", "open/download"),
|
||||
("d", "download folder"),
|
||||
("Tab", "switch user"),
|
||||
("r", "retry"),
|
||||
("w", "close tab"),
|
||||
("Esc", "hide"),
|
||||
]
|
||||
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"),
|
||||
("J/K", "next/prev folder"),
|
||||
("→←", "expand/collapse"),
|
||||
("H/L", "collapse/expand all"),
|
||||
("/", "filter"),
|
||||
("Enter", "open/download"),
|
||||
("d", "download folder"),
|
||||
("Tab", "switch user"),
|
||||
("r", "retry"),
|
||||
("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"),
|
||||
|
||||
@@ -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 => {
|
||||
self.state.rooms.list_is_filtering = false;
|
||||
self.state.rooms.list_filter.clear();
|
||||
let Some(edit) = edit_filter(&mut self.state.rooms.list_filter, key)
|
||||
else {
|
||||
return self.handle_rooms_list_input(key);
|
||||
};
|
||||
match edit {
|
||||
FilterEdit::Changed | FilterEdit::Cleared => {
|
||||
self.state.rooms.list_selected = 0;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.state.rooms.list_is_filtering = false;
|
||||
self.join_selected_room();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.state.rooms.list_filter.push(c);
|
||||
self.state.rooms.list_selected = 0;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.state.rooms.list_filter.pop();
|
||||
self.state.rooms.list_selected = 0;
|
||||
}
|
||||
_ => {}
|
||||
FilterEdit::Kept => self.join_selected_room(),
|
||||
}
|
||||
if edit != FilterEdit::Changed {
|
||||
self.state.rooms.list_is_filtering = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) ",
|
||||
browse.username, browse.file_count, browse.folder_count
|
||||
),
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (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(format!("<{sender}> "), sender_style),
|
||||
],
|
||||
&m.text,
|
||||
primary_style(),
|
||||
width,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
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(format!("<{sender}> "), sender_style),
|
||||
],
|
||||
&m.text,
|
||||
primary_style(),
|
||||
width,
|
||||
)
|
||||
});
|
||||
|
||||
// The newest rows, unless the reader is holding a place in the history.
|
||||
let window = view.window(lines.len(), usize::from(area.height));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
.style(body_style()),
|
||||
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();
|
||||
|
||||
@@ -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 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()),
|
||||
],
|
||||
&l.text,
|
||||
primary_style(),
|
||||
width,
|
||||
),
|
||||
None => {
|
||||
wrap_chat_line(vec![time], &l.text, dimmed_style(), width)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
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())],
|
||||
&l.text,
|
||||
primary_style(),
|
||||
width,
|
||||
),
|
||||
None => wrap_chat_line(vec![time], &l.text, dimmed_style(), width),
|
||||
}
|
||||
});
|
||||
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);
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user