#![warn(clippy::nursery, clippy::pedantic)] (#212)

This commit is contained in:
Xithrius 2022-09-07 20:22:22 -07:00 committed by GitHub
parent 4365fa259c
commit 9a59c8c9ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 580 additions and 495 deletions

View File

@ -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

View File

@ -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()),

View File

@ -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<Self, Self::Err> {
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<Self, Self::Err> {
match s {
"light" => Ok(Theme::Light),
_ => Ok(Theme::Dark),
"light" => Ok(Self::Light),
_ => Ok(Self::Dark),
}
}
}

View File

@ -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::<u32>() as f64;
let hash = f64::from(
self.author
.as_bytes()
.iter()
.map(|&b| u32::from(b))
.sum::<u32>(),
);
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<String>,
username_highlight: Option<String>,
theme_style: Style,
) -> Vec<Row> {
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::<Vec<Span>>(),
)])
} 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::<Vec<Cell>>()
} else {
message
.split('\n')
.map(|s| {
Cell::from(Spans::from(vec![Span::styled(
s.to_owned(),
username_highlight_style,
)]))
})
.collect::<Vec<Cell>>()
};
})
.collect::<Vec<Cell>>()
},
|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::<Vec<Span>>(),
)])
} else {
Cell::from(Spans::from(vec![Span::styled(
s.to_owned(),
username_highlight_style,
)]))
}
})
.collect::<Vec<Cell>>()
},
);
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(""));

View File

@ -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<Event<Key>> {

View File

@ -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"));
}
}

View File

@ -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<String> {
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<String> {
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);
}

212
src/input.rs Normal file
View File

@ -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<TwitchAction>,
) -> Option<TerminalAction> {
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
}

View File

@ -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);

View File

@ -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<CrosstermBackend<Stdout>> {
Terminal::new(backend).unwrap()
}
fn quit_terminal(mut terminal: Terminal<CrosstermBackend<Stdout>>) {
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<Action>,
tx: Sender<TwitchAction>,
mut rx: Receiver<Data>,
) {
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<CrosstermBackend<Stdout>>| {
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;
}
}

View File

@ -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<Data>, mut rx: Receiver<Action>) {
async fn client_stream_reconnect(
err: Error,
tx: Sender<Data>,
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<Data>,
mut rx: Receiver<TwitchAction>,
) {
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<Data>, 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<Data>, 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<Data>, 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<Data>, 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<Data>, 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<Data>, mut rx: Re
}
}
pub async fn handle_roomstate(
tx: &Sender<Data>,
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<Data>, 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();
}

View File

@ -21,40 +21,33 @@ pub fn ui_insert_message<T: Backend>(window: WindowAttributes<T>, 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(),
&current_input[1..],
COMMANDS
.iter()
.map(|s| s.to_string())
.map(ToString::to_string)
.collect::<Vec<String>>(),
);
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(&current_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
};

View File

@ -1 +1,3 @@
pub mod chatting;
pub use chatting::ui_insert_message;

View File

@ -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<T: Backend>(frame: &mut Frame<T>, 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<T: Backend>(frame: &mut Frame<T>, 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<T: Backend>(frame: &mut Frame<T>, 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<T: Backend>(frame: &mut Frame<T>, 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<T: Backend>(frame: &mut Frame<T>, 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<T: Backend>(frame: &mut Frame<T>, 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<T: Backend>(
window: WindowAttributes<T>,
@ -245,24 +243,22 @@ pub fn insert_box_chunk<T: Backend>(
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),
),
]))

View File

@ -1,3 +1,5 @@
use std::string::ToString;
use regex::Regex;
use tui::backend::Backend;
@ -24,11 +26,11 @@ pub fn ui_switch_channels<T: Backend>(window: WindowAttributes<T>, 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::<Vec<String>>(),
)
} else {

View File

@ -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),

View File

@ -1,4 +1,4 @@
// https://css-tricks.com/converting-color-spaces-in-javascript/#hsl-to-rgb
/// <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;

View File

@ -45,7 +45,7 @@ mod tests {
std::env::var("HOME").unwrap(),
BINARY_NAME,
)
)
);
}
#[test]

View File

@ -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<T>(vec: &[Vec<T>]) -> IntoIter<u16>
pub fn vector_column_max<T>(v: &[Vec<T>]) -> IntoIter<u16>
where
T: AsRef<str>,
{
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<T>], 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<u16> = 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<TitleStyle>, style: Style) -> Vec<Span> {
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<String>) -> Option<String> {
if let Some(result) = possibilities
pub fn suggestion_query(search: &str, possibilities: Vec<String>) -> Option<String> {
possibilities
.iter()
.filter(|s| s.starts_with(&search))
.collect::<Vec<&String>>()
.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()));
}
}