diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d6d940..5f1fb6b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - args: [ --markdown-linebreak-ext=md ] + args: [--markdown-linebreak-ext=md] - repo: local hooks: @@ -15,15 +15,16 @@ repos: name: cargo fmt entry: cargo fmt -- language: rust - types: [ rust ] + types: [rust] ci: - autofix_commit_msg: | - [pre-commit.ci] auto fixes from pre-commit.com hooks - for more information, see https://pre-commit.ci - autofix_prs: true - autoupdate_branch: '' - autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' - autoupdate_schedule: weekly - skip: [] - submodules: false + autofix_commit_msg: | + [pre-commit.ci] auto fixes from pre-commit.com hooks + + for more information, see https://pre-commit.ci + autofix_prs: true + autoupdate_branch: "" + autoupdate_commit_msg: "[pre-commit.ci] pre-commit autoupdate" + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/src/handlers/app.rs b/src/handlers/app.rs index e3eff14..1737597 100644 --- a/src/handlers/app.rs +++ b/src/handlers/app.rs @@ -61,7 +61,7 @@ pub struct App { } impl App { - pub fn new(config: CompleteConfig) -> Self { + pub fn new(config: &CompleteConfig) -> Self { let mut input_buffers_map = HashMap::new(); for name in BufferName::into_enum_iter() { @@ -70,8 +70,8 @@ impl App { Self { messages: VecDeque::with_capacity(config.terminal.maximum_messages), - storage: Storage::new("storage.json", config.storage), - filters: Filters::new("filters.txt", config.filters), + storage: Storage::new("storage.json", &config.storage), + filters: Filters::new("filters.txt", &config.filters), state: State::Normal, selected_buffer: BufferName::Chat, buffer_suggestion: Some("".to_string()), diff --git a/src/handlers/config.rs b/src/handlers/config.rs index a8e951d..b92175e 100644 --- a/src/handlers/config.rs +++ b/src/handlers/config.rs @@ -1,3 +1,5 @@ +#![allow(clippy::use_self)] + use std::{ fs::{create_dir_all, read_to_string, File}, io::Write, @@ -147,7 +149,7 @@ pub enum Palette { impl Default for Palette { fn default() -> Self { - Palette::Pastel + Self::Pastel } } @@ -156,10 +158,10 @@ impl FromStr for Palette { fn from_str(s: &str) -> Result { match s { - "vibrant" => Ok(Palette::Vibrant), - "warm" => Ok(Palette::Warm), - "cool" => Ok(Palette::Cool), - _ => Ok(Palette::Pastel), + "vibrant" => Ok(Self::Vibrant), + "warm" => Ok(Self::Warm), + "cool" => Ok(Self::Cool), + _ => Ok(Self::Pastel), } } } @@ -174,7 +176,7 @@ pub enum Theme { impl Default for Theme { fn default() -> Self { - Theme::Dark + Self::Dark } } @@ -183,8 +185,8 @@ impl FromStr for Theme { fn from_str(s: &str) -> Result { match s { - "light" => Ok(Theme::Light), - _ => Ok(Theme::Dark), + "light" => Ok(Self::Light), + _ => Ok(Self::Dark), } } } diff --git a/src/handlers/data.rs b/src/handlers/data.rs index 8bbc875..e880f33 100644 --- a/src/handlers/data.rs +++ b/src/handlers/data.rs @@ -36,11 +36,11 @@ pub struct DataBuilder<'conf> { } impl<'conf> DataBuilder<'conf> { - pub fn new(date_format: &'conf str) -> Self { + pub const fn new(date_format: &'conf str) -> Self { DataBuilder { date_format } } - pub fn user(self, user: String, message: String) -> Data { + pub fn user(user: String, message: String) -> Data { Data { time_sent: Local::now(), author: user, @@ -78,18 +78,19 @@ pub struct Data { impl Data { fn hash_username(&self, palette: &Palette) -> Color { - let hash = self - .author - .as_bytes() - .iter() - .map(|&b| b as u32) - .sum::() as f64; + let hash = f64::from( + self.author + .as_bytes() + .iter() + .map(|&b| u32::from(b)) + .sum::(), + ); let (hue, saturation, lightness) = match palette { Palette::Pastel => (hash % 360. + 1., 0.5, 0.75), Palette::Vibrant => (hash % 360. + 1., 1., 0.6), Palette::Warm => ((hash % 100. + 1.) * 1.2, 0.8, 0.7), - Palette::Cool => ((hash % 100. + 1.) * 1.2 + 180., 0.6, 0.7), + Palette::Cool => ((hash % 100. + 1.).mul_add(1.2, 180.), 0.6, 0.7), }; let rgb = hsl_to_rgb(hue, saturation, lightness); @@ -100,18 +101,18 @@ impl Data { pub fn to_row( &self, frontend_config: &FrontendConfig, - limit: &usize, + limit: usize, search_highlight: Option, username_highlight: Option, theme_style: Style, ) -> Vec { let message = if let PayLoad::Message(m) = &self.payload { - textwrap::fill(m.as_str(), *limit) + textwrap::fill(m.as_str(), limit) } else { panic!("Data.to_row() can only take an enum of PayLoad::Message."); }; - let username_highlight_style = if let Some(username) = username_highlight { + let username_highlight_style = username_highlight.map_or_else(Style::default, |username| { if Regex::new(format!("^.*{}.*$", username).as_str()) .unwrap() .is_match(&message) @@ -123,53 +124,54 @@ impl Data { } else { Style::default() } - } else { - Style::default() - }; + }); - let msg_cells = if let Some(search) = search_highlight { - message - .split('\n') - .map(|s| { - let chars = s.chars(); - - if let Some((_, indices)) = FUZZY_FINDER.fuzzy_indices(s, search.as_str()) { - Cell::from(vec![Spans::from( - chars - .enumerate() - .map(|(i, s)| { - if indices.contains(&i) { - Span::styled( - s.to_string(), - Style::default() - .fg(Color::Red) - .add_modifier(Modifier::BOLD), - ) - } else { - Span::raw(s.to_string()) - } - }) - .collect::>(), - )]) - } else { + let msg_cells = search_highlight.map_or_else( + || { + message + .split('\n') + .map(|s| { Cell::from(Spans::from(vec![Span::styled( s.to_owned(), username_highlight_style, )])) - } - }) - .collect::>() - } else { - message - .split('\n') - .map(|s| { - Cell::from(Spans::from(vec![Span::styled( - s.to_owned(), - username_highlight_style, - )])) - }) - .collect::>() - }; + }) + .collect::>() + }, + |search| { + message + .split('\n') + .map(|s| { + let chars = s.chars(); + + if let Some((_, indices)) = FUZZY_FINDER.fuzzy_indices(s, search.as_str()) { + Cell::from(vec![Spans::from( + chars + .enumerate() + .map(|(i, s)| { + if indices.contains(&i) { + Span::styled( + s.to_string(), + Style::default() + .fg(Color::Red) + .add_modifier(Modifier::BOLD), + ) + } else { + Span::raw(s.to_string()) + } + }) + .collect::>(), + )]) + } else { + Cell::from(Spans::from(vec![Span::styled( + s.to_owned(), + username_highlight_style, + )])) + } + }) + .collect::>() + }, + ); let mut cell_vector = vec![ Cell::from(align_text( @@ -200,7 +202,7 @@ impl Data { if msg_cells.len() > 1 { for cell in msg_cells.iter().skip(1) { - let mut wrapped_msg = vec![Cell::from(""), cell.to_owned()]; + let mut wrapped_msg = vec![Cell::from(""), cell.clone()]; if frontend_config.date_shown { wrapped_msg.insert(0, Cell::from("")); diff --git a/src/handlers/event.rs b/src/handlers/event.rs index e8ce095..4b2edee 100644 --- a/src/handlers/event.rs +++ b/src/handlers/event.rs @@ -52,7 +52,7 @@ pub struct Config { } impl Events { - pub async fn with_config(config: Config) -> Events { + pub async fn with_config(config: Config) -> Self { let (tx, rx) = mpsc::channel(100); tokio::spawn(async move { @@ -83,7 +83,6 @@ impl Events { KeyCode::Tab => Key::Tab, KeyCode::BackTab => Key::BackTab, KeyCode::Enter => Key::Enter, - KeyCode::Null => Key::Null, KeyCode::F(k) => Key::F(k), KeyCode::Char(c) => match key.modifiers { KeyModifiers::NONE | KeyModifiers::SHIFT => Key::Char(c), @@ -126,7 +125,8 @@ impl Events { } } }); - Events { rx } + + Self { rx } } pub async fn next(&mut self) -> Option> { diff --git a/src/handlers/filters.rs b/src/handlers/filters.rs index 31ceb57..37a1315 100644 --- a/src/handlers/filters.rs +++ b/src/handlers/filters.rs @@ -12,7 +12,7 @@ pub struct Filters { } impl Filters { - pub fn new(file: &str, config: FiltersConfig) -> Self { + pub fn new(file: &str, config: &FiltersConfig) -> Self { let file_path = config_path(file); Self { @@ -29,10 +29,10 @@ impl Filters { } } - pub fn contaminated(&self, data: String) -> bool { + pub fn contaminated(&self, data: &str) -> bool { if self.enabled { for re in &self.captures { - if re.is_match(&data) { + if re.is_match(data) { return !self.reversed; } } @@ -41,7 +41,7 @@ impl Filters { self.reversed } - pub fn enabled(&self) -> bool { + pub const fn enabled(&self) -> bool { self.enabled } @@ -50,7 +50,7 @@ impl Filters { } #[allow(dead_code)] - pub fn reversed(&self) -> bool { + pub const fn reversed(&self) -> bool { self.reversed } @@ -75,14 +75,14 @@ mod tests { fn test_contaminated() { let filters = setup(); - assert!(filters.contaminated("bad word".to_string())); + assert!(filters.contaminated("bad word")); } #[test] fn test_non_contaminated() { let filters = setup(); - assert!(!filters.contaminated("not a bad word".to_string())); + assert!(!filters.contaminated("not a bad word")); } #[test] @@ -91,7 +91,7 @@ mod tests { filters.reverse(); - assert!(!filters.contaminated("bad word".to_string())); + assert!(!filters.contaminated("bad word")); } #[test] @@ -100,6 +100,6 @@ mod tests { filters.reverse(); - assert!(filters.contaminated("not a bad word".to_string())); + assert!(filters.contaminated("not a bad word")); } } diff --git a/src/handlers/storage.rs b/src/handlers/storage.rs index 1b6d327..546b73e 100644 --- a/src/handlers/storage.rs +++ b/src/handlers/storage.rs @@ -29,7 +29,7 @@ pub struct StorageItem { } impl Storage { - pub fn new(file: &str, config: StorageConfig) -> Self { + pub fn new(file: &str, config: &StorageConfig) -> Self { let file_path = config_path(file); if !Path::new(&file_path).exists() { @@ -43,7 +43,7 @@ impl Storage { }; items.insert( - item_key.to_string(), + (*item_key).to_string(), StorageItem { content: vec![], enabled, @@ -57,14 +57,14 @@ impl Storage { file.write_all(storage_str.as_bytes()).unwrap(); - return Storage { items, file_path }; + return Self { items, file_path }; } let file_content = read_to_string(&file_path).unwrap(); let items: StorageMap = serde_json::from_str(&file_content).unwrap(); - Storage { items, file_path } + Self { items, file_path } } pub fn dump_data(&self) { @@ -75,9 +75,9 @@ impl Storage { file.write_all(storage_str.as_bytes()).unwrap(); } - pub fn add(&mut self, key: String, value: String) { - if ITEM_KEYS.contains(&key.as_str()) { - if let Some(item) = self.items.get_mut(&key) { + pub fn add(&mut self, key: &str, value: String) { + if ITEM_KEYS.contains(&key) { + if let Some(item) = self.items.get_mut(&key.to_string()) { if !item.content.contains(&value) && item.enabled { item.content.push(value); } @@ -87,13 +87,11 @@ impl Storage { } } - pub fn get(&self, key: String) -> Vec { - if ITEM_KEYS.contains(&key.as_str()) { - if let Some(item) = self.items.get(&key) { - item.content.clone() - } else { - vec![] - } + pub fn get(&self, key: &str) -> Vec { + if ITEM_KEYS.contains(&key) { + self.items + .get(&key.to_string()) + .map_or_else(Vec::new, |item| item.content.clone()) } else { panic!("Attempted to get key {} from JSON storage.", key); } diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..5002811 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,212 @@ +use regex::Regex; +use rustyline::{At, Word}; +use tokio::sync::mpsc::Sender; + +use crate::{ + handlers::{ + app::{App, BufferName, State}, + config::CompleteConfig, + data::DataBuilder, + event::{Event, Events, Key}, + }, + twitch::TwitchAction, + ui::statics::{CHANNEL_NAME_REGEX, TWITCH_MESSAGE_LIMIT}, +}; + +pub enum TerminalAction { + Quitting, +} + +pub async fn handle_user_input( + events: &mut Events, + app: &mut App, + config: &mut CompleteConfig, + tx: Sender, +) -> Option { + if let Some(Event::Input(key)) = events.next().await { + match app.state { + State::Insert | State::MessageSearch | State::Normal => match key { + Key::ScrollUp => { + if app.scroll_offset < app.messages.len() { + app.scroll_offset += 1; + } + } + Key::ScrollDown => { + if app.scroll_offset > 0 { + app.scroll_offset -= 1; + } + } + _ => {} + }, + _ => {} + } + + match app.state { + State::Insert | State::ChannelSwitch | State::MessageSearch => { + let input_buffer = app.current_buffer_mut(); + + match key { + Key::Up => { + if app.state == State::Insert { + app.state = State::Normal; + } + } + Key::Ctrl('f') | Key::Right => { + input_buffer.move_forward(1); + } + Key::Ctrl('b') | Key::Left => { + input_buffer.move_backward(1); + } + Key::Ctrl('a') | Key::Home => { + input_buffer.move_home(); + } + Key::Ctrl('e') | Key::End => { + input_buffer.move_end(); + } + Key::Alt('f') => { + input_buffer.move_to_next_word(At::AfterEnd, Word::Emacs, 1); + } + Key::Alt('b') => { + input_buffer.move_to_prev_word(Word::Emacs, 1); + } + Key::Ctrl('t') => { + input_buffer.transpose_chars(); + } + Key::Alt('t') => { + input_buffer.transpose_words(1); + } + Key::Ctrl('u') => { + input_buffer.discard_line(); + } + Key::Ctrl('k') => { + input_buffer.kill_line(); + } + Key::Ctrl('w') => { + input_buffer.delete_prev_word(Word::Emacs, 1); + } + Key::Ctrl('d') => { + input_buffer.delete(1); + } + Key::Backspace | Key::Delete => { + input_buffer.backspace(1); + } + Key::Tab => { + let suggestion = app.buffer_suggestion.clone(); + + if let Some(suggestion_buffer) = suggestion { + app.input_buffers + .get_mut(&app.selected_buffer) + .unwrap() + .update(suggestion_buffer.as_str(), suggestion_buffer.len()); + } + } + Key::Enter => match app.selected_buffer { + BufferName::Chat => { + let input_message = + app.input_buffers.get_mut(&app.selected_buffer).unwrap(); + + if input_message.is_empty() + || app.filters.contaminated(input_message) + || input_message.len() > *TWITCH_MESSAGE_LIMIT + { + return None; + } + + app.messages.push_front(DataBuilder::user( + config.twitch.username.to_string(), + input_message.to_string(), + )); + + tx.send(TwitchAction::Privmsg(input_message.to_string())) + .await + .unwrap(); + + if let Some(msg) = input_message.strip_prefix('@') { + app.storage.add("mentions", msg.to_string()); + } + + input_message.update("", 0); + } + BufferName::Channel => { + let input_message = + app.input_buffers.get_mut(&app.selected_buffer).unwrap(); + + if input_message.is_empty() + || !Regex::new(*CHANNEL_NAME_REGEX) + .unwrap() + .is_match(input_message) + { + return None; + } + + app.messages.clear(); + + tx.send(TwitchAction::Join(input_message.to_string())) + .await + .unwrap(); + + config.twitch.channel = input_message.to_string(); + + app.storage.add("channels", input_message.to_string()); + + input_message.update("", 0); + + app.selected_buffer = BufferName::Chat; + app.state = State::Normal; + } + BufferName::MessageHighlighter => {} + }, + Key::Char(c) => { + input_buffer.insert(c, 1); + } + Key::Esc => { + input_buffer.update("", 0); + app.state = State::Normal; + } + _ => {} + } + } + _ => match key { + Key::Char('c') => { + app.state = State::Normal; + app.selected_buffer = BufferName::Chat; + } + Key::Char('s') => { + app.state = State::ChannelSwitch; + app.selected_buffer = BufferName::Channel; + } + Key::Ctrl('f') => { + app.state = State::MessageSearch; + app.selected_buffer = BufferName::MessageHighlighter; + } + Key::Ctrl('t') => { + app.filters.toggle(); + } + Key::Ctrl('r') => { + app.filters.reverse(); + } + Key::Char('i') | Key::Insert => { + app.state = State::Insert; + app.selected_buffer = BufferName::Chat; + } + Key::Ctrl('p') => { + panic!("Manual panic triggered by user."); + } + Key::Char('?') => app.state = State::Help, + Key::Char('q') => { + if app.state == State::Normal { + return Some(TerminalAction::Quitting); + } + } + Key::Esc => { + app.scroll_offset = 0; + app.state = State::Normal; + app.selected_buffer = BufferName::Chat; + } + _ => {} + }, + } + } + + None +} diff --git a/src/main.rs b/src/main.rs index e647be9..3722bf9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,16 @@ +#![warn(clippy::nursery, clippy::pedantic)] +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::module_name_repetitions, + clippy::struct_excessive_bools, + clippy::unused_self, + clippy::future_not_send, + clippy::needless_pass_by_value, + clippy::too_many_lines +)] + use clap::Parser; use color_eyre::eyre::{Result, WrapErr}; use log::info; @@ -6,6 +19,7 @@ use tokio::sync::mpsc; use crate::handlers::{app::App, args::Cli, config::CompleteConfig}; mod handlers; +mod input; mod terminal; mod twitch; mod ui; @@ -20,7 +34,7 @@ fn initialize_logging(config: &CompleteConfig) { record.target(), record.level(), message - )) + )); }) .level(if config.terminal.verbose { log::LevelFilter::Debug @@ -28,7 +42,7 @@ fn initialize_logging(config: &CompleteConfig) { log::LevelFilter::Info }); - if let Some(log_file_path) = config.terminal.log_file.to_owned() { + if let Some(log_file_path) = config.terminal.log_file.clone() { if !log_file_path.is_empty() { logger .chain(fern::log_file(log_file_path).unwrap()) @@ -52,7 +66,7 @@ async fn main() -> Result<()> { info!("Logging system initialised"); - let app = App::new(config.clone()); + let app = App::new(&config); let (twitch_tx, terminal_rx) = mpsc::channel(100); let (terminal_tx, twitch_rx) = mpsc::channel(100); diff --git a/src/terminal.rs b/src/terminal.rs index da19cb5..e94c72d 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -10,8 +10,6 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use log::{debug, info}; -use regex::Regex; -use rustyline::{At, Word}; use tokio::sync::mpsc::{Receiver, Sender}; use tui::{backend::CrosstermBackend, layout::Constraint, Terminal}; @@ -20,13 +18,11 @@ use crate::{ app::{App, BufferName, State}, config::CompleteConfig, data::{Data, DataBuilder, PayLoad}, - event::{Config, Event, Events, Key}, - }, - twitch::Action, - ui::{ - draw_ui, - statics::{CHANNEL_NAME_REGEX, TWITCH_MESSAGE_LIMIT}, + event::{Config, Events, Key}, }, + input::{handle_user_input, TerminalAction}, + twitch::TwitchAction, + ui::draw_ui, utils::text::align_text, }; @@ -47,10 +43,23 @@ fn init_terminal() -> Terminal> { Terminal::new(backend).unwrap() } +fn quit_terminal(mut terminal: Terminal>) { + disable_raw_mode().unwrap(); + + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .unwrap(); + + terminal.show_cursor().unwrap(); +} + pub async fn ui_driver( - mut config: CompleteConfig, + config: CompleteConfig, mut app: App, - tx: Sender, + tx: Sender, mut rx: Receiver, ) { info!("Started UI driver."); @@ -78,10 +87,7 @@ pub async fn ui_driver( config.frontend.maximum_username_length, ); - let mut column_titles = vec![ - username_column_title.to_owned(), - "Message content".to_string(), - ]; + let mut column_titles = vec![username_column_title.clone(), "Message content".to_string()]; let mut table_constraints = vec![ Constraint::Length(config.frontend.maximum_username_length), @@ -109,19 +115,6 @@ pub async fn ui_driver( let data_builder = DataBuilder::new(&config.frontend.date_format); - let quitting = |mut terminal: Terminal>| { - disable_raw_mode().unwrap(); - - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - ) - .unwrap(); - - terminal.show_cursor().unwrap(); - }; - loop { if let Ok(info) = rx.try_recv() { match info.payload { @@ -146,191 +139,12 @@ pub async fn ui_driver( .draw(|frame| draw_ui(frame, &mut app, &config)) .unwrap(); - if let Some(Event::Input(key)) = events.next().await { - match app.state { - State::Insert | State::MessageSearch | State::Normal => match key { - Key::ScrollUp => { - if app.scroll_offset < app.messages.len() { - app.scroll_offset += 1; - } - } - Key::ScrollDown => { - if app.scroll_offset > 0 { - app.scroll_offset -= 1; - } - } - _ => {} - }, - _ => {} - } + if let Some(TerminalAction::Quitting) = + handle_user_input(&mut events, &mut app, &mut config.clone(), tx.clone()).await + { + quit_terminal(terminal); - match app.state { - State::Insert | State::ChannelSwitch | State::MessageSearch => { - let input_buffer = app.current_buffer_mut(); - - match key { - Key::Up => { - if let State::Insert = app.state { - app.state = State::Normal; - } - } - Key::Ctrl('f') | Key::Right => { - input_buffer.move_forward(1); - } - Key::Ctrl('b') | Key::Left => { - input_buffer.move_backward(1); - } - Key::Ctrl('a') | Key::Home => { - input_buffer.move_home(); - } - Key::Ctrl('e') | Key::End => { - input_buffer.move_end(); - } - Key::Alt('f') => { - input_buffer.move_to_next_word(At::AfterEnd, Word::Emacs, 1); - } - Key::Alt('b') => { - input_buffer.move_to_prev_word(Word::Emacs, 1); - } - Key::Ctrl('t') => { - input_buffer.transpose_chars(); - } - Key::Alt('t') => { - input_buffer.transpose_words(1); - } - Key::Ctrl('u') => { - input_buffer.discard_line(); - } - Key::Ctrl('k') => { - input_buffer.kill_line(); - } - Key::Ctrl('w') => { - input_buffer.delete_prev_word(Word::Emacs, 1); - } - Key::Ctrl('d') => { - input_buffer.delete(1); - } - Key::Backspace | Key::Delete => { - input_buffer.backspace(1); - } - Key::Tab => { - let suggestion = app.buffer_suggestion.clone(); - - if let Some(suggestion_buffer) = suggestion { - app.input_buffers - .get_mut(&app.selected_buffer) - .unwrap() - .update(suggestion_buffer.as_str(), suggestion_buffer.len()); - } - } - Key::Enter => match app.selected_buffer { - BufferName::Chat => { - let input_message = - app.input_buffers.get_mut(&app.selected_buffer).unwrap(); - - if input_message.is_empty() - || app.filters.contaminated(input_message.to_string()) - || input_message.len() > *TWITCH_MESSAGE_LIMIT - { - continue; - } - - app.messages.push_front(data_builder.user( - config.twitch.username.to_string(), - input_message.to_string(), - )); - - tx.send(Action::Privmsg(input_message.to_string())) - .await - .unwrap(); - - if let Some(msg) = input_message.strip_prefix('@') { - app.storage.add("mentions".to_string(), msg.to_string()) - } - - input_message.update("", 0); - } - BufferName::Channel => { - let input_message = - app.input_buffers.get_mut(&app.selected_buffer).unwrap(); - - if input_message.is_empty() - || !Regex::new(*CHANNEL_NAME_REGEX) - .unwrap() - .is_match(input_message) - { - continue; - } - - app.messages.clear(); - - tx.send(Action::Join(input_message.to_string())) - .await - .unwrap(); - - config.twitch.channel = input_message.to_string(); - - app.storage - .add("channels".to_string(), input_message.to_string()); - - input_message.update("", 0); - - app.selected_buffer = BufferName::Chat; - app.state = State::Normal; - } - _ => {} - }, - Key::Char(c) => { - input_buffer.insert(c, 1); - } - Key::Esc => { - input_buffer.update("", 0); - app.state = State::Normal; - } - _ => {} - } - } - _ => match key { - Key::Char('c') => { - app.state = State::Normal; - app.selected_buffer = BufferName::Chat; - } - Key::Char('s') => { - app.state = State::ChannelSwitch; - app.selected_buffer = BufferName::Channel; - } - Key::Ctrl('f') => { - app.state = State::MessageSearch; - app.selected_buffer = BufferName::MessageHighlighter; - } - Key::Ctrl('t') => { - app.filters.toggle(); - } - Key::Ctrl('r') => { - app.filters.reverse(); - } - Key::Char('i') | Key::Insert => { - app.state = State::Insert; - app.selected_buffer = BufferName::Chat; - } - Key::Ctrl('p') => { - panic!("Manual panic triggered by user."); - } - Key::Char('?') => app.state = State::Help, - Key::Char('q') => { - if let State::Normal = app.state { - quitting(terminal); - break; - } - } - Key::Esc => { - app.scroll_offset = 0; - app.state = State::Normal; - app.selected_buffer = BufferName::Chat; - } - _ => {} - }, - } + break; } } diff --git a/src/twitch.rs b/src/twitch.rs index 7400280..280eda0 100644 --- a/src/twitch.rs +++ b/src/twitch.rs @@ -6,8 +6,8 @@ use irc::{ prelude::{Capability, Config}, Client, ClientStream, }, - error::Error::PingTimeout, - proto::Command, + error::Error::{self, PingTimeout}, + proto::{Command, Message}, }; use log::{debug, info}; use tokio::{ @@ -26,17 +26,17 @@ const SUBSCRIBER_BADGE: char = '\u{2B50}'; const PRIME_GAMING_BADGE: char = '\u{1F451}'; #[derive(Debug)] -pub enum Action { +pub enum TwitchAction { Privmsg(String), Join(String), } async fn create_client_stream(config: CompleteConfig) -> (Client, ClientStream) { let irc_config = Config { - nickname: Some(config.twitch.username.to_owned()), - server: Some(config.twitch.server.to_owned()), + nickname: Some(config.twitch.username.clone()), + server: Some(config.twitch.server.clone()), channels: vec![format!("#{}", config.twitch.channel)], - password: Some(config.twitch.token.to_owned()), + password: Some(config.twitch.token.clone()), port: Some(6667), use_tls: Some(false), ping_timeout: Some(10), @@ -53,7 +53,42 @@ async fn create_client_stream(config: CompleteConfig) -> (Client, ClientStream) (client, stream) } -pub async fn twitch_irc(mut config: CompleteConfig, tx: Sender, mut rx: Receiver) { +async fn client_stream_reconnect( + err: Error, + tx: Sender, + data_builder: DataBuilder<'_>, + client: &mut Client, + stream: &mut ClientStream, + config: &CompleteConfig, +) { + match err { + PingTimeout => { + tx.send( + data_builder + .system("Attempting to reconnect due to Twitch ping timeout.".to_string()), + ) + .await + .unwrap(); + } + _ => { + tx.send(data_builder.system( + format!("Attempting to reconnect due to fatal error: {:?}", err).to_string(), + )) + .await + .unwrap(); + } + } + + (*client, *stream) = create_client_stream(config.clone()).await; + + sleep(Duration::from_millis(1000)).await; +} + +pub async fn twitch_irc( + mut config: CompleteConfig, + tx: Sender, + mut rx: Receiver, +) { info!("Spawned Twitch IRC thread."); let data_builder = DataBuilder::new(&config.frontend.date_format); @@ -89,28 +124,28 @@ pub async fn twitch_irc(mut config: CompleteConfig, tx: Sender, mut rx: Re let current_channel = format!("#{}", config.twitch.channel); match action { - Action::Privmsg(message) => { + TwitchAction::Privmsg(message) => { debug!("Sending message to Twitch: {}", message); client .send_privmsg(current_channel, message) .unwrap(); } - Action::Join(channel) => { + TwitchAction::Join(channel) => { debug!("Switching to channel {}", channel); let channel_list = format!("#{}", channel); // Leave previous channel if let Err(err) = sender.send_part(current_channel) { - tx.send(data_builder.twitch(err.to_string())).await.unwrap() + tx.send(data_builder.twitch(err.to_string())).await.unwrap(); } else { tx.send(data_builder.twitch(format!("Joined {}", channel_list))).await.unwrap(); } // Join specified channel if let Err(err) = sender.send_join(&channel_list) { - tx.send(data_builder.twitch(err.to_string())).await.unwrap() + tx.send(data_builder.twitch(err.to_string())).await.unwrap(); } // Set old channel to new channel @@ -118,12 +153,13 @@ pub async fn twitch_irc(mut config: CompleteConfig, tx: Sender, mut rx: Re } } } - Some(_message) = stream.next() => { - match _message { + Some(message) = stream.next() => { + match message { Ok(message) => { let mut tags: HashMap<&str, &str> = HashMap::new(); - if let Some(ref _tags) = message.tags { - for tag in _tags { + + if let Some(ref ref_tags) = message.tags { + for tag in ref_tags { if let Some(ref tag_value) = tag.1 { tags.insert(&tag.0, tag_value); } @@ -135,62 +171,18 @@ pub async fn twitch_irc(mut config: CompleteConfig, tx: Sender, mut rx: Re // lowercase username from message let mut name = match message.source_nickname() { Some(username) => username.to_string(), - None => "Undefined username".to_string(), + None => { + debug!("Undefined username found, continuing without sending message."); + + continue; + }, }; if config.frontend.badges { - let mut badges = String::new(); - if let Some(ref tags) = message.tags { - let mut vip_badge = None; - let mut moderator_badge = None; - let mut subscriber_badge = None; - let mut prime_badge = None; - let mut display_name = None; - for tag in tags { - if tag.0 == *"display-name" { - if let Some(ref value) = tag.1 { - display_name = Some(value.to_string()); - } - } - if tag.0 == *"badges" { - if let Some(ref value) = tag.1 { - if !value.is_empty() && value.contains("vip") { - vip_badge = Some(VIP_BADGE); - } - if !value.is_empty() && value.contains("moderator") { - moderator_badge = Some(MODERATOR_BADGE); - } - if !value.is_empty() && value.contains("subscriber") { - subscriber_badge = Some(SUBSCRIBER_BADGE); - } - if !value.is_empty() && value.contains("premium") { - prime_badge = Some(PRIME_GAMING_BADGE); - } - } - } - } - if let Some(display_name) = display_name { - name = display_name; - } - if let Some(badge) = vip_badge { - badges.push(badge); - } - if let Some(badge) = moderator_badge { - badges.push(badge); - } - if let Some(badge) = subscriber_badge { - badges.push(badge); - } - if let Some(badge) = prime_badge { - badges.push(badge); - } - if !badges.is_empty() { - name = badges.clone() + &name; - } - } + retrieve_user_badges(&mut name, message.clone()); } - tx.send(data_builder.user(name.to_string(), msg.to_string())) + tx.send(DataBuilder::user(name.to_string(), msg.to_string())) .await .unwrap(); @@ -207,13 +199,14 @@ pub async fn twitch_irc(mut config: CompleteConfig, tx: Sender, mut rx: Re // Only display roomstate on startup, since twitch // sends a NOTICE whenever roomstate changes. if !room_state_startup { - handle_roomstate(&tx, data_builder, &tags).await; + handle_roomstate(&tx, &tags).await; } + room_state_startup = true; } "USERNOTICE" => { if let Some(value) = tags.get("system-msg") { - tx.send(data_builder.twitch(value.to_string())) + tx.send(data_builder.twitch((*value).to_string())) .await .unwrap(); } @@ -225,20 +218,9 @@ pub async fn twitch_irc(mut config: CompleteConfig, tx: Sender, mut rx: Re } } Err(err) => { - debug!("Twitch connection error encountered: {}", err); + debug!("Twitch connection error encountered: {}, attempting to reconnect.", err); - match err { - PingTimeout => { - tx.send(data_builder.system("Attempting to reconnect due to Twitch ping timeout.".to_string())).await.unwrap(); - } - _ => { - tx.send(data_builder.system(format!("Attempting to reconnect due to fatal error: {:?}", err).to_string())).await.unwrap(); - } - } - - (client, stream) = create_client_stream(config.clone()).await; - - sleep(Duration::from_millis(1000)).await; + client_stream_reconnect(err, tx.clone(), data_builder, &mut client, &mut stream, &config).await; } } } @@ -247,12 +229,70 @@ pub async fn twitch_irc(mut config: CompleteConfig, tx: Sender, mut rx: Re } } -pub async fn handle_roomstate( - tx: &Sender, - builder: DataBuilder<'_>, - tags: &HashMap<&str, &str>, -) { +fn retrieve_user_badges(name: &mut String, message: Message) { + let mut badges = String::new(); + + if let Some(ref tags) = message.tags { + let mut vip_badge = None; + let mut moderator_badge = None; + let mut subscriber_badge = None; + let mut prime_badge = None; + let mut display_name = None; + + for tag in tags { + if tag.0 == *"display-name" { + if let Some(ref value) = tag.1 { + display_name = Some(value.to_string()); + } + } + + if tag.0 == *"badges" { + if let Some(ref value) = tag.1 { + if !value.is_empty() && value.contains("vip") { + vip_badge = Some(VIP_BADGE); + } + if !value.is_empty() && value.contains("moderator") { + moderator_badge = Some(MODERATOR_BADGE); + } + if !value.is_empty() && value.contains("subscriber") { + subscriber_badge = Some(SUBSCRIBER_BADGE); + } + if !value.is_empty() && value.contains("premium") { + prime_badge = Some(PRIME_GAMING_BADGE); + } + } + } + } + + if let Some(display_name) = display_name { + *name = display_name; + } + + if let Some(badge) = vip_badge { + badges.push(badge); + } + + if let Some(badge) = moderator_badge { + badges.push(badge); + } + + if let Some(badge) = subscriber_badge { + badges.push(badge); + } + + if let Some(badge) = prime_badge { + badges.push(badge); + } + + if !badges.is_empty() { + *name = badges.clone() + name; + } + } +} + +pub async fn handle_roomstate(tx: &Sender, tags: &HashMap<&str, &str>) { let mut room_state = String::new(); + for (name, value) in tags.iter() { match *name { "emote-only" if *value == "1" => { @@ -272,11 +312,15 @@ pub async fn handle_roomstate( _ => (), } } + // Trim last newline room_state.pop(); - if !room_state.is_empty() { - tx.send(builder.user(String::from("Info"), room_state)) - .await - .unwrap(); + + if room_state.is_empty() { + return; } + + tx.send(DataBuilder::user(String::from("Info"), room_state)) + .await + .unwrap(); } diff --git a/src/ui/chunks/chatting.rs b/src/ui/chunks/chatting.rs index 59e98df..ec43a08 100644 --- a/src/ui/chunks/chatting.rs +++ b/src/ui/chunks/chatting.rs @@ -21,40 +21,33 @@ pub fn ui_insert_message(window: WindowAttributes, mention_sugges let current_input = input_buffer.to_string(); let suggestion = if mention_suggestions { - if let Some(start_character) = input_buffer.chars().next() { - match start_character { + input_buffer + .chars() + .next() + .and_then(|start_character| match start_character { '/' => { let possible_suggestion = suggestion_query( - current_input[1..].to_string(), + ¤t_input[1..], COMMANDS .iter() - .map(|s| s.to_string()) + .map(ToString::to_string) .collect::>(), ); - if let Some(s) = possible_suggestion { - Some(format!("/{}", s)) - } else { - possible_suggestion - } + let default_suggestion = possible_suggestion.clone(); + + possible_suggestion.map_or(default_suggestion, |s| Some(format!("/{}", s))) } '@' => { - let possible_suggestion = suggestion_query( - current_input[1..].to_string(), - app.storage.get("mentions".to_string()), - ); + let possible_suggestion = + suggestion_query(¤t_input[1..], app.storage.get("mentions")); - if let Some(s) = possible_suggestion { - Some(format!("@{}", s)) - } else { - possible_suggestion - } + let default_suggestion = possible_suggestion.clone(); + + possible_suggestion.map_or(default_suggestion, |s| Some(format!("@{}", s))) } _ => None, - } - } else { - None - } + }) } else { None }; diff --git a/src/ui/chunks/mod.rs b/src/ui/chunks/mod.rs index 3c9569b..2cbe4ff 100644 --- a/src/ui/chunks/mod.rs +++ b/src/ui/chunks/mod.rs @@ -1 +1,3 @@ pub mod chatting; + +pub use chatting::ui_insert_message; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index beb390b..db2965f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -17,8 +17,8 @@ use crate::{ data::PayLoad, }, ui::{ - chunks::chatting::ui_insert_message, - popups::{channels::ui_switch_channels, help::ui_show_keybinds}, + chunks::ui_insert_message, + popups::{ui_show_keybinds, ui_switch_channels}, }, utils::{ styles, @@ -72,7 +72,7 @@ pub fn draw_ui(frame: &mut Frame, app: &mut App, config: &Complet .constraints(v_constraints.as_ref()) .split(frame.size()); - let layout = LayoutAttributes::new(v_constraints.to_vec(), v_chunks); + let layout = LayoutAttributes::new(v_constraints, v_chunks); let table_widths = app.table_constraints.as_ref().unwrap(); @@ -98,9 +98,9 @@ pub fn draw_ui(frame: &mut Frame, app: &mut App, config: &Complet let mut scroll_offset = app.scroll_offset; - 'outer: for data in app.messages.iter() { + 'outer: for data in &app.messages { if let PayLoad::Message(msg) = data.payload.clone() { - if app.filters.contaminated(msg) { + if app.filters.contaminated(msg.as_str()) { continue; } } @@ -120,10 +120,18 @@ pub fn draw_ui(frame: &mut Frame, app: &mut App, config: &Complet None }; - let rows = if !buffer.is_empty() { + let rows = if buffer.is_empty() { data.to_row( &config.frontend, - &message_chunk_width, + message_chunk_width, + None, + username_highlight, + app.theme_style, + ) + } else { + data.to_row( + &config.frontend, + message_chunk_width, match app.selected_buffer { BufferName::MessageHighlighter => Some(buffer.to_string()), _ => None, @@ -131,19 +139,11 @@ pub fn draw_ui(frame: &mut Frame, app: &mut App, config: &Complet username_highlight, app.theme_style, ) - } else { - data.to_row( - &config.frontend, - &message_chunk_width, - None, - username_highlight, - app.theme_style, - ) }; for row in rows.iter().rev() { if total_row_height < general_chunk_height { - display_rows.push_front(row.to_owned()); + display_rows.push_front(row.clone()); total_row_height += 1; } else { @@ -186,9 +186,7 @@ pub fn draw_ui(frame: &mut Frame, app: &mut App, config: &Complet }; let table = Table::new(display_rows) - .header( - Row::new(app.column_titles.as_ref().unwrap().to_owned()).style(styles::COLUMN_TITLE), - ) + .header(Row::new(app.column_titles.as_ref().unwrap().clone()).style(styles::COLUMN_TITLE)) .block( Block::default() .borders(Borders::ALL) @@ -210,13 +208,13 @@ pub fn draw_ui(frame: &mut Frame, app: &mut App, config: &Complet // States that require popups State::Help => ui_show_keybinds(window), State::ChannelSwitch => ui_switch_channels(window, config.storage.channels), - _ => {} + State::Normal => {} } } /// Puts a box for user input at the bottom of the screen, /// with an interactive cursor. -/// input_validation checks if the user's input is valid, changes window +/// `input_validation` checks if the user's input is valid, changes window /// theme to red if invalid, default otherwise. pub fn insert_box_chunk( window: WindowAttributes, @@ -245,24 +243,22 @@ pub fn insert_box_chunk( let current_input = buffer.as_str(); - let valid_input = if let Some(check_func) = input_validation { - check_func(current_input.to_string()) - } else { - true - }; + let valid_input = + input_validation.map_or(true, |check_func| check_func(current_input.to_string())); let paragraph = Paragraph::new(Spans::from(vec![ Span::raw(current_input), Span::styled( - if let Some(suggestion_buffer) = suggestion.clone() { - if suggestion_buffer.len() > current_input.len() { - suggestion_buffer[current_input.len()..].to_string() - } else { - "".to_string() - } - } else { - "".to_string() - }, + suggestion.clone().map_or_else( + || "".to_string(), + |suggestion_buffer| { + if suggestion_buffer.len() > current_input.len() { + suggestion_buffer[current_input.len()..].to_string() + } else { + "".to_string() + } + }, + ), Style::default().add_modifier(Modifier::DIM), ), ])) diff --git a/src/ui/popups/channels.rs b/src/ui/popups/channels.rs index 9e7a1d3..d6ac89e 100644 --- a/src/ui/popups/channels.rs +++ b/src/ui/popups/channels.rs @@ -1,3 +1,5 @@ +use std::string::ToString; + use regex::Regex; use tui::backend::Backend; @@ -24,11 +26,11 @@ pub fn ui_switch_channels(window: WindowAttributes, channel_sugge let suggestion = if channel_suggestions { suggestion_query( - input_buffer.to_string(), + input_buffer, app.storage - .get("channels".to_string()) + .get("channels") .iter() - .map(|s| s.to_string()) + .map(ToString::to_string) .collect::>(), ) } else { diff --git a/src/ui/popups/mod.rs b/src/ui/popups/mod.rs index c4cb48a..c65bfcf 100644 --- a/src/ui/popups/mod.rs +++ b/src/ui/popups/mod.rs @@ -3,6 +3,9 @@ use tui::layout::{Constraint, Direction, Layout, Rect}; pub mod channels; pub mod help; +pub use channels::ui_switch_channels; +pub use help::ui_show_keybinds; + const HORIZONTAL_CONSTRAINTS: [Constraint; 3] = [ Constraint::Percentage(15), Constraint::Percentage(70), diff --git a/src/utils/colors.rs b/src/utils/colors.rs index c405f28..d3f617d 100644 --- a/src/utils/colors.rs +++ b/src/utils/colors.rs @@ -1,4 +1,4 @@ -// https://css-tricks.com/converting-color-spaces-in-javascript/#hsl-to-rgb +/// pub fn hsl_to_rgb(hue: f64, saturation: f64, lightness: f64) -> [u8; 3] { // Color intensity let chroma = (1. - (2. * lightness - 1.).abs()) * saturation; diff --git a/src/utils/pathing.rs b/src/utils/pathing.rs index 4e58bda..b58de94 100644 --- a/src/utils/pathing.rs +++ b/src/utils/pathing.rs @@ -45,7 +45,7 @@ mod tests { std::env::var("HOME").unwrap(), BINARY_NAME, ) - ) + ); } #[test] diff --git a/src/utils/text.rs b/src/utils/text.rs index d8bc7b2..cfee2f8 100644 --- a/src/utils/text.rs +++ b/src/utils/text.rs @@ -7,15 +7,18 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; pub fn align_text(text: &str, alignment: &str, maximum_length: u16) -> String { - if maximum_length < 1 { - panic!("Parameter of 'maximum_length' cannot be below 1."); - } + assert!( + maximum_length >= 1, + "Parameter of 'maximum_length' cannot be below 1." + ); // Compute the display width of `text` with support of emojis and CJK characters let mut dw = display_width(text); + if dw > maximum_length as usize { dw = maximum_length as usize; } + match alignment { "right" => format!("{}{}", " ".repeat(maximum_length as usize - dw), text), "center" => { @@ -27,24 +30,25 @@ pub fn align_text(text: &str, alignment: &str, maximum_length: u16) -> String { } } -pub fn vector_column_max(vec: &[Vec]) -> IntoIter +pub fn vector_column_max(v: &[Vec]) -> IntoIter where T: AsRef, { - if vec.is_empty() { - panic!("Vector length should be greater than or equal to 1.") - } + assert!( + !v.is_empty(), + "Vector length should be greater than or equal to 1." + ); let column_max = |vec: &[Vec], index: usize| -> u16 { vec.iter().map(|v| v[index].as_ref().len()).max().unwrap() as u16 }; - let column_amount = vec[0].len(); + let column_amount = v[0].len(); let mut column_max_lengths: Vec = vec![]; for i in 0..column_amount { - column_max_lengths.push(column_max(vec, i)); + column_max_lengths.push(column_max(v, i)); } column_max_lengths.into_iter() @@ -70,41 +74,39 @@ pub fn title_spans(contents: Vec, style: Style) -> Vec { let mut complete = Vec::new(); for (i, item) in contents.iter().enumerate() { - let first_bracket = Span::raw(format!("{}[ ", if i != 0 { " " } else { "" })); + let first_bracket = Span::raw(format!("{}[ ", if i == 0 { "" } else { " " })); complete.extend(match item { TitleStyle::Combined(title, value) => vec![ first_bracket, - Span::styled(title.to_string(), style), + Span::styled((*title).to_string(), style), Span::raw(format!(": {} ]", value)), ], TitleStyle::Single(value) => vec![ first_bracket, - Span::styled(value.to_string(), style), + Span::styled((*value).to_string(), style), Span::raw(" ]"), ], - TitleStyle::Custom(span) => vec![first_bracket, span.to_owned(), Span::raw(" ]")], + TitleStyle::Custom(span) => vec![first_bracket, span.clone(), Span::raw(" ]")], }); } complete } -pub fn suggestion_query(search: String, possibilities: Vec) -> Option { - if let Some(result) = possibilities +pub fn suggestion_query(search: &str, possibilities: Vec) -> Option { + possibilities .iter() .filter(|s| s.starts_with(&search)) .collect::>() .first() - { - if result.len() > search.len() { - Some(result.to_string()) - } else { - None - } - } else { - None - } + .and_then(|result| { + if result.len() > search.len() { + Some((*result).to_string()) + } else { + None + } + }) } #[cfg(test)] @@ -221,8 +223,8 @@ mod tests { fn test_partial_suggestion_output() { let v = vec!["Nope".to_string()]; - let output = suggestion_query("No".to_string(), v); + let output = suggestion_query("No", v); - assert_eq!(output, Some("Nope".to_string())) + assert_eq!(output, Some("Nope".to_string())); } }