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:
michel
2026-07-19 13:38:36 +02:00
parent f6127411d7
commit 05b06ec2ec
5 changed files with 189 additions and 18 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ experience. For me, this is a good balance between learning and practicality.
- [ ] Share files
- [ ] Browse user(s) files
- [ ] Chat in chatrooms
- [ ] Private messaging
- [x] Private messaging
- [ ] Headless mode daemon mode with remote control
## Project Structure
+3 -1
View File
@@ -172,7 +172,9 @@ fn send_private_message(
client
.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
// flush it to the socket before we drop the client and exit.
+32
View File
@@ -32,6 +32,29 @@ pub enum FocusedPane {
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 {
// Searches
pub searches: Vec<SearchEntry>,
@@ -62,6 +85,11 @@ pub struct AppState {
pub command_bar_active: bool,
pub command_bar_input: String,
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
pub searches_pane_area: Option<Rect>,
@@ -104,6 +132,10 @@ impl AppState {
command_bar_active: false,
command_bar_input: String::new(),
command_bar_cursor_position: 0,
command_bar_mode: CommandBarMode::Search,
messages: Vec::new(),
show_messages: false,
searches_pane_area: None,
results_pane_area: None,
+2 -1
View File
@@ -2,6 +2,7 @@ mod app_state;
mod file_display_data;
pub use app_state::{
AppState, DownloadEntry, FocusedPane, SearchEntry, SearchStatus,
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
MessageDirection, SearchEntry, SearchStatus,
};
pub use file_display_data::FileDisplayData;
+151 -15
View File
@@ -1,6 +1,6 @@
use crate::models::{
AppState, DownloadEntry, FileDisplayData, FocusedPane, SearchEntry,
SearchStatus,
AppState, ChatMessage, CommandBarMode, DownloadEntry, FileDisplayData,
FocusedPane, MessageDirection, SearchEntry, SearchStatus,
};
use crate::ui::panes::{
ResultsPaneParams, render_download_info_pane, render_downloads_pane,
@@ -31,6 +31,14 @@ use std::{
};
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 {
client: Arc<Client>,
@@ -86,6 +94,9 @@ impl MainTui {
// Poll for download updates
self.update_downloads();
// Poll for incoming private messages
self.poll_private_messages();
// Update spinner
self.spinner_state = (self.spinner_state + 1) % 10;
@@ -233,26 +244,76 @@ impl MainTui {
} else {
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) {
let shortcuts = if self.state.command_bar_active {
vec![
("Type", "search term"),
("←→", "move cursor"),
("Backspace/Del", "edit"),
("Enter", "search"),
("Esc", "cancel"),
]
match self.state.command_bar_mode {
CommandBarMode::Search => vec![
("Type", "search term"),
("←→", "move cursor"),
("Backspace/Del", "edit"),
("Enter", "search"),
("Esc", "cancel"),
],
CommandBarMode::Message => vec![
("Type", "recipient then message"),
("Enter", "send"),
("Esc", "cancel"),
],
}
} else {
match self.state.focused_pane {
FocusedPane::Searches => vec![
("s", "search"),
("m", "message"),
("i", "inbox"),
("1-3", "focus pane"),
("↑↓", "navigate"),
("Enter", "view results"),
("d", "remove search"),
("C", "clear all"),
("q", "quit"),
],
FocusedPane::Results if self.state.results_is_filtering => {
@@ -296,15 +357,16 @@ impl MainTui {
}
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 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 (visible_input, cursor_column) = visible_input_at_cursor(
&self.state.command_bar_input,
self.state.command_bar_cursor_position,
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(
Block::default()
@@ -332,6 +394,14 @@ impl MainTui {
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
if self.state.results_is_filtering
&& self.state.focused_pane == FocusedPane::Results
@@ -359,10 +429,22 @@ impl MainTui {
}
KeyCode::Char('s') => {
self.state.command_bar_active = true;
self.state.command_bar_mode = CommandBarMode::Search;
self.state.command_bar_input.clear();
self.state.command_bar_cursor_position = 0;
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 {
KeyCode::Enter => {
let query = self.state.command_bar_input.trim().to_string();
if !query.is_empty() {
self.start_search(query);
let input = self.state.command_bar_input.trim().to_string();
if !input.is_empty() {
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_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) {
let cancel_flag = Arc::new(AtomicBool::new(false));
let search_entry = SearchEntry {
@@ -1053,6 +1173,22 @@ pub fn launch_main_tui(
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(
input: &str,
cursor_position: usize,