From aa43f759cb3060b9b16da7b261c61a87a38eaab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mano=20S=C3=A9gransan?= Date: Wed, 28 Feb 2024 07:20:48 +0100 Subject: [PATCH] Add emote picker widget (#524) --- Cargo.lock | 25 ++ Cargo.toml | 4 + src/emotes/graphics_protocol.rs | 475 +++++++++++++++-------- src/emotes/mod.rs | 172 ++++++-- src/handlers/app.rs | 10 +- src/handlers/config.rs | 7 +- src/handlers/data.rs | 28 +- src/main.rs | 45 ++- src/terminal.rs | 55 ++- src/ui/components/channel_switcher.rs | 10 +- src/ui/components/chat.rs | 4 +- src/ui/components/chat_input.rs | 35 +- src/ui/components/emote_picker.rs | 262 +++++++++++++ src/ui/components/message_search.rs | 6 +- src/ui/components/mod.rs | 5 +- src/ui/components/utils/input_widget.rs | 63 +-- src/ui/components/utils/search_widget.rs | 2 +- src/ui/statics.rs | 1 + src/utils/text.rs | 8 +- 19 files changed, 923 insertions(+), 294 deletions(-) create mode 100644 src/ui/components/emote_picker.rs diff --git a/Cargo.lock b/Cargo.lock index ca9ea7bb..5cbc07f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1247,6 +1247,19 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "libwebp-sys2" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2ae528b6c8f543825990b24c00cfd8fe64dde126c8288f4972b18e3d558072" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.12" @@ -2482,6 +2495,7 @@ dependencies = [ "unicode-segmentation", "unicode-width", "webbrowser", + "webp-animation", ] [[package]] @@ -2676,6 +2690,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webp-animation" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a656b424e13a9e8b35f2cb7f96ff81c9d796b7ce3b6966cd4f8fd4a15feba4" +dependencies = [ + "image", + "libwebp-sys2", + "log", +] + [[package]] name = "weezl" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 17de8ada..5a70ba28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,10 @@ serde_with = "3.6.0" once_cell = "1.19.0" webbrowser = "0.8.12" memchr = "2.7.1" +webp-animation = { version = "0.9.0", features = ["image"] } + +[features] +static-webp = ["webp-animation/static"] [[bin]] bench = false diff --git a/src/emotes/graphics_protocol.rs b/src/emotes/graphics_protocol.rs index 7f988932..8b7ffa0a 100644 --- a/src/emotes/graphics_protocol.rs +++ b/src/emotes/graphics_protocol.rs @@ -1,22 +1,21 @@ use base64::{engine::general_purpose::STANDARD, Engine}; use color_eyre::{ - eyre::{anyhow, ContextCompat, Error}, + eyre::{anyhow, ContextCompat}, Result, }; use crossterm::{csi, queue, Command}; use dialoguer::console::{Key, Term}; use image::{ - codecs::{gif::GifDecoder, webp::WebPDecoder}, - imageops::FilterType, - io::Reader, - AnimationDecoder, DynamicImage, GenericImageView, ImageDecoder, ImageFormat, Rgba, + codecs::gif::GifDecoder, imageops::FilterType, io::Reader, AnimationDecoder, DynamicImage, + GenericImageView, ImageDecoder, ImageFormat, RgbaImage, }; use std::{ - env, fmt, fs, + env, fmt, fs::File, - io::{BufReader, Write}, + io::{BufReader, Read, Write}, path::PathBuf, }; +use webp_animation::Decoder; use crate::utils::pathing::{ create_temp_file, pathbuf_try_to_string, remove_temp_file, save_in_temp_file, @@ -34,141 +33,96 @@ macro_rules! gp { /// string to be deleted by the terminal. const GP_PREFIX: &str = "twt.tty-graphics-protocol."; -pub trait Size { - fn width(&self) -> u32; +struct StaticDecoder(DynamicImage); +struct AnimatedDecoder(GifDecoder>); +struct WebPDecoder(Vec); - fn calculate_resize_ratio(height: u32, cell_h: f32) -> f32 { - cell_h / height as f32 - } +trait IntoFrames: Send { + fn frames(self: Box) -> Box>>; } -pub struct StaticImage { - id: u32, - width: u32, - height: u32, - path: PathBuf, +impl IntoFrames for StaticDecoder { + fn frames(self: Box) -> Box>> { + Box::new(std::iter::once(Ok((self.0.to_rgba8(), 0)))) + } } -impl StaticImage { - pub fn new(id: u32, image: Reader>, cell_size: (f32, f32)) -> Result { - let image = image.decode()?; - let (width, height) = image.dimensions(); - let ratio = Self::calculate_resize_ratio(height, cell_size.1); - - let image = image.resize( - (width as f32 * ratio) as u32, - cell_size.1 as u32, - FilterType::Lanczos3, - ); - let (width, height) = image.dimensions(); +impl IntoFrames for AnimatedDecoder { + fn frames(self: Box) -> Box>> { + Box::new(self.0.into_frames().map(|f| { + let frame = f?; - let (mut tempfile, pathbuf) = create_temp_file(GP_PREFIX)?; - if let Err(e) = save_in_temp_file(image.to_rgba8().as_raw(), &mut tempfile) { - remove_temp_file(&pathbuf); - return Err(e); - } + let delay = frame.delay().numer_denom_ms().0; - Ok(Self { - id, - width, - height, - path: pathbuf, - }) + Ok((frame.into_buffer(), delay)) + })) } } -impl Command for StaticImage { - fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - write!( - f, - gp!("a=t,t=t,f=32,s={width},v={height},i={id},q=2;{path}"), - width = self.width, - height = self.height, - id = self.id, - path = STANDARD.encode(pathbuf_try_to_string(&self.path).map_err(|_| fmt::Error)?) - ) - } +impl IntoFrames for WebPDecoder { + fn frames(self: Box) -> Box>> { + let Ok(decoder) = Decoder::new(&self.0) else { + return Box::new(std::iter::empty()); + }; - #[cfg(windows)] - fn execute_winapi(&self) -> std::result::Result<(), std::io::Error> { - panic!("Windows version not supported.") - } -} + let mut timestamp = 0; + Box::new( + decoder + .into_iter() + .collect::>() + .into_iter() + .map(move |frame| { + let current_timestamp = frame.timestamp(); + + let delay = (current_timestamp - timestamp) as u32; + + timestamp = current_timestamp; -impl Size for StaticImage { - fn width(&self) -> u32 { - self.width + Ok((frame.into_rgba_image()?, delay)) + }), + ) } } -pub struct AnimatedImage { - id: u32, +pub struct DecodedImage { width: u32, - frames: Vec<(PathBuf, u32, u32, u32)>, + height: u32, + path: PathBuf, + delay: u32, } -impl AnimatedImage { - pub fn new<'a>( - id: u32, - decoder: impl ImageDecoder<'a> + AnimationDecoder<'a>, - cell_size: (f32, f32), - ) -> Result { - let (width, height) = decoder.dimensions(); - let resize_ratio = Self::calculate_resize_ratio(height, cell_size.1); - let frames = decoder.into_frames(); - - let (ok, err): (Vec<_>, Vec<_>) = frames - .map(|f| { - let frame = f?; - let delay = frame.delay().numer_denom_ms().0; - let image = DynamicImage::from(frame.into_buffer()); - let (w, h) = image.dimensions(); - let image = image.resize( - (w as f32 * resize_ratio).ceil() as u32, - (h as f32 * resize_ratio).ceil() as u32, - FilterType::Lanczos3, - ); - let (w, h) = image.dimensions(); - let (mut tempfile, pathbuf) = create_temp_file(GP_PREFIX)?; - save_in_temp_file(image.to_rgba8().as_raw(), &mut tempfile)?; - - Ok::<(PathBuf, u32, u32, u32), Error>((pathbuf, delay, w, h)) - }) - .partition(Result::is_ok); - - let frames: Vec<(PathBuf, u32, u32, u32)> = ok.into_iter().flatten().collect(); +pub struct DecodedEmote { + id: u32, + cols: u16, + images: Vec, +} - // If we had any error, we need to delete the temp files, as the terminal won't do it for us. - if !err.is_empty() { - for (path, ..) in &frames { - drop(fs::remove_file(path)); - } - return Err(anyhow!("Invalid frame in gif.")); - } +impl DecodedEmote { + pub const fn id(&self) -> u32 { + self.id + } - if frames.is_empty() { - Err(anyhow!("Image has no frames")) - } else { - Ok(Self { - id, - width: (width as f32 * resize_ratio) as u32, - frames, - }) - } + pub const fn cols(&self) -> u16 { + self.cols } } -impl Command for AnimatedImage { +impl Command for DecodedEmote { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - if self.frames.is_empty() { + if self.images.is_empty() { return Err(fmt::Error); } - let mut frames = self.frames.iter(); + let mut frames = self.images.iter(); - // We need to send the data for the first frame as a normal image. + // Sending a static image and an animated one is done with the same command for the first frame. // We can unwrap here because we checked above if frames was empty. - let (path, delay, width, height) = frames.next().unwrap(); + let DecodedImage { + path, + delay, + width, + height, + } = frames.next().unwrap(); write!( f, @@ -178,6 +132,11 @@ impl Command for AnimatedImage { height = height, path = STANDARD.encode(pathbuf_try_to_string(path).map_err(|_| fmt::Error)?) )?; + + if self.images.len() == 1 { + return Ok(()); + } + // r=1: First frame write!( f, @@ -186,7 +145,13 @@ impl Command for AnimatedImage { delay = delay, )?; - for (path, delay, width, height) in frames { + for DecodedImage { + path, + delay, + width, + height, + } in frames + { write!( f, gp!("a=f,t=t,f=32,s={width},v={height},i={id},z={delay},q=2;{path}"), @@ -208,75 +173,141 @@ impl Command for AnimatedImage { } } -impl Size for AnimatedImage { - fn width(&self) -> u32 { - self.width - } -} - -pub enum Load { - Static(StaticImage), - Animated(AnimatedImage), +pub struct Image { + pub name: String, + id: u32, + pub width: u32, + ratio: f32, + overlay: bool, + pub cols: u16, + decoder: Box, } -impl Load { - pub fn new(id: u32, path: &str, cell_size: (f32, f32)) -> Result { +impl Image { + pub fn new( + id: u32, + name: String, + path: &str, + overlay: bool, + (cell_w, cell_h): (f32, f32), + ) -> Result { let path = std::path::PathBuf::from(path); - let image = Reader::open(&path)?.with_guessed_format()?; + let image = Reader::open(path)?.with_guessed_format()?; - match image.format() { - None => Err(anyhow!("Could not guess image format.")), + let (width, height, decoder) = match image.format() { + None => return Err(anyhow!("Could not guess image format.")), Some(ImageFormat::WebP) => { - let mut decoder = WebPDecoder::new(image.into_inner())?; - - if decoder.has_animation() { - // Some animated webp images have a default white background color - // We replace it by a transparent background - decoder.set_background_color(Rgba([0, 0, 0, 0]))?; - Ok(Self::Animated(AnimatedImage::new(id, decoder, cell_size)?)) - } else { - let image = Reader::open(&path)?.with_guessed_format()?; - - Ok(Self::Static(StaticImage::new(id, image, cell_size)?)) - } + let mut reader = image.into_inner(); + let mut buffer = vec![]; + reader.read_to_end(&mut buffer)?; + + let decoder = Decoder::new(&buffer)?; + let (width, height) = decoder.dimensions(); + + ( + width, + height, + Box::new(WebPDecoder(buffer)) as Box, + ) } Some(ImageFormat::Gif) => { let decoder = GifDecoder::new(image.into_inner())?; - Ok(Self::Animated(AnimatedImage::new(id, decoder, cell_size)?)) + let (width, height) = decoder.dimensions(); + + ( + width, + height, + Box::new(AnimatedDecoder(decoder)) as Box, + ) } - Some(_) => Ok(Self::Static(StaticImage::new(id, image, cell_size)?)), - } - } -} + Some(_) => { + let image = image.decode()?; + let (width, height) = image.dimensions(); + + ( + width, + height, + Box::new(StaticDecoder(image)) as Box, + ) + } + }; -impl Command for Load { - fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - match self { - Self::Static(s) => s.write_ansi(f), - Self::Animated(a) => a.write_ansi(f), - } - } + let ratio = cell_h / height as f32; + let width = (width as f32 * ratio).round() as u32; + let cols = (width as f32 / cell_w).ceil() as u16; - #[cfg(windows)] - fn execute_winapi(&self) -> std::result::Result<(), std::io::Error> { - panic!("Windows version not supported.") + Ok(Self { + name, + id, + width, + ratio, + overlay, + cols, + decoder, + }) } -} -impl Size for Load { - fn width(&self) -> u32 { - match self { - Self::Static(s) => s.width(), - Self::Animated(a) => a.width(), + pub fn decode(self) -> Result { + let frames = self.decoder.frames().map(|f| { + let (image, delay) = f?; + let image = if self.overlay { + let (w, h) = image.dimensions(); + image::imageops::resize( + &image, + (w as f32 * self.ratio).round() as u32, + (h as f32 * self.ratio).round() as u32, + FilterType::Lanczos3, + ) + } else { + image + }; + + let (width, height) = image.dimensions(); + let (mut tempfile, path) = create_temp_file(GP_PREFIX)?; + if let Err(e) = save_in_temp_file(image.as_raw(), &mut tempfile) { + remove_temp_file(&path); + return Err(e); + } + + Ok(DecodedImage { + width, + height, + path, + delay, + }) + }); + + let mut images = vec![]; + for f in frames { + match f { + Ok(i) => images.push(i), + Err(e) => { + for DecodedImage { path, .. } in images { + remove_temp_file(&path); + } + + return Err(anyhow!("Unable to decode frame: {e}")); + } + } } + + if images.is_empty() { + return Err(anyhow!("Image has no frames")); + } + + Ok(DecodedEmote { + id: self.id, + cols: self.cols, + images, + }) } } -pub struct Clear; +pub struct Clear(pub u32); impl Command for Clear { fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - write!(f, gp!("a=d,d=A,q=2;")) + write!(f, gp!("a=d,d=I,i={id},q=2;"), id = self.0) } #[cfg(windows)] @@ -367,6 +398,10 @@ impl Command for Chain { } pub trait ApplyCommand: Command { + // While the structs that implement this trait can be sent between thread safely, this function is not thread safe, + // and needs to be called from the main thread. + // A solution would be to call it with `std::io::stdout().lock()`, but this creates noticeable freezes when commands + // are issued and the user is typing. fn apply(&self) -> Result<()> { Ok(queue!(std::io::stdout(), self)?) } @@ -418,3 +453,117 @@ pub fn support_graphics_protocol() -> Result { )? .contains("OK")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_static_image() { + let mut s = String::new(); + + let path = "/tmp/foo/bar.baz"; + let image = DecodedImage { + width: 30, + height: 42, + path: path.into(), + delay: 0, + }; + + let emote = DecodedEmote { + id: 1, + cols: 3, + images: vec![image], + }; + emote.write_ansi(&mut s).unwrap(); + + assert_eq!( + s, + format!( + gp!("a=t,t=t,f=32,s=30,v=42,i=1,q=2;{path}"), + path = STANDARD.encode(path) + ) + ); + } + + #[test] + fn load_animated_image() { + let mut s = String::new(); + + let paths = ["/tmp/foo/bar.baz", "a/b/c", "foo bar-baz/123"]; + + let images = vec![ + DecodedImage { + width: 30, + height: 42, + path: paths[0].into(), + delay: 0, + }, + DecodedImage { + width: 12, + height: 74, + path: paths[1].into(), + delay: 89, + }, + DecodedImage { + width: 54, + height: 45, + path: paths[2].into(), + delay: 4, + }, + ]; + + let emote = DecodedEmote { + id: 1, + cols: 3, + images, + }; + + emote.write_ansi(&mut s).unwrap(); + + assert_eq!( + s, + format!( + gp!("a=t,t=t,f=32,s=30,v=42,i=1,q=2;{path}"), + path = STANDARD.encode(paths[0]) + ) + gp!("a=a,i=1,r=1,z=0,q=2;") + + &format!( + gp!("a=f,t=t,f=32,s=12,v=74,i=1,z=89,q=2;{path}"), + path = STANDARD.encode(paths[1]) + ) + + &format!( + gp!("a=f,t=t,f=32,s=54,v=45,i=1,z=4,q=2;{path}"), + path = STANDARD.encode(paths[2]) + ) + + gp!("a=a,i=1,s=3,v=1,q=2;") + ); + } + + #[test] + fn clear_image() { + let mut s = String::new(); + + Clear(1).write_ansi(&mut s).unwrap(); + + assert_eq!(s, gp!("a=d,d=I,i=1,q=2;")); + } + + #[test] + fn display_image() { + let mut s = String::new(); + + Display::new(1, 2, 3).write_ansi(&mut s).unwrap(); + + assert_eq!(s, gp!("a=p,U=1,i=1,p=2,r=1,c=3,q=2;")); + } + + #[test] + fn overlay_image() { + let mut s = String::new(); + Chain::new(1, 2, (3, 4), 1, 1, 4) + .write_ansi(&mut s) + .unwrap(); + + assert_eq!(s, gp!("a=p,i=1,p=2,P=3,Q=4,z=1,H=1,X=4,q=2;")); + } +} diff --git a/src/emotes/mod.rs b/src/emotes/mod.rs index 9ef36a02..e84a5e8d 100644 --- a/src/emotes/mod.rs +++ b/src/emotes/mod.rs @@ -1,26 +1,33 @@ -use log::{info, warn}; +use color_eyre::{eyre::anyhow, Result}; +use log::{error, info, warn}; use std::{ - collections::{hash_map::DefaultHasher, HashMap}, + cell::{OnceCell, RefCell}, + collections::{hash_map::DefaultHasher, BTreeMap, HashMap}, hash::{Hash, Hasher}, + rc::Rc, + sync::OnceLock, +}; +use tokio::sync::{ + mpsc::{Receiver, Sender}, + oneshot::{Receiver as OSReceiver, Sender as OSSender}, }; use crate::{ - emotes::{ - downloader::get_emotes, - graphics_protocol::{ApplyCommand, Size}, - }, + emotes::{downloader::get_emotes, graphics_protocol::Image}, handlers::config::CompleteConfig, - twitch::TwitchAction, - utils::{emotes::get_emote_offset, pathing::cache_path}, + utils::{ + emotes::{emotes_enabled, get_emote_offset}, + pathing::cache_path, + }, }; -use color_eyre::Result; -use tokio::sync::{broadcast::Receiver, mpsc::Sender}; mod downloader; -pub mod graphics_protocol; +mod graphics_protocol; + +pub use graphics_protocol::{support_graphics_protocol, ApplyCommand, DecodedEmote}; // HashMap of emote name, emote filename, and if the emote is an overlay -pub type DownloadedEmotes = HashMap; +pub type DownloadedEmotes = BTreeMap; #[derive(Copy, Clone, Debug)] pub struct EmoteData { @@ -35,27 +42,42 @@ pub struct LoadedEmote { pub hash: u32, /// Number of emotes that have been displayed pub n: u32, - /// Width of the emote in pixels + /// Width of the emote in pixels (resized so that it's height is equal to cell height) pub width: u32, /// If the emote should be displayed over the previous emote, if no text is between them. pub overlay: bool, } -#[derive(Default, Debug, Clone)] +#[derive(Default, Debug)] pub struct Emotes { /// Map of emote name, filename, and if the emote is an overlay - pub emotes: DownloadedEmotes, + pub emotes: RefCell, /// Info about loaded emotes - pub info: HashMap, + pub info: RefCell>, /// Terminal cell size in pixels: (width, height) - pub cell_size: (f32, f32), + pub cell_size: OnceCell<(f32, f32)>, +} + +pub type SharedEmotes = Rc; + +// This Drop impl is only here to cleanup in case of panics. +// The unload method should be called before exiting the alternate screen instead of relying on the drop impl. +impl Drop for Emotes { + fn drop(&mut self) { + self.unload(); + } } impl Emotes { - pub fn unload(&mut self) { - graphics_protocol::Clear.apply().unwrap_or_default(); - self.emotes.clear(); - self.info.clear(); + pub fn unload(&self) { + self.info + .borrow() + .iter() + .for_each(|(_, LoadedEmote { hash, .. })| { + graphics_protocol::Clear(*hash).apply().unwrap_or_default(); + }); + self.emotes.borrow_mut().clear(); + self.info.borrow_mut().clear(); } } @@ -69,13 +91,26 @@ impl From for EmoteData { } } -pub async fn send_emotes(config: &CompleteConfig, tx: &Sender, channel: &str) { +pub fn query_emotes(config: &CompleteConfig, channel: String) -> OSReceiver { + let (tx, mut rx) = tokio::sync::oneshot::channel(); + + if emotes_enabled(&config.frontend) { + let config = config.clone(); + tokio::spawn(async move { send_emotes(&config, tx, channel).await }); + } else { + rx.close(); + } + + rx +} + +pub async fn send_emotes(config: &CompleteConfig, tx: OSSender, channel: String) { info!("Starting emotes download."); - match get_emotes(config, channel).await { + match get_emotes(config, &channel).await { Ok(emotes) => { info!("Emotes downloaded."); - if let Err(e) = tx.send(emotes).await { - warn!("Unable to send emotes to main thread: {e}"); + if tx.send(emotes).is_err() { + warn!("Unable to send emotes to main thread."); } } Err(e) => { @@ -84,16 +119,17 @@ pub async fn send_emotes(config: &CompleteConfig, tx: &Sender, } } -pub async fn emotes( - config: CompleteConfig, - tx: Sender, - mut rx: Receiver, -) { - send_emotes(&config, &tx, &config.twitch.channel).await; +pub static DECODE_EMOTE_SENDER: OnceLock> = OnceLock::new(); + +pub fn decoder(mut rx: Receiver, tx: &Sender>) { + while let Some(emote) = rx.blocking_recv() { + let name = emote.name.clone(); - loop { - if let Ok(TwitchAction::Join(channel)) = rx.recv().await { - send_emotes(&config, &tx, &channel).await; + let decoded = emote.decode().map_err(|_| name); + + if tx.blocking_send(decoded).is_err() { + error!("Unable to send decoded emote to main thread."); + return; } } } @@ -115,10 +151,72 @@ pub fn load_emote( let hash = hasher.finish() as u32 & 0x00FF_FFFF; // Tells the terminal to load the image for later use - let loaded_image = graphics_protocol::Load::new(hash, &cache_path(filename), cell_size)?; - let width = loaded_image.width(); - loaded_image.apply()?; + let image = Image::new( + hash, + word.to_string(), + &cache_path(filename), + overlay, + cell_size, + )?; + + let width = image.width; + let cols = image.cols; + + let decoded = image.decode()?; + + decoded.apply()?; + + // Emote with placement id 1 is reserved for emote picker + // We tell kitty to display it now, but as it is a unicode placeholder, + // it will only be displayed once we print the unicode placeholder. + display_emote(hash, 1, cols)?; + + let emote = LoadedEmote { + hash, + n: 2, + width, + overlay, + }; + + info.insert(word.to_string(), emote); + Ok(emote) + } +} + +pub fn load_picker_emote( + word: &str, + filename: &str, + overlay: bool, + info: &mut HashMap, + cell_size: (f32, f32), +) -> Result { + if let Some(emote) = info.get(word) { + Ok(*emote) + } else { + let mut hasher = DefaultHasher::new(); + word.hash(&mut hasher); + // ID is encoded on 3 bytes, discard the first one. + let hash = hasher.finish() as u32 & 0x00FF_FFFF; + + // Tells the terminal to load the image for later use + let image = Image::new( + hash, + word.to_string(), + &cache_path(filename), + overlay, + cell_size, + )?; + + let width = image.width; + + // Decode emote in another thread, to avoid blocking main thread as decoding images is slow. + DECODE_EMOTE_SENDER + .get() + .ok_or(anyhow!("Decoding channel has not been initialized."))? + .try_send(image) + .map_err(|e| anyhow!("Unable to send emote to decoder thread. {e}"))?; + // Emote with placement id 1 is reserved for emote picker let emote = LoadedEmote { hash, n: 1, diff --git a/src/handlers/app.rs b/src/handlers/app.rs index 1f8283ca..4a4537a6 100644 --- a/src/handlers/app.rs +++ b/src/handlers/app.rs @@ -8,7 +8,7 @@ use tui::{ }; use crate::{ - emotes::Emotes, + emotes::SharedEmotes, handlers::{ config::{CompleteConfig, SharedCompleteConfig, Theme}, data::MessageData, @@ -48,7 +48,7 @@ pub struct App { /// The theme selected by the user. pub theme: Theme, /// Emotes - pub emotes: Emotes, + pub emotes: SharedEmotes, } macro_rules! shared { @@ -78,11 +78,14 @@ impl App { shared_config_borrow.terminal.maximum_messages, )); + let emotes = SharedEmotes::default(); + let components = Components::new( &shared_config, storage.clone(), filters.clone(), messages.clone(), + &emotes, startup_time, ); @@ -97,7 +100,7 @@ impl App { input_buffer: LineBuffer::with_capacity(LINE_BUFFER_CAPACITY), buffer_suggestion: None, theme: shared_config_borrow.frontend.theme.clone(), - emotes: Emotes::default(), + emotes, } } @@ -165,6 +168,7 @@ impl App { pub fn cleanup(&self) { self.storage.borrow().dump_data(); + self.emotes.unload(); } pub fn clear_messages(&mut self) { diff --git a/src/handlers/config.rs b/src/handlers/config.rs index 7f820669..46e13ac0 100644 --- a/src/handlers/config.rs +++ b/src/handlers/config.rs @@ -14,7 +14,7 @@ use tokio::{runtime::Handle, task}; use tui::widgets::BorderType; use crate::{ - emotes::graphics_protocol, + emotes::support_graphics_protocol, handlers::{ args::{merge_args_into_config, Cli}, interactive::interactive_config, @@ -515,10 +515,9 @@ impl CompleteConfig { bail!("Twitch config section is missing one or more of the following: username, channel, token."); } - if emotes_enabled(&config.frontend) - && !graphics_protocol::support_graphics_protocol().unwrap_or(false) + if emotes_enabled(&config.frontend) && !support_graphics_protocol().unwrap_or(false) { - eprintln!("This terminal does not support the graphics protocol.\nUse a terminal such as kitty or WezTerm, or disable emotes."); + eprintln!("This terminal does not support the graphics protocol.\nUse a terminal such as kitty, or disable emotes."); std::process::exit(1); } } diff --git a/src/handlers/data.rs b/src/handlers/data.rs index d4ba32e8..04702e24 100644 --- a/src/handlers/data.rs +++ b/src/handlers/data.rs @@ -11,7 +11,7 @@ use tui::{ use unicode_width::UnicodeWidthStr; use crate::{ - emotes::{display_emote, load_emote, overlay_emote, EmoteData, Emotes}, + emotes::{display_emote, load_emote, overlay_emote, EmoteData, SharedEmotes}, handlers::config::{FrontendConfig, Palette, Theme}, ui::statics::NAME_MAX_CHARACTERS, utils::{ @@ -350,15 +350,21 @@ impl MessageData { /// If they do, tell the terminal to load the emote, and replace the word by a [`UnicodePlaceholder`]. /// The emote will then be displayed by the terminal by encoding its id in its foreground color, and its pid in its underline color. /// Ratatui removes all ansi escape sequences, so the id/pid of the emote is stored and encoded in [`MessageData::to_vec`]. - pub fn parse_emotes(&mut self, emotes: &mut Emotes) { - if emotes.emotes.is_empty() { + pub fn parse_emotes(&mut self, emotes: &SharedEmotes) { + if emotes.emotes.borrow().is_empty() { return; } let mut words = Vec::new(); + let cell_size = *emotes + .cell_size + .get() + .expect("Terminal cell_size must be defined when emotes are enabled"); + self.payload.split(' ').for_each(|word| { - let Some((filename, zero_width)) = emotes.emotes.get(word) else { + let emotes_ref = emotes.emotes.borrow(); + let Some((filename, zero_width)) = emotes_ref.get(word) else { words.push(Word::Text(word.to_string())); return; }; @@ -367,11 +373,12 @@ impl MessageData { word, filename, *zero_width, - &mut emotes.info, - emotes.cell_size, + &mut emotes.info.borrow_mut(), + cell_size, ) .map_err(|e| warn!("Unable to load emote {word} ({filename}): {e}")) else { - emotes.emotes.remove(word); + drop(emotes_ref); + emotes.emotes.borrow_mut().remove(word); words.push(Word::Text(word.to_string())); return; }; @@ -410,14 +417,13 @@ impl MessageData { .max_by_key(|e| e.width) .expect("Emotes should never be empty") .width as f32; - let cols = (max_width / emotes.cell_size.0).ceil() as u16; + let cols = (max_width / cell_size.0).ceil() as u16; let mut iter = v.into_iter(); let EmoteData { id, pid, width } = iter.next().unwrap(); - let (_, col_offset) = - get_emote_offset(width as u16, emotes.cell_size.0 as u16, cols); + let (_, col_offset) = get_emote_offset(width as u16, cell_size.0 as u16, cols); if let Err(e) = display_emote(id, pid, cols) { warn!("Unable to display emote: {e}"); @@ -431,7 +437,7 @@ impl MessageData { layer as u32, cols, col_offset, - emotes.cell_size.0 as u16, + cell_size.0 as u16, ) { warn!("Unable to display overlay: {e}"); } diff --git a/src/main.rs b/src/main.rs index 51c26df8..d77eea3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ use clap::Parser; use color_eyre::eyre::{Result, WrapErr}; use log::{info, warn}; +use std::thread; use tokio::sync::{broadcast, mpsc}; use crate::{ @@ -69,7 +70,7 @@ async fn main() -> Result<()> { color_eyre::install().unwrap(); - let config = CompleteConfig::new(Cli::parse()) + let mut config = CompleteConfig::new(Cli::parse()) .wrap_err("Configuration error.") .unwrap(); @@ -79,34 +80,46 @@ async fn main() -> Result<()> { let (twitch_tx, terminal_rx) = mpsc::channel(100); let (terminal_tx, twitch_rx) = broadcast::channel(100); - let (emotes_tx, emotes_rx) = mpsc::channel(1); - let mut app = App::new(config.clone(), startup_time); + let app = App::new(config.clone(), startup_time); info!("Started tokio communication channels."); - if emotes_enabled(&config.frontend) { - let cloned_config = config.clone(); - let twitch_rx = twitch_rx.resubscribe(); - + let decoded_rx = if emotes_enabled(&config.frontend) { // We need to probe the terminal for it's size before starting the tui, // as writing on stdout on a different thread can interfere. match crossterm::terminal::window_size() { Ok(size) => { - app.emotes.cell_size = ( - f32::from(size.width / size.columns), - f32::from(size.height / size.rows), - ); - info!("{:?}", app.emotes.cell_size); - tokio::task::spawn(async move { - emotes::emotes(cloned_config, emotes_tx, twitch_rx).await; + app.emotes.cell_size.get_or_init(|| { + ( + f32::from(size.width / size.columns), + f32::from(size.height / size.rows), + ) }); + + let (decoder_tx, decoder_rx) = mpsc::channel(100); + emotes::DECODE_EMOTE_SENDER.get_or_init(|| decoder_tx); + + let (decoded_tx, decoded_rx) = mpsc::channel(100); + + // As decoding an image is a blocking task, spawn a separate thread to handle it. + // We cannot use tokio tasks here as it will create noticeable freezes. + thread::spawn(move || emotes::decoder(decoder_rx, &decoded_tx)); + + Some(decoded_rx) } Err(e) => { + config.frontend.twitch_emotes = false; + config.frontend.betterttv_emotes = false; + config.frontend.seventv_emotes = false; + config.frontend.frankerfacez_emotes = false; warn!("Unable to query terminal for it's dimensions, disabling emotes. {e}"); + None } } - } + } else { + None + }; let cloned_config = config.clone(); @@ -114,7 +127,7 @@ async fn main() -> Result<()> { twitch::twitch_irc(config, twitch_tx, twitch_rx).await; }); - terminal::ui_driver(cloned_config, app, terminal_tx, terminal_rx, emotes_rx).await; + terminal::ui_driver(cloned_config, app, terminal_tx, terminal_rx, decoded_rx).await; std::process::exit(0) } diff --git a/src/terminal.rs b/src/terminal.rs index eb5eac5f..539e3d38 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1,10 +1,10 @@ -use log::{debug, info}; +use log::{debug, info, warn}; use std::time::Duration; use tokio::sync::{broadcast::Sender, mpsc::Receiver}; use crate::{ commands::{init_terminal, quit_terminal, reset_terminal}, - emotes::DownloadedEmotes, + emotes::{display_emote, query_emotes, ApplyCommand, DecodedEmote}, handlers::{ app::App, config::CompleteConfig, @@ -28,7 +28,7 @@ pub async fn ui_driver( mut app: App, tx: Sender, mut rx: Receiver, - mut erx: Receiver, + mut drx: Option>>, ) { info!("Started UI driver."); @@ -46,34 +46,46 @@ pub async fn ui_driver( tick_rate: Duration::from_millis(config.terminal.delay), }); + let mut erx = query_emotes(&config, config.twitch.channel.clone()); + let mut terminal = init_terminal(&config.frontend); terminal.clear().unwrap(); loop { + // Check if we have received any emotes if let Ok(e) = erx.try_recv() { - // If the user switched channels too quickly, - // emotes will be from the wrong channel for a short time. - // Clear the emotes to use the ones from the right channel. - // - // Note: - // This might cause a bug if two channels have an emote with the same name - // and with a different width. As already parsed messages will have been replaced by the - // wrong length of unicode characters. - // The emotes affected by this will be cut of if they are larger than the wrong emote width. - // This is negligible as it will only affect emotes already parsed. - // Todo: abort recv/send if another request is pending? - app.emotes.unload(); - app.emotes.emotes = e; + *app.emotes.emotes.borrow_mut() = e; + for message in &mut *app.messages.borrow_mut() { - message.parse_emotes(&mut app.emotes); + message.parse_emotes(&app.emotes); } }; + // Check if we need to load a decoded emote + if let Some(rx) = &mut drx { + if let Ok(r) = rx.try_recv() { + match r { + Ok(d) => { + if let Err(e) = d.apply() { + warn!("Unable to send command to load emote. {e}"); + } else if let Err(e) = display_emote(d.id(), 1, d.cols()) { + warn!("Unable to send command to display emote. {e}"); + } + } + Err(name) => { + warn!("Unable to load emote: {name}."); + app.emotes.emotes.borrow_mut().remove(&name); + app.emotes.info.borrow_mut().remove(&name); + } + } + } + } + if let Ok(msg) = rx.try_recv() { match msg { TwitchToTerminalAction::Message(mut m) => { - m.parse_emotes(&mut app.emotes); + m.parse_emotes(&app.emotes); app.messages.borrow_mut().push_front(m); // If scrolling is enabled, pad for more messages. @@ -98,6 +110,8 @@ pub async fn ui_driver( if let Some(action) = app.event(&event).await { match action { TerminalAction::Quit => { + // Emotes need to be unloaded before we exit the alternate screen + app.emotes.unload(); quit_terminal(terminal); break; @@ -131,7 +145,7 @@ pub async fn ui_driver( ); if let TwitchToTerminalAction::Message(mut msg) = message_data { - msg.parse_emotes(&mut app.emotes); + msg.parse_emotes(&app.emotes); app.messages.borrow_mut().push_front(msg); @@ -142,7 +156,8 @@ pub async fn ui_driver( app.clear_messages(); app.emotes.unload(); - tx.send(TwitchAction::Join(channel)).unwrap(); + tx.send(TwitchAction::Join(channel.clone())).unwrap(); + erx = query_emotes(&config, channel); app.set_state(State::Normal); } diff --git a/src/ui/components/channel_switcher.rs b/src/ui/components/channel_switcher.rs index d9c39884..3d330769 100644 --- a/src/ui/components/channel_switcher.rs +++ b/src/ui/components/channel_switcher.rs @@ -37,7 +37,7 @@ pub struct ChannelSwitcherWidget { config: SharedCompleteConfig, focused: bool, storage: SharedStorage, - search_input: InputWidget, + search_input: InputWidget, list_state: ListState, filtered_channels: Option>, vertical_scroll_state: ScrollbarState, @@ -46,7 +46,7 @@ pub struct ChannelSwitcherWidget { impl ChannelSwitcherWidget { pub fn new(config: SharedCompleteConfig, storage: SharedStorage) -> Self { - let input_validator = Box::new(|s: String| -> bool { + let input_validator = Box::new(|_, s: String| -> bool { Regex::new(&NAME_RESTRICTION_REGEX) .unwrap() .is_match(s.as_str()) @@ -71,7 +71,7 @@ impl ChannelSwitcherWidget { let search_input = InputWidget::new( config.clone(), "Channel switcher", - Some(input_validator), + Some((storage.clone(), input_validator)), Some(visual_indicator), Some((storage.clone(), input_suggester)), ); @@ -141,7 +141,9 @@ impl ToString for ChannelSwitcherWidget { impl Component for ChannelSwitcherWidget { fn draw(&mut self, f: &mut Frame, area: Option) { - let r = area.map_or_else(|| centered_rect(60, 60, 20, f.size()), |a| a); + let mut r = area.map_or_else(|| centered_rect(60, 60, 23, f.size()), |a| a); + // Make sure we have space for the input widget, which has a height of 3. + r.height -= 3; let channels = self.storage.borrow().get("channels"); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 210ee63e..0085f19c 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -10,6 +10,7 @@ use tui::{ }; use crate::{ + emotes::SharedEmotes, handlers::{ app::SharedMessages, config::SharedCompleteConfig, @@ -47,9 +48,10 @@ impl ChatWidget { config: SharedCompleteConfig, messages: SharedMessages, storage: &SharedStorage, + emotes: &SharedEmotes, filters: SharedFilters, ) -> Self { - let chat_input = ChatInputWidget::new(config.clone(), storage.clone()); + let chat_input = ChatInputWidget::new(config.clone(), storage.clone(), emotes.clone()); let channel_input = ChannelSwitcherWidget::new(config.clone(), storage.clone()); let search_input = MessageSearchWidget::new(config.clone()); let following = FollowingWidget::new(config.clone()); diff --git a/src/ui/components/chat_input.rs b/src/ui/components/chat_input.rs index 305cb880..25574b4f 100644 --- a/src/ui/components/chat_input.rs +++ b/src/ui/components/chat_input.rs @@ -1,6 +1,7 @@ use tui::{layout::Rect, Frame}; use crate::{ + emotes::SharedEmotes, handlers::{ config::SharedCompleteConfig, storage::SharedStorage, @@ -9,22 +10,23 @@ use crate::{ terminal::TerminalAction, twitch::TwitchAction, ui::{ - components::{utils::InputWidget, Component}, + components::{emote_picker::EmotePickerWidget, utils::InputWidget, Component}, statics::{COMMANDS, TWITCH_MESSAGE_LIMIT}, }, - utils::text::first_similarity, + utils::{emotes::emotes_enabled, text::first_similarity}, }; pub struct ChatInputWidget { config: SharedCompleteConfig, storage: SharedStorage, - input: InputWidget, + input: InputWidget, + emote_picker: EmotePickerWidget, } impl ChatInputWidget { - pub fn new(config: SharedCompleteConfig, storage: SharedStorage) -> Self { + pub fn new(config: SharedCompleteConfig, storage: SharedStorage, emotes: SharedEmotes) -> Self { let input_validator = - Box::new(|s: String| -> bool { !s.is_empty() && s.len() < TWITCH_MESSAGE_LIMIT }); + Box::new(|_, s: String| -> bool { !s.is_empty() && s.len() < TWITCH_MESSAGE_LIMIT }); // User should be known of how close they are to the message length limit. let visual_indicator = @@ -62,15 +64,18 @@ impl ChatInputWidget { let input = InputWidget::new( config.clone(), "Chat", - Some(input_validator), + Some((storage.clone(), input_validator)), Some(visual_indicator), Some((storage.clone(), input_suggester)), ); + let emote_picker = EmotePickerWidget::new(config.clone(), emotes); + Self { config, storage, input, + emote_picker, } } @@ -96,10 +101,21 @@ impl ToString for ChatInputWidget { impl Component for ChatInputWidget { fn draw(&mut self, f: &mut Frame, area: Option) { self.input.draw(f, area); + + if self.emote_picker.is_focused() { + self.emote_picker.draw(f, None); + } } async fn event(&mut self, event: &Event) -> Option { - if let Event::Input(key) = event { + if self.emote_picker.is_focused() { + if let Some(TerminalAction::Enter(TwitchAction::Privmsg(emote))) = + self.emote_picker.event(event).await + { + self.input.insert(&emote); + self.input.insert(" "); + } + } else if let Event::Input(key) = event { match key { Key::Enter => { if self.input.is_valid() { @@ -125,6 +141,11 @@ impl Component for ChatInputWidget { return Some(action); } } + Key::Alt('e') => { + if emotes_enabled(&self.config.borrow().frontend) { + self.emote_picker.toggle_focus(); + } + } Key::Esc => { self.input.toggle_focus(); } diff --git a/src/ui/components/emote_picker.rs b/src/ui/components/emote_picker.rs new file mode 100644 index 00000000..8066ceab --- /dev/null +++ b/src/ui/components/emote_picker.rs @@ -0,0 +1,262 @@ +use log::warn; +use memchr::memmem; +use std::cmp::max; +use tui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, + Frame, +}; + +use crate::{ + emotes::{load_picker_emote, SharedEmotes}, + handlers::{ + config::SharedCompleteConfig, + user_input::events::{Event, Key}, + }, + terminal::TerminalAction, + twitch::TwitchAction, + ui::{ + components::{ + utils::{centered_rect, InputWidget}, + Component, + }, + statics::TWITCH_MESSAGE_LIMIT, + }, + utils::{ + colors::u32_to_color, + emotes::UnicodePlaceholder, + text::{first_similarity_iter, title_line, TitleStyle}, + }, +}; + +pub struct EmotePickerWidget { + config: SharedCompleteConfig, + emotes: SharedEmotes, + input: InputWidget, + search_theme: Style, + list_state: ListState, + filtered_emotes: Vec, +} + +impl EmotePickerWidget { + pub fn new(config: SharedCompleteConfig, emotes: SharedEmotes) -> Self { + let input_validator = Box::new(|emotes: SharedEmotes, s: String| -> bool { + !s.is_empty() + && s.len() < TWITCH_MESSAGE_LIMIT + && emotes.emotes.borrow().contains_key(&s) + }); + + let input_suggester = Box::new(|emotes: SharedEmotes, s: String| -> Option { + first_similarity_iter(emotes.emotes.borrow().keys(), &s) + }); + + let input = InputWidget::new( + config.clone(), + "Emote", + Some((emotes.clone(), input_validator)), + None, + Some((emotes.clone(), input_suggester)), + ); + + let search_theme = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); + + Self { + config, + emotes, + input, + search_theme, + list_state: ListState::default(), + filtered_emotes: vec![], + } + } + fn next(&mut self) { + let i = match self.list_state.selected() { + Some(i) => { + if i >= self.filtered_emotes.len() - 1 { + self.filtered_emotes.len() - 1 + } else { + i + 1 + } + } + None => 0, + }; + + self.list_state.select(Some(i)); + } + + fn previous(&mut self) { + let i = self + .list_state + .selected() + .map_or(0, |i| if i == 0 { 0 } else { i - 1 }); + + self.list_state.select(Some(i)); + } + + fn unselect(&mut self) { + self.list_state.select(None); + } + pub const fn is_focused(&self) -> bool { + self.input.is_focused() + } + + pub fn toggle_focus(&mut self) { + self.input.toggle_focus(); + } +} + +impl Component for EmotePickerWidget { + fn draw(&mut self, f: &mut Frame, area: Option) { + let mut r = area.map_or_else(|| centered_rect(60, 60, 23, f.size()), |a| a); + // Make sure we have space for the input widget, which has a height of 3. + r.height -= 3; + + // Only load the emotes that are actually being displayed, as loading every emote is not really possible. + // Some channels can have multiple thousands emotes and decoding all of them takes a while. + let max_len = max( + self.list_state.selected().unwrap_or_default(), + self.list_state.offset(), + ) + r.height as usize; + let mut items = Vec::with_capacity(max_len); + let mut bad_emotes = vec![]; + + let mut current_input = self.input.to_string(); + + let cell_size = *self + .emotes + .cell_size + .get() + .expect("Terminal cell size should be set when emotes are enabled."); + + let finder = if current_input.is_empty() { + None + } else { + current_input.make_ascii_lowercase(); + Some(memmem::Finder::new(¤t_input)) + }; + + for (name, (filename, zero_width)) in self.emotes.emotes.borrow().iter() { + if items.len() >= max_len { + break; + } + + // Skip emotes that do not contain the current input, if it is not empty. + let Some(pos) = finder + .as_ref() + .map_or_else(|| Some(0), |f| f.find(name.to_ascii_lowercase().as_bytes())) + else { + continue; + }; + + let Ok(loaded_emote) = load_picker_emote( + name, + filename, + *zero_width, + &mut self.emotes.info.borrow_mut(), + cell_size, + ) + .map_err(|e| warn!("{e}")) else { + bad_emotes.push(name.clone()); + continue; + }; + + let cols = (loaded_emote.width as f32 / cell_size.0).ceil() as u16; + + let row = vec![ + Span::raw(name[0..pos].to_owned()), + Span::styled( + name[pos..(pos + current_input.len())].to_owned(), + self.search_theme, + ), + Span::raw(name[(pos + current_input.len())..].to_owned()), + Span::raw(" - "), + Span::styled( + UnicodePlaceholder::new(cols as usize).string(), + Style::default() + .fg(u32_to_color(loaded_emote.hash)) + .underline_color(u32_to_color(1)), + ), + ]; + + items.push((name.clone(), ListItem::new(vec![Line::from(row)]))); + } + + // Remove emotes that could not be loaded from list of emotes + for emote in bad_emotes { + self.emotes.info.borrow_mut().remove(&emote); + self.emotes.emotes.borrow_mut().remove(&emote); + } + + let (names, list_items) = items.into_iter().unzip(); + self.filtered_emotes = names; + + let title_binding = [TitleStyle::Single("Emotes")]; + + let list = List::new::>(list_items) + .block( + Block::default() + .title(title_line( + &title_binding, + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_type(self.config.borrow().frontend.border_type.clone().into()), + ) + .highlight_style( + Style::default() + .bg(Color::LightGreen) + .add_modifier(Modifier::BOLD), + ); + + f.render_widget(Clear, r); + f.render_stateful_widget(list, r, &mut self.list_state); + + let bottom_block = Block::default() + .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT) + .border_type(self.config.borrow().frontend.border_type.clone().into()); + + let rect = Rect::new(r.x, r.bottom() - 1, r.width, 1); + + f.render_widget(bottom_block, rect); + + let input_rect = Rect::new(r.x, r.bottom(), r.width, 3); + + self.input.draw(f, Some(input_rect)); + } + + async fn event(&mut self, event: &Event) -> Option { + if let Event::Input(key) = event { + match key { + Key::Esc => self.toggle_focus(), + Key::Ctrl('p') => panic!("Manual panic triggered by user."), + Key::ScrollDown | Key::Down => self.next(), + Key::ScrollUp | Key::Up => self.previous(), + Key::Enter => { + if let Some(idx) = self.list_state.selected() { + let emote = self.filtered_emotes[idx].clone(); + + self.toggle_focus(); + self.input.update(""); + self.unselect(); + self.filtered_emotes.clear(); + + return Some(TerminalAction::Enter(TwitchAction::Privmsg(emote))); + } + } + _ => { + self.input.event(event).await; + + // Assuming that the user inputted something that modified the input + match self.filtered_emotes.len() { + 0 => self.unselect(), + _ => self.list_state.select(Some(0)), + } + } + } + } + + None + } +} diff --git a/src/ui/components/message_search.rs b/src/ui/components/message_search.rs index f22cbda5..a99ae286 100644 --- a/src/ui/components/message_search.rs +++ b/src/ui/components/message_search.rs @@ -14,13 +14,13 @@ use crate::{ pub struct MessageSearchWidget { _config: SharedCompleteConfig, - input: InputWidget, + input: InputWidget<()>, } impl MessageSearchWidget { pub fn new(config: SharedCompleteConfig) -> Self { let input_validator = - Box::new(|s: String| -> bool { !s.is_empty() && s.len() <= TWITCH_MESSAGE_LIMIT }); + Box::new(|(), s: String| -> bool { !s.is_empty() && s.len() <= TWITCH_MESSAGE_LIMIT }); // Indication that user won't get any good results near the twitch message length limit. // TODO: In the future, this should be replaced with how many results have been found. @@ -30,7 +30,7 @@ impl MessageSearchWidget { let input = InputWidget::new( config.clone(), "Message search", - Some(input_validator), + Some(((), input_validator)), Some(visual_indicator), None, ); diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 9770a6db..877ee710 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -9,6 +9,7 @@ mod help; mod message_search; mod state_tabs; +mod emote_picker; pub mod utils; pub use channel_switcher::ChannelSwitcherWidget; @@ -26,6 +27,7 @@ use chrono::{DateTime, Local}; use tui::{layout::Rect, Frame}; use crate::{ + emotes::SharedEmotes, handlers::{ app::SharedMessages, config::SharedCompleteConfig, @@ -85,6 +87,7 @@ impl Components { storage: SharedStorage, filters: SharedFilters, messages: SharedMessages, + emotes: &SharedEmotes, startup_time: DateTime, ) -> Self { let window_size_error = ErrorWidget::new(WINDOW_SIZE_TOO_SMALL_ERROR.to_vec()); @@ -93,7 +96,7 @@ impl Components { tabs: StateTabsWidget::new(config.clone()), debug: DebugWidget::new(config.clone(), startup_time), - chat: ChatWidget::new(config.clone(), messages, &storage, filters), + chat: ChatWidget::new(config.clone(), messages, &storage, emotes, filters), dashboard: DashboardWidget::new(config.clone(), storage), help: HelpWidget::new(config.clone()), window_size_error, diff --git a/src/ui/components/utils/input_widget.rs b/src/ui/components/utils/input_widget.rs index 0e4ac4dd..2d9f2b9b 100644 --- a/src/ui/components/utils/input_widget.rs +++ b/src/ui/components/utils/input_widget.rs @@ -10,7 +10,6 @@ use tui::{ use crate::{ handlers::{ config::SharedCompleteConfig, - storage::SharedStorage, user_input::events::{Event, Key}, }, terminal::TerminalAction, @@ -20,28 +19,28 @@ use crate::{ use super::centered_rect; -pub type InputValidator = Box bool>; +pub type InputValidator = Box bool>; pub type VisualValidator = Box String>; -pub type InputSuggester = Box Option>; +pub type InputSuggester = Box Option>; -pub struct InputWidget { +pub struct InputWidget { config: SharedCompleteConfig, input: LineBuffer, title: String, focused: bool, - input_validator: Option, + input_validator: Option<(T, InputValidator)>, visual_indicator: Option, - input_suggester: Option<(SharedStorage, InputSuggester)>, + input_suggester: Option<(T, InputSuggester)>, suggestion: Option, } -impl InputWidget { +impl InputWidget { pub fn new( config: SharedCompleteConfig, title: &str, - input_validator: Option, + input_validator: Option<(T, InputValidator)>, visual_indicator: Option, - input_suggester: Option<(SharedStorage, InputSuggester)>, + input_suggester: Option<(T, InputSuggester)>, ) -> Self { Self { config, @@ -75,17 +74,30 @@ impl InputWidget { pub fn is_valid(&self) -> bool { self.input_validator .as_ref() - .map_or(true, |validator| validator(self.input.to_string())) + .map_or(true, |(items, validator)| { + validator(items.clone(), self.input.to_string()) + }) + } + + pub fn accept_suggestion(&mut self) { + if let Some(suggestion) = &self.suggestion { + self.input.update(suggestion, 0); + } + } + + pub fn insert(&mut self, s: &str) { + self.input.insert_str(self.input.pos(), s); + self.input.set_pos(self.input.pos() + s.len()); } } -impl ToString for InputWidget { +impl ToString for InputWidget { fn to_string(&self) -> String { self.input.to_string() } } -impl Component for InputWidget { +impl Component for InputWidget { fn draw(&mut self, f: &mut Frame, area: Option) { let r = area.map_or_else(|| centered_rect(60, 60, 20, f.size()), |a| a); @@ -106,15 +118,17 @@ impl Component for InputWidget { Color::Red }; - self.suggestion = if self.config.borrow().storage.channels { - if let Some((storage, suggester)) = &self.input_suggester { - suggester(storage.clone(), self.input.to_string()) - } else { - None - } - } else { - None - }; + self.suggestion = self + .config + .borrow() + .storage + .channels + .then(|| { + self.input_suggester + .as_ref() + .and_then(|(items, suggester)| suggester(items.clone(), self.input.to_string())) + }) + .flatten(); let block = Block::default() .borders(Borders::ALL) @@ -177,7 +191,12 @@ impl Component for InputWidget { if let Event::Input(key) = event { match key { Key::Ctrl('f') | Key::Right => { - self.input.move_forward(1); + if self.input.next_pos(1).is_none() { + self.accept_suggestion(); + self.input.move_end(); + } else { + self.input.move_forward(1); + } } Key::Ctrl('b') | Key::Left => { self.input.move_backward(1); diff --git a/src/ui/components/utils/search_widget.rs b/src/ui/components/utils/search_widget.rs index e68e94eb..a28241a5 100644 --- a/src/ui/components/utils/search_widget.rs +++ b/src/ui/components/utils/search_widget.rs @@ -51,7 +51,7 @@ where filtered_items: Option>, list_state: ListState, - search_input: InputWidget, + search_input: InputWidget<()>, vertical_scroll_state: ScrollbarState, vertical_scroll: usize, diff --git a/src/ui/statics.rs b/src/ui/statics.rs index b2cbc2fc..8891f2b3 100644 --- a/src/ui/statics.rs +++ b/src/ui/statics.rs @@ -59,6 +59,7 @@ pub static HELP_KEYBINDS: Lazy)>> = Lazy::new(|| { ("Alt + f", "Move to the end of the next word"), ("Alt + b", "Move to the start of the previous word"), ("Alt + t", "Swap previous word with current word"), + ("Alt + e", "Toggle emote picker"), ], ), ] diff --git a/src/utils/text.rs b/src/utils/text.rs index ad71ccde..56db9273 100644 --- a/src/utils/text.rs +++ b/src/utils/text.rs @@ -63,12 +63,18 @@ pub fn title_line<'a>(contents: &'a [TitleStyle<'a>], style: Style) -> Vec Option { + first_similarity_iter(possibilities.iter(), search) +} + +pub fn first_similarity_iter<'a>( + possibilities: impl Iterator, + search: &str, +) -> Option { if search.is_empty() { return None; } possibilities - .iter() .filter(|s| s.starts_with(search)) .collect::>() .first()