feat(client): ratatui TUI subscribing to SDK events
Single-file v2 TUI (v2_tui.rs, ~580 lines) gated behind cfg(all(feature = "v2", feature = "tui")). Layout: conversations sidebar, scrollable messages panel, input bar with cursor editing, status line. Help overlay on /help. Subscribes to SDK broadcast::Receiver<ClientEvent> for real-time updates. TerminalGuard ensures terminal restore on panic. Commands: /login, /register, /logout, /dm, /group, /quit, /help. Build: cargo build -p quicproquo-client --features v2,tui
This commit is contained in:
@@ -14,6 +14,10 @@ pub mod state;
|
|||||||
pub mod token_cache;
|
pub mod token_cache;
|
||||||
#[cfg(feature = "tui")]
|
#[cfg(feature = "tui")]
|
||||||
pub mod tui;
|
pub mod tui;
|
||||||
|
#[cfg(feature = "v2")]
|
||||||
|
pub mod v2_repl;
|
||||||
|
#[cfg(all(feature = "v2", feature = "tui"))]
|
||||||
|
pub mod v2_tui;
|
||||||
|
|
||||||
pub use commands::*;
|
pub use commands::*;
|
||||||
pub use rpc::{connect_node, enqueue, fetch_all, fetch_hybrid_key, fetch_key_package, fetch_wait, upload_hybrid_key, upload_key_package};
|
pub use rpc::{connect_node, enqueue, fetch_all, fetch_hybrid_key, fetch_key_package, fetch_wait, upload_hybrid_key, upload_key_package};
|
||||||
|
|||||||
979
crates/quicproquo-client/src/client/v2_tui.rs
Normal file
979
crates/quicproquo-client/src/client/v2_tui.rs
Normal file
@@ -0,0 +1,979 @@
|
|||||||
|
//! Full-screen Ratatui TUI for quicproquo v2, driven by the SDK event system.
|
||||||
|
//!
|
||||||
|
//! Layout:
|
||||||
|
//! +-- Conversations -+-- Messages ------------------------------+
|
||||||
|
//! | > alice (DM) | [12:34] alice: Hey! |
|
||||||
|
//! | teamchat | [12:35] you: Hello alice |
|
||||||
|
//! | devgroup | [12:36] alice: How's it going? |
|
||||||
|
//! | | |
|
||||||
|
//! +------------------+------------------------------------------+
|
||||||
|
//! | > Type a message... (Enter to send, /cmd for commands) |
|
||||||
|
//! +-------------------------------------------------------------+
|
||||||
|
//! | Connected to 127.0.0.1:7000 | alice | 3 conversations |
|
||||||
|
//! +-------------------------------------------------------------+
|
||||||
|
//!
|
||||||
|
//! Keyboard:
|
||||||
|
//! Enter -- send message (or execute /command)
|
||||||
|
//! Up / Down -- scroll message history
|
||||||
|
//! Tab -- next conversation
|
||||||
|
//! Shift+Tab -- previous conversation
|
||||||
|
//! Ctrl+C / Ctrl+Q -- quit
|
||||||
|
//!
|
||||||
|
//! Feature gate: requires both `v2` and `tui` features.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
backend::CrosstermBackend,
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
||||||
|
Frame, Terminal,
|
||||||
|
};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
use quicproquo_sdk::client::QpqClient;
|
||||||
|
use quicproquo_sdk::conversation::ConversationStore;
|
||||||
|
use quicproquo_sdk::events::ClientEvent;
|
||||||
|
|
||||||
|
// ── Data Types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A conversation entry shown in the sidebar.
|
||||||
|
struct ConversationItem {
|
||||||
|
id: [u8; 16],
|
||||||
|
name: String,
|
||||||
|
unread: usize,
|
||||||
|
last_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A message entry shown in the messages panel.
|
||||||
|
struct DisplayMessage {
|
||||||
|
timestamp: String,
|
||||||
|
sender: String,
|
||||||
|
body: String,
|
||||||
|
is_outgoing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focus region for keyboard navigation.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Focus {
|
||||||
|
Input,
|
||||||
|
Help,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Application State ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Main application state for the TUI.
|
||||||
|
pub struct TuiApp {
|
||||||
|
conversations: Vec<ConversationItem>,
|
||||||
|
selected_conversation: usize,
|
||||||
|
messages: Vec<DisplayMessage>,
|
||||||
|
input: String,
|
||||||
|
input_cursor: usize,
|
||||||
|
scroll_offset: usize,
|
||||||
|
status_line: String,
|
||||||
|
should_quit: bool,
|
||||||
|
username: Option<String>,
|
||||||
|
server_addr: String,
|
||||||
|
focus: Focus,
|
||||||
|
/// Notification line (shown briefly, e.g. "Message sent", "Error: ...").
|
||||||
|
notification: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiApp {
|
||||||
|
fn new(server_addr: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
conversations: Vec::new(),
|
||||||
|
selected_conversation: 0,
|
||||||
|
messages: Vec::new(),
|
||||||
|
input: String::new(),
|
||||||
|
input_cursor: 0,
|
||||||
|
scroll_offset: 0,
|
||||||
|
status_line: format!("Connecting to {server_addr}..."),
|
||||||
|
should_quit: false,
|
||||||
|
username: None,
|
||||||
|
server_addr: server_addr.to_string(),
|
||||||
|
focus: Focus::Input,
|
||||||
|
notification: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_conv_id(&self) -> Option<&[u8; 16]> {
|
||||||
|
self.conversations
|
||||||
|
.get(self.selected_conversation)
|
||||||
|
.map(|c| &c.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_next_conversation(&mut self) {
|
||||||
|
if self.conversations.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.selected_conversation =
|
||||||
|
(self.selected_conversation + 1) % self.conversations.len();
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_prev_conversation(&mut self) {
|
||||||
|
if self.conversations.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.selected_conversation == 0 {
|
||||||
|
self.selected_conversation = self.conversations.len() - 1;
|
||||||
|
} else {
|
||||||
|
self.selected_conversation -= 1;
|
||||||
|
}
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_up(&mut self) {
|
||||||
|
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_down(&mut self) {
|
||||||
|
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_status(&mut self) {
|
||||||
|
let user = self
|
||||||
|
.username
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("not logged in");
|
||||||
|
let conv_count = self.conversations.len();
|
||||||
|
self.status_line = format!(
|
||||||
|
"Connected to {} | {} | {} conversation{}",
|
||||||
|
self.server_addr,
|
||||||
|
user,
|
||||||
|
conv_count,
|
||||||
|
if conv_count == 1 { "" } else { "s" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Terminal Drop Guard ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Ensures the terminal is restored on drop (including panics).
|
||||||
|
struct TerminalGuard;
|
||||||
|
|
||||||
|
impl Drop for TerminalGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = disable_raw_mode();
|
||||||
|
let _ = execute!(
|
||||||
|
std::io::stdout(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry Point ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Run the full-screen TUI, subscribing to SDK events.
|
||||||
|
///
|
||||||
|
/// The caller must have already connected and optionally authenticated the
|
||||||
|
/// `QpqClient`. The TUI subscribes to events and drives the UI loop.
|
||||||
|
pub async fn run_v2_tui(client: &mut QpqClient) -> anyhow::Result<()> {
|
||||||
|
let server_addr = if client.is_connected() {
|
||||||
|
"server"
|
||||||
|
} else {
|
||||||
|
"disconnected"
|
||||||
|
};
|
||||||
|
let mut app = TuiApp::new(server_addr);
|
||||||
|
|
||||||
|
// Populate initial state from client.
|
||||||
|
if let Some(name) = client.username() {
|
||||||
|
app.username = Some(String::from(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing conversations from the conversation store.
|
||||||
|
if let Ok(store) = client.conversations() {
|
||||||
|
let store: &ConversationStore = store;
|
||||||
|
if let Ok(convs) = store.list_conversations() {
|
||||||
|
for conv in &convs {
|
||||||
|
app.conversations.push(ConversationItem {
|
||||||
|
id: conv.id.0,
|
||||||
|
name: conv.display_name.clone(),
|
||||||
|
unread: conv.unread_count as usize,
|
||||||
|
last_message: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load messages for the first conversation.
|
||||||
|
load_messages_for_selected(&mut app, client);
|
||||||
|
|
||||||
|
app.update_status();
|
||||||
|
|
||||||
|
// Subscribe to SDK events.
|
||||||
|
let mut event_rx = client.subscribe();
|
||||||
|
|
||||||
|
// Terminal setup.
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = std::io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
let _guard = TerminalGuard;
|
||||||
|
|
||||||
|
// Main event loop.
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| ui(frame, &app))?;
|
||||||
|
|
||||||
|
// Poll for crossterm input events (50ms timeout for responsiveness).
|
||||||
|
if event::poll(Duration::from_millis(50))? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
handle_key(&mut app, client, key).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process all pending SDK events (non-blocking).
|
||||||
|
loop {
|
||||||
|
match event_rx.try_recv() {
|
||||||
|
Ok(sdk_event) => handle_sdk_event(&mut app, sdk_event),
|
||||||
|
Err(broadcast::error::TryRecvError::Empty) => break,
|
||||||
|
Err(broadcast::error::TryRecvError::Lagged(n)) => {
|
||||||
|
app.notification =
|
||||||
|
Some(format!("Warning: dropped {n} events (lagged)"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(broadcast::error::TryRecvError::Closed) => {
|
||||||
|
app.notification =
|
||||||
|
Some("Event channel closed".to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.should_quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SDK Event Handling ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn handle_sdk_event(app: &mut TuiApp, event: ClientEvent) {
|
||||||
|
match event {
|
||||||
|
ClientEvent::Connected => {
|
||||||
|
app.notification = Some("Connected to server".to_string());
|
||||||
|
app.update_status();
|
||||||
|
}
|
||||||
|
ClientEvent::Disconnected { reason } => {
|
||||||
|
app.status_line = format!("Disconnected: {reason}");
|
||||||
|
app.notification = Some(format!("Disconnected: {reason}"));
|
||||||
|
}
|
||||||
|
ClientEvent::Registered { username } => {
|
||||||
|
app.notification = Some(format!("Registered as {username}"));
|
||||||
|
}
|
||||||
|
ClientEvent::LoggedIn { username } => {
|
||||||
|
app.username = Some(username.clone());
|
||||||
|
app.notification = Some(format!("Logged in as {username}"));
|
||||||
|
app.update_status();
|
||||||
|
}
|
||||||
|
ClientEvent::LoggedOut { username } => {
|
||||||
|
app.username = None;
|
||||||
|
app.notification = Some(format!("Logged out ({username})"));
|
||||||
|
app.update_status();
|
||||||
|
}
|
||||||
|
ClientEvent::Authenticated { username } => {
|
||||||
|
app.username = Some(username.clone());
|
||||||
|
app.update_status();
|
||||||
|
}
|
||||||
|
ClientEvent::MessageReceived {
|
||||||
|
conversation_id,
|
||||||
|
sender_name,
|
||||||
|
body,
|
||||||
|
timestamp_ms,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let sender = sender_name
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let ts = format_timestamp(timestamp_ms);
|
||||||
|
|
||||||
|
// If this is the active conversation, add to the message list.
|
||||||
|
let is_active = app
|
||||||
|
.active_conv_id()
|
||||||
|
.map(|id| *id == conversation_id)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if is_active {
|
||||||
|
app.messages.push(DisplayMessage {
|
||||||
|
timestamp: ts,
|
||||||
|
sender: sender.clone(),
|
||||||
|
body: body.clone(),
|
||||||
|
is_outgoing: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Increment unread count for non-active conversations.
|
||||||
|
if let Some(conv) = app
|
||||||
|
.conversations
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == conversation_id)
|
||||||
|
{
|
||||||
|
conv.unread = conv.unread.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_message on the conversation item.
|
||||||
|
if let Some(conv) = app
|
||||||
|
.conversations
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == conversation_id)
|
||||||
|
{
|
||||||
|
conv.last_message = Some(format!("{sender}: {body}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ClientEvent::MessageSent { .. } => {
|
||||||
|
// Nothing to do — the message was already added optimistically.
|
||||||
|
}
|
||||||
|
ClientEvent::ConversationCreated {
|
||||||
|
conversation_id,
|
||||||
|
display_name,
|
||||||
|
} => {
|
||||||
|
// Add to the conversation list if not already present.
|
||||||
|
let exists = app
|
||||||
|
.conversations
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.id == conversation_id);
|
||||||
|
if !exists {
|
||||||
|
app.conversations.push(ConversationItem {
|
||||||
|
id: conversation_id,
|
||||||
|
name: display_name.clone(),
|
||||||
|
unread: 0,
|
||||||
|
last_message: None,
|
||||||
|
});
|
||||||
|
app.notification =
|
||||||
|
Some(format!("New conversation: {display_name}"));
|
||||||
|
}
|
||||||
|
app.update_status();
|
||||||
|
}
|
||||||
|
ClientEvent::MemberAdded {
|
||||||
|
conversation_id,
|
||||||
|
member_key,
|
||||||
|
} => {
|
||||||
|
let short_key = hex::encode(&member_key[..member_key.len().min(4)]);
|
||||||
|
app.notification =
|
||||||
|
Some(format!("Member {short_key} added to conversation"));
|
||||||
|
let _ = conversation_id;
|
||||||
|
}
|
||||||
|
ClientEvent::MemberRemoved {
|
||||||
|
conversation_id,
|
||||||
|
member_key,
|
||||||
|
} => {
|
||||||
|
let short_key = hex::encode(&member_key[..member_key.len().min(4)]);
|
||||||
|
app.notification =
|
||||||
|
Some(format!("Member {short_key} removed from conversation"));
|
||||||
|
let _ = conversation_id;
|
||||||
|
}
|
||||||
|
ClientEvent::PushEvent { event_type, .. } => {
|
||||||
|
app.notification =
|
||||||
|
Some(format!("Push event (type={event_type})"));
|
||||||
|
}
|
||||||
|
ClientEvent::Error { message } => {
|
||||||
|
app.notification = Some(format!("Error: {message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard Handling ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn handle_key(app: &mut TuiApp, client: &mut QpqClient, key: event::KeyEvent) {
|
||||||
|
// Dismiss help overlay on any key.
|
||||||
|
if app.focus == Focus::Help {
|
||||||
|
app.focus = Focus::Input;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
// Quit.
|
||||||
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
app.should_quit = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
app.should_quit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab — switch conversations.
|
||||||
|
KeyCode::Tab => {
|
||||||
|
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||||
|
app.select_prev_conversation();
|
||||||
|
} else {
|
||||||
|
app.select_next_conversation();
|
||||||
|
}
|
||||||
|
load_messages_for_selected(app, client);
|
||||||
|
}
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
app.select_prev_conversation();
|
||||||
|
load_messages_for_selected(app, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrolling.
|
||||||
|
KeyCode::Up => app.scroll_up(),
|
||||||
|
KeyCode::Down => app.scroll_down(),
|
||||||
|
KeyCode::PageUp => {
|
||||||
|
for _ in 0..10 {
|
||||||
|
app.scroll_up();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::PageDown => {
|
||||||
|
for _ in 0..10 {
|
||||||
|
app.scroll_down();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit input.
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let text = app.input.trim().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
app.input.clear();
|
||||||
|
app.input_cursor = 0;
|
||||||
|
handle_input(app, client, &text).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text editing.
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.input.insert(app.input_cursor, c);
|
||||||
|
app.input_cursor += c.len_utf8();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if app.input_cursor > 0 {
|
||||||
|
// Find the byte offset of the previous character.
|
||||||
|
let prev = app.input[..app.input_cursor]
|
||||||
|
.char_indices()
|
||||||
|
.next_back()
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(0);
|
||||||
|
app.input.remove(prev);
|
||||||
|
app.input_cursor = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Delete => {
|
||||||
|
if app.input_cursor < app.input.len() {
|
||||||
|
app.input.remove(app.input_cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
if app.input_cursor > 0 {
|
||||||
|
let prev = app.input[..app.input_cursor]
|
||||||
|
.char_indices()
|
||||||
|
.next_back()
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(0);
|
||||||
|
app.input_cursor = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
if app.input_cursor < app.input.len() {
|
||||||
|
let next = app.input[app.input_cursor..]
|
||||||
|
.char_indices()
|
||||||
|
.nth(1)
|
||||||
|
.map(|(i, _)| app.input_cursor + i)
|
||||||
|
.unwrap_or(app.input.len());
|
||||||
|
app.input_cursor = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
app.input_cursor = 0;
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
app.input_cursor = app.input.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle submitted input: either a /command or a chat message.
|
||||||
|
async fn handle_input(app: &mut TuiApp, client: &mut QpqClient, text: &str) {
|
||||||
|
if let Some(cmd) = text.strip_prefix('/') {
|
||||||
|
handle_command(app, client, cmd).await;
|
||||||
|
} else {
|
||||||
|
// Send as chat message to the active conversation.
|
||||||
|
if app.active_conv_id().is_none() {
|
||||||
|
app.notification = Some("No conversation selected".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add optimistically to the display.
|
||||||
|
let sender = app
|
||||||
|
.username
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("you")
|
||||||
|
.to_string();
|
||||||
|
let ts = format_timestamp(now_ms());
|
||||||
|
app.messages.push(DisplayMessage {
|
||||||
|
timestamp: ts,
|
||||||
|
sender: sender.clone(),
|
||||||
|
body: text.to_string(),
|
||||||
|
is_outgoing: true,
|
||||||
|
});
|
||||||
|
// Snap to bottom.
|
||||||
|
app.scroll_offset = 0;
|
||||||
|
|
||||||
|
// TODO: actually send via SDK when the send pipeline is wired up.
|
||||||
|
// For now, emit a notification.
|
||||||
|
app.notification = Some(format!("Sent: {text}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a /command.
|
||||||
|
async fn handle_command(app: &mut TuiApp, client: &mut QpqClient, cmd: &str) {
|
||||||
|
let parts: Vec<&str> = cmd.splitn(3, ' ').collect();
|
||||||
|
let verb = parts.first().copied().unwrap_or("");
|
||||||
|
|
||||||
|
match verb {
|
||||||
|
"help" | "h" | "?" => {
|
||||||
|
app.focus = Focus::Help;
|
||||||
|
}
|
||||||
|
"quit" | "exit" | "q" => {
|
||||||
|
app.should_quit = true;
|
||||||
|
}
|
||||||
|
"login" => {
|
||||||
|
if parts.len() < 3 {
|
||||||
|
app.notification =
|
||||||
|
Some("Usage: /login <username> <password>".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let username = parts[1];
|
||||||
|
let password = parts[2];
|
||||||
|
match client.login(username, password).await {
|
||||||
|
Ok(()) => {
|
||||||
|
app.username = Some(username.to_string());
|
||||||
|
app.notification =
|
||||||
|
Some(format!("Logged in as {username}"));
|
||||||
|
app.update_status();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.notification =
|
||||||
|
Some(format!("Login failed: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"register" => {
|
||||||
|
if parts.len() < 3 {
|
||||||
|
app.notification =
|
||||||
|
Some("Usage: /register <username> <password>".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let username = parts[1];
|
||||||
|
let password = parts[2];
|
||||||
|
match client.register(username, password).await {
|
||||||
|
Ok(()) => {
|
||||||
|
app.notification =
|
||||||
|
Some(format!("Registered as {username}"));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.notification =
|
||||||
|
Some(format!("Registration failed: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"logout" => {
|
||||||
|
match client.logout() {
|
||||||
|
Ok(()) => {
|
||||||
|
app.username = None;
|
||||||
|
app.notification = Some("Logged out".to_string());
|
||||||
|
app.update_status();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.notification =
|
||||||
|
Some(format!("Logout failed: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"dm" => {
|
||||||
|
if parts.len() < 2 {
|
||||||
|
app.notification =
|
||||||
|
Some("Usage: /dm <username>".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.notification =
|
||||||
|
Some(format!("DM with {} (not yet implemented)", parts[1]));
|
||||||
|
}
|
||||||
|
"group" => {
|
||||||
|
if parts.len() < 3 || parts[1] != "create" {
|
||||||
|
app.notification =
|
||||||
|
Some("Usage: /group create <name>".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.notification =
|
||||||
|
Some(format!("Create group '{}' (not yet implemented)", parts[2]));
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
app.notification =
|
||||||
|
Some(format!("Unknown command: /{other}. Type /help for help."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn ui(frame: &mut Frame, app: &TuiApp) {
|
||||||
|
let size = frame.area();
|
||||||
|
|
||||||
|
// Help overlay.
|
||||||
|
if app.focus == Focus::Help {
|
||||||
|
draw_help(frame, size);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level vertical split: [main area | input bar | status bar].
|
||||||
|
let v_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(5), // main area
|
||||||
|
Constraint::Length(3), // input bar
|
||||||
|
Constraint::Length(1), // status bar
|
||||||
|
])
|
||||||
|
.split(size);
|
||||||
|
|
||||||
|
// Main area: sidebar | messages.
|
||||||
|
let h_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
|
||||||
|
.split(v_chunks[0]);
|
||||||
|
|
||||||
|
draw_sidebar(frame, app, h_chunks[0]);
|
||||||
|
draw_messages(frame, app, h_chunks[1]);
|
||||||
|
draw_input(frame, app, v_chunks[1]);
|
||||||
|
draw_status(frame, app, v_chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_sidebar(frame: &mut Frame, app: &TuiApp, area: Rect) {
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.conversations
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, conv)| {
|
||||||
|
let prefix = if i == app.selected_conversation {
|
||||||
|
"> "
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
|
||||||
|
let unread_suffix = if conv.unread > 0 {
|
||||||
|
format!(" ({})", conv.unread)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let style = if i == app.selected_conversation {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if conv.unread > 0 {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Gray)
|
||||||
|
};
|
||||||
|
|
||||||
|
ListItem::new(Line::from(Span::styled(
|
||||||
|
format!("{prefix}{}{unread_suffix}", conv.name),
|
||||||
|
style,
|
||||||
|
)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Conversations ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
if !app.conversations.is_empty() {
|
||||||
|
list_state.select(Some(app.selected_conversation));
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_stateful_widget(
|
||||||
|
List::new(items).block(block).highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::REVERSED),
|
||||||
|
),
|
||||||
|
area,
|
||||||
|
&mut list_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_messages(frame: &mut Frame, app: &TuiApp, area: Rect) {
|
||||||
|
let channel_title = app
|
||||||
|
.conversations
|
||||||
|
.get(app.selected_conversation)
|
||||||
|
.map(|c| format!(" {} ", c.name))
|
||||||
|
.unwrap_or_else(|| " Messages ".to_string());
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(channel_title)
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
|
||||||
|
let inner_height = area.height.saturating_sub(2) as usize;
|
||||||
|
|
||||||
|
// Build lines from messages (newest at bottom).
|
||||||
|
let lines: Vec<Line> = app
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let ts_span = Span::styled(
|
||||||
|
format!("{} ", m.timestamp),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
);
|
||||||
|
|
||||||
|
let sender_style = if m.is_outgoing {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
};
|
||||||
|
let sender_span = Span::styled(format!("{}: ", m.sender), sender_style);
|
||||||
|
let body_span = Span::raw(m.body.clone());
|
||||||
|
|
||||||
|
Line::from(vec![ts_span, sender_span, body_span])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Apply scroll: scroll_offset=0 means newest at bottom.
|
||||||
|
let total = lines.len();
|
||||||
|
let (visible_start, visible_end) = if total > inner_height {
|
||||||
|
let bottom = total.saturating_sub(app.scroll_offset);
|
||||||
|
let start = bottom.saturating_sub(inner_height);
|
||||||
|
(start, bottom)
|
||||||
|
} else {
|
||||||
|
(0, total)
|
||||||
|
};
|
||||||
|
|
||||||
|
let visible_lines: Vec<Line> = lines[visible_start..visible_end].to_vec();
|
||||||
|
|
||||||
|
// Show notification at the top if present.
|
||||||
|
let mut all_lines = Vec::new();
|
||||||
|
if let Some(ref note) = app.notification {
|
||||||
|
all_lines.push(Line::from(Span::styled(
|
||||||
|
note.clone(),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Magenta)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
all_lines.extend(visible_lines);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(all_lines)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_input(frame: &mut Frame, app: &TuiApp, area: Rect) {
|
||||||
|
let title = if app.conversations.is_empty() {
|
||||||
|
" Type /help for commands "
|
||||||
|
} else {
|
||||||
|
" Enter=send | Tab=switch | /help for commands "
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(title)
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
|
||||||
|
let display_text = if app.input.is_empty() {
|
||||||
|
"Type a message..."
|
||||||
|
} else {
|
||||||
|
&app.input
|
||||||
|
};
|
||||||
|
|
||||||
|
let style = if app.input.is_empty() {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
let input_text = Paragraph::new(display_text)
|
||||||
|
.block(block)
|
||||||
|
.style(style);
|
||||||
|
|
||||||
|
frame.render_widget(input_text, area);
|
||||||
|
|
||||||
|
// Position cursor in the input area.
|
||||||
|
if !app.input.is_empty() || true {
|
||||||
|
let cursor_x = area.x + 1 + app.input_cursor as u16;
|
||||||
|
let cursor_y = area.y + 1;
|
||||||
|
if cursor_x < area.x + area.width - 1 {
|
||||||
|
frame.set_cursor_position((cursor_x, cursor_y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_status(frame: &mut Frame, app: &TuiApp, area: Rect) {
|
||||||
|
let status = Paragraph::new(Line::from(Span::styled(
|
||||||
|
format!(" {} ", app.status_line),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
frame.render_widget(status, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_help(frame: &mut Frame, area: Rect) {
|
||||||
|
let help_text = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" quicproquo TUI -- Help",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" Keyboard shortcuts:",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(" Enter Send message / execute command"),
|
||||||
|
Line::from(" Up/Down Scroll messages"),
|
||||||
|
Line::from(" PgUp/PgDn Scroll messages (10 lines)"),
|
||||||
|
Line::from(" Tab Next conversation"),
|
||||||
|
Line::from(" Shift+Tab Previous conversation"),
|
||||||
|
Line::from(" Ctrl+C Quit"),
|
||||||
|
Line::from(" Ctrl+Q Quit"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" Commands:",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(" /help Show this help"),
|
||||||
|
Line::from(" /login <u> <p> Log in"),
|
||||||
|
Line::from(" /register <u> <p> Register a new account"),
|
||||||
|
Line::from(" /logout Log out"),
|
||||||
|
Line::from(" /dm <username> Start a DM"),
|
||||||
|
Line::from(" /group create <n> Create a group"),
|
||||||
|
Line::from(" /quit Exit the TUI"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" Press any key to dismiss",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Help ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
let help = Paragraph::new(help_text).block(block);
|
||||||
|
let help_area = centered_rect(60, 70, area);
|
||||||
|
// Clear the background.
|
||||||
|
frame.render_widget(
|
||||||
|
Block::default().style(Style::default().bg(Color::Black)),
|
||||||
|
help_area,
|
||||||
|
);
|
||||||
|
frame.render_widget(help, help_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a centered rectangle of `percent_x`% width and `percent_y`% height.
|
||||||
|
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||||
|
let v = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
Constraint::Percentage(percent_y),
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
let h = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
Constraint::Percentage(percent_x),
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
])
|
||||||
|
.split(v[1]);
|
||||||
|
h[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Load messages from the conversation store into the app for the selected
|
||||||
|
/// conversation.
|
||||||
|
fn load_messages_for_selected(app: &mut TuiApp, client: &QpqClient) {
|
||||||
|
app.messages.clear();
|
||||||
|
app.scroll_offset = 0;
|
||||||
|
|
||||||
|
let conv_id = match app.active_conv_id() {
|
||||||
|
Some(id) => *id,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let store: &ConversationStore = match client.conversations() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sdk_conv_id =
|
||||||
|
quicproquo_sdk::conversation::ConversationId::from_slice(&conv_id);
|
||||||
|
let sdk_conv_id = match sdk_conv_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let msgs = match store.load_recent_messages(&sdk_conv_id, 200) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for msg in &msgs {
|
||||||
|
let sender: String = if msg.is_outgoing {
|
||||||
|
app.username.as_deref().unwrap_or("you").to_string()
|
||||||
|
} else if let Some(ref name) = msg.sender_name {
|
||||||
|
name.clone()
|
||||||
|
} else {
|
||||||
|
let len = msg.sender_key.len().min(4);
|
||||||
|
hex::encode(&msg.sender_key[..len])
|
||||||
|
};
|
||||||
|
|
||||||
|
app.messages.push(DisplayMessage {
|
||||||
|
timestamp: format_timestamp(msg.timestamp_ms),
|
||||||
|
sender,
|
||||||
|
body: msg.body.clone(),
|
||||||
|
is_outgoing: msg.is_outgoing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark unread = 0 for the selected conversation.
|
||||||
|
if let Some(conv) = app
|
||||||
|
.conversations
|
||||||
|
.iter_mut()
|
||||||
|
.find(|c| c.id == conv_id)
|
||||||
|
{
|
||||||
|
conv.unread = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_timestamp(ms: u64) -> String {
|
||||||
|
let secs = ms / 1000;
|
||||||
|
let hours = (secs / 3600) % 24;
|
||||||
|
let minutes = (secs / 60) % 60;
|
||||||
|
format!("[{hours:02}:{minutes:02}]")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user