Initial Commit

This commit is contained in:
2025-07-14 15:12:55 -04:00
commit b0f8e535d2
3 changed files with 1227 additions and 0 deletions

1062
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

12
Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "tui-mqtt-chat"
version = "0.1.0"
edition = "2021"
[dependencies]
ratatui = { version = "0.27.0", features = ["crossterm"] }
crossterm = { version = "0.27.0", features = ["event-stream"] }
rumqttc = "0.24.0"
tokio = { version = "1.38.0", features = ["full"] }
tokio-stream = "0.1.15"
rand = "0.8.5"

153
src/main.rs Normal file
View File

@@ -0,0 +1,153 @@
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, EventStream},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{prelude::*, widgets::*};
use rumqttc::{AsyncClient, MqttOptions, QoS};
use std::{io, time::Duration};
use tokio::sync::mpsc;
use tokio_stream::StreamExt;
use rand::Rng;
struct App {
messages: Vec<String>,
input: String,
username: String,
}
impl App {
fn new(username: String) -> App {
App {
messages: Vec::new(),
input: String::new(),
username,
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// generate random username
let mut rng = rand::thread_rng();
let random_number = rng.gen_range(1..=999);
let username = format!("User{}", random_number);
// setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// create app and run it
let app = App::new(username);
let res = run_app(&mut terminal, app).await;
// restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
Ok(())
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<()> {
let (tx, mut rx) = mpsc::channel::<String>(100);
let mut mqttoptions = MqttOptions::new(app.username.clone(), "172.16.0.3", 1883);
mqttoptions.set_keep_alive(Duration::from_secs(5));
let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10);
client.subscribe("chat/msg", QoS::AtMostOnce).await.unwrap();
let client_clone = client.clone();
tokio::spawn(async move {
while let Ok(notification) = eventloop.poll().await {
if let rumqttc::Event::Incoming(rumqttc::Packet::Publish(p)) = notification {
let message = String::from_utf8_lossy(&p.payload).to_string();
if tx.send(message).await.is_err() {
break;
}
}
}
});
let mut reader = EventStream::new();
loop {
terminal.draw(|f| ui(f, &app))?;
tokio::select! {
Some(Ok(event)) = reader.next() => {
if let Event::Key(key) = event {
match key.code {
KeyCode::Enter => {
let message = app.input.drain(..).collect::<String>();
if message.starts_with("/nick ") {
let new_username = message.split_whitespace().nth(1).unwrap_or(&app.username).to_string();
app.username = new_username;
app.messages.push(format!("Username changed to: {}", app.username));
} else {
let formatted_message = format!("{}: {}", app.username, message);
client_clone
.publish("chat/msg", QoS::AtMostOnce, false, formatted_message.as_bytes())
.await
.unwrap();
}
}
KeyCode::Char(c) => {
app.input.push(c);
}
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Esc => {
return Ok(());
}
_ => {}
}
}
}
Some(message) = rx.recv() => {
app.messages.push(message);
}
else => {
break;
}
}
}
Ok(())
}
fn ui(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([Constraint::Percentage(80), Constraint::Percentage(20)].as_ref())
.split(f.size());
let messages: Vec<ListItem> = app
.messages
.iter()
.map(|m| ListItem::new(m.as_str()))
.collect();
let messages = List::new(messages)
.block(Block::default().borders(Borders::ALL).title("Messages"));
f.render_widget(messages, chunks[0]);
let input = Paragraph::new(app.input.as_str())
.style(Style::default())
.block(Block::default().borders(Borders::ALL).title("Input"));
f.render_widget(input, chunks[1]);
}