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;
|
||||
#[cfg(feature = "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 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