feat(tui): show a result's file name and folder in columns of their own
The results list held the whole shared path in one column, so a search was read through its folder: every row began with the same peer prefix and the file name, the part being searched for, sat off the right edge. Split the column at the last separator, the file name first with the larger share of the width and the folder dimmed beside it, so names line up and read at a glance. The one scroll offset moves both columns together, and $ runs to the end of whichever overflows further. The website's mock of the pane follows. Suggested in #55.
This commit is contained in:
@@ -92,13 +92,13 @@ Everything else is a one-shot command that runs headless.
|
|||||||
|
|
||||||
### The TUI
|
### The TUI
|
||||||
|
|
||||||
Results run across the top, with the whole width for long file names;
|
Results run across the top, each file name in a column of its own with the
|
||||||
Searches, Downloads and Info share the row underneath. `Tab`/`Shift-Tab` or
|
folder it came from beside it; Searches, Downloads and Info share the row
|
||||||
`1`–`3` move the focus between panes, `z` zooms the focused pane to the whole
|
underneath. `Tab`/`Shift-Tab` or `1`–`3` move the focus between panes, `z`
|
||||||
window, and `w` hides it so the others widen (its number brings it back).
|
zooms the focused pane to the whole window, and `w` hides it so the others
|
||||||
Every list takes `↑`/`↓`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and
|
widen (its number brings it back). Every list takes `↑`/`↓`, `Home`/`End`,
|
||||||
`h`/`l` or `←`/`→` scroll a long file name or query sideways, `0` and `$` to
|
`PgUp`/`PgDn` and the vim keys, and `h`/`l` or `←`/`→` scroll a long file name,
|
||||||
either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?`
|
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.
|
shows every key for where you are.
|
||||||
|
|
||||||
### Daemon mode
|
### Daemon mode
|
||||||
|
|||||||
@@ -1158,8 +1158,9 @@ mod tests {
|
|||||||
let mut tui = with_session(TalkativeSession::default());
|
let mut tui = with_session(TalkativeSession::default());
|
||||||
tui.state.results_items = results(n);
|
tui.state.results_items = results(n);
|
||||||
tui.state.focused_pane = FocusedPane::Results;
|
tui.state.focused_pane = FocusedPane::Results;
|
||||||
|
// 88 cells wide gives the name column 11 cells and the folder 7.
|
||||||
tui.state.results_pane_area =
|
tui.state.results_pane_area =
|
||||||
Some(ratatui::layout::Rect::new(0, 0, 80, 13));
|
Some(ratatui::layout::Rect::new(0, 0, 88, 13));
|
||||||
tui
|
tui
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1273,6 +1274,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_runs_to_the_end_of_the_folder_when_it_is_the_longer() {
|
||||||
|
let mut tui = results_tui(3);
|
||||||
|
tui.state.results_items[1].filename =
|
||||||
|
format!("{}\\short.mp3", "f".repeat(20));
|
||||||
|
tui.state.results_table_state.select(Some(1));
|
||||||
|
press(&mut tui, KeyCode::Char('$'));
|
||||||
|
assert_eq!(tui.state.results_name_offset, 14);
|
||||||
|
press(&mut tui, KeyCode::Char('l'));
|
||||||
|
assert_eq!(tui.state.results_name_offset, 14, "stops at the end");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn picking_another_search_starts_its_results_unscrolled() {
|
fn picking_another_search_starts_its_results_unscrolled() {
|
||||||
let mut tui = results_tui(3);
|
let mut tui = results_tui(3);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::models::FileDisplayData;
|
|||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
|
BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes,
|
||||||
header_style, info_style, pane_block, pane_title, row_highlight_style,
|
header_style, info_style, pane_block, pane_title, row_highlight_style,
|
||||||
success_style, warning_style,
|
split_path, success_style, warning_style,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
@@ -15,9 +15,13 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
const WIDTHS: [Constraint; 7] = [
|
// The file name and its folder share whatever width the fixed columns leave,
|
||||||
|
// the name getting the larger part: it is what a search is after, and a
|
||||||
|
// folder's tail is a scroll away.
|
||||||
|
const WIDTHS: [Constraint; 8] = [
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
Constraint::Fill(3),
|
Constraint::Fill(3),
|
||||||
|
Constraint::Fill(2),
|
||||||
Constraint::Length(12),
|
Constraint::Length(12),
|
||||||
Constraint::Length(15),
|
Constraint::Length(15),
|
||||||
Constraint::Length(10),
|
Constraint::Length(10),
|
||||||
@@ -39,12 +43,22 @@ pub struct ResultsPaneParams<'a> {
|
|||||||
pub name_offset: usize,
|
pub name_offset: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filename is the second column.
|
/// The file name is the second column, the folder it sits in the third.
|
||||||
const NAME_COLUMN: usize = 1;
|
const NAME_COLUMN: usize = 1;
|
||||||
|
const FOLDER_COLUMN: usize = 2;
|
||||||
|
|
||||||
|
/// The offset that brings the end of a result's longer column into view.
|
||||||
|
/// One offset scrolls the name and the folder together, so it runs until
|
||||||
|
/// both tails are showing.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn name_end_offset(name: &str, area: Rect) -> usize {
|
pub fn name_end_offset(path: &str, area: Rect) -> usize {
|
||||||
end_offset(name, area, &WIDTHS, NAME_COLUMN)
|
let (folder, name) = split_path(path);
|
||||||
|
end_offset(name, area, &WIDTHS, NAME_COLUMN).max(end_offset(
|
||||||
|
folder,
|
||||||
|
area,
|
||||||
|
&WIDTHS,
|
||||||
|
FOLDER_COLUMN,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the rendered row `display_idx` is selected. `selected_indices` holds
|
/// Whether the rendered row `display_idx` is selected. `selected_indices` holds
|
||||||
@@ -118,6 +132,7 @@ pub fn render_results_pane(
|
|||||||
let header = Row::new(vec![
|
let header = Row::new(vec![
|
||||||
Cell::from("✓").style(header_style()),
|
Cell::from("✓").style(header_style()),
|
||||||
Cell::from("Filename").style(header_style()),
|
Cell::from("Filename").style(header_style()),
|
||||||
|
Cell::from("Folder").style(header_style()),
|
||||||
Cell::from("Size").style(header_style()),
|
Cell::from("Size").style(header_style()),
|
||||||
Cell::from("User").style(header_style()),
|
Cell::from("User").style(header_style()),
|
||||||
Cell::from("Bitrate").style(header_style()),
|
Cell::from("Bitrate").style(header_style()),
|
||||||
@@ -127,10 +142,12 @@ pub fn render_results_pane(
|
|||||||
.height(1);
|
.height(1);
|
||||||
|
|
||||||
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
let name_width = column_width(area, &WIDTHS, NAME_COLUMN);
|
||||||
|
let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN);
|
||||||
let rows: Vec<Row> = items
|
let rows: Vec<Row> = items
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, file)| {
|
.map(|(idx, file)| {
|
||||||
|
let (folder, name) = split_path(&file.filename);
|
||||||
let checkbox =
|
let checkbox =
|
||||||
if row_is_selected(idx, original_indices, selected_indices) {
|
if row_is_selected(idx, original_indices, selected_indices) {
|
||||||
"[✓]"
|
"[✓]"
|
||||||
@@ -159,12 +176,10 @@ pub fn render_results_pane(
|
|||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(checkbox).style(checkbox_style),
|
Cell::from(checkbox).style(checkbox_style),
|
||||||
Cell::from(scroll_text(
|
Cell::from(scroll_text(name, name_offset, name_width))
|
||||||
&file.filename,
|
.style(body_style()),
|
||||||
name_offset,
|
Cell::from(scroll_text(folder, name_offset, folder_width))
|
||||||
name_width,
|
.style(dimmed_style()),
|
||||||
))
|
|
||||||
.style(body_style()),
|
|
||||||
Cell::from(format_bytes(file.size)).style(warning_style()),
|
Cell::from(format_bytes(file.size)).style(warning_style()),
|
||||||
Cell::from(file.username.clone()).style(info_style()),
|
Cell::from(file.username.clone()).style(info_style()),
|
||||||
Cell::from(bitrate_str).style(dimmed_style()),
|
Cell::from(bitrate_str).style(dimmed_style()),
|
||||||
@@ -202,8 +217,10 @@ mod tests {
|
|||||||
fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String {
|
fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String {
|
||||||
let mut state = TableState::default();
|
let mut state = TableState::default();
|
||||||
state.select(Some(0));
|
state.select(Some(0));
|
||||||
|
// Wide enough for the name column to hold 11 cells, with 7 for the
|
||||||
|
// folder beside it.
|
||||||
let mut terminal =
|
let mut terminal =
|
||||||
Terminal::new(TestBackend::new(80, 6)).expect("backend");
|
Terminal::new(TestBackend::new(88, 6)).expect("backend");
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
render_results_pane(
|
render_results_pane(
|
||||||
@@ -267,6 +284,59 @@ mod tests {
|
|||||||
assert!(!screen.contains("…\u{301}"), "{screen}");
|
assert!(!screen.contains("…\u{301}"), "{screen}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_path_shows_its_name_and_folder_in_separate_columns() {
|
||||||
|
let items = [file("@@abc\\Music\\Album\\01.flac"), file("cover.jpg")];
|
||||||
|
let screen = render_rows(&items, 0);
|
||||||
|
let header = screen.lines().nth(1).expect("header");
|
||||||
|
let name = header.find("Filename").expect("name header");
|
||||||
|
let folder = header.find("Folder").expect("folder header");
|
||||||
|
assert!(name < folder, "{header}");
|
||||||
|
|
||||||
|
let row = screen.lines().nth(2).expect("first row");
|
||||||
|
assert!(row.contains("01.flac "), "{row}");
|
||||||
|
assert!(!row.contains("Album\\01.flac"), "the name alone: {row}");
|
||||||
|
assert!(row.contains("@@abc\\M"), "{row}");
|
||||||
|
assert!(
|
||||||
|
row.find("01.flac") < row.find("@@abc"),
|
||||||
|
"the name before the folder: {row}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let row = screen.lines().nth(3).expect("second row");
|
||||||
|
assert!(row.contains("cover.jpg"), "{row}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"), "{screen}");
|
||||||
|
assert!(screen.contains("…c\\Musi"), "{screen}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_end_offset_is_the_longer_columns() {
|
||||||
|
use super::name_end_offset;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
// 88 cells wide: 11 for the name, 7 for the folder.
|
||||||
|
let area = Rect::new(0, 0, 88, 6);
|
||||||
|
assert_eq!(name_end_offset("short.mp3", area), 0);
|
||||||
|
assert_eq!(name_end_offset(&"x".repeat(40), area), 30);
|
||||||
|
assert_eq!(
|
||||||
|
name_end_offset(&format!("{}\\short.mp3", "f".repeat(20)), area),
|
||||||
|
14,
|
||||||
|
"the folder overflows its narrower column"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
name_end_offset(
|
||||||
|
&format!("{}\\{}", "f".repeat(20), "x".repeat(40)),
|
||||||
|
area
|
||||||
|
),
|
||||||
|
30,
|
||||||
|
"whichever runs further"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unscrolled_name_starts_at_its_beginning() {
|
fn an_unscrolled_name_starts_at_its_beginning() {
|
||||||
let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")];
|
let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")];
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import { cn } from '@/lib/utils'
|
|||||||
import { TuiPane } from './chrome'
|
import { TuiPane } from './chrome'
|
||||||
import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data'
|
import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data'
|
||||||
|
|
||||||
const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px'
|
const RESULT_COLS = '30px 3fr 2fr 74px 92px 78px 74px 42px'
|
||||||
|
|
||||||
|
/** A shared path split at its last separator, as the TUI's two columns show it. */
|
||||||
|
const splitPath = (path: string): { folder: string; name: string } => {
|
||||||
|
const at = Math.max(path.lastIndexOf('\\'), path.lastIndexOf('/'))
|
||||||
|
return at < 0
|
||||||
|
? { folder: '', name: path }
|
||||||
|
: { folder: path.slice(0, at), name: path.slice(at + 1) }
|
||||||
|
}
|
||||||
const TRANSFER_COLS = '48px 1fr 96px 168px 74px'
|
const TRANSFER_COLS = '48px 1fr 96px 168px 74px'
|
||||||
|
|
||||||
interface SearchesPaneProps {
|
interface SearchesPaneProps {
|
||||||
@@ -134,6 +142,7 @@ export const ResultsPane = ({
|
|||||||
>
|
>
|
||||||
<span>✓</span>
|
<span>✓</span>
|
||||||
<span>Filename</span>
|
<span>Filename</span>
|
||||||
|
<span>Folder</span>
|
||||||
<span>Size</span>
|
<span>Size</span>
|
||||||
<span>User</span>
|
<span>User</span>
|
||||||
<span>Bitrate</span>
|
<span>Bitrate</span>
|
||||||
@@ -143,6 +152,7 @@ export const ResultsPane = ({
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
const { glyph, tone } = resultGlyph(row)
|
const { glyph, tone } = resultGlyph(row)
|
||||||
|
const { folder, name } = splitPath(row.name)
|
||||||
const isSelected = selected === i
|
const isSelected = selected === i
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -167,7 +177,10 @@ export const ResultsPane = ({
|
|||||||
isSelected ? 'text-[var(--color-paper)]' : 'text-[var(--color-paper-dim)]',
|
isSelected ? 'text-[var(--color-paper)]' : 'text-[var(--color-paper-dim)]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.name}
|
{name}
|
||||||
|
</span>
|
||||||
|
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-[var(--color-dust)]">
|
||||||
|
{folder}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right text-[var(--color-tape)]">{row.size}</span>
|
<span className="text-right text-[var(--color-tape)]">{row.size}</span>
|
||||||
<span className="text-[var(--color-signal)]">{row.user}</span>
|
<span className="text-[var(--color-signal)]">{row.user}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user