feat(tui): compose and read private messages
Add private messaging to the TUI: - 'm' opens the command bar in message mode; type '<recipient> <text>' and Enter sends it via the client; - incoming messages are polled each tick into an inbox; - 'i' toggles a centered inbox popup listing sent/received messages. The shared command bar now carries a mode (search vs message) so the prefix, submit action and shortcut hints follow what is being typed.
This commit is contained in:
@@ -38,7 +38,7 @@ experience. For me, this is a good balance between learning and practicality.
|
|||||||
- [ ] Share files
|
- [ ] Share files
|
||||||
- [ ] Browse user(s) files
|
- [ ] Browse user(s) files
|
||||||
- [ ] Chat in chatrooms
|
- [ ] Chat in chatrooms
|
||||||
- [ ] Private messaging
|
- [x] Private messaging
|
||||||
- [ ] Headless mode daemon mode with remote control
|
- [ ] Headless mode daemon mode with remote control
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ fn send_private_message(
|
|||||||
|
|
||||||
client
|
client
|
||||||
.send_private_message(recipient, message)
|
.send_private_message(recipient, message)
|
||||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to send message: {}", e))?;
|
.map_err(|e| {
|
||||||
|
color_eyre::eyre::eyre!("Failed to send message: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
// The send is dispatched asynchronously; give the server actor a moment to
|
// The send is dispatched asynchronously; give the server actor a moment to
|
||||||
// flush it to the socket before we drop the client and exit.
|
// flush it to the socket before we drop the client and exit.
|
||||||
|
|||||||
@@ -32,6 +32,29 @@ pub enum FocusedPane {
|
|||||||
Downloads,
|
Downloads,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the shared command bar is currently capturing input for.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CommandBarMode {
|
||||||
|
Search,
|
||||||
|
Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direction of a private message relative to the local user.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MessageDirection {
|
||||||
|
Incoming,
|
||||||
|
Outgoing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A private message shown in the inbox popup.
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub direction: MessageDirection,
|
||||||
|
/// The other party: the sender for incoming, the recipient for outgoing.
|
||||||
|
pub peer: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
// Searches
|
// Searches
|
||||||
pub searches: Vec<SearchEntry>,
|
pub searches: Vec<SearchEntry>,
|
||||||
@@ -62,6 +85,11 @@ pub struct AppState {
|
|||||||
pub command_bar_active: bool,
|
pub command_bar_active: bool,
|
||||||
pub command_bar_input: String,
|
pub command_bar_input: String,
|
||||||
pub command_bar_cursor_position: usize,
|
pub command_bar_cursor_position: usize,
|
||||||
|
pub command_bar_mode: CommandBarMode,
|
||||||
|
|
||||||
|
// Private messages
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
pub show_messages: bool,
|
||||||
|
|
||||||
// Pane areas for mouse interaction
|
// Pane areas for mouse interaction
|
||||||
pub searches_pane_area: Option<Rect>,
|
pub searches_pane_area: Option<Rect>,
|
||||||
@@ -104,6 +132,10 @@ impl AppState {
|
|||||||
command_bar_active: false,
|
command_bar_active: false,
|
||||||
command_bar_input: String::new(),
|
command_bar_input: String::new(),
|
||||||
command_bar_cursor_position: 0,
|
command_bar_cursor_position: 0,
|
||||||
|
command_bar_mode: CommandBarMode::Search,
|
||||||
|
|
||||||
|
messages: Vec::new(),
|
||||||
|
show_messages: false,
|
||||||
|
|
||||||
searches_pane_area: None,
|
searches_pane_area: None,
|
||||||
results_pane_area: None,
|
results_pane_area: None,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod app_state;
|
|||||||
mod file_display_data;
|
mod file_display_data;
|
||||||
|
|
||||||
pub use app_state::{
|
pub use app_state::{
|
||||||
AppState, DownloadEntry, FocusedPane, SearchEntry, SearchStatus,
|
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
|
||||||
|
MessageDirection, SearchEntry, SearchStatus,
|
||||||
};
|
};
|
||||||
pub use file_display_data::FileDisplayData;
|
pub use file_display_data::FileDisplayData;
|
||||||
|
|||||||
+151
-15
@@ -1,6 +1,6 @@
|
|||||||
use crate::models::{
|
use crate::models::{
|
||||||
AppState, DownloadEntry, FileDisplayData, FocusedPane, SearchEntry,
|
AppState, ChatMessage, CommandBarMode, DownloadEntry, FileDisplayData,
|
||||||
SearchStatus,
|
FocusedPane, MessageDirection, SearchEntry, SearchStatus,
|
||||||
};
|
};
|
||||||
use crate::ui::panes::{
|
use crate::ui::panes::{
|
||||||
ResultsPaneParams, render_download_info_pane, render_downloads_pane,
|
ResultsPaneParams, render_download_info_pane, render_downloads_pane,
|
||||||
@@ -31,6 +31,14 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const COMMAND_BAR_PREFIX: &str = "search: ";
|
const COMMAND_BAR_PREFIX: &str = "search: ";
|
||||||
|
const MESSAGE_BAR_PREFIX: &str = "message (to: recipient text): ";
|
||||||
|
|
||||||
|
const fn command_bar_prefix(mode: CommandBarMode) -> &'static str {
|
||||||
|
match mode {
|
||||||
|
CommandBarMode::Search => COMMAND_BAR_PREFIX,
|
||||||
|
CommandBarMode::Message => MESSAGE_BAR_PREFIX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MainTui {
|
pub struct MainTui {
|
||||||
client: Arc<Client>,
|
client: Arc<Client>,
|
||||||
@@ -86,6 +94,9 @@ impl MainTui {
|
|||||||
// Poll for download updates
|
// Poll for download updates
|
||||||
self.update_downloads();
|
self.update_downloads();
|
||||||
|
|
||||||
|
// Poll for incoming private messages
|
||||||
|
self.poll_private_messages();
|
||||||
|
|
||||||
// Update spinner
|
// Update spinner
|
||||||
self.spinner_state = (self.spinner_state + 1) % 10;
|
self.spinner_state = (self.spinner_state + 1) % 10;
|
||||||
|
|
||||||
@@ -233,26 +244,76 @@ impl MainTui {
|
|||||||
} else {
|
} else {
|
||||||
self.render_shortcuts(frame, main_chunks[2]);
|
self.render_shortcuts(frame, main_chunks[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Messages inbox overlays everything when open.
|
||||||
|
if self.state.show_messages {
|
||||||
|
self.render_messages_popup(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_messages_popup(&self, frame: &mut Frame) {
|
||||||
|
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(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style(true))
|
||||||
|
.border_type(border_type(true))
|
||||||
|
.title(" Messages (m: compose, i/Esc: close) "),
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.render_widget(ratatui::widgets::Clear, area);
|
||||||
|
frame.render_widget(popup, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_shortcuts(&self, frame: &mut Frame, area: Rect) {
|
fn render_shortcuts(&self, frame: &mut Frame, area: Rect) {
|
||||||
let shortcuts = if self.state.command_bar_active {
|
let shortcuts = if self.state.command_bar_active {
|
||||||
vec![
|
match self.state.command_bar_mode {
|
||||||
("Type", "search term"),
|
CommandBarMode::Search => vec![
|
||||||
("←→", "move cursor"),
|
("Type", "search term"),
|
||||||
("Backspace/Del", "edit"),
|
("←→", "move cursor"),
|
||||||
("Enter", "search"),
|
("Backspace/Del", "edit"),
|
||||||
("Esc", "cancel"),
|
("Enter", "search"),
|
||||||
]
|
("Esc", "cancel"),
|
||||||
|
],
|
||||||
|
CommandBarMode::Message => vec![
|
||||||
|
("Type", "recipient then message"),
|
||||||
|
("Enter", "send"),
|
||||||
|
("Esc", "cancel"),
|
||||||
|
],
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
match self.state.focused_pane {
|
match self.state.focused_pane {
|
||||||
FocusedPane::Searches => vec![
|
FocusedPane::Searches => vec![
|
||||||
("s", "search"),
|
("s", "search"),
|
||||||
|
("m", "message"),
|
||||||
|
("i", "inbox"),
|
||||||
("1-3", "focus pane"),
|
("1-3", "focus pane"),
|
||||||
("↑↓", "navigate"),
|
("↑↓", "navigate"),
|
||||||
("Enter", "view results"),
|
("Enter", "view results"),
|
||||||
("d", "remove search"),
|
("d", "remove search"),
|
||||||
("C", "clear all"),
|
|
||||||
("q", "quit"),
|
("q", "quit"),
|
||||||
],
|
],
|
||||||
FocusedPane::Results if self.state.results_is_filtering => {
|
FocusedPane::Results if self.state.results_is_filtering => {
|
||||||
@@ -296,15 +357,16 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_command_bar(&self, frame: &mut Frame, area: Rect) {
|
fn render_command_bar(&self, frame: &mut Frame, area: Rect) {
|
||||||
|
let prefix = command_bar_prefix(self.state.command_bar_mode);
|
||||||
let content_width = area.width.saturating_sub(2);
|
let content_width = area.width.saturating_sub(2);
|
||||||
let prefix_width = COMMAND_BAR_PREFIX.chars().count() as u16;
|
let prefix_width = prefix.chars().count() as u16;
|
||||||
let input_width = content_width.saturating_sub(prefix_width);
|
let input_width = content_width.saturating_sub(prefix_width);
|
||||||
let (visible_input, cursor_column) = visible_input_at_cursor(
|
let (visible_input, cursor_column) = visible_input_at_cursor(
|
||||||
&self.state.command_bar_input,
|
&self.state.command_bar_input,
|
||||||
self.state.command_bar_cursor_position,
|
self.state.command_bar_cursor_position,
|
||||||
input_width,
|
input_width,
|
||||||
);
|
);
|
||||||
let command_text = format!("{COMMAND_BAR_PREFIX}{visible_input}");
|
let command_text = format!("{prefix}{visible_input}");
|
||||||
|
|
||||||
let paragraph = Paragraph::new(command_text).block(
|
let paragraph = Paragraph::new(command_text).block(
|
||||||
Block::default()
|
Block::default()
|
||||||
@@ -332,6 +394,14 @@ impl MainTui {
|
|||||||
return self.handle_command_bar_input(key);
|
return self.handle_command_bar_input(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Messages popup: any of i/Esc/q closes it.
|
||||||
|
if self.state.show_messages {
|
||||||
|
if matches!(key.code, KeyCode::Char('i' | 'q') | KeyCode::Esc) {
|
||||||
|
self.state.show_messages = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Filter mode in Results pane
|
// Filter mode in Results pane
|
||||||
if self.state.results_is_filtering
|
if self.state.results_is_filtering
|
||||||
&& self.state.focused_pane == FocusedPane::Results
|
&& self.state.focused_pane == FocusedPane::Results
|
||||||
@@ -359,10 +429,22 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('s') => {
|
||||||
self.state.command_bar_active = true;
|
self.state.command_bar_active = true;
|
||||||
|
self.state.command_bar_mode = CommandBarMode::Search;
|
||||||
self.state.command_bar_input.clear();
|
self.state.command_bar_input.clear();
|
||||||
self.state.command_bar_cursor_position = 0;
|
self.state.command_bar_cursor_position = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('m') => {
|
||||||
|
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;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Char('i') => {
|
||||||
|
self.state.show_messages = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,9 +464,14 @@ impl MainTui {
|
|||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
let query = self.state.command_bar_input.trim().to_string();
|
let input = self.state.command_bar_input.trim().to_string();
|
||||||
if !query.is_empty() {
|
if !input.is_empty() {
|
||||||
self.start_search(query);
|
match self.state.command_bar_mode {
|
||||||
|
CommandBarMode::Search => self.start_search(input),
|
||||||
|
CommandBarMode::Message => {
|
||||||
|
self.send_message_from_input(&input);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.state.command_bar_active = false;
|
self.state.command_bar_active = false;
|
||||||
self.state.command_bar_input.clear();
|
self.state.command_bar_input.clear();
|
||||||
@@ -840,6 +927,39 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drain any private messages received since the last tick into the inbox.
|
||||||
|
fn poll_private_messages(&mut self) {
|
||||||
|
for msg in self.client.take_private_messages() {
|
||||||
|
self.state.messages.push(ChatMessage {
|
||||||
|
direction: MessageDirection::Incoming,
|
||||||
|
peer: msg.username().to_string(),
|
||||||
|
text: msg.message().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `<recipient> <text>` compose line and send it.
|
||||||
|
fn send_message_from_input(&mut self, input: &str) {
|
||||||
|
let Some((recipient, text)) = input.split_once(char::is_whitespace)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let recipient = recipient.trim();
|
||||||
|
let text = text.trim();
|
||||||
|
if recipient.is_empty() || text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}),
|
||||||
|
Err(e) => eprintln!("Failed to send message: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn start_search(&mut self, query: String) {
|
fn start_search(&mut self, query: String) {
|
||||||
let cancel_flag = Arc::new(AtomicBool::new(false));
|
let cancel_flag = Arc::new(AtomicBool::new(false));
|
||||||
let search_entry = SearchEntry {
|
let search_entry = SearchEntry {
|
||||||
@@ -1053,6 +1173,22 @@ pub fn launch_main_tui(
|
|||||||
tui.run(terminal)
|
tui.run(terminal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `Rect` centered within `area`, sized to the given percentages.
|
||||||
|
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||||
|
let vertical = Layout::vertical([
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
Constraint::Percentage(percent_y),
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
Layout::horizontal([
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
Constraint::Percentage(percent_x),
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
])
|
||||||
|
.split(vertical[1])[1]
|
||||||
|
}
|
||||||
|
|
||||||
const fn clamp_cursor_to_char_boundary(
|
const fn clamp_cursor_to_char_boundary(
|
||||||
input: &str,
|
input: &str,
|
||||||
cursor_position: usize,
|
cursor_position: usize,
|
||||||
|
|||||||
Reference in New Issue
Block a user