mirror of
https://github.com/Xithrius/twitch-tui.git
synced 2024-09-11 05:35:26 +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: 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
|
||||||
|
@ -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()),
|
||||||
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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(""));
|
||||||
|
@ -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>> {
|
||||||
|
@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
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 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);
|
||||||
|
236
src/terminal.rs
236
src/terminal.rs
@ -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;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
224
src/twitch.rs
224
src/twitch.rs
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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(),
|
¤t_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(¤t_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
|
||||||
};
|
};
|
||||||
|
@ -1 +1,3 @@
|
|||||||
pub mod chatting;
|
pub mod chatting;
|
||||||
|
|
||||||
|
pub use chatting::ui_insert_message;
|
||||||
|
@ -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),
|
||||||
),
|
),
|
||||||
]))
|
]))
|
||||||
|
@ -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 {
|
||||||
|
@ -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),
|
||||||
|
@ -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;
|
||||||
|
@ -45,7 +45,7 @@ mod tests {
|
|||||||
std::env::var("HOME").unwrap(),
|
std::env::var("HOME").unwrap(),
|
||||||
BINARY_NAME,
|
BINARY_NAME,
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user