use std::io;
use color_eyre::eyre::Result;
use crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use tears::{
prelude::*,
subscription::{
terminal::TerminalEvents,
websocket::{WebSocket, WebSocketCommand, WebSocketMessage},
},
};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
#[derive(Debug)]
pub enum Msg {
Terminal(Event),
TerminalError(io::Error),
WebSocket(WebSocketMessage),
Quit,
}
pub struct EchoChat {
input: String,
messages: Vec<String>,
ws_sender: Option<mpsc::UnboundedSender<WebSocketCommand>>,
}
impl Default for EchoChat {
fn default() -> Self {
Self {
input: String::new(),
messages: vec!["Connecting to WebSocket...".to_string()],
ws_sender: None,
}
}
}
impl EchoChat {
fn handle_key(&mut self, code: KeyCode) -> Command<Msg> {
match code {
KeyCode::Char('q') => Command::message(Msg::Quit),
KeyCode::Char(c) => {
self.input.push(c);
Command::none()
}
KeyCode::Backspace => {
self.input.pop();
Command::none()
}
KeyCode::Enter => self.submit_message(),
_ => Command::none(),
}
}
fn submit_message(&mut self) -> Command<Msg> {
if self.input.is_empty() {
return Command::none();
}
let text = self.input.clone();
self.input.clear();
if let Some(sender) = &self.ws_sender {
if sender
.send(WebSocketCommand::SendText(text.clone()))
.is_ok()
{
self.messages.push(format!("Sent: {text}"));
} else {
self.messages.push("Failed to send message".to_string());
}
} else {
self.messages.push("Not connected to WebSocket".to_string());
}
Command::none()
}
fn message_style(message: &str) -> Style {
if message.starts_with("Sent:") {
Style::default().fg(Color::Green)
} else if message.starts_with("Received:") {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::Yellow)
}
}
}
impl Application for EchoChat {
type Message = Msg;
type Flags = ();
fn new(_flags: ()) -> (Self, Command<Msg>) {
(Self::default(), Command::none())
}
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg::Terminal(Event::Key(key)) if key.kind == KeyEventKind::Press => {
self.handle_key(key.code)
}
Msg::Terminal(_) => Command::none(),
Msg::TerminalError(e) => {
self.messages.push(format!("Terminal error: {e}"));
Command::none()
}
Msg::WebSocket(ws_msg) => {
match ws_msg {
WebSocketMessage::Connected { sender } => {
self.ws_sender = Some(sender);
self.messages.push("Connected to WebSocket!".to_string());
}
WebSocketMessage::Disconnected => {
self.messages
.push("Disconnected from WebSocket".to_string());
self.ws_sender = None;
}
WebSocketMessage::Received(msg) => {
match msg {
Message::Text(text) => {
self.messages.push(format!("Received: {text}"));
}
Message::Binary(data) => {
self.messages
.push(format!("Received binary: {} bytes", data.len()));
}
_ => {}
}
}
WebSocketMessage::Error { error } => {
self.messages.push(format!("Error: {error}"));
}
}
Command::none()
}
Msg::Quit => Command::effect(Action::Quit),
}
}
fn view(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(3)])
.split(frame.area());
self.render_messages(frame, chunks[0]);
self.render_input(frame, chunks[1]);
}
fn subscriptions(&self) -> Vec<Subscription<Msg>> {
vec![
Subscription::new(TerminalEvents::new()).map(|result| match result {
Ok(event) => Msg::Terminal(event),
Err(e) => Msg::TerminalError(e),
}),
Subscription::new(WebSocket::new("wss://echo.websocket.org")).map(Msg::WebSocket),
]
}
}
impl EchoChat {
fn render_messages(&self, frame: &mut Frame, area: Rect) {
let max_messages = area.height.saturating_sub(2) as usize;
let messages: Vec<ListItem> = self
.messages
.iter()
.rev()
.take(max_messages)
.rev()
.map(|m| {
let style = Self::message_style(m);
ListItem::new(Line::from(Span::styled(m.clone(), style)))
})
.collect();
let messages_widget = List::new(messages).block(
Block::default()
.borders(Borders::ALL)
.title("WebSocket Echo Chat (q: quit)"),
);
frame.render_widget(messages_widget, area);
}
fn render_input(&self, frame: &mut Frame, area: Rect) {
let input_widget = Paragraph::new(self.input.as_str())
.style(Style::default().fg(Color::White))
.block(
Block::default()
.borders(Borders::ALL)
.title("Type message and press Enter"),
);
frame.render_widget(input_widget, area);
}
}
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let mut terminal = ratatui::init();
let runtime = Runtime::<EchoChat>::new((), 60);
let result = runtime.run(&mut terminal).await;
ratatui::restore();
result?;
Ok(())
}