diff --git a/.gitignore b/.gitignore index e1a6a96..ede43cf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ # Ignore package version Cargo.lock + +# Logs +*.log \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..0e31d16 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "berublan.vscode-log-viewer" + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index dcbb46a..814fd6e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,6 @@ "cargo": { "args": [ "build", - "--bin=chat-server", "--package=chat-server" ], "filter": { @@ -16,8 +15,12 @@ "kind": "bin" } }, - "args": [], - "cwd": "${workspaceFolder}" + "stdio": [ + null, + null, + "${workspaceFolder}/server.log", + ], + "cwd": "${workspaceFolder}", }, { "type": "lldb", @@ -26,8 +29,7 @@ "cargo": { "args": [ "build", - "--bin=chat-client", - "--package=chat-client" + "--package=chat-client", ], "filter": { "name": "chat-client", @@ -35,7 +37,13 @@ } }, "args": [], - "cwd": "${workspaceFolder}" + "cwd": "${workspaceFolder}", + "stdio": [ + null, + null, + "${workspaceFolder}/client.log", + ], + "terminal": "external", } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fd9a93c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "logViewer.watch": [ + { + "title": "Client", + "pattern": "client.log" + }, + { + "title": "Server", + "pattern": "server.log" + } + ] +} \ No newline at end of file diff --git a/client/Cargo.toml b/client/Cargo.toml index 948dde1..fef6d5e 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -12,7 +12,11 @@ tokio-tungstenite = { version = "0.21.0", features = [ # Terminal output ratatui = "0.25" -crossterm = "0.27" +crossterm = { version = "0.27", features = ["event-stream"] } # Error handling anyhow = "1.0.76" + +# Logging +log = "0.4.20" +env_logger = "0.10.1" diff --git a/client/src/input.rs b/client/src/input.rs new file mode 100644 index 0000000..883f4bf --- /dev/null +++ b/client/src/input.rs @@ -0,0 +1,69 @@ +use crossterm::event::{EventStream, KeyCode, KeyEvent}; +use futures_util::{FutureExt, StreamExt}; +use log::error; +use tokio::{sync::mpsc, task::JoinHandle}; + +use crate::Event; + +pub struct EventHandler { + channel: mpsc::UnboundedReceiver, + task: JoinHandle<()>, +} + +impl EventHandler { + pub fn new() -> Self { + // Create a channel to send events from the event handler to the main thread + let (events_tx, events_rx) = mpsc::unbounded_channel(); + let mut reader = EventStream::new(); + + // Spawn a task to read events from the terminal + let task = tokio::spawn(async move { + loop { + let event = reader.next().fuse(); + match event.await { + Some(Ok(event)) => { + if let Some(message) = Self::handle_event(event) { + events_tx.send(message).unwrap(); + } + } + Some(Err(e)) => error!("Error: {:?}\r", e), + None => break, + } + } + }); + + Self { + channel: events_rx, + task, + } + } + + /// Handle an event from the terminal + fn handle_event(event: crossterm::event::Event) -> Option { + match event { + crossterm::event::Event::Key(KeyEvent { code, .. }) => Self::handle_key(code), + crossterm::event::Event::Resize(width, height) => Some(Event::Resize { width, height }), + _ => None, + } + } + + /// Handle a key event from the terminal + fn handle_key(key: KeyCode) -> Option { + match key { + KeyCode::Esc => Some(Event::Quit), + KeyCode::Char(c) => Some(Event::Input(c)), + _ => None, + } + } + + /// Get the next event from the event handler + pub async fn next(&mut self) -> Event { + self.channel.recv().await.expect("Failed to receive event") + } +} + +impl Default for EventHandler { + fn default() -> Self { + Self::new() + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs new file mode 100644 index 0000000..b5bbcbe --- /dev/null +++ b/client/src/lib.rs @@ -0,0 +1,10 @@ +pub mod input; +pub mod state; +pub mod tui; + +pub enum Event { + Input(char), + Refresh, + Quit, + Resize { width: u16, height: u16 }, +} diff --git a/client/src/main.rs b/client/src/main.rs index c072865..d6dd129 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,6 +1,8 @@ -use std::io::BufRead; +use std::{io::BufRead, sync::Arc}; +use chat_client::tui::TUI; use futures_util::{stream::SplitStream, SinkExt, StreamExt}; +use log::info; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, net::TcpStream, @@ -9,16 +11,20 @@ use tokio::{ }; use tokio_tungstenite::{connect_async, tungstenite::protocol::Message, WebSocketStream}; -mod tui; - #[tokio::main] async fn main() { - // tui::init().expect("Failed to draw TUI"); + // Setup env_logger to write to stderr + env_logger::init(); + + let s = Arc::default(); + let mut tui = chat_client::tui::TUI::new(s); + + let tui_thread = tokio::spawn(async move { tui.run().await }); let url = "wss://echo.websocket.events"; let (ws_stream, _) = connect_async(url).await.expect("Failed to connect"); - println!("WebSocket handshake has been successfully completed"); + info!("WebSocket handshake has been successfully completed"); let (mut write, read) = ws_stream.split(); let (write_tx, mut write_rx) = mpsc::unbounded_channel(); @@ -30,27 +36,17 @@ async fn main() { Ok(()) }); - let input = tokio::spawn(async move { - let lines = std::io::BufReader::new(std::io::stdin()).lines(); - for line in lines { - println!("Sending: {}", line.as_ref().unwrap()); - let line = line.expect("Failed to read line"); - write_tx.send(Message::text(line.trim())).unwrap(); - } - println!("Terminal got closed, exiting..."); - }); - let output = tokio::spawn(recieve(read)); tokio::select! { Ok(e) = output => println!("Websocket read thread exited: {:?}", e.err()), _ = write_thread => println!("Websocket write thread exited"), - _ = input => println!("stdin thread exited"), _ = tokio::signal::ctrl_c() => println!("Ctrl-C received"), + _ = tui_thread => println!("TUI exited"), } - println!("Exiting..."); - tokio::io::stdout().flush().await.unwrap(); - println!("Exited"); + + info!("Exiting"); + TUI::exit().expect("Failed to reset terminal"); } type WebsocketStream = SplitStream>>; @@ -59,13 +55,13 @@ async fn recieve(mut read: WebsocketStream) -> anyhow::Result<()> { while let Some(message) = read.next().await { let msg = message?; if msg.is_close() { - println!("Received close message"); + info!("Received close message"); break; } if msg.is_text() { let msg = msg.into_text().unwrap(); - println!("Received: {}", msg.trim()); + info!("Received: {}", msg.trim()); } } Ok(()) diff --git a/client/src/state.rs b/client/src/state.rs new file mode 100644 index 0000000..191212e --- /dev/null +++ b/client/src/state.rs @@ -0,0 +1,40 @@ +use std::sync::{atomic, Mutex}; + +use crate::Event; + +pub struct State { + pub counter: atomic::AtomicU32, + pub text_area: Mutex, +} + +impl State { + pub fn new() -> Self { + Self { + counter: atomic::AtomicU32::new(0), + text_area: Mutex::new(String::new()), + } + } + + pub fn update(&self, event: &Event) { + match event { + Event::Input(c) => match c { + 'c' => { + self.counter + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + _ => { + self.text_area.lock().unwrap().push(*c); + } + }, + Event::Refresh => {} + Event::Quit => {} + Event::Resize { width, height } => {} + }; + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} diff --git a/client/src/tui.rs b/client/src/tui.rs index 3d23be0..dddceec 100644 --- a/client/src/tui.rs +++ b/client/src/tui.rs @@ -4,48 +4,134 @@ use crossterm::{ ExecutableCommand, }; use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, prelude::{CrosstermBackend, Stylize, Terminal}, - widgets::Paragraph, + symbols::block, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, }; -use std::io::{stdout, Result, Stdout}; - -pub fn init() -> Result<()> { - initialize_panic_handler(); - stdout().execute(EnterAlternateScreen)?; - enable_raw_mode()?; - let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; - terminal.clear()?; - - loop { - terminal.draw(|frame| { - let area = frame.size(); - frame.render_widget( - Paragraph::new("Hello Ratatui! (press 'q' to quit)") - .white() - .on_blue(), - area, - ); - })?; - - if event::poll(std::time::Duration::from_millis(16))? { - if let event::Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { - break; +use std::{ + fmt::format, + io::{stdout, Result, Stdout}, + sync::Arc, +}; +use tokio::{select, sync::mpsc}; + +use crate::{input, state::State, Event}; + +use log::{debug, error}; + +pub struct TUI { + state: Arc, +} + +impl TUI { + pub fn new(state: Arc) -> Self { + Self { state } + } + + pub async fn run(&mut self) -> Result<()> { + Self::initialize_panic_handler(); + Self::enter()?; + + let mut terminal = + Terminal::new(CrosstermBackend::new(stdout())).expect("Failed to connect to terminal"); + terminal.clear()?; + terminal.show_cursor()?; + + let mut events = input::EventHandler::new(); + + loop { + terminal.draw(|frame| self.draw(frame))?; + + let next_event = events.next().await; + + // Update State + // Get mut arc + + self.state.update(&next_event); + + // Have TUI handle event + match &next_event { + Event::Input(c) => { + if let 'q' = c { + break; + } } - } + Event::Refresh => {} + Event::Quit => break, + Event::Resize { width, height } => { + terminal.resize(Rect::new(0, 0, *width, *height))?; + } + }; } + + Self::exit()?; + Ok(()) } - stdout().execute(LeaveAlternateScreen)?; - disable_raw_mode()?; - Ok(()) -} + fn draw(&self, frame: &mut Frame) { + let area = frame.size(); + frame.render_widget( + Paragraph::new(format!( + "Counter: {}", + self.state.counter.load(std::sync::atomic::Ordering::SeqCst) + )) + .white() + .on_blue(), + area, + ); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints(vec![Constraint::Percentage(80), Constraint::Percentage(20)]) + .split(frame.size()); + + let t = &self.state.text_area.lock().unwrap(); + let avaliable_space = (layout[1].height - 2) * (layout[1].width - 2); + + let text = if avaliable_space < t.len() as u16 { + debug!("Text doesnt fits"); + format!("{}{}", &t[..avaliable_space as usize - 1], block::FULL) + } else { + t.to_string() + }; + + let block = Block::default().borders(Borders::ALL).title("Input"); -pub fn initialize_panic_handler() { - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen).unwrap(); - crossterm::terminal::disable_raw_mode().unwrap(); - original_hook(panic_info); - })); + frame.render_widget( + Paragraph::new(format!("{text}_")) + .white() + .on_blue() + .wrap(Wrap { trim: true }) + .block(block), + layout[1], + ); + } + + /// Enter raw mode and the alternate screen + fn enter() -> Result<()> { + stdout().execute(EnterAlternateScreen)?; + enable_raw_mode()?; + Ok(()) + } + + /// Exit raw mode and the alternate screen + pub fn exit() -> Result<()> { + stdout().execute(LeaveAlternateScreen)?; + disable_raw_mode()?; + Ok(()) + } + + /// Initialize a panic handler that exits raw mode and the alternate screen + /// before aborting the program + fn initialize_panic_handler() { + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen) + .unwrap(); + crossterm::terminal::disable_raw_mode().unwrap(); + original_hook(panic_info); + })); + } } diff --git a/client/src/websocket.rs b/client/src/websocket.rs new file mode 100644 index 0000000..e69de29 diff --git a/server/src/main.rs b/server/src/main.rs index fc096e3..5e23eb1 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -4,7 +4,7 @@ use std::thread::spawn; /// A WebSocket echo server fn main () { let server = TcpListener::bind("127.0.0.1:9001").unwrap(); - for stream in server.incoming() { + /* for stream in server.incoming() { spawn (move || { let mut websocket = accept(stream.unwrap()).unwrap(); loop { @@ -17,5 +17,5 @@ fn main () { } } }); - } + } */ } \ No newline at end of file