mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-06-28 20:19:26 +00:00
* Sort all Cargo.toml deps for less conflicts * Move relative path deps to workspace * Standardize workspace imports * Rename ‘client’ to ‘logos-chat’ * Cleanups
255 lines
8.5 KiB
Rust
255 lines
8.5 KiB
Rust
//! Terminal UI using ratatui.
|
|
|
|
use std::io::{self, Stdout};
|
|
|
|
use crossterm::{
|
|
event::{self, Event, KeyCode, KeyEventKind},
|
|
execute,
|
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
|
};
|
|
use ratatui::{
|
|
Frame, Terminal,
|
|
backend::CrosstermBackend,
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
|
};
|
|
|
|
use logos_chat::DeliveryService;
|
|
|
|
use crate::app::ChatApp;
|
|
|
|
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
|
|
|
/// Initialize the terminal.
|
|
pub fn init() -> io::Result<Tui> {
|
|
execute!(io::stdout(), EnterAlternateScreen)?;
|
|
enable_raw_mode()?;
|
|
let backend = CrosstermBackend::new(io::stdout());
|
|
Terminal::new(backend)
|
|
}
|
|
|
|
/// Restore the terminal to its original state.
|
|
pub fn restore() -> io::Result<()> {
|
|
disable_raw_mode()?;
|
|
execute!(io::stdout(), LeaveAlternateScreen)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Draw the UI.
|
|
pub fn draw<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3), // Header
|
|
Constraint::Min(10), // Messages
|
|
Constraint::Length(3), // Input
|
|
Constraint::Length(3), // Status
|
|
])
|
|
.split(frame.area());
|
|
|
|
draw_header(frame, app, chunks[0]);
|
|
draw_messages(frame, app, chunks[1]);
|
|
draw_input(frame, app, chunks[2]);
|
|
draw_status(frame, app, chunks[3]);
|
|
}
|
|
|
|
fn draw_header<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
|
let title = match app.current_session() {
|
|
Some(session) => {
|
|
let id = &session.chat_id[..8.min(session.chat_id.len())];
|
|
match &session.nickname {
|
|
Some(name) => format!(" 💬 Chat: {} ↔ {name} ({id}) ", app.user_name),
|
|
None => format!(" 💬 Chat: {} ↔ ({id}) ", app.user_name),
|
|
}
|
|
}
|
|
None => format!(" 💬 {} — no active chat ", app.user_name),
|
|
};
|
|
|
|
let header = Paragraph::new(title)
|
|
.style(
|
|
Style::default()
|
|
.fg(Color::Cyan)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.block(Block::default().borders(Borders::ALL));
|
|
|
|
frame.render_widget(header, area);
|
|
}
|
|
|
|
fn draw_messages<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
|
let remote_name = app
|
|
.current_session()
|
|
.map(|s| s.display_name())
|
|
.unwrap_or("Them");
|
|
|
|
// Inner width: area minus borders (2) for wrapping long content.
|
|
let inner_width = area.width.saturating_sub(2) as usize;
|
|
|
|
let messages: Vec<ListItem> = app
|
|
.messages()
|
|
.iter()
|
|
.flat_map(|msg| {
|
|
let (prefix, style) = if msg.from_self {
|
|
("You", Style::default().fg(Color::Green))
|
|
} else {
|
|
(remote_name, Style::default().fg(Color::Yellow))
|
|
};
|
|
|
|
let prefix_str = format!("{}: ", prefix);
|
|
let prefix_len = prefix_str.len();
|
|
|
|
// Split content into lines that fit within inner_width.
|
|
let content = &msg.content;
|
|
if content.is_empty() {
|
|
return vec![ListItem::new(Line::from(vec![Span::styled(
|
|
prefix_str,
|
|
style.add_modifier(Modifier::BOLD),
|
|
)]))];
|
|
}
|
|
|
|
let mut items = Vec::new();
|
|
let first_line_width = inner_width.saturating_sub(prefix_len).max(1);
|
|
|
|
// First line includes the prefix.
|
|
let (first_chunk, rest): (&str, &str) = if content.len() <= first_line_width {
|
|
(content.as_str(), "")
|
|
} else {
|
|
content.split_at(first_line_width)
|
|
};
|
|
|
|
items.push(ListItem::new(Line::from(vec![
|
|
Span::styled(prefix_str, style.add_modifier(Modifier::BOLD)),
|
|
Span::raw(first_chunk),
|
|
])));
|
|
|
|
// Continuation lines are indented to align with content.
|
|
let indent = " ".repeat(prefix_len);
|
|
let mut remaining: &str = rest;
|
|
while !remaining.is_empty() {
|
|
let chunk_width = inner_width.saturating_sub(prefix_len).max(1);
|
|
let (chunk, tail) = if remaining.len() <= chunk_width {
|
|
(remaining, "")
|
|
} else {
|
|
remaining.split_at(chunk_width)
|
|
};
|
|
items.push(ListItem::new(Line::from(vec![
|
|
Span::raw(indent.clone()),
|
|
Span::raw(chunk),
|
|
])));
|
|
remaining = tail;
|
|
}
|
|
|
|
items
|
|
})
|
|
.collect();
|
|
|
|
let title = match app.current_session() {
|
|
Some(s) => match &s.nickname {
|
|
Some(name) => format!(" Messages with {name} "),
|
|
None => format!(" Messages ({}) ", &s.chat_id[..8.min(s.chat_id.len())]),
|
|
},
|
|
None => " Command output ".to_string(),
|
|
};
|
|
|
|
let item_count = messages.len();
|
|
let messages_widget =
|
|
List::new(messages).block(Block::default().title(title).borders(Borders::ALL));
|
|
|
|
// Scroll so the last line is always visible (area height minus two borders).
|
|
let visible = area.height.saturating_sub(2) as usize;
|
|
let offset = item_count.saturating_sub(visible);
|
|
let mut list_state = ratatui::widgets::ListState::default().with_offset(offset);
|
|
frame.render_stateful_widget(messages_widget, area, &mut list_state);
|
|
}
|
|
|
|
fn draw_input<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
|
// Inner width: area minus borders (2).
|
|
let inner_width = area.width.saturating_sub(2) as usize;
|
|
let input_len = app.input.len();
|
|
|
|
// Scroll the view so the cursor (end of input) is always visible.
|
|
let scroll_offset = if input_len >= inner_width {
|
|
input_len - inner_width + 1
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let visible_input = &app.input[scroll_offset..];
|
|
|
|
let input = Paragraph::new(visible_input).style(Style::default()).block(
|
|
Block::default()
|
|
.title(" Input (Enter to send) ")
|
|
.borders(Borders::ALL),
|
|
);
|
|
|
|
frame.render_widget(input, area);
|
|
|
|
// Place cursor at the visible end of the input.
|
|
let cursor_x = area.x + (input_len - scroll_offset) as u16 + 1;
|
|
frame.set_cursor_position((cursor_x, area.y + 1));
|
|
}
|
|
|
|
fn draw_status<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
|
let status = Paragraph::new(app.status.as_str())
|
|
.style(Style::default().fg(Color::Gray))
|
|
.block(Block::default().title(" Status ").borders(Borders::ALL))
|
|
.wrap(Wrap { trim: true });
|
|
|
|
frame.render_widget(status, area);
|
|
}
|
|
|
|
/// Handle keyboard events.
|
|
pub fn handle_events<D: DeliveryService + 'static>(app: &mut ChatApp<D>) -> io::Result<bool> {
|
|
// Poll for events with a short timeout to allow checking incoming messages
|
|
if event::poll(std::time::Duration::from_millis(100))?
|
|
&& let Event::Key(key) = event::read()?
|
|
{
|
|
if key.kind != KeyEventKind::Press {
|
|
return Ok(true);
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Esc => return Ok(false),
|
|
// Handle Ctrl+C
|
|
KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
|
|
return Ok(false);
|
|
}
|
|
KeyCode::Enter if !app.input.is_empty() => {
|
|
let input = std::mem::take(&mut app.input);
|
|
|
|
if input.starts_with('/') {
|
|
match app.handle_command(&input) {
|
|
Ok(Some(response)) => {
|
|
app.status = response;
|
|
}
|
|
Ok(None) => {
|
|
// Quit signal
|
|
return Ok(false);
|
|
}
|
|
Err(e) => {
|
|
app.status = format!("Error: {}", e);
|
|
}
|
|
}
|
|
} else if app.current_session().is_some() {
|
|
if let Err(e) = app.send_message(&input) {
|
|
app.status = format!("Send error: {}", e);
|
|
}
|
|
} else {
|
|
app.status = "No active chat. Use /connect first.".to_string();
|
|
}
|
|
}
|
|
KeyCode::Char(c) => {
|
|
app.input.push(c);
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.input.pop();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Ok(true)
|
|
}
|