diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..34dc322 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,9 @@ +control_brace_style = "ClosingNextLine" +use_small_heuristics = "Max" +reorder_impl_items = true +reorder_imports = false +reorder_modules = false +trailing_semicolon = false +use_field_init_shorthand = true +use_try_shorthand = true +where_single_line = true diff --git a/README.md b/README.md index 65feb1f..ae3e165 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,10 @@ You can set a custom tcp sever port with `-t ` ### Commands Termchat treats messages containings the following commands in a special way: -- **`?send <$path_to_file>`**: sends the specified file to everyone on the network, exp: `?send ./myfile` +- **`?send <$path_to_file>`**: sends the specified file to everyone on the network, + example: `?send ./myfile` + Note: The received files can be found in `/tmp/termchat//` ## Frequently Asked Questions diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..0d7c629 --- /dev/null +++ b/src/action.rs @@ -0,0 +1,12 @@ +use crate::state::{State}; + +use message_io::network::{NetworkManager}; + +pub enum Processing { + Completed, + Partial, +} + +pub trait Action: Send { + fn process(&mut self, state: &mut State, network: &mut NetworkManager) -> Processing; +} diff --git a/src/application.rs b/src/application.rs index 219a9d9..5fe2533 100644 --- a/src/application.rs +++ b/src/application.rs @@ -1,69 +1,47 @@ -use super::state::{ - ApplicationState, CursorMovement, LogMessage, MessageType, ScrollMovement, TermchatMessageType, -}; -use super::terminal_events::TerminalEventCollector; -use super::ui::{self}; -use crate::util::{stringify_sendall_errors, termchat_message, Error, Result}; +use super::state::{State, CursorMovement, ChatMessage, MessageType, ScrollMovement}; +use crate::terminal_events::{TerminalEventCollector}; +use crate::renderer::{Renderer}; +use crate::action::{Action, Processing}; +use crate::commands::{CommandManager}; +use crate::message::{NetMessage, Chunk}; +use crate::util::{Error, Result}; +use crate::commands::send_file::{SendFileCommand}; use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers}; -use crossterm::{ - terminal::{self}, - ExecutableCommand, -}; -use tui::backend::CrosstermBackend; -use tui::Terminal; +use message_io::events::{EventQueue}; +use message_io::network::{NetEvent, NetworkManager, Endpoint}; -use message_io::events::EventQueue; -use message_io::network::{NetEvent, NetworkManager}; - -use serde::{Deserialize, Serialize}; - -use std::io::{self, Stdout}; -use std::net::SocketAddr; - -mod commands; -mod read_event; - -use read_event::{read_file, Chunk, ReadFile}; - -#[derive(Serialize, Deserialize)] -enum NetMessage { - HelloLan(String, u16), // user_name, server_port - HelloUser(String), // user_name - UserMessage(String), // content - UserData(String, Option<(Vec, usize)>, Option), // file_name, Option, Option -} +use std::net::{SocketAddrV4}; +use std::io::{ErrorKind}; enum Event { Network(NetEvent), Terminal(TermEvent), - ReadFile(Result), + Action(Box), // Close event with an optional error in case of failure // Close(None) means no error happened Close(Option), } -pub struct Application { - event_queue: EventQueue, +pub struct Config { + pub discovery_addr: SocketAddrV4, + pub tcp_server_port: u16, + pub user_name: String, +} + +pub struct Application<'a> { + config: &'a Config, + state: State, network: NetworkManager, - terminal: Terminal>, - read_file_ev: ReadFile, + commands: CommandManager, + //read_file_ev: ReadFile, _terminal_events: TerminalEventCollector, - discovery_addr: SocketAddr, - tcp_server_addr: SocketAddr, - user_name: String, + event_queue: EventQueue, } -impl Application { - pub fn new( - discovery_addr: SocketAddr, - tcp_server_port: u16, - user_name: &str, - ) -> Result { - // Guard to make sure to cleanup if a failure happens in the next lines - let _g = Guard; - +impl<'a> Application<'a> { + pub fn new(config: &'a Config) -> Result> { let mut event_queue = EventQueue::new(); let sender = event_queue.sender().clone(); // Collect network events @@ -75,296 +53,213 @@ impl Application { Err(e) => sender.send(Event::Close(Some(e))), })?; - let sender = event_queue.sender().clone(); // Collect read_file events - let read_file_ev = ReadFile::new(Box::new(move |file, file_name, file_size, id| { - let chunk = read_file(file, file_name, file_size, id); - sender.send(Event::ReadFile(chunk)); - })); - - terminal::enable_raw_mode()?; - io::stdout().execute(terminal::EnterAlternateScreen)?; - let terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; - - let tcp_server_addr = ([0, 0, 0, 0], tcp_server_port).into(); - - std::mem::forget(_g); - Ok(Application { - event_queue, + config, + state: State::new(), network, - terminal, - read_file_ev, - // Stored because we want its internal thread functionality until the Application was dropped + commands: CommandManager::default().with(SendFileCommand), + // Stored because we need its internal thread running until the Application was dropped _terminal_events, - discovery_addr, - tcp_server_addr, - user_name: user_name.into(), + event_queue, }) } pub fn run(&mut self) -> Result<()> { - let mut state = ApplicationState::new(); - ui::draw(&mut self.terminal, &state)?; - - let (_, server_addr) = self.network.listen_tcp(self.tcp_server_addr)?; - let server_port = server_addr.port(); + let mut renderer = Renderer::new()?; + renderer.render(&self.state)?; - self.network.listen_udp_multicast(self.discovery_addr)?; + let server_addr = ("0.0.0.0", self.config.tcp_server_port); + let (_, server_addr) = self.network.listen_tcp(server_addr)?; + self.network.listen_udp_multicast(self.config.discovery_addr)?; - let discovery_endpoint = self.network.connect_udp(self.discovery_addr)?; - - let message = NetMessage::HelloLan(self.user_name.clone(), server_port); + let discovery_endpoint = self.network.connect_udp(self.config.discovery_addr)?; + let message = NetMessage::HelloLan(self.config.user_name.clone(), server_addr.port()); self.network.send(discovery_endpoint, message)?; loop { match self.event_queue.receive() { - Event::ReadFile(chunk) => { - let try_send = || -> Result<()> { - let Chunk { - file, - id, - file_name, - data, - bytes_read, - file_size, - } = chunk?; - - self.network - .send_all( - state.all_user_endpoints(), - NetMessage::UserData( - file_name.clone(), - Some((data, bytes_read)), - None, - ), - ) - .map_err(stringify_sendall_errors)?; + Event::Network(net_event) => match net_event { + NetEvent::Message(endpoint, message) => { + self.process_network_message(endpoint, message); + } + NetEvent::AddedEndpoint(_) => (), + NetEvent::RemovedEndpoint(endpoint) => self.state.disconnected_user(endpoint), + }, + Event::Terminal(term_event) => { + self.process_terminal_event(term_event); + } + Event::Action(action) => { + self.process_action(action); + } + Event::Close(error) => { + return match error { + Some(error) => Err(error), + None => Ok(()), + } + } + } + renderer.render(&self.state)?; + } + //Renderer is destroyed here and the terminal is recovered + } - if bytes_read == 0 { - state.progress_stop(id); - } else { - state.progress_pulse(id, file_size, bytes_read); - let chunk = read_file(file, file_name, file_size, id); - self.event_queue.sender().send(Event::ReadFile(chunk)); - } + fn process_network_message(&mut self, endpoint: Endpoint, message: NetMessage) { + match message { + // by udp (multicast): + NetMessage::HelloLan(user, server_port) => { + let server_addr = (endpoint.addr().ip(), server_port); + if user != self.config.user_name { + let mut try_connect = || -> Result<()> { + let user_endpoint = self.network.connect_tcp(server_addr)?; + let message = NetMessage::HelloUser(self.config.user_name.clone()); + self.network.send(user_endpoint, message)?; + self.state.connected_user(user_endpoint, &user); Ok(()) }; - - if let Err(e) = try_send() { - // we dont have the file_name here - // we'll just stop the last progress - state.progress_stop_last(); - let msg = format!("Error sending file. error: {}", e); - state.add_message(termchat_message(msg, TermchatMessageType::Error)); + if let Err(e) = try_connect() { + self.state.add_system_error_message(e.to_string()); } } - Event::Network(net_event) => match net_event { - NetEvent::Message(endpoint, message) => match message { - // by udp (multicast): - NetMessage::HelloLan(user, server_port) => { - let server_addr = (endpoint.addr().ip(), server_port); - if user != self.user_name { - let mut try_connect = || -> Result<()> { - let user_endpoint = self.network.connect_tcp(server_addr)?; - let message = NetMessage::HelloUser(self.user_name.clone()); - self.network.send(user_endpoint, message)?; - state.connected_user(user_endpoint, &user); - Ok(()) - }; - if let Err(e) = try_connect() { - let message = - termchat_message(e.to_string(), TermchatMessageType::Error); - state.add_message(message); - } - } - } - // by tcp: - NetMessage::HelloUser(user) => { - state.connected_user(endpoint, &user); + } + // by tcp: + NetMessage::HelloUser(user) => { + self.state.connected_user(endpoint, &user); + } + NetMessage::UserMessage(content) => { + if let Some(user) = self.state.user_name(endpoint) { + let message = ChatMessage::new(user.into(), MessageType::Text(content)); + self.state.add_message(message); + } + } + NetMessage::UserData(file_name, chunk) => { + use std::io::Write; + if self.state.user_name(endpoint).is_some() { + // safe unwrap due to check + let user = self.state.user_name(endpoint).unwrap().to_owned(); + + match chunk { + Chunk::Error => { + let msg = + format!("'{}' had an error while sending '{}'", user, file_name); + self.state.add_system_error_message(msg); } - NetMessage::UserMessage(content) => { - if let Some(user) = state.user_name(endpoint) { - let message = - LogMessage::new(user.into(), MessageType::Content(content)); - state.add_message(message); - } + Chunk::End => { + let msg = format!( + "Successfully received file '{}' from user '{}'!", + file_name, user + ); + self.state.add_system_info_message(msg); } - NetMessage::UserData(file_name, maybe_data, maybe_error) => { - use std::io::Write; - if state.user_name(endpoint).is_some() { - // safe unwrap due to check - let user = state.user_name(endpoint).unwrap().to_owned(); - - let try_write = || -> Result<()> { - if let Some(error) = maybe_error { - return Err(format!( - "{} encountred an error while sending {}, error: {}", - user, file_name, error - ) - .into()); - } - // if the error is none we know that maybe_data is some - let (data, bytes_read) = maybe_data.unwrap(); - - //done - if bytes_read == 0 { - let msg = format!( - "Successfully received file {} from user {} !", - file_name, user - ); - let msg = termchat_message( - msg, - TermchatMessageType::Notification, - ); - state.add_message(msg); - return Ok(()); - } + Chunk::Data(data) => { + let try_write = || -> Result<()> { + let user_path = std::env::temp_dir().join("termchat").join(&user); + match std::fs::create_dir_all(&user_path) { + Ok(_) => (), + Err(ref err) if err.kind() == ErrorKind::AlreadyExists => (), + Err(e) => Err(e)?, + } - let path = std::env::temp_dir().join("termchat"); - let user_path = path.join(&user); - // Ignore already exists error - let _ = std::fs::create_dir_all(&user_path); - let file_path = user_path.join(file_name); + let file_path = user_path.join(file_name); + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(file_path)? + .write_all(&data)?; - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(file_path)?; - file.write_all(&data)?; - Ok(()) - }; + Ok(()) + }; - if let Err(e) = try_write() { - let message = format!( - "termchat: Failed to write data sent from user: {}", - user - ); - state.add_message(termchat_message( - message, - TermchatMessageType::Error, - )); - state.add_message(termchat_message( - e.to_string(), - TermchatMessageType::Error, - )); - } + if let Err(error) = try_write() { + self.state.add_system_error_message(error.to_string()); } } - }, - NetEvent::AddedEndpoint(_) => (), - NetEvent::RemovedEndpoint(endpoint) => { - state.disconnected_user(endpoint); } - }, - Event::Terminal(term_event) => match term_event { - TermEvent::Key(KeyEvent { code, modifiers }) => match code { - KeyCode::Esc => { - self.event_queue - .sender() - .send_with_priority(Event::Close(None)); - } - KeyCode::Char(character) => { - if character == 'c' && modifiers.contains(KeyModifiers::CONTROL) { - self.event_queue - .sender() - .send_with_priority(Event::Close(None)); - } else { - state.input_write(character); - } - } - KeyCode::Enter => { - if let Some(input) = state.reset_input() { - let message = if let Err(e) = self.network.send_all( - state.all_user_endpoints(), - NetMessage::UserMessage(input.clone()), - ) { - termchat_message( - stringify_sendall_errors(e), - TermchatMessageType::Error, - ) - } else { - LogMessage::new( - format!("{} (me)", self.user_name), - MessageType::Content(input.clone()), - ) - }; + } + } + } + } - state.add_message(message); + fn process_terminal_event(&mut self, term_event: TermEvent) { + match term_event { + TermEvent::Mouse(_) => (), + TermEvent::Resize(_, _) => (), + TermEvent::Key(KeyEvent { code, modifiers }) => match code { + KeyCode::Esc => { + self.event_queue.sender().send_with_priority(Event::Close(None)); + } + KeyCode::Char(character) => { + if character == 'c' && modifiers.contains(KeyModifiers::CONTROL) { + self.event_queue.sender().send_with_priority(Event::Close(None)); + } + else { + self.state.input_write(character); + } + } + KeyCode::Enter => { + if let Some(input) = self.state.reset_input() { + match self.commands.find_command_action(&input).transpose() { + Ok(action) => { + let message = ChatMessage::new( + format!("{} (me)", self.config.user_name), + MessageType::Text(input.clone()), + ); + self.state.add_message(message); + + self.network + .send_all( + self.state.all_user_endpoints(), + NetMessage::UserMessage(input.clone()), + ) + .ok(); //Best effort - if let Err(parse_error) = self.parse_input(&input, &mut state) { - state.add_message(termchat_message( - parse_error.to_string(), - TermchatMessageType::Error, - )); + if let Some(action) = action { + self.process_action(action) } } - } - KeyCode::Delete => { - state.input_remove(); - } - KeyCode::Backspace => { - state.input_remove_previous(); - } - KeyCode::Left => { - state.input_move_cursor(CursorMovement::Left); - } - KeyCode::Right => { - state.input_move_cursor(CursorMovement::Right); - } - KeyCode::Home => { - state.input_move_cursor(CursorMovement::Start); - } - KeyCode::End => { - state.input_move_cursor(CursorMovement::End); - } - KeyCode::Up => { - state.messages_scroll(ScrollMovement::Up); - } - KeyCode::Down => { - state.messages_scroll(ScrollMovement::Down); - } - KeyCode::PageUp => { - state.messages_scroll(ScrollMovement::Start); - } - _ => (), - }, - TermEvent::Mouse(_) => (), - TermEvent::Resize(_, _) => (), - }, - Event::Close(e) => { - if let Some(error) = e { - return Err(error); - } else { - return Ok(()); + Err(error) => { + self.state.add_system_error_message(error.to_string()); + } + }; } } - } - ui::draw(&mut self.terminal, &state)?; + KeyCode::Delete => { + self.state.input_remove(); + } + KeyCode::Backspace => { + self.state.input_remove_previous(); + } + KeyCode::Left => { + self.state.input_move_cursor(CursorMovement::Left); + } + KeyCode::Right => { + self.state.input_move_cursor(CursorMovement::Right); + } + KeyCode::Home => { + self.state.input_move_cursor(CursorMovement::Start); + } + KeyCode::End => { + self.state.input_move_cursor(CursorMovement::End); + } + KeyCode::Up => { + self.state.messages_scroll(ScrollMovement::Up); + } + KeyCode::Down => { + self.state.messages_scroll(ScrollMovement::Down); + } + KeyCode::PageUp => { + self.state.messages_scroll(ScrollMovement::Start); + } + _ => (), + }, } } -} - -impl Drop for Application { - fn drop(&mut self) { - clean_terminal(); - } -} -struct Guard; -impl Drop for Guard { - fn drop(&mut self) { - clean_terminal(); - } -} - -fn clean_terminal() { - io::stdout() - .execute(terminal::LeaveAlternateScreen) - .expect("Could not leave alternate screen"); - terminal::disable_raw_mode().expect("Could not disable raw mode at exit"); - if std::thread::panicking() { - eprintln!( - "termchat paniced, to log the error you can redirect stderror to a file, example: `termchat 2>termchat_log`" - ); + fn process_action(&mut self, mut action: Box) { + match action.process(&mut self.state, &mut self.network) { + Processing::Completed => (), + Processing::Partial => { + self.event_queue.sender().send(Event::Action(action)); + } + } } } diff --git a/src/application/commands.rs b/src/application/commands.rs deleted file mode 100644 index a3d89d5..0000000 --- a/src/application/commands.rs +++ /dev/null @@ -1,31 +0,0 @@ -use super::Application; -use crate::state::ApplicationState; -use crate::util::Result; - -impl Application { - pub fn parse_input(&mut self, input: &str, state: &mut ApplicationState) -> Result<()> { - const SEND_COMMAND: &str = "?send"; - if input.starts_with(SEND_COMMAND) { - self.handle_send_command(input, state)?; - } - Ok(()) - } - - fn handle_send_command(&mut self, input: &str, state: &mut ApplicationState) -> Result<()> { - const READ_FILENAME_ERROR: &str = "Unable to read file name"; - - let path = - std::path::Path::new(input.split_whitespace().nth(1).ok_or("No file specified")?); - let file_name = path - .file_name() - .ok_or(READ_FILENAME_ERROR)? - .to_str() - .ok_or(READ_FILENAME_ERROR)? - .to_string(); - - let send_id = self.read_file_ev.send(file_name, path.to_path_buf())?; - state.progress_start(send_id); - - Ok(()) - } -} diff --git a/src/application/read_event.rs b/src/application/read_event.rs deleted file mode 100644 index 6502034..0000000 --- a/src/application/read_event.rs +++ /dev/null @@ -1,63 +0,0 @@ -use crate::util::Result; - -type CallBack = Box; - -pub struct ReadFile { - callback: CallBack, - id: usize, -} - -pub struct Chunk { - pub file: std::fs::File, - pub id: usize, - pub file_name: String, - pub data: Vec, - pub bytes_read: usize, - pub file_size: usize, -} - -impl ReadFile { - pub fn new(callback: CallBack) -> Self { - Self { callback, id: 0 } - } - - pub fn send(&mut self, file_name: String, path: std::path::PathBuf) -> Result { - use std::convert::TryInto; - - let file_size = std::fs::metadata(&path)?.len().try_into()?; - let file = std::fs::File::open(path)?; - - let send_id = self.id; - (self.callback)(file, file_name, file_size, send_id); - self.id += 1; - - Ok(send_id) - } -} - -pub fn read_file( - mut file: std::fs::File, - file_name: String, - file_size: usize, - id: usize, -) -> Result { - use std::io::Read; - - const BLOCK: usize = 65536; - let mut data = [0; BLOCK]; - - match file.read(&mut data) { - Ok(bytes_read) => { - let chunk = Chunk { - file, - id, - file_name, - data: data[..bytes_read].to_vec(), - bytes_read, - file_size, - }; - Ok(chunk) - } - Err(e) => Err(e.into()), - } -} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..346b813 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,36 @@ +pub mod send_file; + +use crate::action::{Action}; +use crate::util::{Result}; + +use std::collections::{HashMap}; + +pub trait Command { + fn name(&self) -> &'static str; + fn parse_params(&self, params: Vec<&str>) -> Result>; +} + +#[derive(Default)] +pub struct CommandManager { + parsers: HashMap<&'static str, Box>, +} + +impl CommandManager { + pub const COMMAND_PREFIX: &'static str = "?"; + + pub fn with(mut self, command_parser: impl Command + 'static) -> Self { + self.parsers.insert(command_parser.name(), Box::new(command_parser)); + self + } + + pub fn find_command_action(&self, input: &str) -> Option>> { + let mut input = input.split_whitespace(); + let start = input.next().expect("Input must have some content"); + if start.starts_with(Self::COMMAND_PREFIX) { + if let Some(parser) = self.parsers.get(&start[1..]) { + return Some(parser.parse_params(input.collect())) + } + } + None + } +} diff --git a/src/commands/send_file.rs b/src/commands/send_file.rs new file mode 100644 index 0000000..a6e7db0 --- /dev/null +++ b/src/commands/send_file.rs @@ -0,0 +1,80 @@ +use crate::action::{Action, Processing}; +use crate::commands::{Command}; +use crate::state::{State}; +use crate::message::{NetMessage, Chunk}; +use crate::util::{Result}; + +use message_io::network::{NetworkManager}; + +use std::path::{Path}; +use std::io::{Read}; + +pub struct SendFileCommand; + +impl Command for SendFileCommand { + fn name(&self) -> &'static str { + "send" + } + + fn parse_params(&self, params: Vec<&str>) -> Result> { + let file_path = params.get(0).ok_or("No file specified")?; + match SendFile::new(file_path) { + Ok(action) => Ok(Box::new(action)), + Err(e) => Err(e), + } + } +} + +pub struct SendFile { + file: std::fs::File, + file_name: String, + file_size: u64, + progress_id: Option, +} + +impl SendFile { + const CHUNK_SIZE: usize = 65500; + + pub fn new(file_path: &str) -> Result { + const READ_FILENAME_ERROR: &str = "Unable to read file name"; + let file_path = Path::new(file_path); + let file_name = file_path + .file_name() + .ok_or(READ_FILENAME_ERROR)? + .to_str() + .ok_or(READ_FILENAME_ERROR)? + .to_string(); + + let file_size = std::fs::metadata(file_path)?.len(); + let file = std::fs::File::open(file_path)?; + + Ok(SendFile { file, file_name, file_size, progress_id: None }) + } +} + +impl Action for SendFile { + fn process(&mut self, state: &mut State, network: &mut NetworkManager) -> Processing { + if self.progress_id.is_none() { + let id = state.add_progress_message(&self.file_name, self.file_size); + self.progress_id = Some(id); + } + + let mut data = [0; Self::CHUNK_SIZE]; + let (bytes_read, chunk, processing) = match self.file.read(&mut data) { + Ok(0) => (0, Chunk::End, Processing::Completed), + Ok(bytes_read) => (bytes_read, Chunk::Data(data.to_vec()), Processing::Partial), + Err(error) => { + let msg = format!("Error sending file. error: {}", error); + state.add_system_error_message(msg); + (0, Chunk::Error, Processing::Completed) + } + }; + + state.progress_message_update(self.progress_id.unwrap(), bytes_read as u64); + + let message = NetMessage::UserData(self.file_name.clone(), chunk); + network.send_all(state.all_user_endpoints(), message).ok(); //Best effort + + processing + } +} diff --git a/src/main.rs b/src/main.rs index 74ec07c..8ece76d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,19 @@ mod application; mod state; mod terminal_events; +mod message; +mod renderer; +mod action; +mod commands; mod ui; mod util; -use application::Application; +use application::{Application, Config}; use clap::{App, Arg}; +use std::net::{SocketAddrV4}; + fn main() { let os_username = whoami::username(); @@ -20,6 +26,10 @@ fn main() { .long("discovery") .short("d") .default_value("238.255.0.1:5877") + .validator(|addr| match addr.parse::() { + Ok(_) => Ok(()), + Err(_) => Err("The value must have syntax ipv4:port".into()), + }) .help("Multicast address to found others 'termchat' applications"), ) .arg( @@ -27,6 +37,10 @@ fn main() { .long("tcp-server-port") .short("t") .default_value("0") + .validator(|port| match port.parse::() { + Ok(_) => Ok(()), + Err(_) => Err("The value must be in range 0..65535".into()), + }) .help("Tcp server port used when communicating with other termchat instances"), ) .arg( @@ -38,31 +52,19 @@ fn main() { ) .get_matches(); - // The next unwraps are safe because we specified a default value - - let discovery_addr = match matches.value_of("discovery").unwrap().parse() { - Ok(discovery_addr) => discovery_addr, - Err(_) => return eprintln!("'discovery' must be a valid multicast address"), + // The next unwraps are safe because we specified a default value and a validator + let config = Config { + discovery_addr: matches.value_of("discovery").unwrap().parse().unwrap(), + tcp_server_port: matches.value_of("tcp_server_port").unwrap().parse().unwrap(), + user_name: matches.value_of("username").unwrap().into(), }; - let tcp_server_port = match matches.value_of("tcp_server_port").unwrap().parse() { - Ok(port) => port, - Err(_) => return eprintln!("Unable to parse tcp server port"), + let result = match Application::new(&config) { + Ok(mut app) => app.run(), + Err(e) => Err(e), }; - let name = matches.value_of("username").unwrap(); - - let error = match Application::new(discovery_addr, tcp_server_port, &name) { - Ok(mut app) => { - if let Err(e) = app.run() { - Some(e) - } else { - None - } - } - Err(e) => Some(e), - }; - if let Some(e) = error { + if let Err(e) = result { // app is now dropped we can print to stderr safely eprintln!("termchat exited with error: {}", e); } diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..a942523 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub enum Chunk { + Data(Vec), + Error, + End, +} + +#[derive(Serialize, Deserialize)] +pub enum NetMessage { + HelloLan(String, u16), // user_name, server_port + HelloUser(String), // user_name + UserMessage(String), // content + UserData(String, Chunk), // file_name, chunk +} diff --git a/src/renderer.rs b/src/renderer.rs new file mode 100644 index 0000000..debf706 --- /dev/null +++ b/src/renderer.rs @@ -0,0 +1,43 @@ +use crate::ui::{self}; +use crate::state::{State}; +use crate::util::{Result}; + +use crossterm::terminal::{self}; +use crossterm::{ExecutableCommand}; + +use tui::{Terminal}; +use tui::backend::{CrosstermBackend}; + +use std::io::{self, Stdout}; + +pub struct Renderer { + terminal: Terminal>, +} + +impl Renderer { + pub fn new() -> Result { + terminal::enable_raw_mode()?; + io::stdout().execute(terminal::EnterAlternateScreen)?; + + Ok(Renderer { terminal: Terminal::new(CrosstermBackend::new(io::stdout()))? }) + } + + pub fn render(&mut self, state: &State) -> Result<()> { + self.terminal.draw(|frame| ui::draw(frame, state, frame.size()))?; + Ok(()) + } +} + +impl Drop for Renderer { + fn drop(&mut self) { + io::stdout().execute(terminal::LeaveAlternateScreen).expect("Could not execute to stdout"); + terminal::disable_raw_mode().expect("Terminal doesn't support to disable raw mode"); + if std::thread::panicking() { + eprintln!( + "{}, example: {}", + "termchat paniced, to log the error you can redirect stderror to a file", + "termchat 2> termchat_log" + ); + } + } +} diff --git a/src/state.rs b/src/state.rs index f4ce910..eb849f9 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,41 +4,43 @@ use chrono::{DateTime, Local}; use std::collections::HashMap; -pub mod progress; -use progress::ProgressState; +#[derive(PartialEq)] +pub enum SystemMessageType { + Info, + #[allow(dead_code)] + Warning, + Error, +} + +#[derive(PartialEq)] +pub enum ProgressState { + Started(u64), // file_size + Working(u64, u64), // file_size, current_bytes + Completed, +} pub enum MessageType { Connection, Disconnection, - Content(String), - Termchat(String, TermchatMessageType), + Text(String), + System(String, SystemMessageType), Progress(ProgressState), } -#[derive(PartialEq)] -pub enum TermchatMessageType { - Error, - Notification, -} - -pub struct LogMessage { +pub struct ChatMessage { pub date: DateTime, pub user: String, pub message_type: MessageType, } -impl LogMessage { - pub fn new(user: String, message_type: MessageType) -> LogMessage { - LogMessage { - date: Local::now(), - user, - message_type, - } +impl ChatMessage { + pub fn new(user: String, message_type: MessageType) -> ChatMessage { + ChatMessage { date: Local::now(), user, message_type } } } -pub struct ApplicationState { - messages: Vec, +pub struct State { + messages: Vec, scroll_messages_view: usize, input: Vec, input_cursor: usize, @@ -60,9 +62,9 @@ pub enum ScrollMovement { Start, } -impl ApplicationState { - pub fn new() -> ApplicationState { - ApplicationState { +impl State { + pub fn new() -> State { + State { messages: Vec::new(), scroll_messages_view: 0, input: vec![], @@ -73,7 +75,7 @@ impl ApplicationState { } } - pub fn messages(&self) -> &Vec { + pub fn messages(&self) -> &Vec { &self.messages } @@ -129,14 +131,14 @@ impl ApplicationState { self.users_id.insert(user.into(), self.last_user_id); } self.last_user_id += 1; - self.add_message(LogMessage::new(user.into(), MessageType::Connection)); + self.add_message(ChatMessage::new(user.into(), MessageType::Connection)); } pub fn disconnected_user(&mut self, endpoint: Endpoint) { if self.lan_users.contains_key(&endpoint) { // unwrap is safe because of the check above let user = self.lan_users.remove(&endpoint).unwrap(); - self.add_message(LogMessage::new(user, MessageType::Disconnection)); + self.add_message(ChatMessage::new(user, MessageType::Disconnection)); } } @@ -198,12 +200,54 @@ impl ApplicationState { pub fn reset_input(&mut self) -> Option { if !self.input.is_empty() { self.input_cursor = 0; - return Some(self.input.drain(..).collect()); + return Some(self.input.drain(..).collect()) } None } - pub fn add_message(&mut self, message: LogMessage) { + pub fn add_message(&mut self, message: ChatMessage) { self.messages.push(message); } + + pub fn add_system_info_message(&mut self, content: String) { + let message_type = MessageType::System(content, SystemMessageType::Info); + let message = ChatMessage::new("Termchat: ".into(), message_type); + self.messages.push(message); + } + + pub fn add_system_error_message(&mut self, content: String) { + let message_type = MessageType::System(content, SystemMessageType::Error); + let message = ChatMessage::new("Termchat: ".into(), message_type); + self.messages.push(message); + } + + pub fn add_progress_message(&mut self, file_name: &str, total: u64) -> usize { + let message = ChatMessage::new( + format!("Sending '{}'", file_name), + MessageType::Progress(ProgressState::Started(total)), + ); + self.messages.push(message); + self.messages.len() - 1 + } + + pub fn progress_message_update(&mut self, index: usize, increment: u64) { + match &mut self.messages[index].message_type { + MessageType::Progress(ref mut state) => { + *state = match state { + ProgressState::Started(total) => ProgressState::Working(*total, increment), + ProgressState::Working(total, current) => { + let new_current = *current + increment; + if new_current == *total { + ProgressState::Completed + } + else { + ProgressState::Working(*total, new_current) + } + } + ProgressState::Completed => ProgressState::Completed, + }; + } + _ => panic!("Must be a Progress MessageType"), + } + } } diff --git a/src/state/progress.rs b/src/state/progress.rs deleted file mode 100644 index e38e829..0000000 --- a/src/state/progress.rs +++ /dev/null @@ -1,84 +0,0 @@ -use super::{ApplicationState, LogMessage, MessageType}; - -#[derive(PartialEq)] -pub enum ProgressState { - Started(usize), // id - Working(usize, usize, usize), // id, file_size, current_bytes - Stopped(usize), // file_size -} - -impl ApplicationState { - pub fn progress_start(&mut self, id: usize) { - self.messages.push(LogMessage::new( - "Sending".into(), - MessageType::Progress(ProgressState::Started(id)), - )) - } - - pub fn progress_pulse(&mut self, id: usize, file_size: usize, bytes_read: usize) { - for msg in self.messages.iter_mut().rev() { - match &msg.message_type { - MessageType::Progress(ProgressState::Started(msg_id)) => { - if msg_id == &id { - msg.message_type = MessageType::Progress(ProgressState::Working( - id, file_size, bytes_read, - )); - break; - } - } - // same file_size - MessageType::Progress(ProgressState::Working(msg_id, _, current_bytes)) => { - if msg_id == &id { - msg.message_type = MessageType::Progress(ProgressState::Working( - id, - file_size, - current_bytes + bytes_read, - )); - break; - } - } - _ => (), - } - } - } - - pub fn progress_stop(&mut self, id: usize) { - for msg in self.messages.iter_mut().rev() { - match &msg.message_type { - MessageType::Progress(ProgressState::Started(msg_id)) => { - if msg_id == &id { - msg.message_type = MessageType::Progress(ProgressState::Stopped(0)); - break; - } - } - // same file_size - MessageType::Progress(ProgressState::Working(msg_id, _, current_bytes)) => { - if msg_id == &id { - msg.message_type = - MessageType::Progress(ProgressState::Stopped(*current_bytes)); - break; - } - } - _ => (), - } - } - } - - pub fn progress_stop_last(&mut self) { - for msg in self.messages.iter_mut().rev() { - match &msg.message_type { - MessageType::Progress(ProgressState::Started(_)) => { - msg.message_type = MessageType::Progress(ProgressState::Stopped(0)); - break; - } - // same file_size - MessageType::Progress(ProgressState::Working(_, _, current_bytes)) => { - msg.message_type = - MessageType::Progress(ProgressState::Stopped(*current_bytes)); - break; - } - _ => (), - } - } - } -} diff --git a/src/terminal_events.rs b/src/terminal_events.rs index 7462c51..0f95d06 100644 --- a/src/terminal_events.rs +++ b/src/terminal_events.rs @@ -17,16 +17,13 @@ pub struct TerminalEventCollector { impl TerminalEventCollector { pub fn new(event_callback: C) -> Result - where - C: Fn(Result) + Send + 'static, - { + where C: Fn(Result) + Send + 'static { let collector_thread_running = Arc::new(AtomicBool::new(true)); let collector_thread_handle = { let running = collector_thread_running.clone(); let timeout = Duration::from_millis(EVENT_SAMPLING_TIMEOUT); - thread::Builder::new() - .name("termchat: terminal event collector".into()) - .spawn(move || { + thread::Builder::new().name("termchat: terminal event collector".into()).spawn( + move || { let try_read = || -> Result<()> { if crossterm::event::poll(timeout)? { let event = crossterm::event::read()?; @@ -39,7 +36,8 @@ impl TerminalEventCollector { event_callback(Err(e)); } } - }) + }, + ) }?; Ok(TerminalEventCollector { @@ -51,8 +49,7 @@ impl TerminalEventCollector { impl Drop for TerminalEventCollector { fn drop(&mut self) { - self.collector_thread_running - .store(false, Ordering::Relaxed); + self.collector_thread_running.store(false, Ordering::Relaxed); // the first unwrap is safe, beacuse we now the handle is some and this is the only time we take it self.collector_thread_handle .take() diff --git a/src/ui.rs b/src/ui.rs index a2dba89..b615277 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,35 +1,27 @@ -use super::state::{progress::ProgressState, ApplicationState, MessageType, TermchatMessageType}; -use super::util::{split_each, Result}; +use super::state::{ProgressState, State, MessageType, SystemMessageType}; +use super::commands::{CommandManager}; +use super::util::{split_each}; use tui::backend::CrosstermBackend; use tui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use tui::style::{Color, Modifier, Style}; use tui::text::{Span, Spans}; use tui::widgets::{Block, Borders, Paragraph, Wrap}; -use tui::{Frame, Terminal}; +use tui::{Frame}; use std::io::Stdout; -pub fn draw( - terminal: &mut Terminal>, - state: &ApplicationState, -) -> Result<()> { - Ok(terminal.draw(|frame| { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(6)].as_ref()) - .split(frame.size()); - - draw_messages_panel(frame, state, chunks[0]); - draw_input_panel(frame, state, chunks[1]); - })?) +pub fn draw(frame: &mut Frame>, state: &State, chunk: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(6)].as_ref()) + .split(chunk); + + draw_messages_panel(frame, state, chunks[0]); + draw_input_panel(frame, state, chunks[1]); } -fn draw_messages_panel( - frame: &mut Frame>, - state: &ApplicationState, - chunk: Rect, -) { +fn draw_messages_panel(frame: &mut Frame>, state: &State, chunk: Rect) { const MESSAGE_COLORS: [Color; 4] = [Color::Blue, Color::Yellow, Color::Cyan, Color::Magenta]; let messages = state @@ -39,7 +31,8 @@ fn draw_messages_panel( .map(|message| { let color = if let Some(id) = state.users_id().get(&message.user) { MESSAGE_COLORS[id % MESSAGE_COLORS.len()] - } else { + } + else { Color::Green //because is a message of the own user }; let date = message.date.format("%H:%M:%S ").to_string(); @@ -54,7 +47,7 @@ fn draw_messages_panel( Span::styled(&message.user, Style::default().fg(color)), Span::styled(" is offline", Style::default().fg(color)), ]), - MessageType::Content(content) => { + MessageType::Text(content) => { let mut ui_message = vec![ Span::styled(date, Style::default().fg(Color::DarkGray)), Span::styled(&message.user, Style::default().fg(color)), @@ -63,10 +56,11 @@ fn draw_messages_panel( ui_message.extend(parse_content(content)); Spans::from(ui_message) } - MessageType::Termchat(content, msg_type) => { + MessageType::System(content, msg_type) => { let (user_color, content_color) = match msg_type { - TermchatMessageType::Notification => (Color::Yellow, Color::LightYellow), - TermchatMessageType::Error => (Color::Red, Color::LightRed), + SystemMessageType::Info => (Color::Cyan, Color::LightCyan), + SystemMessageType::Warning => (Color::Yellow, Color::LightYellow), + SystemMessageType::Error => (Color::Red, Color::LightRed), }; Spans::from(vec![ Span::styled(date, Style::default().fg(Color::DarkGray)), @@ -80,10 +74,11 @@ fn draw_messages_panel( .collect::>(); let messages_panel = Paragraph::new(messages) - .block(Block::default().borders(Borders::ALL).title(Span::styled( - "LAN Room", - Style::default().add_modifier(Modifier::BOLD), - ))) + .block( + Block::default() + .borders(Borders::ALL) + .title(Span::styled("LAN Room", Style::default().add_modifier(Modifier::BOLD))), + ) .style(Style::default().fg(Color::White)) .alignment(Alignment::Left) .scroll((state.scroll_messages_view() as u16, 0)) @@ -93,25 +88,20 @@ fn draw_messages_panel( } fn add_progress_bar(panel_width: u16, progress: &ProgressState) -> Vec { - let (title, current, max) = match progress { - ProgressState::Started(_) => ("Pending: ", 0, 0), - ProgressState::Working(_, max, current) => ("Sending: ", *current, *max), - ProgressState::Stopped(max) => ("Done! ", *max, *max), - }; - let color = Color::LightGreen; - - let width = panel_width - 20; - - let width = width as f64; - let current = current as f64; - let max = max as f64; - - let pct = current / max; - let pct = if !pct.is_finite() { 0.0 } else { pct }; - - let ui_current = (pct * width) as usize; - let ui_remaining = width as usize - ui_current; + let width = (panel_width - 20) as usize; + + let (title, ui_current, ui_remaining) = match progress { + ProgressState::Started(_) => ("Pending: ", 0, width), + ProgressState::Working(total, current) => { + eprintln!("{} {}", total, current); + let percentage = *current as f64 / *total as f64; + let ui_current = (percentage * width as f64) as usize; + let ui_remaining = width - ui_current; + ("Sending: ", ui_current, ui_remaining) + } + ProgressState::Completed => ("Done! ", width, 0), + }; let current: String = std::iter::repeat("#").take(ui_current).collect(); let remaining: String = std::iter::repeat("-").take(ui_remaining).collect(); @@ -125,36 +115,27 @@ fn add_progress_bar(panel_width: u16, progress: &ProgressState) -> Vec { } fn parse_content(content: &str) -> Vec { - let color_command = |command| { + if content.starts_with(CommandManager::COMMAND_PREFIX) { + // The content represents a command content - .splitn(2, command) + .split_whitespace() .enumerate() .map(|(index, part)| { - // ?send if index == 0 { - Span::styled(command, Style::default().fg(Color::LightYellow)) - } else { - Span::raw(part) + Span::styled(part, Style::default().fg(Color::LightYellow)) + } + else { + Span::raw(format!(" {}", part)) } }) .collect() - }; - - const SEND_COMMAND: &str = "?send"; - - if content.starts_with(SEND_COMMAND) { - color_command(SEND_COMMAND) - // other commands can be handled here the same way - } else { + } + else { vec![Span::raw(content)] } } -fn draw_input_panel( - frame: &mut Frame>, - state: &ApplicationState, - chunk: Rect, -) { +fn draw_input_panel(frame: &mut Frame>, state: &State, chunk: Rect) { let inner_width = (chunk.width - 2) as usize; let input = state.input().iter().collect::(); @@ -164,10 +145,11 @@ fn draw_input_panel( .collect::>(); let input_panel = Paragraph::new(input) - .block(Block::default().borders(Borders::ALL).title(Span::styled( - "Your message", - Style::default().add_modifier(Modifier::BOLD), - ))) + .block( + Block::default() + .borders(Borders::ALL) + .title(Span::styled("Your message", Style::default().add_modifier(Modifier::BOLD))), + ) .style(Style::default().fg(Color::White)) .alignment(Alignment::Left); diff --git a/src/util.rs b/src/util.rs index 41b032e..e91d7b1 100644 --- a/src/util.rs +++ b/src/util.rs @@ -22,19 +22,12 @@ pub fn split_each(input: String, width: usize) -> Vec { splitted } -// Termchat messages convenience function -use crate::state::{LogMessage, MessageType, TermchatMessageType}; -pub fn termchat_message(content: String, msg_type: TermchatMessageType) -> LogMessage { - LogMessage::new( - "Termchat: ".into(), - MessageType::Termchat(content, msg_type), - ) -} - // Errors pub type Error = Box; pub type Result = std::result::Result; +/* +//TODO: Should send the file even if some endpoint of send_all gives an error. pub fn stringify_sendall_errors(e: Vec<(message_io::network::Endpoint, std::io::Error)>) -> String { let mut out = String::new(); for (endpoint, error) in e { @@ -48,3 +41,4 @@ pub fn stringify_sendall_errors(e: Vec<(message_io::network::Endpoint, std::io:: } out } +*/