Rust TUI Chat Application - Mastering Terminal User Interfaces

Trish - Oct 29 - - Dev Community

Overview

Today, we're going to build a chat application using Rust's terminal user interface (TUI) libraries. This application will allow two instances to communicate over a network, simulating a simple chat. We'll use tui for the interface and tokio for asynchronous networking.

Difficulty

🌲 Advanced

🔗 Keep the conversation going on Twitter(X): @trish_07

🔗 GitHub Repository: Explore the 7Days7RustProjects Repository

Prerequisites

  • Intermediate Rust knowledge
  • Familiarity with asynchronous programming
  • Basic understanding of networking in Rust

Project Structure

First, let's set up our project:

mkdir rust-tui-chat
cd rust-tui-chat
cargo init --bin
Enter fullscreen mode Exit fullscreen mode

Now, let’s define our folder structure:

rust-tui-chat/
│
├── src/
│   ├── main.rs
│   ├── ui.rs
│   ├── network.rs
│   └── message.rs
│
├── Cargo.toml
└── README.md
Enter fullscreen mode Exit fullscreen mode

Step 1: Setting up Cargo.toml

[package]
name = "rust_tui_chat"
version = "0.1.0"
edition = "2018"

[dependencies]
crossterm = "0.20"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.6", features = ["codec"] }
tui = { version = "0.16", default-features = false, features = ['crossterm'] }
anyhow = "1.0"
Enter fullscreen mode Exit fullscreen mode

Step 2: ui.rs - Terminal User Interface

use tui::{
    backend::CrosstermBackend,
    layout::{Constraint, Direction, Layout},
    widgets::{Block, Borders, Paragraph},
    Terminal,
};
use crossterm::{
    event::{self, DisableMouseCapture, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};

pub struct Ui {
    terminal: Terminal<CrosstermBackend<std::io::Stdout>>,
}

impl Ui {
    pub fn new() -> Result<Self, anyhow::Error> {
        enable_raw_mode()?;
        let mut stdout = std::io::stdout();
        execute!(stdout, EnterAlternateScreen)?;
        let backend = CrosstermBackend::new(stdout);
        let terminal = Terminal::new(backend)?;
        Ok(Self { terminal })
    }

    pub fn draw(&mut self, input: &str, messages: &[String]) -> Result<(), anyhow::Error> {
        self.terminal.draw(|f| {
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .margin(1)
                .constraints([Constraint::Percentage(80), Constraint::Percentage(20)].as_ref())
                .split(f.size());
            let messages_block = Block::default().title("Chat").borders(Borders::ALL);
            let input_block = Block::default().title("Input").borders(Borders::ALL);
            f.render_widget(messages_block, chunks[0]);
            f.render_widget(Paragraph::new(input), chunks[1]);
        })?;
        Ok(())
    }

    pub fn cleanup(&mut self) -> Result<(), anyhow::Error> {
        disable_raw_mode()?;
        execute!(self.terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
        self.terminal.show_cursor()?;
        Ok(())
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: network.rs - Network Communication

use tokio::{
    net::{TcpListener, TcpStream},
    io::{AsyncReadExt, AsyncWriteExt},
};

pub struct Network {
    stream: Option<TcpStream>,
}

impl Network {
    pub async fn new(addr: &str) -> Result<Self, anyhow::Error> {
        let stream = if addr.contains(":") {
            TcpStream::connect(addr).await?
        } else {
            let listener = TcpListener::bind(addr).await?;
            let (stream, _) = listener.accept().await?;
            stream
        };
        Ok(Self { stream: Some(stream) })
    }

    pub async fn send_message(&mut self, msg: &str) -> Result<(), anyhow::Error> {
        if let Some(stream) = self.stream.as_mut() {
            stream.write_all(&[msg.len() as u8]).await?;
            stream.write_all(msg.as_bytes()).await?;
        }
        Ok(())
    }

    pub async fn receive_message(&mut self) -> Result<String, anyhow::Error> {
        if let Some(stream) = self.stream.as_mut() {
            let mut len = [0u8; 1];
            stream.read_exact(&mut len).await?;
            let mut buffer = vec![0; len[0] as usize];
            stream.read_exact(&mut buffer).await?;
            return String::from_utf8(buffer).map_err(|e| e.into());
        }
        Err(anyhow::anyhow!("No stream available"))
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: message.rs - Message Handling

pub struct Message {
    pub text: String,
}

impl Message {
    pub fn new(text: String) -> Self {
        Message { text }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: main.rs - Main Application Logic

mod ui;
mod network;
mod message;

use anyhow::Result;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<()> {
    let mut ui = ui::Ui::new()?;
    let (tx, rx) = mpsc::channel();
    let mut network = network::Network::new("127.0.0.1:8080").await?;

    let handle = thread::spawn(move || {
        let mut input = String::new();
        loop {
            if let Ok(Event::Key(key)) = event::read() {
                match key.code {
                    KeyCode::Char(c) => input.push(c),
                    KeyCode::Enter => {
                        let _ = tx.send(input.clone());
                        input.clear();
                    },
                    KeyCode::Backspace => { input.pop(); },
                    KeyCode::Esc => break,
                    _ => {},
                }
            }
        }
    });

    let mut messages = Vec::new();
    loop {
        match rx.try_recv() {
            Ok(msg) => {
                network.send_message(&msg).await?;
                messages.push(msg);
            },
            Err(_) => {
                if let Ok(msg) = network.receive_message().await {
                    messages.push(msg);
                }
            },
        }
        ui.draw(&input, &messages)?;
        thread::sleep(Duration::from_millis(100));
    }

    ui.cleanup()?;
    handle.join().unwrap();
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Running Your Chat App

To run the application:

cargo run
Enter fullscreen mode Exit fullscreen mode

Explanation

  • UI: We've used tui for creating a clean, interactive terminal interface where users can see messages and type their own.
  • Networking: Async networking with tokio allows for sending and receiving messages over TCP. Here, you can run one instance as the server and another as the client locally or over a network.
  • Message Handling: Simple struct to manage messages in our application.

Conclusion

This project is an advanced step into Rust programming, combining TUI, networking, and asynchronous programming. It opens the door to further enhancements like:

  • Adding user authentication.
  • Implementing chat rooms or private messaging.
  • Enhancing the UI with more widgets or color.

This chat application serves as a solid foundation for experimenting with terminal applications and network communication in Rust.

. . . . . .