mirror of
https://github.com/Xithrius/twitch-tui.git
synced 2024-10-26 06:00:35 +03:00
#![warn(clippy::nursery, clippy::pedantic)]
(#212)
This commit is contained in:
parent
4365fa259c
commit
9a59c8c9ac
@ -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_branch: ""
|
||||
autoupdate_commit_msg: "[pre-commit.ci] pre-commit autoupdate"
|
||||
autoupdate_schedule: weekly
|
||||
skip: []
|
||||
submodules: false
|
||||
|
@ -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()),
|
||||
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
let hash = f64::from(
|
||||
self.author
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.map(|&b| b as u32)
|
||||
.sum::<u32>() as f64;
|
||||
.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,11 +124,21 @@ impl Data {
|
||||
} else {
|
||||
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')
|
||||
.map(|s| {
|
||||
Cell::from(Spans::from(vec![Span::styled(
|
||||
s.to_owned(),
|
||||
username_highlight_style,
|
||||
)]))
|
||||
})
|
||||
.collect::<Vec<Cell>>()
|
||||
},
|
||||
|search| {
|
||||
message
|
||||
.split('\n')
|
||||
.map(|s| {
|
||||
@ -159,17 +170,8 @@ impl Data {
|
||||
}
|
||||
})
|
||||
.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>>()
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
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(""));
|
||||
|
@ -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>> {
|
||||
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
@ -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
212
src/input.rs
Normal 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
|
||||
}
|
20
src/main.rs
20
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);
|
||||
|
232
src/terminal.rs
232
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<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,193 +139,14 @@ 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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
|
||||
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
|
||||
if let Some(TerminalAction::Quitting) =
|
||||
handle_user_input(&mut events, &mut app, &mut config.clone(), tx.clone()).await
|
||||
{
|
||||
continue;
|
||||
}
|
||||
quit_terminal(terminal);
|
||||
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.cleanup();
|
||||
|
||||
|
210
src/twitch.rs
210
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<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,23 +171,81 @@ 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 {
|
||||
retrieve_user_badges(&mut name, message.clone());
|
||||
}
|
||||
|
||||
tx.send(DataBuilder::user(name.to_string(), msg.to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
debug!("Message received from twitch: {} - {}", name, msg);
|
||||
}
|
||||
Command::NOTICE(ref _target, ref msg) => {
|
||||
tx.send(data_builder.twitch(msg.to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Command::Raw(ref cmd, ref _items) => {
|
||||
match cmd.as_ref() {
|
||||
"ROOMSTATE" => {
|
||||
// Only display roomstate on startup, since twitch
|
||||
// sends a NOTICE whenever roomstate changes.
|
||||
if !room_state_startup {
|
||||
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()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
_ => ()
|
||||
}
|
||||
}
|
||||
_ => ()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
debug!("Twitch connection error encountered: {}, attempting to reconnect.", err);
|
||||
|
||||
client_stream_reconnect(err, tx.clone(), data_builder, &mut client, &mut stream, &config).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
else => {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
@ -169,90 +263,36 @@ pub async fn twitch_irc(mut config: CompleteConfig, tx: Sender<Data>, mut rx: Re
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(display_name) = display_name {
|
||||
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;
|
||||
*name = badges.clone() + name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.send(data_builder.user(name.to_string(), msg.to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
debug!("Message received from twitch: {} - {}", name, msg);
|
||||
}
|
||||
Command::NOTICE(ref _target, ref msg) => {
|
||||
tx.send(data_builder.twitch(msg.to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Command::Raw(ref cmd, ref _items) => {
|
||||
match cmd.as_ref() {
|
||||
"ROOMSTATE" => {
|
||||
// Only display roomstate on startup, since twitch
|
||||
// sends a NOTICE whenever roomstate changes.
|
||||
if !room_state_startup {
|
||||
handle_roomstate(&tx, data_builder, &tags).await;
|
||||
}
|
||||
room_state_startup = true;
|
||||
}
|
||||
"USERNOTICE" => {
|
||||
if let Some(value) = tags.get("system-msg") {
|
||||
tx.send(data_builder.twitch(value.to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
_ => ()
|
||||
}
|
||||
}
|
||||
_ => ()
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
debug!("Twitch connection error encountered: {}", 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
else => {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_roomstate(
|
||||
tx: &Sender<Data>,
|
||||
builder: DataBuilder<'_>,
|
||||
tags: &HashMap<&str, &str>,
|
||||
) {
|
||||
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))
|
||||
|
||||
if room_state.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
tx.send(DataBuilder::user(String::from("Info"), room_state))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
¤t_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(¤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
|
||||
};
|
||||
|
@ -1 +1,3 @@
|
||||
pub mod chatting;
|
||||
|
||||
pub use chatting::ui_insert_message;
|
||||
|
@ -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() {
|
||||
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()
|
||||
}
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
),
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
),
|
||||
]))
|
||||
|
@ -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 {
|
||||
|
@ -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),
|
||||
|
@ -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;
|
||||
|
@ -45,7 +45,7 @@ mod tests {
|
||||
std::env::var("HOME").unwrap(),
|
||||
BINARY_NAME,
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -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()
|
||||
{
|
||||
.and_then(|result| {
|
||||
if result.len() > search.len() {
|
||||
Some(result.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user