Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/target
.claude/
plans/
83 changes: 83 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
```
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
44 changes: 44 additions & 0 deletions src/README.md
Original file line number Diff line number Diff line change
@@ -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<OpenMeteoProvider>`. 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.
11 changes: 9 additions & 2 deletions src/animation/airplanes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 9 additions & 2 deletions src/animation/birds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 6 additions & 5 deletions src/animation/chimney.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/animation/clouds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 7 additions & 5 deletions src/animation/fireflies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<f32>() < 0.02 {
self.vx = (rng.random::<f32>() - 0.5) * 0.3;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 11 additions & 4 deletions src/animation/fog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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));
Expand Down
19 changes: 13 additions & 6 deletions src/animation/leaves.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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));
Expand Down
13 changes: 10 additions & 3 deletions src/animation/raindrops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down
Loading