From 95862e8273e016efd20b832ad17326b40533d41f Mon Sep 17 00:00:00 2001 From: Bryan Zane Date: Sun, 15 Feb 2026 12:23:17 -0500 Subject: [PATCH 1/2] feat: add interactive keyboard controls Add pause (p), speed (+/-), HUD toggle (h), manual refresh (r), and help (?). Thread speed multiplier through the animation pipeline. --- .gitignore | 2 + README.md | 8 +- src/animation/airplanes.rs | 11 +- src/animation/birds.rs | 11 +- src/animation/chimney.rs | 11 +- src/animation/clouds.rs | 4 +- src/animation/fireflies.rs | 12 +- src/animation/fog.rs | 15 +- src/animation/leaves.rs | 19 ++- src/animation/raindrops.rs | 13 +- src/animation/snow.rs | 13 +- src/animation/stars.rs | 16 +- src/animation/thunderstorm.rs | 11 +- src/animation_manager.rs | 47 ++++-- src/app.rs | 287 ++++++++++++++++++++++++++++++---- 15 files changed, 394 insertions(+), 86 deletions(-) diff --git a/.gitignore b/.gitignore index ea8c4bf..1637970 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +.claude/ +plans/ diff --git a/README.md b/README.md index ffa4e0c..0efe194 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,12 @@ weathr --imperial --auto-location - `q` or `Q` - Quit - `Ctrl+C` - Exit +- `p` - Pause/resume animations +- `+` or `=` - Speed up animations (0.25x increments, max 4.0x) +- `-` - Slow down animations (0.25x decrements, min 0.25x) +- `h` - Toggle HUD visibility +- `r` - Refresh weather data +- `?` - Toggle help overlay ### Environment Variables @@ -190,7 +196,7 @@ This is optional. You can disable auto-location and manually specify coordinates - [ ] Support for OpenWeatherMap, WeatherAPI, etc. - [ ] Installation via AUR. -- [ ] Key bindings for manual refresh, speed up animations, pause animations, and toggle HUD. +- [x] Key bindings for manual refresh, speed up animations, pause animations, and toggle HUD. ## License diff --git a/src/animation/airplanes.rs b/src/animation/airplanes.rs index e3d8006..78dab21 100644 --- a/src/animation/airplanes.rs +++ b/src/animation/airplanes.rs @@ -27,12 +27,19 @@ impl AirplaneSystem { } } - pub fn update(&mut self, terminal_width: u16, terminal_height: u16, rng: &mut impl Rng) { + pub fn update( + &mut self, + terminal_width: u16, + terminal_height: u16, + rng: &mut impl Rng, + speed: f32, + ) { self.terminal_width = terminal_width; self.terminal_height = terminal_height; for plane in &mut self.planes { - plane.x += plane.speed; + // Scale position delta by speed multiplier to control animation rate + plane.x += plane.speed * speed; } self.planes.retain(|p| p.x < terminal_width as f32); diff --git a/src/animation/birds.rs b/src/animation/birds.rs index 6194dac..00138c5 100644 --- a/src/animation/birds.rs +++ b/src/animation/birds.rs @@ -27,12 +27,19 @@ impl BirdSystem { } } - pub fn update(&mut self, terminal_width: u16, terminal_height: u16, rng: &mut impl Rng) { + pub fn update( + &mut self, + terminal_width: u16, + terminal_height: u16, + rng: &mut impl Rng, + speed: f32, + ) { self.terminal_width = terminal_width; self.terminal_height = terminal_height; for bird in &mut self.birds { - bird.x += bird.speed; + // Scale position delta by speed multiplier to control animation rate + bird.x += bird.speed * speed; bird.flap_timer += 1; if bird.flap_timer > 5 { bird.flap_state = !bird.flap_state; diff --git a/src/animation/chimney.rs b/src/animation/chimney.rs index 216485d..7319a6e 100644 --- a/src/animation/chimney.rs +++ b/src/animation/chimney.rs @@ -27,10 +27,11 @@ impl SmokeParticle { } } - fn update(&mut self) { + fn update(&mut self, speed: f32) { self.age += 1; - self.y -= 0.2; - self.x += self.drift; + // Scale velocity by speed multiplier to control animation rate + self.y -= 0.2 * speed; + self.x += self.drift * speed; } fn is_alive(&self) -> bool { @@ -64,9 +65,9 @@ impl ChimneySmoke { } } - pub fn update(&mut self, chimney_x: u16, chimney_y: u16, rng: &mut impl Rng) { + pub fn update(&mut self, chimney_x: u16, chimney_y: u16, rng: &mut impl Rng, speed: f32) { for particle in &mut self.particles { - particle.update(); + particle.update(speed); } self.particles.retain(|p| p.is_alive() && p.y >= 0.0); diff --git a/src/animation/clouds.rs b/src/animation/clouds.rs index 4e18586..b9ad8df 100644 --- a/src/animation/clouds.rs +++ b/src/animation/clouds.rs @@ -128,12 +128,14 @@ impl CloudSystem { is_clear: bool, cloud_color: Color, rng: &mut impl Rng, + speed: f32, ) { self.terminal_width = terminal_width; self.terminal_height = terminal_height; for cloud in &mut self.clouds { - cloud.x += cloud.speed; + // Scale scroll speed by speed multiplier to control animation rate + cloud.x += cloud.speed * speed; } self.clouds.retain(|c| c.x < terminal_width as f32); diff --git a/src/animation/fireflies.rs b/src/animation/fireflies.rs index 473f1c5..238e477 100644 --- a/src/animation/fireflies.rs +++ b/src/animation/fireflies.rs @@ -37,9 +37,10 @@ impl Firefly { } } - fn update(&mut self, terminal_width: u16, horizon_y: u16, rng: &mut impl Rng) { - self.x += self.vx; - self.y += self.vy; + fn update(&mut self, terminal_width: u16, horizon_y: u16, rng: &mut impl Rng, speed: f32) { + // Scale velocity by speed multiplier to control animation rate + self.x += self.vx * speed; + self.y += self.vy * speed; if rng.random::() < 0.02 { self.vx = (rng.random::() - 0.5) * 0.3; @@ -63,7 +64,7 @@ impl Firefly { self.vy = -self.vy.abs(); // Bounce up } - self.glow_phase += self.glow_speed; + self.glow_phase += self.glow_speed * speed; if self.glow_phase > std::f32::consts::PI * 2.0 { self.glow_phase -= std::f32::consts::PI * 2.0; } @@ -132,12 +133,13 @@ impl FireflySystem { terminal_height: u16, horizon_y: u16, rng: &mut impl Rng, + speed: f32, ) { self.terminal_width = terminal_width; self.terminal_height = terminal_height; for firefly in &mut self.fireflies { - firefly.update(terminal_width, horizon_y, rng); + firefly.update(terminal_width, horizon_y, rng, speed); } let target_count = std::cmp::max(3, terminal_width / 15) as usize; diff --git a/src/animation/fog.rs b/src/animation/fog.rs index 2138ab6..3a99767 100644 --- a/src/animation/fog.rs +++ b/src/animation/fog.rs @@ -48,8 +48,9 @@ impl FogWisp { } } - fn update(&mut self) { - self.x += self.speed_x; + fn update(&mut self, speed: f32) { + // Scale position delta by speed multiplier to control animation rate + self.x += self.speed_x * speed; self.lifetime += 1; } @@ -89,12 +90,18 @@ impl FogSystem { self.intensity = intensity; } - pub fn update(&mut self, terminal_width: u16, terminal_height: u16, rng: &mut impl Rng) { + pub fn update( + &mut self, + terminal_width: u16, + terminal_height: u16, + rng: &mut impl Rng, + speed: f32, + ) { self.terminal_width = terminal_width; self.terminal_height = terminal_height; for wisp in &mut self.wisps { - wisp.update(); + wisp.update(speed); } self.wisps.retain(|w| w.is_alive(terminal_width)); diff --git a/src/animation/leaves.rs b/src/animation/leaves.rs index c7a8d54..6df8909 100644 --- a/src/animation/leaves.rs +++ b/src/animation/leaves.rs @@ -79,16 +79,17 @@ impl Leaf { } } - fn update(&mut self) { - self.y += self.fall_speed; + fn update(&mut self, speed: f32) { + // Scale position deltas by speed multiplier to control animation rate + self.y += self.fall_speed * speed; - self.sway_phase += self.sway_speed; + self.sway_phase += self.sway_speed * speed; if self.sway_phase > std::f32::consts::PI * 2.0 { self.sway_phase -= std::f32::consts::PI * 2.0; } let sway_offset = self.sway_phase.sin() * self.sway_amplitude; - self.x += sway_offset * 0.1; + self.x += sway_offset * 0.1 * speed; self.rotation = ((self.sway_phase * 2.0).sin() * 4.0) as u8; } @@ -148,12 +149,18 @@ impl FallingLeaves { } } - pub fn update(&mut self, terminal_width: u16, terminal_height: u16, rng: &mut impl Rng) { + pub fn update( + &mut self, + terminal_width: u16, + terminal_height: u16, + rng: &mut impl Rng, + speed: f32, + ) { self.terminal_width = terminal_width; self.terminal_height = terminal_height; for leaf in &mut self.leaves { - leaf.update(); + leaf.update(speed); } self.leaves.retain(|l| !l.is_offscreen(terminal_height)); diff --git a/src/animation/raindrops.rs b/src/animation/raindrops.rs index 50547f0..2d7001c 100644 --- a/src/animation/raindrops.rs +++ b/src/animation/raindrops.rs @@ -143,7 +143,13 @@ impl RaindropSystem { }); } - pub fn update(&mut self, terminal_width: u16, terminal_height: u16, rng: &mut impl Rng) { + pub fn update( + &mut self, + terminal_width: u16, + terminal_height: u16, + rng: &mut impl Rng, + speed: f32, + ) { self.terminal_width = terminal_width; self.terminal_height = terminal_height; @@ -174,8 +180,9 @@ impl RaindropSystem { }; self.drops.retain_mut(|drop| { - drop.y += drop.speed_y; - drop.x += drop.speed_x; + // Scale velocity by speed multiplier to control animation rate + drop.y += drop.speed_y * speed; + drop.x += drop.speed_x * speed; // Hit ground? if drop.y >= (terminal_height - 1) as f32 { diff --git a/src/animation/snow.rs b/src/animation/snow.rs index 276e5a4..1eb9f14 100644 --- a/src/animation/snow.rs +++ b/src/animation/snow.rs @@ -96,7 +96,13 @@ impl SnowSystem { }); } - pub fn update(&mut self, terminal_width: u16, terminal_height: u16, rng: &mut impl Rng) { + pub fn update( + &mut self, + terminal_width: u16, + terminal_height: u16, + rng: &mut impl Rng, + speed: f32, + ) { self.terminal_width = terminal_width; self.terminal_height = terminal_height; @@ -118,11 +124,12 @@ impl SnowSystem { } self.flakes.retain_mut(|flake| { - flake.y += flake.speed_y; + // Scale velocity by speed multiplier to control animation rate + flake.y += flake.speed_y * speed; // Add horizontal sway let sway = (flake.y * 0.2 + flake.sway_offset).sin() * 0.05; - flake.x += flake.speed_x + sway; + flake.x += (flake.speed_x + sway) * speed; // Hit ground or out of bounds if flake.y >= (terminal_height - 1) as f32 { diff --git a/src/animation/stars.rs b/src/animation/stars.rs index 12a02fe..f61a723 100644 --- a/src/animation/stars.rs +++ b/src/animation/stars.rs @@ -71,20 +71,26 @@ impl StarSystem { } } - pub fn update(&mut self, terminal_width: u16, terminal_height: u16, rng: &mut impl Rng) { + pub fn update( + &mut self, + terminal_width: u16, + terminal_height: u16, + rng: &mut impl Rng, + speed: f32, + ) { self.terminal_width = terminal_width; self.terminal_height = terminal_height; - // Twinkle + // Twinkle — scale phase increment by speed multiplier for star in &mut self.stars { - star.phase += 0.05; + star.phase += 0.05 * speed; star.brightness = (star.phase.sin() + 1.0) / 2.0; // 0.0 to 1.0 } // Shooting Star Logic if let Some(ref mut star) = self.shooting_star { - star.x += star.speed_x; - star.y += star.speed_y; + star.x += star.speed_x * speed; + star.y += star.speed_y * speed; if star.x < 0.0 || star.y as u16 >= terminal_height || star.length == 0 { self.shooting_star = None; diff --git a/src/animation/thunderstorm.rs b/src/animation/thunderstorm.rs index 12515e7..3ebee10 100644 --- a/src/animation/thunderstorm.rs +++ b/src/animation/thunderstorm.rs @@ -103,7 +103,16 @@ impl ThunderstormSystem { } } - pub fn update(&mut self, terminal_width: u16, terminal_height: u16, rng: &mut impl Rng) { + // `speed` parameter unused: lightning timing is state-machine driven with + // randomized intervals (next_strike_in, flash duration). Scaling these would + // make lightning feel artificial. Parameter exists for API consistency. + pub fn update( + &mut self, + terminal_width: u16, + terminal_height: u16, + rng: &mut impl Rng, + _speed: f32, + ) { self.terminal_width = terminal_width; self.terminal_height = terminal_height; diff --git a/src/animation_manager.rs b/src/animation_manager.rs index 142c0d3..39f0fc2 100644 --- a/src/animation_manager.rs +++ b/src/animation_manager.rs @@ -73,6 +73,7 @@ impl AnimationManager { self.fog_system.set_intensity(intensity); } + #[allow(clippy::too_many_arguments)] pub fn render_background( &mut self, renderer: &mut TerminalRenderer, @@ -81,20 +82,22 @@ impl AnimationManager { term_width: u16, term_height: u16, mut rng: &mut impl rand::Rng, + speed: f32, ) -> io::Result<()> { // Calculate horizon_y early so it's available for all systems let ground_height = WorldScene::GROUND_HEIGHT; let horizon_y = term_height.saturating_sub(ground_height); if !conditions.is_day { - self.star_system.update(term_width, term_height, &mut rng); + self.star_system + .update(term_width, term_height, &mut rng, speed); self.star_system.render(renderer)?; self.moon_system.update(term_width, term_height); self.moon_system.render(renderer)?; if state.should_show_fireflies() { self.firefly_system - .update(term_width, term_height, horizon_y, &mut rng); + .update(term_width, term_height, horizon_y, &mut rng, speed); self.firefly_system.render(renderer)?; } } @@ -104,7 +107,8 @@ impl AnimationManager { && !conditions.is_snowing && conditions.is_day { - self.bird_system.update(term_width, term_height, &mut rng); + self.bird_system + .update(term_width, term_height, &mut rng, speed); self.bird_system.render(renderer)?; } @@ -133,8 +137,14 @@ impl AnimationManager { if conditions.is_cloudy || is_clear { self.cloud_system.set_cloud_color(is_clear); - self.cloud_system - .update(term_width, term_height, is_clear, cloud_color, &mut rng); + self.cloud_system.update( + term_width, + term_height, + is_clear, + cloud_color, + &mut rng, + speed, + ); self.cloud_system.render(renderer)?; } } @@ -145,7 +155,7 @@ impl AnimationManager { && !conditions.is_foggy { self.airplane_system - .update(term_width, term_height, &mut rng); + .update(term_width, term_height, &mut rng, speed); self.airplane_system.render(renderer)?; } @@ -159,6 +169,7 @@ impl AnimationManager { term_width: u16, term_height: u16, mut rng: &mut impl rand::Rng, + speed: f32, ) -> io::Result<()> { if conditions.is_raining || conditions.is_thunderstorm { return Ok(()); @@ -172,7 +183,8 @@ impl AnimationManager { let chimney_x = house_x + House::CHIMNEY_X_OFFSET; let chimney_y = house_y; - self.chimney_smoke.update(chimney_x, chimney_y, &mut rng); + self.chimney_smoke + .update(chimney_x, chimney_y, &mut rng, speed); self.chimney_smoke.render(renderer)?; Ok(()) @@ -185,14 +197,15 @@ impl AnimationManager { term_width: u16, term_height: u16, mut rng: &mut impl rand::Rng, + speed: f32, ) -> io::Result<()> { if conditions.is_thunderstorm { self.raindrop_system - .update(term_width, term_height, &mut rng); + .update(term_width, term_height, &mut rng, speed); self.raindrop_system.render(renderer)?; self.thunderstorm_system - .update(term_width, term_height, &mut rng); + .update(term_width, term_height, &mut rng, speed); self.thunderstorm_system.render(renderer)?; if self.thunderstorm_system.is_flashing() { @@ -200,15 +213,17 @@ impl AnimationManager { } } else if conditions.is_raining { self.raindrop_system - .update(term_width, term_height, &mut rng); + .update(term_width, term_height, &mut rng, speed); self.raindrop_system.render(renderer)?; } else if conditions.is_snowing { - self.snow_system.update(term_width, term_height, &mut rng); + self.snow_system + .update(term_width, term_height, &mut rng, speed); self.snow_system.render(renderer)?; } if conditions.is_foggy { - self.fog_system.update(term_width, term_height, &mut rng); + self.fog_system + .update(term_width, term_height, &mut rng, speed); self.fog_system.render(renderer)?; } @@ -218,18 +233,20 @@ impl AnimationManager { && !conditions.is_snowing { self.falling_leaves - .update(term_width, term_height, &mut rng); + .update(term_width, term_height, &mut rng, speed); self.falling_leaves.render(renderer)?; } Ok(()) } - pub fn update_sunny_animation(&mut self, conditions: &WeatherConditions) { + pub fn update_sunny_animation(&mut self, conditions: &WeatherConditions, speed: f32) { + // Scale frame delay inversely with speed: higher speed = shorter delay + let scaled_delay = FRAME_DELAY.div_f32(speed.max(0.25)); if !conditions.is_raining && !conditions.is_thunderstorm && !conditions.is_snowing - && self.last_frame_time.elapsed() >= FRAME_DELAY + && self.last_frame_time.elapsed() >= scaled_delay { self.animation_controller.next_frame(&self.sunny_animation); self.last_frame_time = Instant::now(); diff --git a/src/app.rs b/src/app.rs index 3f256a6..61555aa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ use crate::error::WeatherError; use crate::render::TerminalRenderer; use crate::scene::WorldScene; use crate::weather::{ - OpenMeteoProvider, WeatherClient, WeatherCondition, WeatherData, WeatherLocation, + OpenMeteoProvider, WeatherClient, WeatherCondition, WeatherData, WeatherLocation, WeatherUnits, }; use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use std::io; @@ -61,6 +61,14 @@ pub struct App { scene: WorldScene, weather_receiver: mpsc::Receiver>, hide_hud: bool, + paused: bool, + speed_multiplier: f32, + show_help: bool, + weather_task: Option>, + weather_location: WeatherLocation, + weather_units: WeatherUnits, + weather_provider: Option>, + refreshing: bool, } impl App { @@ -84,6 +92,9 @@ impl App { let (tx, rx) = mpsc::channel(1); + let mut weather_task: Option> = None; + let mut weather_provider: Option> = None; + if let Some(ref condition_str) = simulate_condition { let simulated_condition = condition_str @@ -129,10 +140,10 @@ impl App { animations.update_wind(wind_speed as f32, wind_direction as f32); } else { let provider = Arc::new(OpenMeteoProvider::new()); - let weather_client = WeatherClient::new(provider, REFRESH_INTERVAL); + let weather_client = WeatherClient::new(provider.clone(), REFRESH_INTERVAL); let units = config.units; - tokio::spawn(async move { + let task = tokio::spawn(async move { loop { let result = weather_client.get_current_weather(&location, &units).await; if tx.send(result).await.is_err() { @@ -141,6 +152,8 @@ impl App { tokio::time::sleep(REFRESH_INTERVAL).await; } }); + weather_task = Some(task); + weather_provider = Some(provider); } Self { @@ -149,6 +162,18 @@ impl App { scene, weather_receiver: rx, hide_hud: config.hide_hud, + paused: false, + speed_multiplier: 1.0, + show_help: false, + weather_task, + weather_location: WeatherLocation { + latitude: config.location.latitude, + longitude: config.location.longitude, + elevation: None, + }, + weather_units: config.units, + weather_provider, + refreshing: false, } } @@ -156,6 +181,7 @@ impl App { let mut rng = rand::rng(); loop { if let Ok(result) = self.weather_receiver.try_recv() { + self.refreshing = false; match result { Ok(weather) => { let rain_intensity = weather.condition.rain_intensity(); @@ -203,43 +229,68 @@ impl App { let (term_width, term_height) = renderer.get_size(); - self.animations.render_background( - renderer, - &self.state.weather_conditions, - &self.state, - term_width, - term_height, - &mut rng, - )?; + if !self.paused { + self.animations.render_background( + renderer, + &self.state.weather_conditions, + &self.state, + term_width, + term_height, + &mut rng, + self.speed_multiplier, + )?; - self.scene - .render(renderer, &self.state.weather_conditions)?; + self.scene + .render(renderer, &self.state.weather_conditions)?; - self.animations.render_chimney_smoke( - renderer, - &self.state.weather_conditions, - term_width, - term_height, - &mut rng, - )?; + self.animations.render_chimney_smoke( + renderer, + &self.state.weather_conditions, + term_width, + term_height, + &mut rng, + self.speed_multiplier, + )?; - self.animations.render_foreground( - renderer, - &self.state.weather_conditions, - term_width, - term_height, - &mut rng, - )?; + self.animations.render_foreground( + renderer, + &self.state.weather_conditions, + term_width, + term_height, + &mut rng, + self.speed_multiplier, + )?; + } self.state.update_loading_animation(); self.state.update_cached_info(); if !self.hide_hud { + let hud_text = if self.refreshing { + format!("[Refreshing...] {}", &self.state.cached_weather_info) + } else { + self.state.cached_weather_info.clone() + }; + renderer.render_line_colored(2, 1, &hud_text, crossterm::style::Color::Cyan)?; + } + + // Help at term_height-2 avoids collision with attribution at term_height-1 + // when MIN_WIDTH=70. Guard term_height >= 3 prevents underflow. + if self.show_help && term_height >= 3 { + let help_text = "q:Quit p:Pause r:Refresh h:HUD +/-:Speed ?:Help"; + let help_y = term_height - 2; + let display_text = if (term_width as usize) < help_text.len() { + // Truncated with ellipsis when terminal too narrow + let truncated = &help_text[..((term_width as usize).saturating_sub(3))]; + format!("{}...", truncated) + } else { + help_text.to_string() + }; renderer.render_line_colored( - 2, - 1, - &self.state.cached_weather_info, - crossterm::style::Color::Cyan, + 0, + help_y, + &display_text, + crossterm::style::Color::DarkGrey, )?; } @@ -271,6 +322,25 @@ impl App { { break; } + KeyCode::Char('p') => { + self.paused = !self.paused; + } + KeyCode::Char('+') | KeyCode::Char('=') => { + // 0.25 floor prevents invisible animations, 4.0 ceiling prevents unusable speed + self.speed_multiplier = (self.speed_multiplier + 0.25).clamp(0.25, 4.0); + } + KeyCode::Char('-') => { + self.speed_multiplier = (self.speed_multiplier - 0.25).clamp(0.25, 4.0); + } + KeyCode::Char('h') => { + self.hide_hud = !self.hide_hud; + } + KeyCode::Char('?') => { + self.show_help = !self.show_help; + } + KeyCode::Char('r') => { + self.refresh_weather(); + } _ => {} }, _ => {} @@ -280,10 +350,161 @@ impl App { let (term_width, term_height) = renderer.get_size(); self.scene.update_size(term_width, term_height); - self.animations - .update_sunny_animation(&self.state.weather_conditions); + if !self.paused { + self.animations + .update_sunny_animation(&self.state.weather_conditions, self.speed_multiplier); + } } Ok(()) } + + /// Abort the current weather fetch task and spawn a fresh one. + /// Spawns the same looping task as App::new() so automatic + /// 5-minute periodic refresh continues after manual refresh. + /// No-op when running in simulation mode (no provider). + fn refresh_weather(&mut self) { + // Simulation mode has no provider — refresh is a no-op + let provider = match &self.weather_provider { + Some(p) => p.clone(), + None => return, + }; + + // Aborts existing task to prevent duplicate fetchers running. + // tokio::JoinHandle::abort() is documented as safe; spawned task drops cleanly. + if let Some(task) = self.weather_task.take() { + task.abort(); + } + + self.refreshing = true; + + // Creates new channel. Previous sender becomes disconnected, causing aborted task to exit. + let (tx, rx) = mpsc::channel(1); + self.weather_receiver = rx; + + let location = self.weather_location; + let units = self.weather_units; + let weather_client = WeatherClient::new(provider, REFRESH_INTERVAL); + + // Fetches immediately, sends result, then sleeps(REFRESH_INTERVAL) to maintain automatic 5-minute periodic refresh cycle + self.weather_task = Some(tokio::spawn(async move { + loop { + let result = weather_client.get_current_weather(&location, &units).await; + if tx.send(result).await.is_err() { + break; + } + tokio::time::sleep(REFRESH_INTERVAL).await; + } + })); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn test_pause_toggles_correctly() { + let mut paused = false; + paused = !paused; + assert!(paused, "First toggle should pause"); + paused = !paused; + assert!(!paused, "Second toggle should unpause"); + } + + #[test] + fn test_pause_defaults_to_false() { + let paused: bool = false; + assert!(!paused); + } + + #[test] + fn test_speed_multiplier_defaults_to_1_0() { + let speed: f32 = 1.0; + assert!((speed - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn test_speed_increment_by_0_25() { + let mut speed: f32 = 1.0; + speed = (speed + 0.25).clamp(0.25, 4.0); + speed = (speed + 0.25).clamp(0.25, 4.0); + speed = (speed + 0.25).clamp(0.25, 4.0); + assert!( + (speed - 1.75).abs() < f32::EPSILON, + "Three increments from 1.0 should yield 1.75" + ); + } + + #[test] + fn test_speed_decrement_by_0_25() { + let mut speed: f32 = 1.0; + speed = (speed - 0.25).clamp(0.25, 4.0); + assert!( + (speed - 0.75).abs() < f32::EPSILON, + "One decrement from 1.0 should yield 0.75" + ); + } + + #[test] + fn test_speed_no_underflow_at_minimum() { + let mut speed: f32 = 0.25; + speed = (speed - 0.25).clamp(0.25, 4.0); + assert!( + (speed - 0.25).abs() < f32::EPSILON, + "Speed should not go below 0.25" + ); + } + + #[test] + fn test_speed_no_overflow_at_maximum() { + let mut speed: f32 = 4.0; + speed = (speed + 0.25).clamp(0.25, 4.0); + assert!( + (speed - 4.0).abs() < f32::EPSILON, + "Speed should not exceed 4.0" + ); + } + + #[test] + fn test_hud_toggle() { + let mut hide_hud = false; + hide_hud = !hide_hud; + assert!(hide_hud, "First toggle should hide HUD"); + hide_hud = !hide_hud; + assert!(!hide_hud, "Second toggle should show HUD"); + } + + #[test] + fn test_help_text_toggle() { + let mut show_help = false; + show_help = !show_help; + assert!(show_help, "First toggle should show help"); + show_help = !show_help; + assert!(!show_help, "Second toggle should hide help"); + } + + #[test] + fn test_help_text_fits_min_width() { + let help_text = "q:Quit p:Pause r:Refresh h:HUD +/-:Speed ?:Help"; + assert_eq!( + help_text.len(), + 47, + "Help text should be exactly 47 characters" + ); + assert!( + help_text.len() < 70, + "Help text must fit within MIN_WIDTH=70" + ); + } + + #[test] + fn test_rapid_toggles_return_to_original() { + let mut paused = false; + for _ in 0..100 { + paused = !paused; + } + assert!( + !paused, + "100 toggles (even) should return to original state" + ); + } } From 2c28fe942613a8286e2583ae7ebaed665e9636fc Mon Sep 17 00:00:00 2001 From: Bryan Zane Date: Sun, 15 Feb 2026 12:38:01 -0500 Subject: [PATCH 2/2] fix: preserve frame on pause and add transient speed indicator --- CLAUDE.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/README.md | 44 +++++++++++++++++++++++++++ src/app.rs | 23 ++++++++++++-- 3 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/README.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..00f6aea --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +weathr is a Rust terminal application that displays animated ASCII weather scenes driven by real-time data from Open-Meteo. It renders at 30 FPS using crossterm with double-buffered rendering, particle systems for precipitation, and layered compositing (background → scene → foreground → HUD). + +## Build & Development Commands + +```bash +cargo build --release # Production build (thin LTO, stripped) +cargo test --verbose # Run all tests (unit + integration) +cargo check # Type-check without building +cargo fmt -- --check # Verify formatting +cargo clippy -- -D warnings # Lint (CI treats warnings as errors) +``` + +Run the app: +```bash +cargo run # Real weather for configured location +cargo run -- --simulate rain # Simulate a weather condition +cargo run -- --simulate snow --night # Simulate with forced night mode +cargo run -- --auto-location # Auto-detect location via IP +``` + +Minimum Rust version: 1.85.0 (edition 2024). + +## Architecture + +### Data Flow + +``` +CLI (clap) → Config (TOML) → Geolocation (ipinfo.io) → App::run() + │ +OpenMeteo API → WeatherClient (with cache) → AppState → AnimationManager + │ + TerminalRenderer (double-buffered) +``` + +### Rendering Pipeline (layered, back-to-front) + +1. **Background**: sky gradient, stars, clouds, sun/moon +2. **Scene**: ASCII house (`scene/house.rs`), ground, decorations +3. **Chimney smoke** (between scene and foreground) +4. **Foreground**: rain, snow, fog, leaves, birds, airplanes, fireflies +5. **HUD**: status bar with weather data, location, units + +### Key Modules + +- `app.rs` — Main event loop: polls input at 30 FPS, fetches weather every 5 min, drives rendering. Keyboard controls (p/+/-/h/r/?) manage pause, speed [0.25-4.0x], HUD, refresh, and help +- `animation_manager.rs` — Orchestrates all animations, decides which to activate based on weather. Speed multiplier parameter threads through render methods to scale animation rates +- `animation/` — Each file is a self-contained animation. All implement the `Animation` trait. Particle systems (rain, snow, fireflies) use physics with wind influence +- `render/mod.rs` — `TerminalRenderer` with cell-level double buffering (only redraws changed cells). Min terminal size: 70x20 +- `render/capabilities.rs` — Detects truecolor/256-color/NO_COLOR support +- `weather/client.rs` — `WeatherClient` with async fetch and disk caching +- `weather/provider.rs` — `WeatherProvider` trait; `open_meteo.rs` is the implementation +- `weather/normalizer.rs` — Converts raw API response to `WeatherData` +- `config.rs` — Loads TOML from platform-specific paths (Linux: `~/.config/weathr/`, macOS: `~/Library/Application Support/weathr/`) +- `error.rs` — Comprehensive error types with `user_friendly_message()` methods +- `geolocation.rs` — IP-based location detection with retry logic +- `cache.rs` — Disk cache for location (24h TTL) and weather data (5min TTL) + +### Animation System + +All animations implement the `Animation` trait (`animation/mod.rs`). `AnimationController` manages frame cycling. Particle-based animations (raindrops, snow, fireflies) maintain their own state vectors and update per-frame with wind, gravity, and randomness. + +### Config Precedence + +CLI flags override config file values. Config file is optional — defaults to auto-location if missing. + +## CI + +GitHub Actions runs on push to main and PRs: `cargo check` → `cargo test` → `cargo fmt --check` → `cargo clippy -- -D warnings` → `cargo audit`. + +## Testing + +Integration tests live in `tests/`. Unit tests are inline in modules (e.g., `config.rs`, `app_state.rs`). Run a single test: + +```bash +cargo test test_name +cargo test --test config_integration_test # Run one integration test file +``` diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..8647e85 --- /dev/null +++ b/src/README.md @@ -0,0 +1,44 @@ +# Interactive Controls Implementation + +## Overview + +weathr's keyboard controls (pause, speed adjustment, HUD toggle, manual refresh) are implemented directly in `App::run()` using inline state fields rather than extracted control modules or config-driven key bindings. This design prioritizes simplicity for a small set of hardcoded keys over extensibility for remapping. + +## Architecture + +Key press events from crossterm flow into `App::run()` match arms. Control state lives on the `App` struct as simple fields (`paused: bool`, `speed_multiplier: f32`, `hide_hud: bool`, `show_help: bool`) because these are UI concerns, not weather data. `AppState` holds weather data and formatting logic. This boundary mirrors the existing `hide_hud` placement and prevents UI state from bleeding into the data layer. + +Speed multiplier flows: `App.speed_multiplier` → `AnimationManager.render_*()` → each animation system's `update()` call. Animations apply speed in one of four patterns: + +1. **Velocity-based** (rain, snow, fireflies, chimney): multiply `speed_y` and `speed_x` deltas by speed multiplier. These use physics vectors — scaling velocity changes fall/drift rate. +2. **Positional** (leaves, birds, airplanes, fog): multiply position increment (x/y change per frame) by speed multiplier. These move entities by fixed increments. +3. **Scroll/twinkle** (stars, clouds): multiply scroll offset or twinkle rate by speed multiplier. +4. **Frame-based** (sunny): adjust frame delay threshold inversely — `FRAME_DELAY / speed_multiplier`. Higher speed = shorter delay = faster frame cycling. + +Manual refresh (`r` key) aborts the existing weather background task and spawns a new one. This is heavier than signaling (creates new task + channel) but eliminates thread-safety concerns and reuses the existing spawn logic verbatim. The spawned task is identical to `App::new()`'s task: fetch weather, send via channel, sleep 5 minutes, repeat. + +## Design Decisions + +**Inline state over extracted Controls module**: Five key bindings fit comfortably in `App::run()` match arms. An extracted module would add file + enum + trait for trivial toggle/clamp logic. This is acceptable over-engineering for current scope. If key bindings grow significantly (10+ keys, nested modes, config remapping), extraction becomes justified. + +**Hardcoded keys over config-driven bindings**: Config-driven keys require a string-to-KeyCode parser, validation, schema changes, and documentation. Hardcoded keys (`p`, `+`, `-`, `h`, `r`, `?`) cover all stated requirements with zero parser complexity. Trade-off: users cannot remap keys without code changes. Acceptable because remapping demand is unproven and config layer can be added later without architectural disruption. + +**Separate `paused: bool` over `speed_multiplier = 0.0`**: Pause has different semantics than minimum speed. Users expect pause to freeze all motion instantly. Using `speed_multiplier = 0.0` would require special-casing resume to restore the previous speed value (requires storing pre-pause speed). Separate boolean is clearer: pause is a distinct mode, not a speed setting. + +**Abort/respawn for manual refresh over command channel**: The existing weather task uses a one-way `mpsc::channel` (task → app). Adding a command channel would require refactoring the sleep loop to poll a flag periodically, introducing up to 300s delay (the REFRESH_INTERVAL sleep duration). Abort/respawn is instant: `JoinHandle::abort()` marks the task for cancellation (documented as safe by tokio), and spawning a new task reuses the same loop structure. Trade-off: slightly heavier (creates new task) but zero thread-safety risk and no sleep-loop modification. + +**Speed multiplier as parameter over delta-time refactor**: The animation system uses implicit per-frame deltas (no delta-time parameter). Proper delta-time would require changing every animation's `update()` method signature and multiplying all physics by `dt`. Instead, speed multiplier scales velocity/position deltas at call sites (`drop.y += drop.speed_y * speed`). This achieves identical results for the fixed 30 FPS loop without invasive refactoring. Trade-off: multiplier is a "scaling hack" rather than proper time-based animation, but the fixed framerate makes delta-time unnecessary. + +**Help text at `term_height - 2`**: The HUD renders weather info at `(2, 1)`. Attribution text renders at `(term_width - 32, term_height - 1)` (bottom-right corner). Placing help at `term_height - 2` (one row above attribution) avoids overlap at the minimum supported terminal width (70 columns). Help text renders in DarkGrey to match the attribution text styling and remain unobtrusive. If `term_width < 47` (help text length), the help is truncated with ellipsis. Guard `term_height >= 3` prevents underflow. + +**Pause suppresses rendering only, not internal state**: Animation internal state (elapsed counters, particle ages) continues accumulating while paused. Only the render calls are guarded by `if !self.paused {}`. This is simpler than freezing all internal timers. Trade-off: when resumed, animations show their current timeline position (e.g., leaf swaying from mid-cycle) rather than resuming from the exact frozen frame. This feels more natural — pause is "stop rendering" not "stop time." + +**Refresh indicator cleared on both success and error**: When weather fetch fails, the existing error handling (`app.rs` lines 174-199) generates offline fallback weather and sends it via the same `mpsc::channel` as successful fetches. Both paths produce an `Ok(weather)` message, so `try_recv()` clears the `[Refreshing...]` indicator in both cases. No timeout needed because the error path always produces a result. + +## Invariants + +- `speed_multiplier` is always in [0.25, 4.0]. Enforced at mutation site via `f32::clamp(0.25, 4.0)`. Values outside this range are clamped before assignment. 0.25 floor prevents invisible animations; 4.0 ceiling prevents unusable speed. +- `paused` and `speed_multiplier` are independent. Pause state does not modify or constrain speed. Speed can be adjusted while paused; the new speed takes effect when unpaused. +- Weather refresh abort/respawn preserves `WeatherLocation`, `WeatherUnits`, and `Arc`. These are stored on `App` and captured in the respawn closure to ensure refresh fetches the same data as the original task. +- Help text rendering must not exceed `term_width`. If terminal is narrower than 47 characters (help text length), the text is truncated with `...` suffix. Guard prevents buffer overruns. +- Manual refresh is a no-op in simulation mode. `weather_provider` is `None` when `--simulate` flag is active. `refresh_weather()` early-returns if provider is absent. diff --git a/src/app.rs b/src/app.rs index 61555aa..ad30e0a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,7 @@ use crate::weather::{ use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use std::io; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio::sync::mpsc; const REFRESH_INTERVAL: Duration = Duration::from_secs(300); @@ -69,6 +69,7 @@ pub struct App { weather_units: WeatherUnits, weather_provider: Option>, refreshing: bool, + speed_changed_at: Option, } impl App { @@ -174,6 +175,7 @@ impl App { weather_units: config.units, weather_provider, refreshing: false, + speed_changed_at: None, } } @@ -225,11 +227,10 @@ impl App { } } - renderer.clear()?; - let (term_width, term_height) = renderer.get_size(); if !self.paused { + renderer.clear()?; self.animations.render_background( renderer, &self.state.weather_conditions, @@ -294,6 +295,20 @@ impl App { )?; } + if let Some(changed_at) = self.speed_changed_at { + if changed_at.elapsed() < Duration::from_secs(2) { + let speed_text = format!("Speed: {}x", self.speed_multiplier); + renderer.render_line_colored( + 2, + 2, + &speed_text, + crossterm::style::Color::Yellow, + )?; + } else { + self.speed_changed_at = None; + } + } + let attribution = "Weather data by Open-Meteo.com"; let attribution_x = if term_width > attribution.len() as u16 { term_width - attribution.len() as u16 - 2 @@ -328,9 +343,11 @@ impl App { KeyCode::Char('+') | KeyCode::Char('=') => { // 0.25 floor prevents invisible animations, 4.0 ceiling prevents unusable speed self.speed_multiplier = (self.speed_multiplier + 0.25).clamp(0.25, 4.0); + self.speed_changed_at = Some(Instant::now()); } KeyCode::Char('-') => { self.speed_multiplier = (self.speed_multiplier - 0.25).clamp(0.25, 4.0); + self.speed_changed_at = Some(Instant::now()); } KeyCode::Char('h') => { self.hide_hud = !self.hide_hud;