From bc4824037da5af267b7a519dee333f353d48442d Mon Sep 17 00:00:00 2001 From: muk Date: Fri, 20 Feb 2026 14:27:01 +0000 Subject: [PATCH] Add Giphy ASCII animation widget Implement a new widget that fetches GIFs from the Giphy API, decodes them frame-by-frame, converts each frame to ASCII art using luminance mapping, and animates them in the terminal. New files: - src/feeds/giphy.rs: Giphy API client supporting search, trending, and random modes. Downloads GIFs and decodes frames into ASCII art using brightness-to-character mapping (" .:-=+*#%@"). - src/ui/widgets/giphy.rs: TUI widget that renders ASCII frames with configurable animation speed, pause/resume, and manual frame stepping via scroll keys. Modified files: - Cargo.toml: Add gif crate for GIF frame decoding - src/feeds/mod.rs: Add Giphy variant to FeedData enum, GiphyGif struct - src/config.rs: Add GiphyConfig with api_key, query, mode, frame_delay_ms options - src/ui/widgets/mod.rs: Register giphy widget module - src/app.rs: Wire up GiphyWidget creation and animation ticking Configuration example (config.toml): [[widgets]] type = "giphy" title = "GIF Player" api_key = "YOUR_GIPHY_API_KEY" query = "cats" mode = "search" frame_delay_ms = 150 position = { row = 0, col = 2 } Closes #55 Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 13 ++- Cargo.toml | 1 + src/app.rs | 19 +++- src/config.rs | 19 ++++ src/feeds/giphy.rs | 205 ++++++++++++++++++++++++++++++++++++++ src/feeds/mod.rs | 11 +++ src/ui/widgets/giphy.rs | 212 ++++++++++++++++++++++++++++++++++++++++ src/ui/widgets/mod.rs | 1 + 8 files changed, 476 insertions(+), 5 deletions(-) create mode 100644 src/feeds/giphy.rs create mode 100644 src/ui/widgets/giphy.rs diff --git a/Cargo.lock b/Cargo.lock index c6e1d27..65eac0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -728,6 +728,7 @@ dependencies = [ "dirs", "feed-rs", "futures", + "gif 0.13.3", "image", "jiff", "open", @@ -931,6 +932,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.14.1" @@ -1235,7 +1246,7 @@ dependencies = [ "byteorder-lite", "color_quant", "exr", - "gif", + "gif 0.14.1", "image-webp", "moxcms", "num-traits", diff --git a/Cargo.toml b/Cargo.toml index a08de45..2d892c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ open = "5" textwrap = "0.16" scraper = "0.22" image = "0.25" +gif = "0.13" [dev-dependencies] tempfile = "3" diff --git a/src/app.rs b/src/app.rs index 0903d68..cdde6f8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,10 +8,10 @@ use crate::twitter_parser; use crate::ui::article_reader::ArticleReader; use crate::ui::creature_menu::CreatureMenu; use crate::ui::widgets::{ - clock::Clock, creature::CreatureWidget, github::GithubWidget, hackernews::HackernewsWidget, - pixelart::PixelArtWidget, rss::RssWidget, sports::SportsWidget, stocks::StocksWidget, - twitter::TwitterWidget, twitter_archive::TwitterArchiveWidget, youtube::YoutubeWidget, - FeedWidget, + clock::Clock, creature::CreatureWidget, giphy::GiphyWidget, github::GithubWidget, + hackernews::HackernewsWidget, pixelart::PixelArtWidget, rss::RssWidget, sports::SportsWidget, + stocks::StocksWidget, twitter::TwitterWidget, twitter_archive::TwitterArchiveWidget, + youtube::YoutubeWidget, FeedWidget, }; use anyhow::Result; use crossterm::{ @@ -76,6 +76,7 @@ impl App { } WidgetConfig::Pixelart(cfg) => Box::new(PixelArtWidget::new(cfg.clone())), WidgetConfig::Clock(cfg) => Box::new(Clock::new(cfg.clone())), + WidgetConfig::Giphy(cfg) => Box::new(GiphyWidget::new(cfg.clone())), WidgetConfig::Creature(cfg) => { creature_widget_idx = Some(widgets.len()); Box::new(CreatureWidget::new(cfg.clone(), creature.clone())) @@ -521,6 +522,16 @@ impl App { clock.tick_stopwatch(); } } + + // Tick all giphy widgets for animation + for widget in &mut self.widgets { + if let Some(giphy) = widget + .as_any_mut() + .and_then(|w| w.downcast_mut::()) + { + giphy.tick(); + } + } } /// Open the article reader for the currently selected item diff --git a/src/config.rs b/src/config.rs index 6c47732..e1ef313 100644 --- a/src/config.rs +++ b/src/config.rs @@ -50,6 +50,7 @@ pub enum WidgetConfig { TwitterArchive(TwitterArchiveConfig), Pixelart(PixelArtConfig), Clock(ClockConfig), + Giphy(GiphyConfig), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -262,6 +263,24 @@ fn default_clock_title() -> String { "World Clock".to_string() } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GiphyConfig { + #[serde(default = "default_giphy_title")] + pub title: String, + pub api_key: String, + #[serde(default)] + pub query: Option, + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub frame_delay_ms: Option, + pub position: Position, +} + +fn default_giphy_title() -> String { + "Giphy".to_string() +} + fn default_timezones() -> Vec { vec![ "America/New_York".to_string(), diff --git a/src/feeds/giphy.rs b/src/feeds/giphy.rs new file mode 100644 index 0000000..76a480d --- /dev/null +++ b/src/feeds/giphy.rs @@ -0,0 +1,205 @@ +use super::{FeedData, FeedFetcher, GiphyGif}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Deserialize; + +pub struct GiphyFetcher { + api_key: String, + query: Option, + mode: GiphyMode, + client: reqwest::Client, +} + +#[derive(Debug, Clone)] +pub enum GiphyMode { + Search, + Trending, + Random, +} + +#[derive(Debug, Deserialize)] +struct GiphyResponse { + data: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct GiphyGifData { + id: String, + title: String, + images: GiphyImages, +} + +#[derive(Debug, Deserialize)] +struct GiphyImages { + fixed_height_small: GiphyImageVariant, +} + +#[derive(Debug, Deserialize)] +struct GiphyImageVariant { + url: String, + width: String, + height: String, +} + +impl GiphyFetcher { + pub fn new(api_key: String, query: Option, mode: GiphyMode) -> Self { + Self { + api_key, + query, + mode, + client: reqwest::Client::new(), + } + } + + async fn fetch_gif_url(&self) -> Result> { + let url = match self.mode { + GiphyMode::Search => { + let q = self.query.as_deref().unwrap_or("cat"); + format!( + "https://api.giphy.com/v1/gifs/search?api_key={}&q={}&limit=1&rating=g", + self.api_key, + urlencoding::encode(q) + ) + } + GiphyMode::Trending => { + format!( + "https://api.giphy.com/v1/gifs/trending?api_key={}&limit=1&rating=g", + self.api_key + ) + } + GiphyMode::Random => { + let tag = self.query.as_deref().unwrap_or(""); + format!( + "https://api.giphy.com/v1/gifs/random?api_key={}&tag={}&rating=g", + self.api_key, + urlencoding::encode(tag) + ) + } + }; + + let response = self.client.get(&url).send().await?; + let giphy_resp: GiphyResponse = response.json().await?; + + // Parse based on mode (random returns a single object, others return arrays) + let gif_data: Option = match self.mode { + GiphyMode::Random => serde_json::from_value(giphy_resp.data).ok(), + _ => { + let arr: Vec = + serde_json::from_value(giphy_resp.data).unwrap_or_default(); + arr.into_iter().next() + } + }; + + let Some(gif) = gif_data else { + return Ok(None); + }; + + let width = gif.images.fixed_height_small.width.parse().unwrap_or(100); + let height = gif.images.fixed_height_small.height.parse().unwrap_or(100); + + // Download the GIF and decode frames into ASCII + let gif_url = &gif.images.fixed_height_small.url; + let gif_bytes = self.client.get(gif_url).send().await?.bytes().await?; + let frames = decode_gif_to_ascii(&gif_bytes, width, height); + + Ok(Some(GiphyGif { + id: gif.id, + title: gif.title, + frames, + })) + } +} + +/// Decode a GIF byte stream into a vector of ASCII art frames +fn decode_gif_to_ascii(data: &[u8], target_width: u32, target_height: u32) -> Vec> { + use gif::DecodeOptions; + use std::io::Cursor; + + let ascii_chars: &[u8] = b" .:-=+*#%@"; + + let mut opts = DecodeOptions::new(); + opts.set_color_output(gif::ColorOutput::RGBA); + + let Ok(mut decoder) = opts.read_info(Cursor::new(data)) else { + return vec![vec!["Error decoding GIF".to_string()]]; + }; + + let gif_width = decoder.width() as u32; + let gif_height = decoder.height() as u32; + + // Scale to fit a reasonable terminal size (max ~60 cols, ~20 rows) + let max_cols = target_width.min(60); + let max_rows = target_height.min(20); + + let scale_x = if gif_width > 0 { + gif_width / max_cols.max(1) + } else { + 1 + } + .max(1); + let scale_y = if gif_height > 0 { + gif_height / max_rows.max(1) + } else { + 1 + } + .max(1); + + let out_w = gif_width / scale_x; + let out_h = gif_height / scale_y; + + let mut frames = Vec::new(); + let max_frames = 30; // Cap frames to prevent memory issues + + while let Ok(Some(frame)) = decoder.read_next_frame() { + if frames.len() >= max_frames { + break; + } + + let buf = &frame.buffer; + let frame_w = frame.width as u32; + let frame_h = frame.height as u32; + + let mut lines = Vec::with_capacity(out_h as usize); + + for row in 0..out_h { + let mut line = String::with_capacity(out_w as usize); + for col in 0..out_w { + let src_x = (col * scale_x).min(frame_w.saturating_sub(1)); + let src_y = (row * scale_y).min(frame_h.saturating_sub(1)); + let idx = ((src_y * frame_w + src_x) * 4) as usize; + + if idx + 2 < buf.len() { + let r = buf[idx] as f32; + let g = buf[idx + 1] as f32; + let b = buf[idx + 2] as f32; + // Luminance formula + let luminance = 0.299 * r + 0.587 * g + 0.114 * b; + let char_idx = ((luminance / 255.0) * (ascii_chars.len() - 1) as f32) as usize; + line.push(ascii_chars[char_idx.min(ascii_chars.len() - 1)] as char); + } else { + line.push(' '); + } + } + lines.push(line); + } + + frames.push(lines); + } + + if frames.is_empty() { + vec![vec!["No frames decoded".to_string()]] + } else { + frames + } +} + +#[async_trait] +impl FeedFetcher for GiphyFetcher { + async fn fetch(&self) -> Result { + match self.fetch_gif_url().await { + Ok(Some(gif)) => Ok(FeedData::Giphy(gif)), + Ok(None) => Ok(FeedData::Error("No GIF found".to_string())), + Err(e) => Ok(FeedData::Error(format!("Giphy error: {}", e))), + } + } +} diff --git a/src/feeds/mod.rs b/src/feeds/mod.rs index 6b371a5..1065258 100644 --- a/src/feeds/mod.rs +++ b/src/feeds/mod.rs @@ -1,3 +1,4 @@ +pub mod giphy; pub mod github; pub mod hackernews; pub mod rss; @@ -24,10 +25,20 @@ pub enum FeedData { Github(GithubDashboard), Youtube(Vec), TwitterArchive(Vec), + Giphy(GiphyGif), Loading, Error(String), } +#[derive(Debug, Clone)] +pub struct GiphyGif { + #[allow(dead_code)] + pub id: String, + pub title: String, + /// Each frame is a Vec of lines (ASCII art rows) + pub frames: Vec>, +} + #[derive(Debug, Clone)] pub struct HnStory { pub id: u64, diff --git a/src/ui/widgets/giphy.rs b/src/ui/widgets/giphy.rs new file mode 100644 index 0000000..95206c4 --- /dev/null +++ b/src/ui/widgets/giphy.rs @@ -0,0 +1,212 @@ +use crate::config::GiphyConfig; +use crate::feeds::giphy::{GiphyFetcher, GiphyMode}; +use crate::feeds::{FeedData, FeedFetcher, GiphyGif}; +use crate::ui::widgets::FeedWidget; +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use std::time::Instant; + +pub struct GiphyWidget { + config: GiphyConfig, + gif: Option, + current_frame: usize, + last_frame_time: Instant, + loading: bool, + paused: bool, + error: Option, + selected: bool, +} + +impl GiphyWidget { + pub fn new(config: GiphyConfig) -> Self { + Self { + config, + gif: None, + current_frame: 0, + last_frame_time: Instant::now(), + loading: true, + paused: false, + error: None, + selected: false, + } + } + + /// Advance the animation frame based on elapsed time + pub fn tick(&mut self) { + if self.paused { + return; + } + + let frame_delay_ms = self.config.frame_delay_ms.unwrap_or(150); + + if self.last_frame_time.elapsed().as_millis() >= frame_delay_ms as u128 { + if let Some(ref gif) = self.gif { + if !gif.frames.is_empty() { + self.current_frame = (self.current_frame + 1) % gif.frames.len(); + self.last_frame_time = Instant::now(); + } + } + } + } + + #[allow(dead_code)] + pub fn toggle_pause(&mut self) { + self.paused = !self.paused; + } +} + +impl FeedWidget for GiphyWidget { + fn id(&self) -> String { + format!( + "giphy-{}-{}", + self.config.position.row, self.config.position.col + ) + } + + fn title(&self) -> &str { + &self.config.title + } + + fn position(&self) -> (usize, usize) { + (self.config.position.row, self.config.position.col) + } + + fn render(&self, frame: &mut Frame, area: Rect, selected: bool) { + let border_style = if selected { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Magenta) + }; + + let title = if let Some(ref gif) = self.gif { + let status = if self.paused { " [PAUSED]" } else { "" }; + let frame_info = format!(" [{}/{}]", self.current_frame + 1, gif.frames.len()); + format!( + " {} - {}{}{} ", + self.config.title, gif.title, frame_info, status + ) + } else { + format!(" {} ", self.config.title) + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style); + + if self.loading && self.gif.is_none() { + let loading_text = Paragraph::new("Loading GIF...").block(block); + frame.render_widget(loading_text, area); + return; + } + + if let Some(ref error) = self.error { + let error_text = Paragraph::new(format!("Error: {}", error)).block(block); + frame.render_widget(error_text, area); + return; + } + + if let Some(ref gif) = self.gif { + if gif.frames.is_empty() { + let empty = Paragraph::new("No frames").block(block); + frame.render_widget(empty, area); + return; + } + + let current = &gif.frames[self.current_frame]; + let inner_height = area.height.saturating_sub(2) as usize; + + let lines: Vec = current + .iter() + .take(inner_height) + .map(|line| { + Line::from(Span::styled( + line.clone(), + Style::default().fg(Color::Green), + )) + }) + .collect(); + + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, area); + } else { + let empty = Paragraph::new("No GIF loaded").block(block); + frame.render_widget(empty, area); + } + } + + fn update_data(&mut self, data: FeedData) { + self.loading = false; + match data { + FeedData::Giphy(gif) => { + self.gif = Some(gif); + self.current_frame = 0; + self.error = None; + } + FeedData::Error(e) => { + self.error = Some(e); + } + FeedData::Loading => { + self.loading = true; + } + _ => {} + } + } + + fn create_fetcher(&self) -> Box { + let mode = match self.config.mode.as_deref() { + Some("trending") => GiphyMode::Trending, + Some("random") => GiphyMode::Random, + _ => GiphyMode::Search, + }; + + Box::new(GiphyFetcher::new( + self.config.api_key.clone(), + self.config.query.clone(), + mode, + )) + } + + fn scroll_up(&mut self) { + // Scroll backward through frames manually + if let Some(ref gif) = self.gif { + if !gif.frames.is_empty() { + self.current_frame = if self.current_frame == 0 { + gif.frames.len() - 1 + } else { + self.current_frame - 1 + }; + } + } + } + + fn scroll_down(&mut self) { + // Scroll forward through frames manually + if let Some(ref gif) = self.gif { + if !gif.frames.is_empty() { + self.current_frame = (self.current_frame + 1) % gif.frames.len(); + } + } + } + + fn set_selected(&mut self, selected: bool) { + self.selected = selected; + } + + fn get_selected_discussion_url(&self) -> Option { + None + } + + fn as_any(&self) -> Option<&dyn std::any::Any> { + Some(self) + } + + fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> { + Some(self) + } +} diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs index af85e6c..b7a240a 100644 --- a/src/ui/widgets/mod.rs +++ b/src/ui/widgets/mod.rs @@ -1,5 +1,6 @@ pub mod clock; pub mod creature; +pub mod giphy; pub mod github; pub mod hackernews; pub mod pixelart;