fix(peer): serve full browse listings + harden the listener; TUI transfer/UX work
Peer layer: - Stream peer messages with a partial-write queue instead of write_all, so a large SharedFileListResponse over the non-blocking socket is no longer truncated (third-party clients like SoulseekQt can now browse our shares). - Bound the peer-init handshake with a read timeout and handle each accepted connection on its own thread, so one stalled peer can't wedge the listener. - e2e: third-party direct/brokered/large-share browse + stalled-peer coverage. TUI: - Uploads share the downloads table columns (real Speed) and the info pane now renders details for a selected upload. - Download progress bar counts only in-progress transfers and hides when idle. - Always-visible unread badge (private messages + rooms) in the shortcuts bar, self-drawn blink so it works under tmux. - Persist the unread private-message count across restarts.
This commit is contained in:
+114
-23
@@ -300,6 +300,32 @@ impl MultiDownloadProgress {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes transferred, bytes expected and combined speed across the downloads
|
||||
/// that are actually running — or `None` when none are, so the caller can hide
|
||||
/// the progress readout entirely.
|
||||
///
|
||||
/// Only `InProgress` downloads count. A queued download has not started and a
|
||||
/// failed one never will, so adding their size to the total just pins the bar
|
||||
/// near zero for the rest of the session.
|
||||
fn active_progress(downloads: &[DownloadEntry]) -> Option<(u64, u64, f64)> {
|
||||
let mut transferred = 0;
|
||||
let mut expected = 0;
|
||||
let mut speed = 0.0;
|
||||
let mut running = false;
|
||||
|
||||
for entry in downloads {
|
||||
if !matches!(entry.download.status, DownloadStatus::InProgress { .. }) {
|
||||
continue;
|
||||
}
|
||||
running = true;
|
||||
transferred += entry.download.bytes_downloaded();
|
||||
expected += entry.download.size;
|
||||
speed += entry.download.speed_bytes_per_sec();
|
||||
}
|
||||
|
||||
running.then_some((transferred, expected, speed))
|
||||
}
|
||||
|
||||
/// Renders download statistics in a reusable way
|
||||
pub fn render_download_stats(
|
||||
frame: &mut Frame,
|
||||
@@ -329,29 +355,7 @@ pub fn render_download_stats(
|
||||
.filter(|d| matches!(d.download.status, DownloadStatus::Paused { .. }))
|
||||
.count();
|
||||
|
||||
let total_downloaded: u64 = downloads
|
||||
.iter()
|
||||
.map(|d| d.download.bytes_downloaded())
|
||||
.sum();
|
||||
let total_size: u64 = downloads.iter().map(|d| d.download.size).sum();
|
||||
let overall_progress = if total_size > 0 {
|
||||
(total_downloaded as f64 / total_size as f64 * 100.0) as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let progress_ratio = if total_size > 0 {
|
||||
total_downloaded as f64 / total_size as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let total_speed: f64 = downloads
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
matches!(d.download.status, DownloadStatus::InProgress { .. })
|
||||
})
|
||||
.map(|d| d.download.speed_bytes_per_sec())
|
||||
.sum();
|
||||
let speed_mb = (total_speed / BYTES_PER_MB * 100.0).round() / 100.0;
|
||||
let active = active_progress(downloads);
|
||||
|
||||
let block = pane_block(false).title(plain_title("Status", false));
|
||||
|
||||
@@ -387,6 +391,23 @@ pub fn render_download_stats(
|
||||
let stats_paragraph = Paragraph::new(stats_line);
|
||||
frame.render_widget(stats_paragraph, chunks[0]);
|
||||
|
||||
// Nothing is transferring, so there is no progress to draw: leave the
|
||||
// right-hand half blank instead of showing an idle 0 B / 0 B bar.
|
||||
let Some((total_downloaded, total_size, total_speed)) = active else {
|
||||
return;
|
||||
};
|
||||
let overall_progress = if total_size > 0 {
|
||||
(total_downloaded as f64 / total_size as f64 * 100.0) as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let progress_ratio = if total_size > 0 {
|
||||
total_downloaded as f64 / total_size as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let speed_mb = (total_speed / BYTES_PER_MB * 100.0).round() / 100.0;
|
||||
|
||||
let right_width = chunks[1].width as usize;
|
||||
let bar_width = right_width.saturating_sub(42).max(10);
|
||||
let progress_bar =
|
||||
@@ -453,3 +474,73 @@ pub fn show_multi_download_progress(
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DownloadEntry, DownloadStatus, active_progress};
|
||||
use soulseek_rs::types::{Download, DownloadMetadata};
|
||||
|
||||
fn entry(size: u64, status: DownloadStatus) -> DownloadEntry {
|
||||
let (sender, _receiver) = std::sync::mpsc::channel();
|
||||
DownloadEntry {
|
||||
download: Download {
|
||||
username: "peer".to_string(),
|
||||
filename: "song.flac".to_string(),
|
||||
token: 1,
|
||||
size,
|
||||
download_directory: "/tmp".to_string(),
|
||||
status,
|
||||
sender,
|
||||
queue_position: None,
|
||||
metadata: DownloadMetadata::default(),
|
||||
},
|
||||
receiver: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_running_downloads_count_towards_the_bar() {
|
||||
let downloads = vec![
|
||||
entry(
|
||||
100,
|
||||
DownloadStatus::InProgress {
|
||||
bytes_downloaded: 40,
|
||||
total_bytes: 100,
|
||||
speed_bytes_per_sec: 512.0,
|
||||
},
|
||||
),
|
||||
// None of these may reach the bar: a queued file has not started,
|
||||
// and a failed or timed-out one never will. Counting their size
|
||||
// would drag the bar down for the rest of the session.
|
||||
entry(9_000, DownloadStatus::Queued),
|
||||
entry(9_000, DownloadStatus::Failed(None)),
|
||||
entry(9_000, DownloadStatus::TimedOut),
|
||||
];
|
||||
|
||||
let (transferred, expected, speed) =
|
||||
active_progress(&downloads).expect("one download is running");
|
||||
assert_eq!(transferred, 40);
|
||||
assert_eq!(expected, 100, "queued and failed sizes must be excluded");
|
||||
assert!((speed - 512.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_running_hides_the_bar() {
|
||||
assert!(active_progress(&[]).is_none(), "no downloads at all");
|
||||
|
||||
// Completed and paused transfers are not processing either, so the
|
||||
// readout stays hidden rather than showing a stale bar.
|
||||
let idle = vec![
|
||||
entry(100, DownloadStatus::Completed),
|
||||
entry(100, DownloadStatus::Queued),
|
||||
entry(
|
||||
100,
|
||||
DownloadStatus::Paused {
|
||||
bytes_downloaded: 50,
|
||||
total_bytes: 100,
|
||||
},
|
||||
),
|
||||
];
|
||||
assert!(active_progress(&idle).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,9 @@ impl MainTui {
|
||||
return self.handle_command_bar_input(key);
|
||||
}
|
||||
|
||||
// Messages popup: any of i/Esc/q closes it.
|
||||
// Chat popup takes over navigation while open.
|
||||
if self.state.show_messages {
|
||||
if matches!(key.code, KeyCode::Char('i' | 'q') | KeyCode::Esc) {
|
||||
self.state.show_messages = false;
|
||||
}
|
||||
return;
|
||||
return self.handle_chat_input(key);
|
||||
}
|
||||
|
||||
// Browse popup takes over navigation while open.
|
||||
@@ -116,6 +113,57 @@ impl MainTui {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat popup: composing captures typing, otherwise these are navigation
|
||||
/// keys over the conversation list.
|
||||
fn handle_chat_input(&mut self, key: KeyEvent) {
|
||||
if self.state.chat_composing {
|
||||
match key.code {
|
||||
KeyCode::Enter => self.send_chat_message(),
|
||||
KeyCode::Esc => {
|
||||
self.state.chat_composing = false;
|
||||
self.state.chat_input.clear();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.state.chat_input.pop();
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if !key.modifiers.intersects(
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||||
) =>
|
||||
{
|
||||
self.state.chat_input.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('i' | 'q') | KeyCode::Esc => {
|
||||
self.state.show_messages = false;
|
||||
}
|
||||
// Move through the conversation list on the right.
|
||||
KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.state.cycle_chat_peer(true);
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.state.cycle_chat_peer(false);
|
||||
}
|
||||
// Start a conversation with someone not in the list yet.
|
||||
KeyCode::Char('m') => {
|
||||
self.state.show_messages = false;
|
||||
self.state.command_bar_active = true;
|
||||
self.state.command_bar_mode = CommandBarMode::Message;
|
||||
self.state.command_bar_input.clear();
|
||||
self.state.command_bar_cursor_position = 0;
|
||||
}
|
||||
KeyCode::Enter if self.state.active_chat_peer().is_some() => {
|
||||
self.state.chat_composing = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_command_bar_input(&mut self, key: KeyEvent) {
|
||||
self.state.command_bar_cursor_position = self
|
||||
.state
|
||||
|
||||
@@ -8,7 +8,7 @@ mod settings;
|
||||
|
||||
use crate::models::AppState;
|
||||
use crate::persist::{
|
||||
snapshot::{Snapshot, restore_searches},
|
||||
snapshot::{Snapshot, restore_messages, restore_searches},
|
||||
state::StateStore,
|
||||
};
|
||||
use color_eyre::Result;
|
||||
@@ -61,6 +61,10 @@ impl MainTui {
|
||||
let Some(store) = &self.store else { return };
|
||||
|
||||
restore_searches(&mut self.state, &store.load_search_queries());
|
||||
restore_messages(&mut self.state, &store.load_messages());
|
||||
// Restore the unread badge so messages left unread carry over. Set
|
||||
// before the baseline snapshot below so it isn't seen as a change.
|
||||
self.state.unread_messages = store.load_unread();
|
||||
|
||||
for room in store.load_rooms() {
|
||||
if self.state.rooms.focus_or_open(&room)
|
||||
@@ -139,6 +143,16 @@ impl MainTui {
|
||||
{
|
||||
soulseek_rs::warn!("Could not save room state: {e}");
|
||||
}
|
||||
if snapshot.messages != self.saved_snapshot.messages
|
||||
&& let Err(e) = store.save_messages(&snapshot.messages)
|
||||
{
|
||||
soulseek_rs::warn!("Could not save message history: {e}");
|
||||
}
|
||||
if snapshot.unread_messages != self.saved_snapshot.unread_messages
|
||||
&& let Err(e) = store.save_unread(snapshot.unread_messages)
|
||||
{
|
||||
soulseek_rs::warn!("Could not save unread count: {e}");
|
||||
}
|
||||
self.saved_snapshot = snapshot;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
use super::MainTui;
|
||||
use crate::models::{CommandBarMode, FocusedPane, MessageDirection, RoomsView};
|
||||
use crate::models::{CommandBarMode, FocusedPane, RoomsView};
|
||||
use crate::ui::panes::{
|
||||
ResultsPaneParams, render_browse_pane, render_download_info_pane,
|
||||
render_downloads_pane, render_results_pane, render_rooms_pane,
|
||||
render_searches_pane,
|
||||
ResultsPaneParams, render_browse_pane, render_chat_pane,
|
||||
render_download_info_pane, render_downloads_pane, render_results_pane,
|
||||
render_rooms_pane, render_searches_pane, selected_transfer,
|
||||
};
|
||||
use crate::ui::{
|
||||
accent_style, format_shortcuts_styled, pane_block, plain_title,
|
||||
primary_style, render_download_stats,
|
||||
accent_style, dimmed_style, format_shortcuts_styled, pane_block,
|
||||
plain_title, primary_style, render_download_stats, warning_style,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout, Position, Rect},
|
||||
layout::{Alignment, Constraint, Layout, Position, Rect},
|
||||
style::Modifier,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
};
|
||||
@@ -141,15 +142,15 @@ impl MainTui {
|
||||
self.state.focused_pane == FocusedPane::Downloads,
|
||||
);
|
||||
|
||||
let selected_download = self
|
||||
.state
|
||||
.downloads_table_state
|
||||
.selected()
|
||||
.and_then(|idx| self.state.downloads.get(idx));
|
||||
let selected_transfer = selected_transfer(
|
||||
self.state.downloads_table_state.selected(),
|
||||
&self.state.downloads,
|
||||
&self.state.uploads,
|
||||
);
|
||||
render_download_info_pane(
|
||||
frame,
|
||||
downloads_chunks[1],
|
||||
selected_download,
|
||||
selected_transfer,
|
||||
self.state.focused_pane == FocusedPane::Downloads,
|
||||
);
|
||||
}
|
||||
@@ -251,38 +252,11 @@ impl MainTui {
|
||||
}
|
||||
|
||||
fn render_messages_popup(&self, frame: &mut Frame) {
|
||||
// The per-conversation chat box supersedes the old flat message list;
|
||||
// it renders the messages and the compose line itself.
|
||||
let area = centered_rect(70, 60, frame.area());
|
||||
|
||||
let lines: Vec<ratatui::text::Line> = if self.state.messages.is_empty()
|
||||
{
|
||||
vec![ratatui::text::Line::from(
|
||||
"No messages yet. Press 'm' to send one.",
|
||||
)]
|
||||
} else {
|
||||
self.state
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let (arrow, who) = match m.direction {
|
||||
MessageDirection::Incoming => ("⇦ from", &m.peer),
|
||||
MessageDirection::Outgoing => ("⇨ to ", &m.peer),
|
||||
};
|
||||
ratatui::text::Line::from(format!(
|
||||
"{arrow} {who}: {}",
|
||||
m.text
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let popup =
|
||||
Paragraph::new(lines).block(pane_block(true).title(plain_title(
|
||||
"Messages · m: compose · i/Esc: close",
|
||||
true,
|
||||
)));
|
||||
|
||||
frame.render_widget(ratatui::widgets::Clear, area);
|
||||
frame.render_widget(popup, area);
|
||||
render_chat_pane(frame, area, &self.state, self.client.username());
|
||||
}
|
||||
|
||||
/// Context shortcuts for the chat-rooms popup.
|
||||
@@ -325,19 +299,6 @@ impl MainTui {
|
||||
}
|
||||
|
||||
fn render_shortcuts(&self, frame: &mut Frame, area: Rect) {
|
||||
// Unread badges for the inbox and chat shortcuts.
|
||||
let inbox_label = if self.state.unread_messages > 0 {
|
||||
format!("inbox ({})", self.state.unread_messages)
|
||||
} else {
|
||||
"inbox".to_string()
|
||||
};
|
||||
let chat_unread = self.state.rooms.total_unread();
|
||||
let chat_label = if chat_unread > 0 {
|
||||
format!("chat ({chat_unread})")
|
||||
} else {
|
||||
"chat".to_string()
|
||||
};
|
||||
|
||||
let shortcuts = if self.state.settings.is_some() {
|
||||
vec![
|
||||
("↑↓", "move"),
|
||||
@@ -347,6 +308,21 @@ impl MainTui {
|
||||
("r", "re-index"),
|
||||
("Esc", "close"),
|
||||
]
|
||||
} else if self.state.show_messages {
|
||||
if self.state.chat_composing {
|
||||
vec![
|
||||
("Type", "message"),
|
||||
("Enter", "send"),
|
||||
("Esc", "stop typing"),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
("Enter", "type"),
|
||||
("↑↓/Tab", "switch chat"),
|
||||
("m", "new chat"),
|
||||
("i/Esc", "close"),
|
||||
]
|
||||
}
|
||||
} else if self.state.show_rooms {
|
||||
self.rooms_shortcuts()
|
||||
} else if self.state.show_browse {
|
||||
@@ -385,8 +361,8 @@ impl MainTui {
|
||||
FocusedPane::Searches => vec![
|
||||
("s", "search"),
|
||||
("m", "message"),
|
||||
("i", inbox_label.as_str()),
|
||||
("c", chat_label.as_str()),
|
||||
("i", "inbox"),
|
||||
("c", "chat"),
|
||||
("b", "browse user"),
|
||||
("1-3", "focus pane"),
|
||||
("↑↓", "navigate"),
|
||||
@@ -405,7 +381,7 @@ impl MainTui {
|
||||
("Space", "select"),
|
||||
("Enter", "download"),
|
||||
("b", "browse owner"),
|
||||
("c", chat_label.as_str()),
|
||||
("c", "chat"),
|
||||
("/", "filter"),
|
||||
("a/A", "select all/none"),
|
||||
("1-3", "focus pane"),
|
||||
@@ -434,10 +410,37 @@ impl MainTui {
|
||||
more => format!("{} folders", more.len()),
|
||||
};
|
||||
let title = format!("Shortcuts · Sharing: {sharing}");
|
||||
let shortcuts_widget = Paragraph::new(shortcuts_line)
|
||||
.block(pane_block(false).title(plain_title(title, false)));
|
||||
// Themed block (master's design system), but we take its inner area so
|
||||
// the unread badge can be split off to the right below.
|
||||
let block = pane_block(false).title(plain_title(title, false));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
frame.render_widget(shortcuts_widget, area);
|
||||
// A prominent unread badge, right-aligned so the context shortcuts on
|
||||
// the left never crowd it out. It rides in the always-visible shortcuts
|
||||
// bar, so unread mail/chat shows in every pane, not just some. The
|
||||
// spinner tick (0..10, ~100ms each) drives a ~1s blink: bright for the
|
||||
// first half of the cycle, dim for the second.
|
||||
let unread = unread_indicator(
|
||||
self.state.unread_messages,
|
||||
self.state.rooms.total_unread(),
|
||||
self.spinner_state < 5,
|
||||
);
|
||||
if unread.spans.is_empty() {
|
||||
frame.render_widget(Paragraph::new(shortcuts_line), inner);
|
||||
} else {
|
||||
let badge_width = unread.width() as u16;
|
||||
let cols = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(badge_width),
|
||||
])
|
||||
.split(inner);
|
||||
frame.render_widget(Paragraph::new(shortcuts_line), cols[0]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(unread).alignment(Alignment::Right),
|
||||
cols[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_command_bar(&self, frame: &mut Frame, area: Rect) {
|
||||
@@ -487,6 +490,38 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||
.split(vertical[1])[1]
|
||||
}
|
||||
|
||||
/// Build the shortcuts-bar unread badge: `✉ N` for unread private messages and
|
||||
/// `💬 N` for unread chat-room messages. Returns an empty line when nothing is
|
||||
/// unread so the caller can skip it.
|
||||
///
|
||||
/// `bright` drives a self-drawn blink: the caller flips it on a timer so the
|
||||
/// badge pulses between bold warning colour and dim. We do the blink ourselves
|
||||
/// rather than lean on the terminal's SLOW_BLINK attribute, which tmux and most
|
||||
/// macOS terminals silently drop. The text is identical in both phases, so the
|
||||
/// badge keeps its width and the bar never reflows.
|
||||
fn unread_indicator(
|
||||
messages: usize,
|
||||
rooms: usize,
|
||||
bright: bool,
|
||||
) -> Line<'static> {
|
||||
let mut counts: Vec<String> = Vec::new();
|
||||
if messages > 0 {
|
||||
counts.push(format!("✉ {messages}"));
|
||||
}
|
||||
if rooms > 0 {
|
||||
counts.push(format!("💬 {rooms}"));
|
||||
}
|
||||
if counts.is_empty() {
|
||||
return Line::from(Vec::new());
|
||||
}
|
||||
let style = if bright {
|
||||
warning_style().add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
dimmed_style()
|
||||
};
|
||||
Line::from(Span::styled(format!("⟨ {} ⟩", counts.join(" ")), style))
|
||||
}
|
||||
|
||||
fn visible_input_at_cursor(
|
||||
input: &str,
|
||||
cursor_position: usize,
|
||||
@@ -513,3 +548,46 @@ fn visible_input_at_cursor(
|
||||
|
||||
(visible_input, cursor_column as u16)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::unread_indicator;
|
||||
|
||||
fn text(messages: usize, rooms: usize) -> String {
|
||||
unread_indicator(messages, rooms, true)
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unread_indicator_is_empty_when_nothing_is_unread() {
|
||||
// Empty in both blink phases — no badge means no badge.
|
||||
assert!(unread_indicator(0, 0, true).spans.is_empty());
|
||||
assert!(unread_indicator(0, 0, false).spans.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unread_indicator_shows_each_source_and_both_together() {
|
||||
// Bracketed so the badge reads as one distinct block.
|
||||
assert_eq!(text(3, 0), "⟨ ✉ 3 ⟩");
|
||||
assert_eq!(text(0, 2), "⟨ 💬 2 ⟩");
|
||||
// Both present: DMs first, rooms second, separated so they don't run
|
||||
// together.
|
||||
assert_eq!(text(3, 2), "⟨ ✉ 3 💬 2 ⟩");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blink_phases_share_text_but_differ_in_style() {
|
||||
// The self-drawn blink must keep the same glyphs (so the bar never
|
||||
// reflows) while changing style, or it would not read as blinking.
|
||||
let bright = unread_indicator(1, 0, true);
|
||||
let dim = unread_indicator(1, 0, false);
|
||||
assert_eq!(bright.spans[0].content, dim.spans[0].content);
|
||||
assert_ne!(
|
||||
bright.spans[0].style, dim.spans[0].style,
|
||||
"bright and dim phases must look different"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::MainTui;
|
||||
use crate::models::{CommandBarMode, RoomsView};
|
||||
use crate::models::RoomsView;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
impl MainTui {
|
||||
@@ -126,16 +126,11 @@ impl MainTui {
|
||||
}
|
||||
|
||||
/// Compose a private message to the member highlighted in the active room.
|
||||
/// Closes the rooms popup and opens the message command bar pre-filled with
|
||||
/// the recipient.
|
||||
/// Closes the rooms popup and opens the chat box on that conversation.
|
||||
fn message_selected_room_user(&mut self) {
|
||||
if let Some(user) = self.state.rooms.selected_user() {
|
||||
self.state.show_rooms = false;
|
||||
self.state.command_bar_active = true;
|
||||
self.state.command_bar_mode = CommandBarMode::Message;
|
||||
self.state.command_bar_input = format!("{user} ");
|
||||
self.state.command_bar_cursor_position =
|
||||
self.state.command_bar_input.len();
|
||||
self.state.open_chat(user);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::models::{
|
||||
ChatMessage, FileDisplayData, FocusedPane, MessageDirection, SearchEntry,
|
||||
SearchStatus,
|
||||
};
|
||||
use chrono::Local;
|
||||
use std::{
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
thread,
|
||||
@@ -105,6 +106,7 @@ impl MainTui {
|
||||
direction: MessageDirection::Incoming,
|
||||
peer: msg.username().to_string(),
|
||||
text: msg.message().to_string(),
|
||||
at: Local::now(),
|
||||
});
|
||||
// Badge the inbox when it isn't currently open.
|
||||
if !self.state.show_messages {
|
||||
@@ -126,13 +128,43 @@ impl MainTui {
|
||||
}
|
||||
|
||||
match self.client.send_private_message(recipient, text) {
|
||||
Ok(()) => {
|
||||
self.state.messages.push(ChatMessage {
|
||||
direction: MessageDirection::Outgoing,
|
||||
peer: recipient.to_string(),
|
||||
text: text.to_string(),
|
||||
at: Local::now(),
|
||||
});
|
||||
// Land in the chat box on the conversation just started.
|
||||
self.state.open_chat(recipient.to_string());
|
||||
self.state.chat_composing = false;
|
||||
}
|
||||
Err(e) => soulseek_rs::warn!("Failed to send message: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the chat popup's compose buffer to the active conversation,
|
||||
/// staying in compose mode so a reply can follow immediately.
|
||||
pub(super) fn send_chat_message(&mut self) {
|
||||
let Some(peer) = self.state.active_chat_peer().map(ToString::to_string)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let text = self.state.chat_input.trim().to_string();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.client.send_private_message(&peer, &text) {
|
||||
Ok(()) => self.state.messages.push(ChatMessage {
|
||||
direction: MessageDirection::Outgoing,
|
||||
peer: recipient.to_string(),
|
||||
text: text.to_string(),
|
||||
peer,
|
||||
text,
|
||||
at: Local::now(),
|
||||
}),
|
||||
Err(e) => soulseek_rs::warn!("Failed to send message: {e}"),
|
||||
}
|
||||
self.state.chat_input.clear();
|
||||
}
|
||||
|
||||
pub(super) fn start_search(&mut self, query: String) {
|
||||
|
||||
@@ -57,6 +57,9 @@ impl MainTui {
|
||||
let result = crate::persist::config::FileConfig::load(&path)
|
||||
.and_then(|mut config| {
|
||||
config.download_dir = Some(download_dir);
|
||||
// The singular `shared_dir` is merged with `shared_dirs` on
|
||||
// load, so leaving it would resurrect a removed folder.
|
||||
config.shared_dir = None;
|
||||
config.shared_dirs = Some(share_dirs);
|
||||
config.save(&path)
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
use crate::models::{AppState, MessageDirection};
|
||||
use crate::ui::{
|
||||
accent_style, border_style, border_type, dimmed_style, highlight_style,
|
||||
info_style, primary_style,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
|
||||
/// Render the private-message chat box: title is the conversation partner,
|
||||
/// messages left, the list of people you have chatted with on the right, and
|
||||
/// the compose line pinned to the bottom. `own_username` labels our own
|
||||
/// messages.
|
||||
pub fn render_chat_pane(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
own_username: &str,
|
||||
) {
|
||||
let peer = state.active_chat_peer();
|
||||
let title = peer.map_or_else(
|
||||
|| " Messages (m: compose, i/Esc: close) ".to_string(),
|
||||
|peer| format!(" {peer} (↑↓/Tab: switch, m: to…, i/Esc: close) "),
|
||||
);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style(true))
|
||||
.border_type(border_type(true))
|
||||
.title(title);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
|
||||
.split(inner);
|
||||
|
||||
// Body: messages (left) + conversation list (right).
|
||||
let body =
|
||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)])
|
||||
.split(chunks[0]);
|
||||
render_peers(frame, body[1], state, peer);
|
||||
|
||||
let Some(peer) = peer else {
|
||||
frame.render_widget(
|
||||
Paragraph::new("No messages yet. Press 'm' to send one.")
|
||||
.style(dimmed_style()),
|
||||
body[0],
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
render_messages(frame, body[0], state, peer, own_username);
|
||||
render_compose(frame, chunks[1], state);
|
||||
}
|
||||
|
||||
fn render_peers(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
active: Option<&str>,
|
||||
) {
|
||||
let peers = state.chat_peers();
|
||||
let block = Block::default()
|
||||
.borders(Borders::LEFT)
|
||||
.border_style(dimmed_style())
|
||||
.title(format!(" Chats ({}) ", peers.len()));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let height = inner.height as usize;
|
||||
let selected = active
|
||||
.and_then(|active| peers.iter().position(|p| *p == active))
|
||||
.unwrap_or(0);
|
||||
// Keep the active conversation in view by scrolling the window to it.
|
||||
let start = selected.saturating_sub(height.saturating_sub(1));
|
||||
let shown: Vec<Line> = peers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start)
|
||||
.take(height)
|
||||
.map(|(i, peer)| {
|
||||
if i == selected {
|
||||
Line::from(Span::styled(format!("▸ {peer}"), highlight_style()))
|
||||
} else {
|
||||
Line::from(Span::styled(format!(" {peer}"), primary_style()))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(shown), inner);
|
||||
}
|
||||
|
||||
fn render_messages(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
peer: &str,
|
||||
own_username: &str,
|
||||
) {
|
||||
let lines: Vec<Line> = state
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.peer == peer)
|
||||
.map(|m| {
|
||||
let (sender, sender_style) = match m.direction {
|
||||
MessageDirection::Incoming => (peer, info_style()),
|
||||
MessageDirection::Outgoing => (own_username, accent_style()),
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(m.at.format("%H:%M ").to_string(), dimmed_style()),
|
||||
Span::styled(format!("<{sender}> "), sender_style),
|
||||
Span::styled(m.text.clone(), primary_style()),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Auto-scroll: one row per message (no wrapping), so tailing by the pane
|
||||
// height keeps the newest message visible.
|
||||
let start = lines.len().saturating_sub((area.height as usize).max(1));
|
||||
frame.render_widget(Paragraph::new(lines[start..].to_vec()), area);
|
||||
}
|
||||
|
||||
fn render_compose(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let line = if state.chat_composing {
|
||||
Line::from(vec![
|
||||
Span::styled("› ", accent_style()),
|
||||
Span::styled(state.chat_input.clone(), primary_style()),
|
||||
Span::styled("▏", accent_style()),
|
||||
])
|
||||
} else {
|
||||
Line::from(Span::styled("Enter: type a message", dimmed_style()))
|
||||
};
|
||||
frame.render_widget(Paragraph::new(line), area);
|
||||
}
|
||||
@@ -12,21 +12,47 @@ use ratatui::{
|
||||
widgets::{Paragraph, Wrap},
|
||||
};
|
||||
use soulseek_rs::DownloadStatus;
|
||||
use soulseek_rs::types::{UploadInfo, UploadStatus};
|
||||
use soulseek_rs::utils::path::expand_tilde;
|
||||
|
||||
const LABEL_WIDTH: usize = 20;
|
||||
|
||||
/// Whichever transfer the table has highlighted. Rows are downloads first and
|
||||
/// uploads after, so a raw index has to be resolved against both lists.
|
||||
pub enum SelectedTransfer<'a> {
|
||||
Download(&'a DownloadEntry),
|
||||
Upload(&'a UploadInfo),
|
||||
}
|
||||
|
||||
/// Resolve the transfers table's selected row index into the download or
|
||||
/// upload it points at, matching the order [`render_downloads_pane`] renders.
|
||||
#[must_use]
|
||||
pub fn selected_transfer<'a>(
|
||||
index: Option<usize>,
|
||||
downloads: &'a [DownloadEntry],
|
||||
uploads: &'a [UploadInfo],
|
||||
) -> Option<SelectedTransfer<'a>> {
|
||||
let index = index?;
|
||||
if let Some(download) = downloads.get(index) {
|
||||
return Some(SelectedTransfer::Download(download));
|
||||
}
|
||||
// Past the downloads, so this row is an upload.
|
||||
uploads
|
||||
.get(index - downloads.len())
|
||||
.map(SelectedTransfer::Upload)
|
||||
}
|
||||
|
||||
pub fn render_download_info_pane(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
selected: Option<&DownloadEntry>,
|
||||
selected: Option<SelectedTransfer>,
|
||||
focused: bool,
|
||||
) {
|
||||
let block = pane_block(focused).title(pane_title("Info", "", focused));
|
||||
|
||||
let Some(entry) = selected else {
|
||||
let Some(selected) = selected else {
|
||||
let paragraph = Paragraph::new(Line::from(Span::styled(
|
||||
"Select a download for details.",
|
||||
"Select a transfer for details.",
|
||||
dimmed_style(),
|
||||
)))
|
||||
.block(block);
|
||||
@@ -34,7 +60,10 @@ pub fn render_download_info_pane(
|
||||
return;
|
||||
};
|
||||
|
||||
let lines = build_info_lines(&entry.download);
|
||||
let lines = match selected {
|
||||
SelectedTransfer::Download(entry) => build_info_lines(&entry.download),
|
||||
SelectedTransfer::Upload(upload) => build_upload_info_lines(upload),
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.block(block)
|
||||
@@ -42,6 +71,73 @@ pub fn render_download_info_pane(
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
/// Details for a file we are serving to a peer, laid out like the download
|
||||
/// view so the pane reads the same whichever direction is selected.
|
||||
fn build_upload_info_lines(upload: &UploadInfo) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
let (basename, parent_dir) = split_filename(&upload.filename);
|
||||
lines.push(Line::from(Span::styled(
|
||||
basename,
|
||||
primary_style().add_modifier(ratatui::style::Modifier::BOLD),
|
||||
)));
|
||||
if !parent_dir.is_empty() {
|
||||
lines.push(Line::from(Span::styled(parent_dir, dimmed_style())));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// The pane is shared with downloads, so name the direction explicitly.
|
||||
lines.push(label_value("Direction", "↑ Upload"));
|
||||
lines.push(label_value("To user", &upload.username));
|
||||
lines.push(label_value("Size", &format_bytes(upload.size)));
|
||||
|
||||
let (status_text, status_style) = match &upload.status {
|
||||
UploadStatus::InProgress => {
|
||||
("In progress".to_string(), warning_style())
|
||||
}
|
||||
UploadStatus::Completed => ("Completed".to_string(), success_style()),
|
||||
UploadStatus::Cancelled => ("Cancelled".to_string(), inactive_style()),
|
||||
UploadStatus::Failed(_) => ("Failed".to_string(), error_style()),
|
||||
};
|
||||
lines.push(label_value_styled("Status", status_text, status_style));
|
||||
|
||||
match &upload.status {
|
||||
UploadStatus::InProgress => {
|
||||
lines.push(Line::from(""));
|
||||
push_transfer_progress(
|
||||
&mut lines,
|
||||
"Sent",
|
||||
upload.bytes_sent,
|
||||
upload.size,
|
||||
);
|
||||
lines.push(label_value(
|
||||
"Speed",
|
||||
&format_speed(upload.speed_bytes_per_sec),
|
||||
));
|
||||
|
||||
if upload.speed_bytes_per_sec > 0.0
|
||||
&& upload.size > upload.bytes_sent
|
||||
{
|
||||
let remaining = upload.size - upload.bytes_sent;
|
||||
let eta_secs =
|
||||
(remaining as f64 / upload.speed_bytes_per_sec) as u32;
|
||||
lines.push(label_value("ETA", &format_duration(eta_secs)));
|
||||
}
|
||||
}
|
||||
UploadStatus::Failed(reason) => {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("{:<LABEL_WIDTH$}", "Error"),
|
||||
dimmed_style(),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(reason.clone(), error_style())));
|
||||
}
|
||||
UploadStatus::Completed | UploadStatus::Cancelled => {}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn build_info_lines(
|
||||
download: &soulseek_rs::types::Download,
|
||||
) -> Vec<Line<'static>> {
|
||||
@@ -184,19 +280,30 @@ fn push_progress_lines(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
bytes_downloaded: u64,
|
||||
total_bytes: u64,
|
||||
) {
|
||||
push_transfer_progress(lines, "Downloaded", bytes_downloaded, total_bytes);
|
||||
}
|
||||
|
||||
/// A progress bar plus a `<label> <done> / <total>` line, shared by the
|
||||
/// download ("Downloaded") and upload ("Sent") views.
|
||||
fn push_transfer_progress(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
label: &str,
|
||||
transferred: u64,
|
||||
total_bytes: u64,
|
||||
) {
|
||||
let (progress, percent) = if total_bytes > 0 {
|
||||
let ratio = bytes_downloaded as f64 / total_bytes as f64;
|
||||
let ratio = transferred as f64 / total_bytes as f64;
|
||||
(ratio, (ratio * 100.0) as u8)
|
||||
} else {
|
||||
(0.0, 0)
|
||||
};
|
||||
lines.push(format_progress_bar(progress, 20, percent));
|
||||
lines.push(label_value(
|
||||
"Downloaded",
|
||||
label,
|
||||
&format!(
|
||||
"{} / {}",
|
||||
format_bytes(bytes_downloaded),
|
||||
format_bytes(transferred),
|
||||
format_bytes(total_bytes)
|
||||
),
|
||||
));
|
||||
|
||||
@@ -138,10 +138,17 @@ pub fn render_downloads_pane(
|
||||
percent
|
||||
)
|
||||
}
|
||||
UploadStatus::Completed => format_bytes(upload.size),
|
||||
UploadStatus::Completed => "Completed".to_string(),
|
||||
UploadStatus::Cancelled => "Cancelled".to_string(),
|
||||
UploadStatus::Failed(_) => "Failed".to_string(),
|
||||
};
|
||||
// Same rule as a download: a rate only while the transfer is running.
|
||||
let speed_text = match &upload.status {
|
||||
UploadStatus::InProgress => {
|
||||
format_speed(upload.speed_bytes_per_sec)
|
||||
}
|
||||
_ => "-".to_string(),
|
||||
};
|
||||
let basename = upload
|
||||
.filename
|
||||
.rsplit(['\\', '/'])
|
||||
@@ -152,7 +159,7 @@ pub fn render_downloads_pane(
|
||||
Cell::from(basename.to_string()).style(body_style()),
|
||||
Cell::from(upload.username.clone()).style(info_style()),
|
||||
Cell::from(progress_text).style(primary_style()),
|
||||
Cell::from(String::new()),
|
||||
Cell::from(speed_text).style(warning_style()),
|
||||
])
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod browse_pane;
|
||||
mod chat_pane;
|
||||
mod download_info_pane;
|
||||
mod downloads_pane;
|
||||
mod results_pane;
|
||||
@@ -6,7 +7,8 @@ mod rooms_pane;
|
||||
mod searches_pane;
|
||||
|
||||
pub use browse_pane::render_browse_pane;
|
||||
pub use download_info_pane::render_download_info_pane;
|
||||
pub use chat_pane::render_chat_pane;
|
||||
pub use download_info_pane::{render_download_info_pane, selected_transfer};
|
||||
pub use downloads_pane::render_downloads_pane;
|
||||
pub use results_pane::{ResultsPaneParams, render_results_pane};
|
||||
pub use rooms_pane::render_rooms_pane;
|
||||
|
||||
@@ -181,12 +181,20 @@ fn render_messages(
|
||||
let start = lines.len().saturating_sub(height.max(1));
|
||||
let rendered: Vec<Line> = lines[start..]
|
||||
.iter()
|
||||
.map(|l| match &l.username {
|
||||
Some(user) => Line::from(vec![
|
||||
Span::styled(format!("<{user}> "), info_style()),
|
||||
Span::styled(l.text.clone(), primary_style()),
|
||||
]),
|
||||
None => Line::from(Span::styled(l.text.clone(), dimmed_style())),
|
||||
.map(|l| {
|
||||
let time =
|
||||
Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style());
|
||||
match &l.username {
|
||||
Some(user) => Line::from(vec![
|
||||
time,
|
||||
Span::styled(format!("<{user}> "), info_style()),
|
||||
Span::styled(l.text.clone(), primary_style()),
|
||||
]),
|
||||
None => Line::from(vec![
|
||||
time,
|
||||
Span::styled(l.text.clone(), dimmed_style()),
|
||||
]),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(rendered), area);
|
||||
|
||||
Reference in New Issue
Block a user