commit e382cb7488dfda268ea6848a7788dcf0cab8c0b3 Author: Lucas Schumacher Date: Sat Nov 11 13:32:54 2023 -0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7bcbd8f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,400 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anyhow" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.4.0", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "indoc" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c785eefb63ebd0e33416dfcb8d6da0bf27ce752843a45632a67bf10d4d4b5c4" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "lock_api" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + +[[package]] +name = "proc-macro2" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2e4cd95294a85c3b4446e63ef054eea43e0205b1fd60120c16b74ff7ff96ad" +dependencies = [ + "bitflags 2.4.0", + "cassowary", + "crossterm", + "indoc", + "itertools", + "paste", + "strum", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8d03b598d3d0fff69bf533ee3ef19b8eeb342729596df84bcc7e1f96ec4059" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c324c494eba9d92503e6f1ef2e6df781e78f6a7705a0202d9801b198807d518a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tnc-chat" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "ratatui", +] + +[[package]] +name = "unicode-ident" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" + +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d3ce2b0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "tnc-chat" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.75" +crossterm = "0.27.0" +ratatui = "0.23.0" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fb79990 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,341 @@ +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{prelude::*, widgets::*}; +use std::{io, thread, sync::mpsc::{channel, Receiver, Sender}}; + +use std::net::TcpStream; +use std::io::{Read, Write}; +use anyhow::Result; + +enum InputMode { + Normal, + Editing, +} +enum MasterEvent { + Event(Event), + Message(String), + Error(String), +} + +fn tnc_loop(tx: &Sender, mut stream: TcpStream) -> Result<()> { + let mut buffer = [0_u8; 4096]; + let mut frame = vec![]; + let mut escaped = false; + loop { + let n = stream.read(&mut buffer)?; + for byte in buffer[..n].iter() { + if escaped { + match *byte { + 0xDC => frame.push(0xC0), + 0xDD => frame.push(0xDB), + _ => {/* ERROR: invalid escape sequence */}, + } + escaped = false; + }else { + match *byte { + 0xDB => escaped = true, + 0xC0 => { + //TODO send and clear frame if not empty + if frame.len() == 0 {continue;}; + let mut s = String::new(); + for byte in &frame { + //if byte >= ' '.into() && byte <= '~'.into() { + if *byte >= 32 && *byte <= 126 { + s.push((*byte).into()); + }else { + s.push_str(&format!("\\{:x?} ", *byte)); + } + } + tx.send(MasterEvent::Message(s))?; + frame.clear(); + }, + b => frame.push(b), + } + } + } + } + //Ok(()) +} + +/// App holds the state of the application +struct App { + /// Current value of the input box + input: String, + /// Position of cursor in the editor area. + cursor_position: usize, + /// Current input mode + input_mode: InputMode, + /// History of recorded messages + messages: Vec, + events: Receiver, + stream: TcpStream, +} + +impl App { + fn new() -> Result { + let (tx, rx) = channel(); + let stream = TcpStream::connect("127.0.0.1:8001")?; + + let q = tx.clone(); + thread::spawn(move || { + loop { + match event::read() { + Ok(e) => { + q.send(MasterEvent::Event(e)).ok(); + }, + Err(err) => { + q.send(MasterEvent::Error(err.to_string())).ok(); + }, + } + } + }); + + let q = tx.clone(); + let strm = stream.try_clone()?; + thread::spawn(move || { + if let Err(e) = tnc_loop(&q, strm) { + q.send(MasterEvent::Error(e.to_string())).ok(); + } + }); + + Ok(App { + input: String::new(), + input_mode: InputMode::Normal, + messages: Vec::new(), + cursor_position: 0, + events: rx, + stream, + }) + } +} + +impl App { + fn move_cursor_left(&mut self) { + let cursor_moved_left = self.cursor_position.saturating_sub(1); + self.cursor_position = self.clamp_cursor(cursor_moved_left); + } + + fn move_cursor_right(&mut self) { + let cursor_moved_right = self.cursor_position.saturating_add(1); + self.cursor_position = self.clamp_cursor(cursor_moved_right); + } + + fn enter_char(&mut self, new_char: char) { + self.input.insert(self.cursor_position, new_char); + + self.move_cursor_right(); + } + + fn delete_char(&mut self) { + let is_not_cursor_leftmost = self.cursor_position != 0; + if is_not_cursor_leftmost { + // Method "remove" is not used on the saved text for deleting the selected char. + // Reason: Using remove on String works on bytes instead of the chars. + // Using remove would require special care because of char boundaries. + + let current_index = self.cursor_position; + let from_left_to_current_index = current_index - 1; + + // Getting all characters before the selected character. + let before_char_to_delete = self.input.chars().take(from_left_to_current_index); + // Getting all characters after selected character. + let after_char_to_delete = self.input.chars().skip(current_index); + + // Put all characters together except the selected one. + // By leaving the selected one out, it is forgotten and therefore deleted. + self.input = before_char_to_delete.chain(after_char_to_delete).collect(); + self.move_cursor_left(); + } + } + + fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { + new_cursor_pos.clamp(0, self.input.len()) + } + + fn reset_cursor(&mut self) { + self.cursor_position = 0; + } + + fn gen_kiss_frame(&self) -> Vec { + let mut frame = vec![0xC0, 0x00]; + // Direwolf inner Frame length allowable range is 15 to 2123 + //const MAXLEN: usize = 2123; //TODO actually check + const MINLEN: usize = 15; + for byte in self.input.as_bytes() { + match *byte { + 0xC0 => {frame.push(0xDB); frame.push(0xDC);}, + 0xDB => {frame.push(0xDB); frame.push(0xDD);}, + byte => frame.push(byte), + } + } + frame.push(0x00); //For our C str friends + while frame.len()-2 < MINLEN {frame.push(0x00);} //add buffer to end of short frames + frame.push(0xC0); + frame + } + + fn submit_message(&mut self) { + self.messages.push(self.input.clone()); + if let Err(e) = self.stream.write(&self.gen_kiss_frame()) { + self.messages.push(e.to_string()); + } + self.input.clear(); + self.reset_cursor(); + } +} + +fn main() -> Result<()> { + // 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()?; + let res = run_app(&mut terminal, app); + + // restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{err:?}"); + } + + Ok(()) +} + +fn run_app(terminal: &mut Terminal, mut app: App) -> Result<()> { + loop { + terminal.draw(|f| ui(f, &app))?; + + if let Ok(m_event) = app.events.recv() { + match m_event { + MasterEvent::Error(err) => app.messages.push(err.to_string()), + MasterEvent::Message(s) => app.messages.push(s), + MasterEvent::Event(Event::Key(key)) => match app.input_mode { + InputMode::Normal => match key.code { + KeyCode::Char('e') => { + app.input_mode = InputMode::Editing; + } + KeyCode::Char('q') => { + return Ok(()); + } + _ => {} + }, + InputMode::Editing if key.kind == KeyEventKind::Press => match key.code { + KeyCode::Enter => app.submit_message(), + KeyCode::Char(to_insert) => { + app.enter_char(to_insert); + } + KeyCode::Backspace => { + app.delete_char(); + } + KeyCode::Left => { + app.move_cursor_left(); + } + KeyCode::Right => { + app.move_cursor_right(); + } + KeyCode::Esc => { + app.input_mode = InputMode::Normal; + } + _ => {} + }, + _ => {} + }, + MasterEvent::Event(_) => {}, + } + } + } +} + +fn ui(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(1), + ] + .as_ref(), + ) + .split(f.size()); + + let (msg, style) = match app.input_mode { + InputMode::Normal => ( + vec![ + "Press ".into(), + "q".bold(), + " to exit, ".into(), + "e".bold(), + " to start editing.".bold(), + ], + Style::default().add_modifier(Modifier::RAPID_BLINK), + ), + InputMode::Editing => ( + vec![ + "Press ".into(), + "Esc".bold(), + " to stop editing, ".into(), + "Enter".bold(), + " to record the message".into(), + ], + Style::default(), + ), + }; + let mut text = Text::from(Line::from(msg)); + text.patch_style(style); + let help_message = Paragraph::new(text); + f.render_widget(help_message, chunks[0]); + + let input = Paragraph::new(app.input.as_str()) + .style(match app.input_mode { + InputMode::Normal => Style::default(), + InputMode::Editing => Style::default().fg(Color::Yellow), + }) + .block(Block::default().borders(Borders::ALL).title("Input")); + f.render_widget(input, chunks[1]); + match app.input_mode { + InputMode::Normal => + // Hide the cursor. `Frame` does this by default, so we don't need to do anything here + {} + + InputMode::Editing => { + // Make the cursor visible and ask ratatui to put it at the specified coordinates after + // rendering + f.set_cursor( + // Draw the cursor at the current position in the input field. + // This position is can be controlled via the left and right arrow key + chunks[1].x + app.cursor_position as u16 + 1, + // Move one line down, from the border to the input line + chunks[1].y + 1, + ) + } + } + + let messages: Vec = app + .messages + .iter() + .enumerate() + .map(|(i, m)| { + let content = Line::from(Span::raw(format!("{i}: {m}"))); + ListItem::new(content) + }) + .collect(); + let messages = + List::new(messages).block(Block::default().borders(Borders::ALL).title("Messages")); + f.render_widget(messages, chunks[2]); +} +