#![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: check-yaml
- id: end-of-file-fixer - id: end-of-file-fixer
- id: trailing-whitespace - id: trailing-whitespace
args: [ --markdown-linebreak-ext=md ] args: [--markdown-linebreak-ext=md]
- repo: local - repo: local
hooks: hooks:
@ -15,15 +15,16 @@ repos:
name: cargo fmt name: cargo fmt
entry: cargo fmt -- entry: cargo fmt --
language: rust language: rust
types: [ rust ] types: [rust]
ci: ci:
autofix_commit_msg: | autofix_commit_msg: |
[pre-commit.ci] auto fixes from pre-commit.com hooks [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
autofix_prs: true for more information, see https://pre-commit.ci
autoupdate_branch: '' autofix_prs: true
autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' autoupdate_branch: ""
autoupdate_schedule: weekly autoupdate_commit_msg: "[pre-commit.ci] pre-commit autoupdate"
skip: [] autoupdate_schedule: weekly
submodules: false skip: []
submodules: false

View File

@ -61,7 +61,7 @@ pub struct App {
} }
impl App { impl App {
pub fn new(config: CompleteConfig) -> Self { pub fn new(config: &CompleteConfig) -> Self {
let mut input_buffers_map = HashMap::new(); let mut input_buffers_map = HashMap::new();
for name in BufferName::into_enum_iter() { for name in BufferName::into_enum_iter() {
@ -70,8 +70,8 @@ impl App {
Self { Self {
messages: VecDeque::with_capacity(config.terminal.maximum_messages), messages: VecDeque::with_capacity(config.terminal.maximum_messages),
storage: Storage::new("storage.json", config.storage), storage: Storage::new("storage.json", &config.storage),
filters: Filters::new("filters.txt", config.filters), filters: Filters::new("filters.txt", &config.filters),
state: State::Normal, state: State::Normal,
selected_buffer: BufferName::Chat, selected_buffer: BufferName::Chat,
buffer_suggestion: Some("".to_string()), buffer_suggestion: Some("".to_string()),

View File

@ -1,3 +1,5 @@
#![allow(clippy::use_self)]
use std::{ use std::{
fs::{create_dir_all, read_to_string, File}, fs::{create_dir_all, read_to_string, File},
io::Write, io::Write,
@ -147,7 +149,7 @@ pub enum Palette {
impl Default for Palette { impl Default for Palette {
fn default() -> Self { fn default() -> Self {
Palette::Pastel Self::Pastel
} }
} }
@ -156,10 +158,10 @@ impl FromStr for Palette {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s { match s {
"vibrant" => Ok(Palette::Vibrant), "vibrant" => Ok(Self::Vibrant),
"warm" => Ok(Palette::Warm), "warm" => Ok(Self::Warm),
"cool" => Ok(Palette::Cool), "cool" => Ok(Self::Cool),
_ => Ok(Palette::Pastel), _ => Ok(Self::Pastel),
} }
} }
} }
@ -174,7 +176,7 @@ pub enum Theme {
impl Default for Theme { impl Default for Theme {
fn default() -> Self { fn default() -> Self {
Theme::Dark Self::Dark
} }
} }
@ -183,8 +185,8 @@ impl FromStr for Theme {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s { match s {
"light" => Ok(Theme::Light), "light" => Ok(Self::Light),
_ => Ok(Theme::Dark), _ => Ok(Self::Dark),
} }
} }
} }

View File

@ -36,11 +36,11 @@ pub struct DataBuilder<'conf> {
} }
impl<'conf> 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 } DataBuilder { date_format }
} }
pub fn user(self, user: String, message: String) -> Data { pub fn user(user: String, message: String) -> Data {
Data { Data {
time_sent: Local::now(), time_sent: Local::now(),
author: user, author: user,
@ -78,18 +78,19 @@ pub struct Data {
impl Data { impl Data {
fn hash_username(&self, palette: &Palette) -> Color { fn hash_username(&self, palette: &Palette) -> Color {
let hash = self let hash = f64::from(
.author self.author
.as_bytes() .as_bytes()
.iter() .iter()
.map(|&b| b as u32) .map(|&b| u32::from(b))
.sum::<u32>() as f64; .sum::<u32>(),
);
let (hue, saturation, lightness) = match palette { let (hue, saturation, lightness) = match palette {
Palette::Pastel => (hash % 360. + 1., 0.5, 0.75), Palette::Pastel => (hash % 360. + 1., 0.5, 0.75),
Palette::Vibrant => (hash % 360. + 1., 1., 0.6), Palette::Vibrant => (hash % 360. + 1., 1., 0.6),
Palette::Warm => ((hash % 100. + 1.) * 1.2, 0.8, 0.7), 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); let rgb = hsl_to_rgb(hue, saturation, lightness);
@ -100,18 +101,18 @@ impl Data {
pub fn to_row( pub fn to_row(
&self, &self,
frontend_config: &FrontendConfig, frontend_config: &FrontendConfig,
limit: &usize, limit: usize,
search_highlight: Option<String>, search_highlight: Option<String>,
username_highlight: Option<String>, username_highlight: Option<String>,
theme_style: Style, theme_style: Style,
) -> Vec<Row> { ) -> Vec<Row> {
let message = if let PayLoad::Message(m) = &self.payload { let message = if let PayLoad::Message(m) = &self.payload {
textwrap::fill(m.as_str(), *limit) textwrap::fill(m.as_str(), limit)
} else { } else {
panic!("Data.to_row() can only take an enum of PayLoad::Message."); 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()) if Regex::new(format!("^.*{}.*$", username).as_str())
.unwrap() .unwrap()
.is_match(&message) .is_match(&message)
@ -123,53 +124,54 @@ impl Data {
} else { } else {
Style::default() Style::default()
} }
} else { });
Style::default()
};
let msg_cells = if let Some(search) = search_highlight { let msg_cells = search_highlight.map_or_else(
message || {
.split('\n') message
.map(|s| { .split('\n')
let chars = s.chars(); .map(|s| {
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( Cell::from(Spans::from(vec![Span::styled(
s.to_owned(), s.to_owned(),
username_highlight_style, username_highlight_style,
)])) )]))
} })
}) .collect::<Vec<Cell>>()
.collect::<Vec<Cell>>() },
} else { |search| {
message message
.split('\n') .split('\n')
.map(|s| { .map(|s| {
Cell::from(Spans::from(vec![Span::styled( let chars = s.chars();
s.to_owned(),
username_highlight_style, if let Some((_, indices)) = FUZZY_FINDER.fuzzy_indices(s, search.as_str()) {
)])) Cell::from(vec![Spans::from(
}) chars
.collect::<Vec<Cell>>() .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![ let mut cell_vector = vec![
Cell::from(align_text( Cell::from(align_text(
@ -200,7 +202,7 @@ impl Data {
if msg_cells.len() > 1 { if msg_cells.len() > 1 {
for cell in msg_cells.iter().skip(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 { if frontend_config.date_shown {
wrapped_msg.insert(0, Cell::from("")); wrapped_msg.insert(0, Cell::from(""));

View File

@ -52,7 +52,7 @@ pub struct Config {
} }
impl Events { impl Events {
pub async fn with_config(config: Config) -> Events { pub async fn with_config(config: Config) -> Self {
let (tx, rx) = mpsc::channel(100); let (tx, rx) = mpsc::channel(100);
tokio::spawn(async move { tokio::spawn(async move {
@ -83,7 +83,6 @@ impl Events {
KeyCode::Tab => Key::Tab, KeyCode::Tab => Key::Tab,
KeyCode::BackTab => Key::BackTab, KeyCode::BackTab => Key::BackTab,
KeyCode::Enter => Key::Enter, KeyCode::Enter => Key::Enter,
KeyCode::Null => Key::Null,
KeyCode::F(k) => Key::F(k), KeyCode::F(k) => Key::F(k),
KeyCode::Char(c) => match key.modifiers { KeyCode::Char(c) => match key.modifiers {
KeyModifiers::NONE | KeyModifiers::SHIFT => Key::Char(c), 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>> { pub async fn next(&mut self) -> Option<Event<Key>> {

View File

@ -12,7 +12,7 @@ pub struct Filters {
} }
impl 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); let file_path = config_path(file);
Self { Self {
@ -29,10 +29,10 @@ impl Filters {
} }
} }
pub fn contaminated(&self, data: String) -> bool { pub fn contaminated(&self, data: &str) -> bool {
if self.enabled { if self.enabled {
for re in &self.captures { for re in &self.captures {
if re.is_match(&data) { if re.is_match(data) {
return !self.reversed; return !self.reversed;
} }
} }
@ -41,7 +41,7 @@ impl Filters {
self.reversed self.reversed
} }
pub fn enabled(&self) -> bool { pub const fn enabled(&self) -> bool {
self.enabled self.enabled
} }
@ -50,7 +50,7 @@ impl Filters {
} }
#[allow(dead_code)] #[allow(dead_code)]
pub fn reversed(&self) -> bool { pub const fn reversed(&self) -> bool {
self.reversed self.reversed
} }
@ -75,14 +75,14 @@ mod tests {
fn test_contaminated() { fn test_contaminated() {
let filters = setup(); let filters = setup();
assert!(filters.contaminated("bad word".to_string())); assert!(filters.contaminated("bad word"));
} }
#[test] #[test]
fn test_non_contaminated() { fn test_non_contaminated() {
let filters = setup(); let filters = setup();
assert!(!filters.contaminated("not a bad word".to_string())); assert!(!filters.contaminated("not a bad word"));
} }
#[test] #[test]
@ -91,7 +91,7 @@ mod tests {
filters.reverse(); filters.reverse();
assert!(!filters.contaminated("bad word".to_string())); assert!(!filters.contaminated("bad word"));
} }
#[test] #[test]
@ -100,6 +100,6 @@ mod tests {
filters.reverse(); 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 { impl Storage {
pub fn new(file: &str, config: StorageConfig) -> Self { pub fn new(file: &str, config: &StorageConfig) -> Self {
let file_path = config_path(file); let file_path = config_path(file);
if !Path::new(&file_path).exists() { if !Path::new(&file_path).exists() {
@ -43,7 +43,7 @@ impl Storage {
}; };
items.insert( items.insert(
item_key.to_string(), (*item_key).to_string(),
StorageItem { StorageItem {
content: vec![], content: vec![],
enabled, enabled,
@ -57,14 +57,14 @@ impl Storage {
file.write_all(storage_str.as_bytes()).unwrap(); 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 file_content = read_to_string(&file_path).unwrap();
let items: StorageMap = serde_json::from_str(&file_content).unwrap(); let items: StorageMap = serde_json::from_str(&file_content).unwrap();
Storage { items, file_path } Self { items, file_path }
} }
pub fn dump_data(&self) { pub fn dump_data(&self) {
@ -75,9 +75,9 @@ impl Storage {
file.write_all(storage_str.as_bytes()).unwrap(); file.write_all(storage_str.as_bytes()).unwrap();
} }
pub fn add(&mut self, key: String, value: String) { pub fn add(&mut self, key: &str, value: String) {
if ITEM_KEYS.contains(&key.as_str()) { if ITEM_KEYS.contains(&key) {
if let Some(item) = self.items.get_mut(&key) { if let Some(item) = self.items.get_mut(&key.to_string()) {
if !item.content.contains(&value) && item.enabled { if !item.content.contains(&value) && item.enabled {
item.content.push(value); item.content.push(value);
} }
@ -87,13 +87,11 @@ impl Storage {
} }
} }
pub fn get(&self, key: String) -> Vec<String> { pub fn get(&self, key: &str) -> Vec<String> {
if ITEM_KEYS.contains(&key.as_str()) { if ITEM_KEYS.contains(&key) {
if let Some(item) = self.items.get(&key) { self.items
item.content.clone() .get(&key.to_string())
} else { .map_or_else(Vec::new, |item| item.content.clone())
vec![]
}
} else { } else {
panic!("Attempted to get key {} from JSON storage.", key); 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 clap::Parser;
use color_eyre::eyre::{Result, WrapErr}; use color_eyre::eyre::{Result, WrapErr};
use log::info; use log::info;
@ -6,6 +19,7 @@ use tokio::sync::mpsc;
use crate::handlers::{app::App, args::Cli, config::CompleteConfig}; use crate::handlers::{app::App, args::Cli, config::CompleteConfig};
mod handlers; mod handlers;
mod input;
mod terminal; mod terminal;
mod twitch; mod twitch;
mod ui; mod ui;
@ -20,7 +34,7 @@ fn initialize_logging(config: &CompleteConfig) {
record.target(), record.target(),
record.level(), record.level(),
message message
)) ));
}) })
.level(if config.terminal.verbose { .level(if config.terminal.verbose {
log::LevelFilter::Debug log::LevelFilter::Debug
@ -28,7 +42,7 @@ fn initialize_logging(config: &CompleteConfig) {
log::LevelFilter::Info 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() { if !log_file_path.is_empty() {
logger logger
.chain(fern::log_file(log_file_path).unwrap()) .chain(fern::log_file(log_file_path).unwrap())
@ -52,7 +66,7 @@ async fn main() -> Result<()> {
info!("Logging system initialised"); info!("Logging system initialised");
let app = App::new(config.clone()); let app = App::new(&config);
let (twitch_tx, terminal_rx) = mpsc::channel(100); let (twitch_tx, terminal_rx) = mpsc::channel(100);
let (terminal_tx, twitch_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}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use log::{debug, info}; use log::{debug, info};
use regex::Regex;
use rustyline::{At, Word};
use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::mpsc::{Receiver, Sender};
use tui::{backend::CrosstermBackend, layout::Constraint, Terminal}; use tui::{backend::CrosstermBackend, layout::Constraint, Terminal};
@ -20,13 +18,11 @@ use crate::{
app::{App, BufferName, State}, app::{App, BufferName, State},
config::CompleteConfig, config::CompleteConfig,
data::{Data, DataBuilder, PayLoad}, data::{Data, DataBuilder, PayLoad},
event::{Config, Event, Events, Key}, event::{Config, Events, Key},
},
twitch::Action,
ui::{
draw_ui,
statics::{CHANNEL_NAME_REGEX, TWITCH_MESSAGE_LIMIT},
}, },
input::{handle_user_input, TerminalAction},
twitch::TwitchAction,
ui::draw_ui,
utils::text::align_text, utils::text::align_text,
}; };
@ -47,10 +43,23 @@ fn init_terminal() -> Terminal<CrosstermBackend<Stdout>> {
Terminal::new(backend).unwrap() 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( pub async fn ui_driver(
mut config: CompleteConfig, config: CompleteConfig,
mut app: App, mut app: App,
tx: Sender<Action>, tx: Sender<TwitchAction>,
mut rx: Receiver<Data>, mut rx: Receiver<Data>,
) { ) {
info!("Started UI driver."); info!("Started UI driver.");
@ -78,10 +87,7 @@ pub async fn ui_driver(
config.frontend.maximum_username_length, config.frontend.maximum_username_length,
); );
let mut column_titles = vec![ let mut column_titles = vec![username_column_title.clone(), "Message content".to_string()];
username_column_title.to_owned(),
"Message content".to_string(),
];
let mut table_constraints = vec![ let mut table_constraints = vec![
Constraint::Length(config.frontend.maximum_username_length), 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 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 { loop {
if let Ok(info) = rx.try_recv() { if let Ok(info) = rx.try_recv() {
match info.payload { match info.payload {
@ -146,191 +139,12 @@ pub async fn ui_driver(
.draw(|frame| draw_ui(frame, &mut app, &config)) .draw(|frame| draw_ui(frame, &mut app, &config))
.unwrap(); .unwrap();
if let Some(Event::Input(key)) = events.next().await { if let Some(TerminalAction::Quitting) =
match app.state { handle_user_input(&mut events, &mut app, &mut config.clone(), tx.clone()).await
State::Insert | State::MessageSearch | State::Normal => match key { {
Key::ScrollUp => { quit_terminal(terminal);
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 { break;
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;
}
_ => {}
},
}
} }
} }

View File

@ -6,8 +6,8 @@ use irc::{
prelude::{Capability, Config}, prelude::{Capability, Config},
Client, ClientStream, Client, ClientStream,
}, },
error::Error::PingTimeout, error::Error::{self, PingTimeout},
proto::Command, proto::{Command, Message},
}; };
use log::{debug, info}; use log::{debug, info};
use tokio::{ use tokio::{
@ -26,17 +26,17 @@ const SUBSCRIBER_BADGE: char = '\u{2B50}';
const PRIME_GAMING_BADGE: char = '\u{1F451}'; const PRIME_GAMING_BADGE: char = '\u{1F451}';
#[derive(Debug)] #[derive(Debug)]
pub enum Action { pub enum TwitchAction {
Privmsg(String), Privmsg(String),
Join(String), Join(String),
} }
async fn create_client_stream(config: CompleteConfig) -> (Client, ClientStream) { async fn create_client_stream(config: CompleteConfig) -> (Client, ClientStream) {
let irc_config = Config { let irc_config = Config {
nickname: Some(config.twitch.username.to_owned()), nickname: Some(config.twitch.username.clone()),
server: Some(config.twitch.server.to_owned()), server: Some(config.twitch.server.clone()),
channels: vec![format!("#{}", config.twitch.channel)], channels: vec![format!("#{}", config.twitch.channel)],
password: Some(config.twitch.token.to_owned()), password: Some(config.twitch.token.clone()),
port: Some(6667), port: Some(6667),
use_tls: Some(false), use_tls: Some(false),
ping_timeout: Some(10), ping_timeout: Some(10),
@ -53,7 +53,42 @@ async fn create_client_stream(config: CompleteConfig) -> (Client, ClientStream)
(client, stream) (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."); info!("Spawned Twitch IRC thread.");
let data_builder = DataBuilder::new(&config.frontend.date_format); 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); let current_channel = format!("#{}", config.twitch.channel);
match action { match action {
Action::Privmsg(message) => { TwitchAction::Privmsg(message) => {
debug!("Sending message to Twitch: {}", message); debug!("Sending message to Twitch: {}", message);
client client
.send_privmsg(current_channel, message) .send_privmsg(current_channel, message)
.unwrap(); .unwrap();
} }
Action::Join(channel) => { TwitchAction::Join(channel) => {
debug!("Switching to channel {}", channel); debug!("Switching to channel {}", channel);
let channel_list = format!("#{}", channel); let channel_list = format!("#{}", channel);
// Leave previous channel // Leave previous channel
if let Err(err) = sender.send_part(current_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 { } else {
tx.send(data_builder.twitch(format!("Joined {}", channel_list))).await.unwrap(); tx.send(data_builder.twitch(format!("Joined {}", channel_list))).await.unwrap();
} }
// Join specified channel // Join specified channel
if let Err(err) = sender.send_join(&channel_list) { 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 // 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() => { Some(message) = stream.next() => {
match _message { match message {
Ok(message) => { Ok(message) => {
let mut tags: HashMap<&str, &str> = HashMap::new(); 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 { if let Some(ref tag_value) = tag.1 {
tags.insert(&tag.0, tag_value); 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 // lowercase username from message
let mut name = match message.source_nickname() { let mut name = match message.source_nickname() {
Some(username) => username.to_string(), Some(username) => username.to_string(),
None => "Undefined username".to_string(), None => {
debug!("Undefined username found, continuing without sending message.");
continue;
},
}; };
if config.frontend.badges { if config.frontend.badges {
let mut badges = String::new(); retrieve_user_badges(&mut name, message.clone());
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;
}
}
} }
tx.send(data_builder.user(name.to_string(), msg.to_string())) tx.send(DataBuilder::user(name.to_string(), msg.to_string()))
.await .await
.unwrap(); .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 // Only display roomstate on startup, since twitch
// sends a NOTICE whenever roomstate changes. // sends a NOTICE whenever roomstate changes.
if !room_state_startup { if !room_state_startup {
handle_roomstate(&tx, data_builder, &tags).await; handle_roomstate(&tx, &tags).await;
} }
room_state_startup = true; room_state_startup = true;
} }
"USERNOTICE" => { "USERNOTICE" => {
if let Some(value) = tags.get("system-msg") { 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 .await
.unwrap(); .unwrap();
} }
@ -225,20 +218,9 @@ pub async fn twitch_irc(mut config: CompleteConfig, tx: Sender<Data>, mut rx: Re
} }
} }
Err(err) => { Err(err) => {
debug!("Twitch connection error encountered: {}", err); debug!("Twitch connection error encountered: {}, attempting to reconnect.", err);
match err { client_stream_reconnect(err, tx.clone(), data_builder, &mut client, &mut stream, &config).await;
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;
} }
} }
} }
@ -247,12 +229,70 @@ pub async fn twitch_irc(mut config: CompleteConfig, tx: Sender<Data>, mut rx: Re
} }
} }
pub async fn handle_roomstate( fn retrieve_user_badges(name: &mut String, message: Message) {
tx: &Sender<Data>, let mut badges = String::new();
builder: DataBuilder<'_>,
tags: &HashMap<&str, &str>, 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(); let mut room_state = String::new();
for (name, value) in tags.iter() { for (name, value) in tags.iter() {
match *name { match *name {
"emote-only" if *value == "1" => { "emote-only" if *value == "1" => {
@ -272,11 +312,15 @@ pub async fn handle_roomstate(
_ => (), _ => (),
} }
} }
// Trim last newline // Trim last newline
room_state.pop(); room_state.pop();
if !room_state.is_empty() {
tx.send(builder.user(String::from("Info"), room_state)) if room_state.is_empty() {
.await return;
.unwrap();
} }
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 current_input = input_buffer.to_string();
let suggestion = if mention_suggestions { let suggestion = if mention_suggestions {
if let Some(start_character) = input_buffer.chars().next() { input_buffer
match start_character { .chars()
.next()
.and_then(|start_character| match start_character {
'/' => { '/' => {
let possible_suggestion = suggestion_query( let possible_suggestion = suggestion_query(
current_input[1..].to_string(), &current_input[1..],
COMMANDS COMMANDS
.iter() .iter()
.map(|s| s.to_string()) .map(ToString::to_string)
.collect::<Vec<String>>(), .collect::<Vec<String>>(),
); );
if let Some(s) = possible_suggestion { let default_suggestion = possible_suggestion.clone();
Some(format!("/{}", s))
} else { possible_suggestion.map_or(default_suggestion, |s| Some(format!("/{}", s)))
possible_suggestion
}
} }
'@' => { '@' => {
let possible_suggestion = suggestion_query( let possible_suggestion =
current_input[1..].to_string(), suggestion_query(&current_input[1..], app.storage.get("mentions"));
app.storage.get("mentions".to_string()),
);
if let Some(s) = possible_suggestion { let default_suggestion = possible_suggestion.clone();
Some(format!("@{}", s))
} else { possible_suggestion.map_or(default_suggestion, |s| Some(format!("@{}", s)))
possible_suggestion
}
} }
_ => None, _ => None,
} })
} else {
None
}
} else { } else {
None None
}; };

View File

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

View File

@ -17,8 +17,8 @@ use crate::{
data::PayLoad, data::PayLoad,
}, },
ui::{ ui::{
chunks::chatting::ui_insert_message, chunks::ui_insert_message,
popups::{channels::ui_switch_channels, help::ui_show_keybinds}, popups::{ui_show_keybinds, ui_switch_channels},
}, },
utils::{ utils::{
styles, 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()) .constraints(v_constraints.as_ref())
.split(frame.size()); .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(); 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; 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 let PayLoad::Message(msg) = data.payload.clone() {
if app.filters.contaminated(msg) { if app.filters.contaminated(msg.as_str()) {
continue; continue;
} }
} }
@ -120,10 +120,18 @@ pub fn draw_ui<T: Backend>(frame: &mut Frame<T>, app: &mut App, config: &Complet
None None
}; };
let rows = if !buffer.is_empty() { let rows = if buffer.is_empty() {
data.to_row( data.to_row(
&config.frontend, &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 { match app.selected_buffer {
BufferName::MessageHighlighter => Some(buffer.to_string()), BufferName::MessageHighlighter => Some(buffer.to_string()),
_ => None, _ => None,
@ -131,19 +139,11 @@ pub fn draw_ui<T: Backend>(frame: &mut Frame<T>, app: &mut App, config: &Complet
username_highlight, username_highlight,
app.theme_style, app.theme_style,
) )
} else {
data.to_row(
&config.frontend,
&message_chunk_width,
None,
username_highlight,
app.theme_style,
)
}; };
for row in rows.iter().rev() { for row in rows.iter().rev() {
if total_row_height < general_chunk_height { if total_row_height < general_chunk_height {
display_rows.push_front(row.to_owned()); display_rows.push_front(row.clone());
total_row_height += 1; total_row_height += 1;
} else { } 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) let table = Table::new(display_rows)
.header( .header(Row::new(app.column_titles.as_ref().unwrap().clone()).style(styles::COLUMN_TITLE))
Row::new(app.column_titles.as_ref().unwrap().to_owned()).style(styles::COLUMN_TITLE),
)
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .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 // States that require popups
State::Help => ui_show_keybinds(window), State::Help => ui_show_keybinds(window),
State::ChannelSwitch => ui_switch_channels(window, config.storage.channels), State::ChannelSwitch => ui_switch_channels(window, config.storage.channels),
_ => {} State::Normal => {}
} }
} }
/// Puts a box for user input at the bottom of the screen, /// Puts a box for user input at the bottom of the screen,
/// with an interactive cursor. /// 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. /// theme to red if invalid, default otherwise.
pub fn insert_box_chunk<T: Backend>( pub fn insert_box_chunk<T: Backend>(
window: WindowAttributes<T>, window: WindowAttributes<T>,
@ -245,24 +243,22 @@ pub fn insert_box_chunk<T: Backend>(
let current_input = buffer.as_str(); let current_input = buffer.as_str();
let valid_input = if let Some(check_func) = input_validation { let valid_input =
check_func(current_input.to_string()) input_validation.map_or(true, |check_func| check_func(current_input.to_string()));
} else {
true
};
let paragraph = Paragraph::new(Spans::from(vec![ let paragraph = Paragraph::new(Spans::from(vec![
Span::raw(current_input), Span::raw(current_input),
Span::styled( Span::styled(
if let Some(suggestion_buffer) = suggestion.clone() { suggestion.clone().map_or_else(
if suggestion_buffer.len() > current_input.len() { || "".to_string(),
suggestion_buffer[current_input.len()..].to_string() |suggestion_buffer| {
} else { if suggestion_buffer.len() > current_input.len() {
"".to_string() suggestion_buffer[current_input.len()..].to_string()
} } else {
} else { "".to_string()
"".to_string() }
}, },
),
Style::default().add_modifier(Modifier::DIM), Style::default().add_modifier(Modifier::DIM),
), ),
])) ]))

View File

@ -1,3 +1,5 @@
use std::string::ToString;
use regex::Regex; use regex::Regex;
use tui::backend::Backend; 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 { let suggestion = if channel_suggestions {
suggestion_query( suggestion_query(
input_buffer.to_string(), input_buffer,
app.storage app.storage
.get("channels".to_string()) .get("channels")
.iter() .iter()
.map(|s| s.to_string()) .map(ToString::to_string)
.collect::<Vec<String>>(), .collect::<Vec<String>>(),
) )
} else { } else {

View File

@ -3,6 +3,9 @@ use tui::layout::{Constraint, Direction, Layout, Rect};
pub mod channels; pub mod channels;
pub mod help; pub mod help;
pub use channels::ui_switch_channels;
pub use help::ui_show_keybinds;
const HORIZONTAL_CONSTRAINTS: [Constraint; 3] = [ const HORIZONTAL_CONSTRAINTS: [Constraint; 3] = [
Constraint::Percentage(15), Constraint::Percentage(15),
Constraint::Percentage(70), 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] { pub fn hsl_to_rgb(hue: f64, saturation: f64, lightness: f64) -> [u8; 3] {
// Color intensity // Color intensity
let chroma = (1. - (2. * lightness - 1.).abs()) * saturation; let chroma = (1. - (2. * lightness - 1.).abs()) * saturation;

View File

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

View File

@ -7,15 +7,18 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
pub fn align_text(text: &str, alignment: &str, maximum_length: u16) -> String { pub fn align_text(text: &str, alignment: &str, maximum_length: u16) -> String {
if maximum_length < 1 { assert!(
panic!("Parameter of 'maximum_length' cannot be below 1."); maximum_length >= 1,
} "Parameter of 'maximum_length' cannot be below 1."
);
// Compute the display width of `text` with support of emojis and CJK characters // Compute the display width of `text` with support of emojis and CJK characters
let mut dw = display_width(text); let mut dw = display_width(text);
if dw > maximum_length as usize { if dw > maximum_length as usize {
dw = maximum_length as usize; dw = maximum_length as usize;
} }
match alignment { match alignment {
"right" => format!("{}{}", " ".repeat(maximum_length as usize - dw), text), "right" => format!("{}{}", " ".repeat(maximum_length as usize - dw), text),
"center" => { "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 where
T: AsRef<str>, T: AsRef<str>,
{ {
if vec.is_empty() { assert!(
panic!("Vector length should be greater than or equal to 1.") !v.is_empty(),
} "Vector length should be greater than or equal to 1."
);
let column_max = |vec: &[Vec<T>], index: usize| -> u16 { let column_max = |vec: &[Vec<T>], index: usize| -> u16 {
vec.iter().map(|v| v[index].as_ref().len()).max().unwrap() as 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![]; let mut column_max_lengths: Vec<u16> = vec![];
for i in 0..column_amount { 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() 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(); let mut complete = Vec::new();
for (i, item) in contents.iter().enumerate() { 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 { complete.extend(match item {
TitleStyle::Combined(title, value) => vec![ TitleStyle::Combined(title, value) => vec![
first_bracket, first_bracket,
Span::styled(title.to_string(), style), Span::styled((*title).to_string(), style),
Span::raw(format!(": {} ]", value)), Span::raw(format!(": {} ]", value)),
], ],
TitleStyle::Single(value) => vec![ TitleStyle::Single(value) => vec![
first_bracket, first_bracket,
Span::styled(value.to_string(), style), Span::styled((*value).to_string(), style),
Span::raw(" ]"), Span::raw(" ]"),
], ],
TitleStyle::Custom(span) => vec![first_bracket, span.to_owned(), Span::raw(" ]")], TitleStyle::Custom(span) => vec![first_bracket, span.clone(), Span::raw(" ]")],
}); });
} }
complete complete
} }
pub fn suggestion_query(search: String, possibilities: Vec<String>) -> Option<String> { pub fn suggestion_query(search: &str, possibilities: Vec<String>) -> Option<String> {
if let Some(result) = possibilities possibilities
.iter() .iter()
.filter(|s| s.starts_with(&search)) .filter(|s| s.starts_with(&search))
.collect::<Vec<&String>>() .collect::<Vec<&String>>()
.first() .first()
{ .and_then(|result| {
if result.len() > search.len() { if result.len() > search.len() {
Some(result.to_string()) Some((*result).to_string())
} else { } else {
None None
} }
} else { })
None
}
} }
#[cfg(test)] #[cfg(test)]
@ -221,8 +223,8 @@ mod tests {
fn test_partial_suggestion_output() { fn test_partial_suggestion_output() {
let v = vec!["Nope".to_string()]; 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()));
} }
} }