feat(ui): snow winter scene, rain panel size, and snow fixes#13
feat(ui): snow winter scene, rain panel size, and snow fixes#13bschellenberger2600 wants to merge 7 commits into
Conversation
… canvas Add snow animation with falling flakes, unbounded ground accumulation, log cabin with chimney smoke and lit windows, random trees with frost, and a staged snowman. Introduce rain_panel_size (compact/comfortable/tall) with terminal clamping via panel height measurement. Default comfortable (8 rows). Settings TUI and README updated.
- Break rainVisible vs measured header cycle (rainStripIncludedInLayoutMeasure + header gate)\n- Add snow_accumulation_rate (1–8× per landing) and settings TUI row\n- Trees spawn at init; snowman elevated above ground bank; bolder styling and split parens on base
📝 WalkthroughWalkthroughAdds a "snow" rain animation mode, configurable rain panel sizing (compact/comfortable/tall), and snow accumulation controls; implements a full snow scene (trees, cabin, snowman, smoke), adjusts rain-panel height clamping/layout measurement, and expands config defaults/tests to cover new settings. Changes
Sequence DiagramsequenceDiagram
participant Config as Configuration
participant Loader as Config Loader
participant RainBg as RainBackground
participant SnowScene as SnowScene State
participant Renderer as Frame Renderer
Config->>Loader: Load config (rain mode, panel size, accumulation)
Loader->>Config: Normalize presets (NormalizeRainPanelSize)
Loader->>RainBg: Initialize NewRainBackground(mode, clampedHeight)
RainBg->>SnowScene: initSnowScene() allocate ground, trees, cabin, snowman
loop Each Frame
RainBg->>RainBg: spawnDrop() (snowflake glyph)
RainBg->>RainBg: update drop position
alt drop lands on ground
RainBg->>SnowScene: SnowGround[x] += accumPerLanding
RainBg->>SnowScene: snowNoteFlakeLand(x)
end
RainBg->>SnowScene: snowAdvanceScene() (frost, trees, snowman, smoke)
RainBg->>Renderer: paintSnowScene(cells)
Renderer-->>RainBg: rendered frame
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Fixes golangci-lint staticcheck SA1019 in CI (Go 1.20+).
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
internal/config/config_test.go (1)
30-37: Consider making this test table-driven.
NormalizeRainPanelSizetrims whitespace, lowercases, and falls back tocomfortablefor unknown/empty input — none of which this test exercises. As per coding guidelines ("Prefer table-driven tests for multi-case functions"), extending to a table-driven shape would also improve error messages.♻️ Suggested refactor
func TestNormalizeRainPanelSize(t *testing.T) { - if got := config.NormalizeRainPanelSize(" TALL "); got != config.UIRainPanelTall { - t.Errorf("NormalizeRainPanelSize = %q, want %q", got, config.UIRainPanelTall) - } - if got := config.NormalizeRainPanelSize("compact"); got != config.UIRainPanelCompact { - t.Errorf("got %q", got) - } + tests := []struct { + in string + want string + }{ + {" TALL ", config.UIRainPanelTall}, + {"compact", config.UIRainPanelCompact}, + {"Comfortable", config.UIRainPanelComfortable}, + {"", config.UIRainPanelComfortable}, + {" ", config.UIRainPanelComfortable}, + {"bogus", config.UIRainPanelComfortable}, + } + for _, tc := range tests { + if got := config.NormalizeRainPanelSize(tc.in); got != tc.want { + t.Errorf("NormalizeRainPanelSize(%q) = %q, want %q", tc.in, got, tc.want) + } + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/config/config_test.go` around lines 30 - 37, Convert TestNormalizeRainPanelSize into a table-driven test that iterates over cases for inputs and expected outputs; include cases exercising trimming (" TALL "), different casing ("compact"), lowercase, empty string, unknown value to assert fallback to config.UIRainPanelComfortable, and any other variants; update test to loop over a slice of structs with fields name/input/expected and use t.Run(name, func(t *testing.T){ if got := config.NormalizeRainPanelSize(input); got != expected { t.Fatalf("NormalizeRainPanelSize(%q)= %q, want %q", input, got, expected) } }); keep references to NormalizeRainPanelSize, UIRainPanelTall, UIRainPanelCompact, and UIRainPanelComfortable to locate the code.internal/ui/repo_selector.go (2)
290-294: Stream init skips snow tuning.Unlike the static ctor (line 213) which calls
base.applyGardenTuning(base.rainBg), the stream path only wires garden tuning viaSetGardenTuningand never appliesSetSnowAccumPerLanding. IfRainAnimationMode == "snow",snowAccumPerLandingremains 0 until the firsttea.WindowSizeMsg(line 348) runsapplyGardenTuning. In practice the first WindowSizeMsg arrives early so the runtime guard atrain_bg.go:788(if add < 1 { add = 1 }) masks this, but the two initializers drifting apart will bite the next time snow-only tuning is added.♻️ Proposed alignment
rainH := base.clampedRainBackgroundHeight() rainBg := NewRainBackground(bgW, rainH, animMode) - rainBg.SetGardenTuning(gardenTuningFromConfig(cfg, rainTickMS, bgW)) base.rainBg = rainBg + base.applyGardenTuning(base.rainBg) return base🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/ui/repo_selector.go` around lines 290 - 294, The stream initialization creates rainBg with NewRainBackground and calls SetGardenTuning but never applies snow-specific tuning, leaving snowAccumPerLanding at 0 until tea.WindowSizeMsg triggers applyGardenTuning; fix this by invoking the same application path used in the static ctor—after base.rainBg = rainBg call, call base.applyGardenTuning(base.rainBg) (or explicitly call rainBg.SetSnowAccumPerLanding(...) using the gardenTuningFromConfig result when cfg.RainAnimationMode == "snow") so snow tuning is applied immediately (see symbols: NewRainBackground, SetGardenTuning, applyGardenTuning, SetSnowAccumPerLanding, rain_bg.go:788, tea.WindowSizeMsg).
747-773: Clamp loop allocates a fresh RainBackground per probe.
clampedRainBackgroundHeightlinearly steps frompresetdown to 3, and eachrainPanelFitsHeightcall allocates a newRainBackground(plusFlowers/CloudRow/garden state) and runs two binary-searchedlipgloss.Heightmeasurements of the rendered panel. This runs on everytea.WindowSizeMsg, andUpdate'sWindowSizeMsghandler itself reconstructsm.rainBgright after. Not a correctness issue, but worth reusing a single probeRainBackground(e.g. mutate itsHeight/re-Reset) or short-circuiting once the first fitting height is found via binary search rather than linear scan when responsiveness matters on large terminals.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@internal/ui/config_view.go`:
- Around line 199-217: The switch in snowAccumLabel incorrectly maps unspecified
values (5 and 7) to "1×"; instead compute n :=
config.SnowAccumPerLanding(cfg.UI.SnowAccumulationRate) and return a dynamic
label using that value (e.g. format "%d×") so any integer 1–8 is shown
correctly; update snowAccumLabel to handle nil cfg as before, then use
fmt.Sprintf with the computed n to produce the label.
In `@internal/ui/rain_bg_test.go`:
- Around line 298-351: Tests call the deprecated global rand.Seed which triggers
staticcheck SA1019; make RainBackground (and its snow scene) accept an injected
*rand.Rand (add RainBackground.SetRand(r *rand.Rand) and use that RNG in
spawnDrop/spawnDropGarden/snowNoteFlakeLand/snowAdvanceScene instead of
package-level rand) so tests can do rb.SetRand(rand.New(rand.NewSource(42))) for
deterministic behavior; if that refactor is out of scope for this PR, suppress
the linter in the test by replacing the rand.Seed calls with a
`//nolint:staticcheck` comment on the same line to unblock CI.
In `@internal/ui/snow_scene.go`:
- Around line 61-68: The current Snowman placement logic sets rb.SnowmanX to
rb.Width-5 (or falls back left of the cabin) which lets the snowman hat/scarf
overlap the cabin for mid-range widths; change the computation of rb.SnowmanX to
ensure a 3-column buffer to the right of the cabin by taking rb.SnowmanX =
max(rb.SnowCabinLeft + snowCabinW + 3, rb.Width - 5) and then clamp that value
into the valid horizontal bounds (at least 2 and at most rb.Width-3) before
using it; update the branch that sets rb.SnowmanX (and any fallback logic) to
use these max/clamp calculations and reference rb.SnowmanX, rb.SnowCabinLeft,
snowCabinW and rb.Width when making the change.
- Around line 289-297: snowPaintLine misuses the range loop index (byte offset)
causing multi-byte runes to be placed at wrong columns; fix by iterating runes
and maintaining a separate column counter (e.g., col) instead of using the range
index i, so compute x := left + col and call rb.snowPaintCell(cells, x, y,
string(r), st) while incrementing col for each rune; update the loop in
snowPaintLine to use for _, r := range line with a column counter (or iterate
over []rune(line)) so multi-byte characters like '█' and '≋' map to consecutive
terminal cells.
---
Nitpick comments:
In `@internal/config/config_test.go`:
- Around line 30-37: Convert TestNormalizeRainPanelSize into a table-driven test
that iterates over cases for inputs and expected outputs; include cases
exercising trimming (" TALL "), different casing ("compact"), lowercase, empty
string, unknown value to assert fallback to config.UIRainPanelComfortable, and
any other variants; update test to loop over a slice of structs with fields
name/input/expected and use t.Run(name, func(t *testing.T){ if got :=
config.NormalizeRainPanelSize(input); got != expected {
t.Fatalf("NormalizeRainPanelSize(%q)= %q, want %q", input, got, expected) } });
keep references to NormalizeRainPanelSize, UIRainPanelTall, UIRainPanelCompact,
and UIRainPanelComfortable to locate the code.
In `@internal/ui/repo_selector.go`:
- Around line 290-294: The stream initialization creates rainBg with
NewRainBackground and calls SetGardenTuning but never applies snow-specific
tuning, leaving snowAccumPerLanding at 0 until tea.WindowSizeMsg triggers
applyGardenTuning; fix this by invoking the same application path used in the
static ctor—after base.rainBg = rainBg call, call
base.applyGardenTuning(base.rainBg) (or explicitly call
rainBg.SetSnowAccumPerLanding(...) using the gardenTuningFromConfig result when
cfg.RainAnimationMode == "snow") so snow tuning is applied immediately (see
symbols: NewRainBackground, SetGardenTuning, applyGardenTuning,
SetSnowAccumPerLanding, rain_bg.go:788, tea.WindowSizeMsg).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 856334c3-7e2f-40ba-b8a0-a7500388a225
📒 Files selected for processing (13)
README.mdinternal/config/config_test.gointernal/config/defaults.gointernal/config/loader.gointernal/config/snow_accum_test.gointernal/config/types.gointernal/ui/config_view.gointernal/ui/rain_bg.gointernal/ui/rain_bg_test.gointernal/ui/repo_selector.gointernal/ui/snow_scene.gointernal/ui/view_layout.gointernal/ui/view_layout_test.go
| if rb.Width > 16 { | ||
| rb.SnowmanX = rb.Width - 5 | ||
| } else { | ||
| rb.SnowmanX = rb.SnowCabinLeft - 4 | ||
| if rb.SnowmanX < 2 { | ||
| rb.SnowmanX = min(rb.Width-3, rb.SnowCabinLeft+snowCabinW+3) | ||
| } | ||
| } |
There was a problem hiding this comment.
Snowman placement for mid-range widths can land on the cabin.
For Width > 16 the snowman always sits at Width - 5. At modest widths (say Width = 18), SnowCabinLeft = 18/2 - 3 = 6, so the cabin occupies cells 6..12 (snowCabinW = 7). The snowman at x = 13 is immediately adjacent to the cabin's right wall; with snowPaintLine drawing the hat brim/scarf at SnowmanX-2..SnowmanX+2, those pieces overlap the cabin once the snowman finishes building. The snowFootprintFree check is only used for trees, not for the snowman anchor, so nothing else prevents the collision.
Consider computing SnowmanX as max(SnowCabinLeft + snowCabinW + 3, Width - 5) (and clamping back into [2, Width-3]) so the 18..22-ish width range still leaves breathing room between cabin and snowman.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/ui/snow_scene.go` around lines 61 - 68, The current Snowman
placement logic sets rb.SnowmanX to rb.Width-5 (or falls back left of the cabin)
which lets the snowman hat/scarf overlap the cabin for mid-range widths; change
the computation of rb.SnowmanX to ensure a 3-column buffer to the right of the
cabin by taking rb.SnowmanX = max(rb.SnowCabinLeft + snowCabinW + 3, rb.Width -
5) and then clamp that value into the valid horizontal bounds (at least 2 and at
most rb.Width-3) before using it; update the branch that sets rb.SnowmanX (and
any fallback logic) to use these max/clamp calculations and reference
rb.SnowmanX, rb.SnowCabinLeft, snowCabinW and rb.Width when making the change.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Multi-byte runes cause wrong cell positions in
snowPaintLinesnowPaintLinenow advances a rune column counter instead of using therangebyte index forx, so multi-byte glyphs paint in consecutive cells.
Preview (ad6153eefb)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -357,6 +357,24 @@
halve growth speed, or `0.5` to roughly double it. The other knobs trade
visual density (more or fewer seeds, longer or shorter blooms) for clarity.
+### Snow mode and rain panel size
+
+`rain_animation_mode = "snow"` uses the same animation strip for a winter scene:
+falling snowflakes, snow that keeps piling on the ground, a small log cabin
+with chimney smoke and lit windows, occasional evergreen trees that pick up
+frost, and a snowman that grows in stages (two spheres, then face, pipe, and
+top hat).
+
+`rain_panel_size` controls how many terminal rows the animation canvas uses:
+`compact` (5), `comfortable` (8, default), or `tall` (11). The TUI clamps the
+height automatically so the bordered panel still fits short terminals.
+
+```toml
+[ui]
+rain_animation_mode = "snow"
+rain_panel_size = "comfortable"
+```
+
### Config file, locks, and crashes
**Registry (`repos.toml`)** — Writes use a cross-process lock file (`repos.toml.lock`), atomic replace, and stale-lock detection (owner PID). If a process dies mid-run you may still see a leftover lock: the CLI prompts to remove it when safe, or you can use **`--force-unlock-registry`** in scripts. This is the same class of “stale lock / don’t corrupt the database” problem as other multi-repo tools; treat lock removal like any other forced unlock — only when you are sure no other `git-rain` is running.
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -9,9 +9,39 @@
"github.com/git-rain/git-rain/internal/config"
)
+func TestRainPanelRowsPresets(t *testing.T) {
+ tests := []struct {
+ in string
+ want int
+ }{
+ {config.UIRainPanelCompact, 5},
+ {config.UIRainPanelComfortable, 8},
+ {config.UIRainPanelTall, 11},
+ {"", 8},
+ {"unknown-preset", 8},
+ }
+ for _, tc := range tests {
+ if got := config.RainPanelRows(tc.in); got != tc.want {
+ t.Errorf("RainPanelRows(%q) = %d, want %d", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestNormalizeRainPanelSize(t *testing.T) {
+ if got := config.NormalizeRainPanelSize(" TALL "); got != config.UIRainPanelTall {
+ t.Errorf("NormalizeRainPanelSize = %q, want %q", got, config.UIRainPanelTall)
+ }
+ if got := config.NormalizeRainPanelSize("compact"); got != config.UIRainPanelCompact {
+ t.Errorf("got %q", got)
+ }
+}
+
func TestDefaultConfig_Values(t *testing.T) {
cfg := config.DefaultConfig()
+ if cfg.UI.RainPanelSize != config.UIRainPanelComfortable {
+ t.Errorf("default RainPanelSize = %q, want %q", cfg.UI.RainPanelSize, config.UIRainPanelComfortable)
+ }
if cfg.Global.BranchMode != "mainline" {
t.Errorf("default BranchMode = %q, want %q", cfg.Global.BranchMode, "mainline")
}
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -37,11 +37,14 @@
UI: UIConfig{
ShowRainAnimation: true,
RainAnimationMode: UIRainAnimationBasic,
+ RainPanelSize: UIRainPanelComfortable,
ShowStartupQuote: true,
StartupQuoteBehavior: UIQuoteBehaviorRefresh,
StartupQuoteIntervalSec: DefaultUIStartupQuoteIntervalSec,
RainTickMS: DefaultUIRainTickMS,
ColorProfile: UIColorProfileStorm,
+ // SnowAccumulationRate 0 => runtime uses 1× (see SnowAccumPerLanding).
+ SnowAccumulationRate: 0,
},
}
}
@@ -114,10 +117,14 @@
show_rain_animation = true
# Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
-# "matrix" (falling code characters), or "garden" (seeds bloom into a meadow,
-# then the rain stops and the sun comes out)
+# "matrix" (falling code characters), "garden" (seeds bloom into a meadow,
+# then the rain stops and the sun comes out), or "snow" (winter scene)
rain_animation_mode = "basic"
+# Animation canvas height: "compact" (5 rows), "comfortable" (8), or "tall" (11).
+# Clamped automatically if the terminal is short.
+rain_panel_size = "comfortable"
+
# Show flavor quotes in the TUI banner
show_startup_quote = true
@@ -133,6 +140,10 @@
# Color profile: "storm", "drizzle", "monsoon", "rainbow", "synthwave"
color_profile = "storm"
+# --- Snow mode (rain_animation_mode = "snow") -------------------------------
+# Ground depth added per landed flake (1–8). 1 = default; 3 ≈ three times faster piling.
+# snow_accumulation_rate = 1
+
# --- Garden mode tuning (advanced) -----------------------------------------
# These keys only affect rain_animation_mode = "garden". Leave them unset
# (or at 0) to use the built-in defaults; tweak to make growth slower or
diff --git a/internal/config/loader.go b/internal/config/loader.go
--- a/internal/config/loader.go
+++ b/internal/config/loader.go
@@ -69,6 +69,8 @@
return nil, fmt.Errorf("invalid config: %w", err)
}
+ cfg.UI.RainPanelSize = NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+
return &cfg, nil
}
@@ -92,6 +94,7 @@
v.SetDefault("ui.show_rain_animation", defaults.UI.ShowRainAnimation)
v.SetDefault("ui.rain_animation_mode", defaults.UI.RainAnimationMode)
+ v.SetDefault("ui.rain_panel_size", defaults.UI.RainPanelSize)
v.SetDefault("ui.show_startup_quote", defaults.UI.ShowStartupQuote)
v.SetDefault("ui.startup_quote_behavior", defaults.UI.StartupQuoteBehavior)
v.SetDefault("ui.startup_quote_interval_sec", defaults.UI.StartupQuoteIntervalSec)
@@ -108,6 +111,7 @@
v.SetDefault("ui.garden_offspring_min", defaults.UI.GardenOffspringMin)
v.SetDefault("ui.garden_offspring_max", defaults.UI.GardenOffspringMax)
v.SetDefault("ui.garden_offspring_spread", defaults.UI.GardenOffspringSpread)
+ v.SetDefault("ui.snow_accumulation_rate", defaults.UI.SnowAccumulationRate)
}
// Bounded lock acquisition for config.toml: SaveConfig runs from the TUI on
diff --git a/internal/config/snow_accum_test.go b/internal/config/snow_accum_test.go
new file mode 100644
--- /dev/null
+++ b/internal/config/snow_accum_test.go
@@ -1,0 +1,25 @@
+package config
+
+import "testing"
+
+func TestSnowAccumPerLanding(t *testing.T) {
+ tests := []struct {
+ rate float64
+ want int
+ }{
+ {0, 1},
+ {-1, 1},
+ {1, 1},
+ {1.4, 1},
+ {1.5, 2},
+ {3, 3},
+ {8, 8},
+ {8.4, 8},
+ {99, 8},
+ }
+ for _, tt := range tests {
+ if got := SnowAccumPerLanding(tt.rate); got != tt.want {
+ t.Errorf("SnowAccumPerLanding(%v) = %d, want %d", tt.rate, got, tt.want)
+ }
+ }
+}
diff --git a/internal/config/types.go b/internal/config/types.go
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -1,6 +1,11 @@
// Package config defines the git-rain configuration schema and related constants.
package config
+import (
+ "math"
+ "strings"
+)
+
// Config represents the complete git-rain configuration
type Config struct {
Global GlobalConfig `mapstructure:"global" toml:"global"`
@@ -57,10 +62,15 @@
ShowRainAnimation bool `mapstructure:"show_rain_animation" toml:"show_rain_animation"`
// Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
- // "matrix" (falling code glyphs in the same column pattern), or "garden"
- // (seeds, rain, growth, then sun).
+ // "matrix" (falling code glyphs in the same column pattern), "garden"
+ // (seeds, rain, growth, then sun), or "snow" (winter scene).
RainAnimationMode string `mapstructure:"rain_animation_mode" toml:"rain_animation_mode"`
+ // RainPanelSize is how tall the animation canvas is in the TUI: "compact",
+ // "comfortable", or "tall". The runtime clamps to the terminal so the panel
+ // still fits (see RainPanelRows).
+ RainPanelSize string `mapstructure:"rain_panel_size" toml:"rain_panel_size"`
+
// Show flavor quotes: TUI banner plus CLI motivation lines.
ShowStartupQuote bool `mapstructure:"show_startup_quote" toml:"show_startup_quote"`
@@ -111,6 +121,11 @@
// GardenOffspringSpread is the half-width X jitter applied around the
// parent column when scattering offspring seeds. 0 = default.
GardenOffspringSpread int `mapstructure:"garden_offspring_spread" toml:"garden_offspring_spread,omitempty"`
+
+ // SnowAccumulationRate scales how much ground snow depth each landed flake
+ // adds when rain_animation_mode = "snow". 1 = default; 2 ≈ twice as fast.
+ // Values are rounded to a whole number of depth units per landing (1..8).
+ SnowAccumulationRate float64 `mapstructure:"snow_accumulation_rate" toml:"snow_accumulation_rate,omitempty"`
}
const (
@@ -127,8 +142,38 @@
UIRainAnimationAdvanced = "advanced"
UIRainAnimationMatrix = "matrix"
UIRainAnimationGarden = "garden"
+ UIRainAnimationSnow = "snow"
+
+ UIRainPanelCompact = "compact"
+ UIRainPanelComfortable = "comfortable"
+ UIRainPanelTall = "tall"
)
+// RainPanelRows returns the target animation height in terminal rows for a
+// panel size preset. Unknown or empty values use comfortable.
+func RainPanelRows(preset string) int {
+ switch strings.ToLower(strings.TrimSpace(preset)) {
+ case UIRainPanelCompact:
+ return 5
+ case UIRainPanelTall:
+ return 11
+ default:
+ return 8
+ }
+}
+
+// NormalizeRainPanelSize returns a canonical preset name.
+func NormalizeRainPanelSize(preset string) string {
+ switch strings.ToLower(strings.TrimSpace(preset)) {
+ case UIRainPanelCompact:
+ return UIRainPanelCompact
+ case UIRainPanelTall:
+ return UIRainPanelTall
+ default:
+ return UIRainPanelComfortable
+ }
+}
+
// UIColorProfiles returns valid built-in UI color profile names.
func UIColorProfiles() []string {
return []string{
@@ -139,3 +184,19 @@
UIColorProfileSynthwave,
}
}
+
+// SnowAccumPerLanding returns ground depth units added per landed snowflake.
+// rate is cfg.UI.SnowAccumulationRate; 0 or negative means 1. Result is in [1, 8].
+func SnowAccumPerLanding(rate float64) int {
+ if rate <= 0 {
+ return 1
+ }
+ n := int(math.Round(rate))
+ if n < 1 {
+ return 1
+ }
+ if n > 8 {
+ return 8
+ }
+ return n
+}
diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go
--- a/internal/ui/config_view.go
+++ b/internal/ui/config_view.go
@@ -42,7 +42,13 @@
config.UIRainAnimationAdvanced,
config.UIRainAnimationMatrix,
config.UIRainAnimationGarden,
+ config.UIRainAnimationSnow,
}},
+ {label: "Rain panel size", kind: configRowEnum, options: []string{
+ config.UIRainPanelCompact,
+ config.UIRainPanelComfortable,
+ config.UIRainPanelTall,
+ }},
{label: "Show flavor quotes", kind: configRowBool},
{label: "Flavor quote behavior", kind: configRowEnum, options: []string{
config.UIQuoteBehaviorRefresh,
@@ -58,37 +64,57 @@
{label: "Custom hex palette", kind: configRowComingSoon},
}
-// Garden settings rows appear in the menu only when rain mode is garden,
-// directly under "Rain animation mode" (see logicalRowIndex).
+// firstModeExtensionVisibleRow is where mode-specific rows are inserted
+// (after "Rain animation mode" and "Rain panel size").
+const firstModeExtensionVisibleRow = 6
+
+// Garden settings rows appear only when rain mode is garden.
var gardenSettingsConfigRows = []configRow{
{label: "Garden growth pace", kind: configRowEnum, options: []string{"calm", "normal", "fast"}},
{label: "Garden seed rate", kind: configRowEnum, options: []string{"rare", "normal", "often"}},
{label: "Garden offspring", kind: configRowEnum, options: []string{"few", "default", "many"}},
}
-func gardenSettingsRowCount(cfg *config.Config) int {
- if cfg != nil && strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationGarden) {
- return len(gardenSettingsConfigRows)
+// Snow settings rows appear only when rain mode is snow.
+var snowSettingsConfigRows = []configRow{
+ {label: "Snow accumulation", kind: configRowEnum, options: []string{"1×", "2×", "3×", "4×", "6×", "8×"}},
+}
+
+func modeExtensionRows(cfg *config.Config) []configRow {
+ if cfg == nil {
+ return nil
}
- return 0
+ switch strings.ToLower(strings.TrimSpace(cfg.UI.RainAnimationMode)) {
+ case config.UIRainAnimationGarden:
+ return gardenSettingsConfigRows
+ case config.UIRainAnimationSnow:
+ return snowSettingsConfigRows
+ default:
+ return nil
+ }
}
+func modeExtensionRowCount(cfg *config.Config) int {
+ r := modeExtensionRows(cfg)
+ return len(r)
+}
+
func visibleConfigRowCount(cfg *config.Config) int {
- return len(configRows) + gardenSettingsRowCount(cfg)
+ return len(configRows) + modeExtensionRowCount(cfg)
}
// logicalRowIndex maps a visible settings row to legacy ids 0..len(configRows)-1
-// or len(configRows)+k for garden-only rows.
+// or len(configRows)+k for mode-extension rows (garden or snow).
func logicalRowIndex(visibleI int, cfg *config.Config) int {
- g := gardenSettingsRowCount(cfg)
+ g := modeExtensionRowCount(cfg)
if g == 0 {
return visibleI
}
- if visibleI < 5 {
+ if visibleI < firstModeExtensionVisibleRow {
return visibleI
}
- if visibleI < 5+g {
- return len(configRows) + (visibleI - 5)
+ if visibleI < firstModeExtensionVisibleRow+g {
+ return len(configRows) + (visibleI - firstModeExtensionVisibleRow)
}
return visibleI - g
}
@@ -98,9 +124,10 @@
if li < len(configRows) {
return configRows[li]
}
+ ext := modeExtensionRows(cfg)
gi := li - len(configRows)
- if gi >= 0 && gi < len(gardenSettingsConfigRows) {
- return gardenSettingsConfigRows[gi]
+ if ext != nil && gi >= 0 && gi < len(ext) {
+ return ext[gi]
}
return configRows[len(configRows)-1]
}
@@ -169,6 +196,26 @@
return "default"
}
+func snowAccumLabel(cfg *config.Config) string {
+ if cfg == nil {
+ return "1×"
+ }
+ switch config.SnowAccumPerLanding(cfg.UI.SnowAccumulationRate) {
+ case 2:
+ return "2×"
+ case 3:
+ return "3×"
+ case 4:
+ return "4×"
+ case 6:
+ return "6×"
+ case 8:
+ return "8×"
+ default:
+ return "1×"
+ }
+}
+
func configRowValue(visibleI int, cfg *config.Config) string {
if cfg == nil {
return ""
@@ -194,28 +241,33 @@
}
return cfg.UI.RainAnimationMode
case 5:
+ return config.NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+ case 6:
if cfg.UI.ShowStartupQuote {
return "true"
}
return "false"
- case 6:
+ case 7:
return cfg.UI.StartupQuoteBehavior
- case 7:
+ case 8:
return strconv.Itoa(cfg.UI.StartupQuoteIntervalSec)
- case 8:
+ case 9:
if cfg.UI.RainTickMS <= 0 {
return strconv.Itoa(config.DefaultUIRainTickMS)
}
return strconv.Itoa(cfg.UI.RainTickMS)
- case 9:
+ case 10:
return cfg.UI.ColorProfile
- case 10:
+ case 11:
return "coming soon"
- case 11:
+ case 12:
+ if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+ return snowAccumLabel(cfg)
+ }
return gardenGrowthPaceLabel(cfg)
- case 12:
+ case 13:
return gardenSeedRateLabel(cfg)
- case 13:
+ case 14:
return gardenOffspringLabel(cfg)
}
return ""
@@ -234,7 +286,7 @@
cfg.Global.DisableScan = !cfg.Global.DisableScan
case 3:
cfg.UI.ShowRainAnimation = !cfg.UI.ShowRainAnimation
- case 5:
+ case 6:
cfg.UI.ShowStartupQuote = !cfg.UI.ShowStartupQuote
}
case configRowEnum:
@@ -258,27 +310,46 @@
}
case 4:
cfg.UI.RainAnimationMode = opts[idx]
- case 6:
+ case 5:
+ cfg.UI.RainPanelSize = opts[idx]
+ case 7:
cfg.UI.StartupQuoteBehavior = opts[idx]
- case 7:
+ case 8:
sec, err := strconv.Atoi(opts[idx])
if err == nil && sec > 0 {
cfg.UI.StartupQuoteIntervalSec = sec
}
- case 8:
+ case 9:
applyRainTickChange(cfg, opts, dir)
- case 9:
+ case 10:
cfg.UI.ColorProfile = opts[idx]
- case 11:
- switch opts[idx] {
- case "calm":
- cfg.UI.GardenGrowthPace = 1.32
- case "fast":
- cfg.UI.GardenGrowthPace = 0.78
- default:
- cfg.UI.GardenGrowthPace = 0
+ case 12:
+ if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+ switch opts[idx] {
+ case "2×":
+ cfg.UI.SnowAccumulationRate = 2
+ case "3×":
+ cfg.UI.SnowAccumulationRate = 3
+ case "4×":
+ cfg.UI.SnowAccumulationRate = 4
+ case "6×":
+ cfg.UI.SnowAccumulationRate = 6
+ case "8×":
+ cfg.UI.SnowAccumulationRate = 8
+ default:
+ cfg.UI.SnowAccumulationRate = 0
+ }
+ } else {
+ switch opts[idx] {
+ case "calm":
+ cfg.UI.GardenGrowthPace = 1.32
+ case "fast":
+ cfg.UI.GardenGrowthPace = 0.78
+ default:
+ cfg.UI.GardenGrowthPace = 0
+ }
}
- case 12:
+ case 13:
switch opts[idx] {
case "rare":
cfg.UI.GardenSeedRate = 0.06
@@ -287,7 +358,7 @@
default:
cfg.UI.GardenSeedRate = 0
}
- case 13:
+ case 14:
switch opts[idx] {
case "few":
cfg.UI.GardenOffspringMin = 1
@@ -497,9 +568,10 @@
m.rainTick = time.Duration(m.cfg.UI.RainTickMS) * time.Millisecond
m.rainAnimationMode = m.cfg.UI.RainAnimationMode
if m.rainBg != nil {
- bgW, h := m.rainBg.Width, m.rainBg.Height
- if m.rainBg.Mode != m.rainAnimationMode {
- m.rainBg = NewRainBackground(bgW, h, m.rainAnimationMode)
+ bgW := resolveRainBackgroundWidth(m.windowWidth)
+ wantH := m.clampedRainBackgroundHeight()
+ if m.rainBg.Mode != m.rainAnimationMode || m.rainBg.Width != bgW || m.rainBg.Height != wantH {
+ m.rainBg = NewRainBackground(bgW, wantH, m.rainAnimationMode)
} else {
m.rainBg.Mode = m.rainAnimationMode
}
diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go
--- a/internal/ui/rain_bg.go
+++ b/internal/ui/rain_bg.go
@@ -364,6 +364,18 @@
GardenPlots []gardenPlot
GardenSunny bool // garden mode: rain finished, sky cleared
Garden GardenTuning // pacing knobs (always resolved to non-zero values)
+
+ // Snow mode (winter scene); used only when Mode == UIRainAnimationSnow.
+ SnowGround []int
+ SnowCabinLeft int
+ SnowTrees []snowTree
+ SnowSmoke []snowSmoke
+ SnowmanPhase int
+ SnowmanX int
+ SnowmanBuild int
+ SnowmanAux int // frame counter for accessory delays
+ SnowCabinFrost int
+ snowAccumPerLanding int // ground depth per landed flake (1..8)
}
// NewRainBackground creates a new rain background
@@ -393,6 +405,18 @@
rb.Garden = ResolveGardenTuning(t)
}
+// SetSnowAccumPerLanding sets how many ground depth units each snowflake adds
+// when it lands (snow mode only). Values are clamped to [1, 8].
+func (rb *RainBackground) SetSnowAccumPerLanding(n int) {
+ if n < 1 {
+ n = 1
+ }
+ if n > 8 {
+ n = 8
+ }
+ rb.snowAccumPerLanding = n
+}
+
func (rb *RainBackground) buildCloudRow() []string {
row := make([]string, rb.Width)
for x := 0; x < rb.Width; x++ {
@@ -412,9 +436,12 @@
if rb.Mode == config.UIRainAnimationGarden && rb.Width > 0 {
rb.GardenPlots = make([]gardenPlot, rb.Width)
}
+ if rb.Mode == config.UIRainAnimationSnow && rb.Width > 0 {
+ rb.initSnowScene()
+ }
startY := 0
- if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
- startY = 1 // leave top row for clouds / sky
+ if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
+ startY = 1 // leave top row for sky / clouds / night
}
targetCount := rb.Width * 2
if rb.Mode == config.UIRainAnimationGarden {
@@ -447,6 +474,10 @@
}
}
}
+ if rb.Mode == config.UIRainAnimationSnow {
+ char = snowflakeChars[rand.Intn(len(snowflakeChars))]
+ speed = 2 + rand.Intn(2)
+ }
drop := RainDrop{
X: rand.Intn(rb.Width),
Y: startY,
@@ -663,9 +694,9 @@
minY := 0
maxDropY := rb.Height - 1
- if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
+ if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
minY = 1
- maxDropY = rb.Height - 2 // leave bottom row for plants / flowers
+ maxDropY = rb.Height - 2 // leave bottom row for ground / plants / snow pile
}
if rb.Mode == config.UIRainAnimationGarden && rb.GardenSunny {
@@ -751,6 +782,16 @@
}
p.Age = p.MaxAge
}
+
+ if rb.Mode == config.UIRainAnimationSnow && p.Y >= maxDropY && p.Y < rb.Height && rb.SnowGround != nil && p.X >= 0 && p.X < len(rb.SnowGround) {
+ add := rb.snowAccumPerLanding
+ if add < 1 {
+ add = 1
+ }
+ rb.SnowGround[p.X] += add
+ rb.snowNoteFlakeLand(p.X)
+ p.Age = p.MaxAge
+ }
}
// Remove dead drops (off screen or expired)
@@ -775,7 +816,7 @@
}
// Periodically refresh cloud row in advanced / garden (storm) mode
- if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden) && rb.Frame%30 == 0 && rb.Width > 0 {
+ if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow) && rb.Frame%30 == 0 && rb.Width > 0 {
rb.CloudRow = rb.buildCloudRow()
}
@@ -783,6 +824,10 @@
rb.gardenAdvancePlots()
rb.gardenMaybeFinishStorm()
}
+
+ if rb.Mode == config.UIRainAnimationSnow {
+ rb.snowAdvanceScene()
+ }
}
// Render returns the rain background as a string
@@ -816,6 +861,8 @@
switch rb.Mode {
case config.UIRainAnimationGarden:
rb.paintGardenOverlays(cells)
+ case config.UIRainAnimationSnow:
+ rb.paintSnowScene(cells)
case config.UIRainAnimationAdvanced:
// Top row: clouds
if len(rb.CloudRow) >= rb.Width {
@@ -1264,6 +1311,24 @@
return result.String()
}
+ if mode == config.UIRainAnimationSnow {
+ night := lipgloss.NewStyle().Foreground(lipgloss.Color("#37474F"))
+ snow := lipgloss.NewStyle().Foreground(lipgloss.Color("#ECEFF1"))
+ for x := 0; x < width; x++ {
+ phase := float64(frame)*0.03 + float64(x)*0.11
+ ch := "·"
+ if int(phase*3)%4 == 0 {
+ ch = " "
+ } else if int(phase*5)%7 == 0 {
+ ch = snow.Render("*")
+ result.WriteString(ch)
+ continue
+ }
+ result.WriteString(night.Render(ch))
+ }
+ return result.String()
+ }
+
if mode == config.UIRainAnimationMatrix {
for x := 0; x < width; x++ {
phase := float64(frame)*0.075 + float64(x)*0.24
@@ -1321,6 +1386,9 @@
if mode == config.UIRainAnimationMatrix {
return matrixRainColors
}
+ if mode == config.UIRainAnimationSnow {
+ return snowRainColors
+ }
if mode == config.UIRainAnimationGarden {
return gardenRainColors
}
@@ -1367,3 +1435,15 @@
lipgloss.Color("#9FA8DA"),
lipgloss.Color("#C5CAE9"),
}
+
+// snowRainColors is a cool snowflake palette (high → light).
+var snowRainColors = []lipgloss.Color{
+ lipgloss.Color("#263238"),
+ lipgloss.Color("#37474F"),
+ lipgloss.Color("#455A64"),
+ lipgloss.Color("#78909C"),
+ lipgloss.Color("#90A4AE"),
+ lipgloss.Color("#B0BEC5"),
+ lipgloss.Color("#CFD8DC"),
+ lipgloss.Color("#ECEFF1"),
+}
diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go
--- a/internal/ui/rain_bg_test.go
+++ b/internal/ui/rain_bg_test.go
@@ -285,3 +285,63 @@
t.Fatalf("expected drops cleared, got %d", len(rb.Drops))
}
}
+
+func TestRenderRainWaveSnowWidth(t *testing.T) {
+ const width = 40
+ s := RenderRainWave(width, 3, config.UIRainAnimationSnow, false)
+ if got := lipgloss.Width(s); got != width {
+ t.Fatalf("lipgloss.Width(RenderRainWave snow) = %d, want %d", got, width)
+ }
+}
+
+func TestRainBackgroundSnowRenderLineWidths(t *testing.T) {
+ const w, h = 32, 8
+ rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+ for i := 0; i < 50; i++ {
+ rb.Update()
+ }
+ out := rb.Render()
+ lines := strings.Split(out, "\n")
+ if len(lines) != h {
+ t.Fatalf("expected %d lines, got %d", h, len(lines))
+ }
+ for i, line := range lines {
+ if got := lipgloss.Width(line); got != w {
+ t.Fatalf("line %d: lipgloss.Width = %d, want %d\n%q", i, got, w, line)
+ }
+ }
+}
+
+func TestSnowGroundDepthIncreases(t *testing.T) {
+ const w, h = 24, 6
+ rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+ rb.SetSnowAccumPerLanding(4)
+ sum0 := 0
+ for _, v := range rb.SnowGround {
+ sum0 += v
+ }
+ for i := 0; i < 120; i++ {
+ rb.Update()
+ }
+ sum1 := 0
+ for _, v := range rb.SnowGround {
+ sum1 += v
+ }
+ if sum1 <= sum0 {
+ t.Fatalf("expected snow ground accumulation, sum0=%d sum1=%d", sum0, sum1)
+ }
+}
+
+func TestSnowmanProgressesWithLandings(t *testing.T) {
+ const w, h = 40, 8
+ rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+ rb.SnowmanPhase = snowmanPhaseBaseDot
+ rb.SnowmanBuild = 0
+ for i := 0; i < 30; i++ {
+ rb.snowNoteFlakeLand(rb.SnowmanX)
+ }
+ rb.snowAdvanceScene()
+ if rb.SnowmanPhase == snowmanPhaseBaseDot {
+ t.Fatal("expected snowman phase to advance after landings near anchor")
+ }
+}
diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go
--- a/internal/ui/repo_selector.go
+++ b/internal/ui/repo_selector.go
@@ -186,13 +186,10 @@
s.Style = lipgloss.NewStyle().Foreground(activeProfile().boxBorder)
animMode := config.UIRainAnimationBasic
- rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode)
... diff truncated: showing 800 of 1559 linesYou can send follow-ups to the cloud agent here.
snowPaintLine used the range index as x offset; in Go that index is the UTF-8 byte position, so multi-byte glyphs (█, ≋, ─) were spaced incorrectly. Track a rune/column counter for correct cell placement. Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Replace fixed width slots with rejection sampling: tree count scales with width and height, minimum trunk spacing scales with width, canopy uses a 3-column footprint check vs cabin/snowman. Add tests for spacing and valid sites.
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
internal/ui/snow_scene.go (1)
61-68:⚠️ Potential issue | 🟡 MinorSnowman placement still overlaps the cabin at mid-range widths.
For
Width > 16,SnowmanXis unconditionally set toWidth - 5. AtWidth = 18,SnowCabinLeft = 18/2 - 3 = 6, so the cabin spans cells 6..12 (snowCabinW = 7) andSnowmanX = 13puts the snowman immediately adjacent to the cabin's right wall. Once the hat brim and scarf render atSnowmanX-2..SnowmanX+2(lines 461/464), they overlap the cabin.snowFootprintFreeis only consulted for trees, so nothing else prevents this collision.🐛 Proposed fix — enforce a buffer to the right of the cabin and clamp into canvas bounds
- if rb.Width > 16 { - rb.SnowmanX = rb.Width - 5 - } else { - rb.SnowmanX = rb.SnowCabinLeft - 4 - if rb.SnowmanX < 2 { - rb.SnowmanX = min(rb.Width-3, rb.SnowCabinLeft+snowCabinW+3) - } - } + if rb.Width > 16 { + rb.SnowmanX = max(rb.SnowCabinLeft+snowCabinW+3, rb.Width-5) + if rb.SnowmanX > rb.Width-3 { + rb.SnowmanX = rb.Width - 3 + } + } else { + rb.SnowmanX = rb.SnowCabinLeft - 4 + if rb.SnowmanX < 2 { + rb.SnowmanX = min(rb.Width-3, rb.SnowCabinLeft+snowCabinW+3) + } + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/ui/snow_scene.go` around lines 61 - 68, The SnowmanX assignment for the Width>16 branch can place the snowman so its hat/scarf (SnowmanX-2..SnowmanX+2) overlap the cabin; modify the Width>16 branch (where SnowmanX is set to rb.Width-5) to enforce a right-side buffer from the cabin by computing cabinRight := rb.SnowCabinLeft + snowCabinW - 1 and then set rb.SnowmanX = clamp(rb.Width-5, cabinRight+buffer, rb.Width-2) (pick buffer = 3) so the snowman is moved at least `buffer` cells to the right of the cabin and still clamped inside the canvas; keep the existing fallback logic for small widths intact and do not change snowFootprintFree usage.
🧹 Nitpick comments (1)
internal/ui/snow_scene.go (1)
300-319: Hoist per-frame style construction to package-level vars (lines 344–363).
paintSnowSceneallocates ~19 freshlipgloss.Stylevalues every frame vialipgloss.NewStyle().Foreground(...). These are pure constants — they don't depend on frame state or dimensions. Declaring them once at package scope (e.g.var snowNightStyle = lipgloss.NewStyle().Foreground(...)) removes these allocations from the render hot path and matches how other parts of the codebase cache style values.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/ui/snow_scene.go` around lines 300 - 319, The paintSnowScene function currently allocates ~19 lipgloss.Style instances each frame (variables night, star, wood, woodHi, win, ever, everFrost, snowBall, snowHi, snowParen, coal, nose, smokeSt, hatSt, frost); hoist these constant style constructions to package-level vars (e.g. define corresponding var nightStyle = lipgloss.NewStyle().Foreground(...) etc.) so they are created once at init time, then replace the local declarations in paintSnowScene with references to those package-level variables to remove per-frame allocations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@internal/ui/snow_scene.go`:
- Around line 460-471: The scarf is being drawn above the face because scarfY is
set to feetY - 3 (above faceY = feetY - 2); change the scarf row to sit below
the face by setting scarfY to faceY + 1 (or feetY - 1) where scarfY is computed,
and keep the existing rb.snowPaintLine call that uses scarfY so the scarf
renders at the neck between head and body instead of above the face (update the
variable assignment near the snowman layout code that defines scarfY).
- Around line 398-412: The loop in snow_scene.go repeatedly calls
lipgloss.NewStyle() per cell; hoist the three depth styles (e.g.,
sLight="#E3F2FD", sMid="#BBDEFB", sDeep="#90CAF9") and the faint variant once
outside the for loop and pick the appropriate precomputed style inside it
instead of allocating new ones each iteration; update the branch that sets st
and the faint usage (st.Faint(true)) to use the precomputed faint-style
variable, keeping rb.SnowGround, snowGroundGlyph and rb.snowPaintCell calls
unchanged.
- Around line 74-101: In snowSpawnInitialTrees the slots slice can contain
duplicate or too-close x positions and snowFootprintFree only checks
cabin/snowman, so dedupe and enforce spacing before appending: compute a unique,
sorted list of candidate x positions from slots, then for each candidate check
snowFootprintFree(x,1) AND ensure no existing rb.SnowTrees (including ones just
appended in this call) have abs(tree.x - x) < MIN_GAP (choose MIN_GAP=2 or 3),
and skip candidates that are outside safe bounds or violate the gap; finally
append only when both checks pass (update references to snowSpawnInitialTrees,
slots, snowFootprintFree, rb.SnowTrees, and the snowTree struct).
---
Duplicate comments:
In `@internal/ui/snow_scene.go`:
- Around line 61-68: The SnowmanX assignment for the Width>16 branch can place
the snowman so its hat/scarf (SnowmanX-2..SnowmanX+2) overlap the cabin; modify
the Width>16 branch (where SnowmanX is set to rb.Width-5) to enforce a
right-side buffer from the cabin by computing cabinRight := rb.SnowCabinLeft +
snowCabinW - 1 and then set rb.SnowmanX = clamp(rb.Width-5, cabinRight+buffer,
rb.Width-2) (pick buffer = 3) so the snowman is moved at least `buffer` cells to
the right of the cabin and still clamped inside the canvas; keep the existing
fallback logic for small widths intact and do not change snowFootprintFree
usage.
---
Nitpick comments:
In `@internal/ui/snow_scene.go`:
- Around line 300-319: The paintSnowScene function currently allocates ~19
lipgloss.Style instances each frame (variables night, star, wood, woodHi, win,
ever, everFrost, snowBall, snowHi, snowParen, coal, nose, smokeSt, hatSt,
frost); hoist these constant style constructions to package-level vars (e.g.
define corresponding var nightStyle = lipgloss.NewStyle().Foreground(...) etc.)
so they are created once at init time, then replace the local declarations in
paintSnowScene with references to those package-level variables to remove
per-frame allocations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 7ae41f14-58d5-4b83-94e3-926e6022a691
📒 Files selected for processing (2)
internal/ui/rain_bg_test.gointernal/ui/snow_scene.go
🚧 Files skipped from review as they are similar to previous changes (1)
- internal/ui/rain_bg_test.go
| for x := 0; x < rb.Width && x < len(rb.SnowGround); x++ { | ||
| d := rb.SnowGround[x] | ||
| g := snowGroundGlyph(d) | ||
| st := lipgloss.NewStyle().Foreground(lipgloss.Color("#E3F2FD")) | ||
| if d >= 18 { | ||
| st = lipgloss.NewStyle().Foreground(lipgloss.Color("#BBDEFB")) | ||
| } | ||
| if d >= 40 { | ||
| st = lipgloss.NewStyle().Foreground(lipgloss.Color("#90CAF9")) | ||
| } | ||
| rb.snowPaintCell(cells, x, gy, g, st) | ||
| if d >= 55 && gy-1 >= 1 { | ||
| rb.snowPaintCell(cells, x, gy-1, "░", st.Faint(true)) | ||
| } | ||
| } |
There was a problem hiding this comment.
Per-cell lipgloss.NewStyle() allocation in the ground-snow loop.
For every column of every frame you allocate up to three fresh lipgloss.Style values (#E3F2FD, #BBDEFB, #90CAF9), plus another st.Faint(true) (line 410) copy for the second-row glyph. On an 80-wide terminal at typical frame rates this is thousands of throwaway styles/sec for the ground bank alone — easy to avoid by hoisting the three styles once and picking among them.
♻️ Proposed fix — precompute the three depth styles (and the faint variant) outside the loop
- // Ground snow
- gy := rb.Height - 1
- for x := 0; x < rb.Width && x < len(rb.SnowGround); x++ {
- d := rb.SnowGround[x]
- g := snowGroundGlyph(d)
- st := lipgloss.NewStyle().Foreground(lipgloss.Color("#E3F2FD"))
- if d >= 18 {
- st = lipgloss.NewStyle().Foreground(lipgloss.Color("#BBDEFB"))
- }
- if d >= 40 {
- st = lipgloss.NewStyle().Foreground(lipgloss.Color("#90CAF9"))
- }
- rb.snowPaintCell(cells, x, gy, g, st)
- if d >= 55 && gy-1 >= 1 {
- rb.snowPaintCell(cells, x, gy-1, "░", st.Faint(true))
- }
- }
+ // Ground snow (styles precomputed to avoid per-cell allocation)
+ groundLight := lipgloss.NewStyle().Foreground(lipgloss.Color("#E3F2FD"))
+ groundMed := lipgloss.NewStyle().Foreground(lipgloss.Color("#BBDEFB"))
+ groundDeep := lipgloss.NewStyle().Foreground(lipgloss.Color("#90CAF9"))
+ groundLightF := groundLight.Faint(true)
+ groundMedF := groundMed.Faint(true)
+ groundDeepF := groundDeep.Faint(true)
+ gy := rb.Height - 1
+ for x := 0; x < rb.Width && x < len(rb.SnowGround); x++ {
+ d := rb.SnowGround[x]
+ st, stF := groundLight, groundLightF
+ if d >= 18 {
+ st, stF = groundMed, groundMedF
+ }
+ if d >= 40 {
+ st, stF = groundDeep, groundDeepF
+ }
+ rb.snowPaintCell(cells, x, gy, snowGroundGlyph(d), st)
+ if d >= 55 && gy-1 >= 1 {
+ rb.snowPaintCell(cells, x, gy-1, "░", stF)
+ }
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/ui/snow_scene.go` around lines 398 - 412, The loop in snow_scene.go
repeatedly calls lipgloss.NewStyle() per cell; hoist the three depth styles
(e.g., sLight="#E3F2FD", sMid="#BBDEFB", sDeep="#90CAF9") and the faint variant
once outside the for loop and pick the appropriate precomputed style inside it
instead of allocating new ones each iteration; update the branch that sets st
and the faint usage (st.Faint(true)) to use the precomputed faint-style
variable, keeping rb.SnowGround, snowGroundGlyph and rb.snowPaintCell calls
unchanged.
| if rb.SnowmanPhase >= snowmanPhaseHat && scarfY >= 1 { | ||
| rb.snowPaintLine(cells, rb.SnowmanX-2, scarfY, "≋≋≋", scarfSt) | ||
| } | ||
| if rb.SnowmanPhase >= snowmanPhaseHat && hatBrimY >= 1 { | ||
| rb.snowPaintLine(cells, rb.SnowmanX-2, hatBrimY, "───", hatSt) | ||
| } | ||
| if rb.SnowmanPhase >= snowmanPhaseHat && hatTopY >= 1 { | ||
| rb.snowPaintCell(cells, rb.SnowmanX, hatTopY, "█", hatSt) | ||
| if hatTopY-1 >= 1 { | ||
| rb.snowPaintCell(cells, rb.SnowmanX, hatTopY-1, "●", hatSt.Foreground(lipgloss.Color("#B71C1C"))) | ||
| } | ||
| } |
There was a problem hiding this comment.
Scarf is rendered above the face, not below it.
scarfY = feetY - 3 is one row above faceY = feetY - 2, so the snowman's "scarf" sits between the face and the hat brim (which is even higher at feetY - 4). Anatomically scarves sit at the neck (between head and body); as drawn, the hat brim at hatBrimY = feetY - 4 and the scarf at feetY - 3 stack directly above the face — visually the "scarf" reads more like a hat band.
If this is the intended stylization, disregard; otherwise consider swapping the rows so scarf sits at the neck (either between faceY and bellyY, or overlaid on the bellyY "head" row depending on how you want the three-ball stack to read).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/ui/snow_scene.go` around lines 460 - 471, The scarf is being drawn
above the face because scarfY is set to feetY - 3 (above faceY = feetY - 2);
change the scarf row to sit below the face by setting scarfY to faceY + 1 (or
feetY - 1) where scarfY is computed, and keep the existing rb.snowPaintLine call
that uses scarfY so the scarf renders at the neck between head and body instead
of above the face (update the variable assignment near the snowman layout code
that defines scarfY).
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Snow accumulation rate not applied at stream startup
- NewRepoSelectorModelStream now assigns the rain background and calls applyGardenTuning so snow accumulation rate is applied on startup, not only after WindowSizeMsg.
- ✅ Fixed: Trees placed at snowman's future position during init
- snowFootprintFree now treats the snowman reservation as active during phase none when Width >= 18, matching when the snowman will spawn so initial trees avoid that band.
Preview (639adee692)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -357,6 +357,24 @@
halve growth speed, or `0.5` to roughly double it. The other knobs trade
visual density (more or fewer seeds, longer or shorter blooms) for clarity.
+### Snow mode and rain panel size
+
+`rain_animation_mode = "snow"` uses the same animation strip for a winter scene:
+falling snowflakes, snow that keeps piling on the ground, a small log cabin
+with chimney smoke and lit windows, occasional evergreen trees that pick up
+frost, and a snowman that grows in stages (two spheres, then face, pipe, and
+top hat).
+
+`rain_panel_size` controls how many terminal rows the animation canvas uses:
+`compact` (5), `comfortable` (8, default), or `tall` (11). The TUI clamps the
+height automatically so the bordered panel still fits short terminals.
+
+```toml
+[ui]
+rain_animation_mode = "snow"
+rain_panel_size = "comfortable"
+```
+
### Config file, locks, and crashes
**Registry (`repos.toml`)** — Writes use a cross-process lock file (`repos.toml.lock`), atomic replace, and stale-lock detection (owner PID). If a process dies mid-run you may still see a leftover lock: the CLI prompts to remove it when safe, or you can use **`--force-unlock-registry`** in scripts. This is the same class of “stale lock / don’t corrupt the database” problem as other multi-repo tools; treat lock removal like any other forced unlock — only when you are sure no other `git-rain` is running.
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -9,9 +9,39 @@
"github.com/git-rain/git-rain/internal/config"
)
+func TestRainPanelRowsPresets(t *testing.T) {
+ tests := []struct {
+ in string
+ want int
+ }{
+ {config.UIRainPanelCompact, 5},
+ {config.UIRainPanelComfortable, 8},
+ {config.UIRainPanelTall, 11},
+ {"", 8},
+ {"unknown-preset", 8},
+ }
+ for _, tc := range tests {
+ if got := config.RainPanelRows(tc.in); got != tc.want {
+ t.Errorf("RainPanelRows(%q) = %d, want %d", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestNormalizeRainPanelSize(t *testing.T) {
+ if got := config.NormalizeRainPanelSize(" TALL "); got != config.UIRainPanelTall {
+ t.Errorf("NormalizeRainPanelSize = %q, want %q", got, config.UIRainPanelTall)
+ }
+ if got := config.NormalizeRainPanelSize("compact"); got != config.UIRainPanelCompact {
+ t.Errorf("got %q", got)
+ }
+}
+
func TestDefaultConfig_Values(t *testing.T) {
cfg := config.DefaultConfig()
+ if cfg.UI.RainPanelSize != config.UIRainPanelComfortable {
+ t.Errorf("default RainPanelSize = %q, want %q", cfg.UI.RainPanelSize, config.UIRainPanelComfortable)
+ }
if cfg.Global.BranchMode != "mainline" {
t.Errorf("default BranchMode = %q, want %q", cfg.Global.BranchMode, "mainline")
}
diff --git a/internal/config/defaults.go b/internal/config/defaults.go
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -37,11 +37,14 @@
UI: UIConfig{
ShowRainAnimation: true,
RainAnimationMode: UIRainAnimationBasic,
+ RainPanelSize: UIRainPanelComfortable,
ShowStartupQuote: true,
StartupQuoteBehavior: UIQuoteBehaviorRefresh,
StartupQuoteIntervalSec: DefaultUIStartupQuoteIntervalSec,
RainTickMS: DefaultUIRainTickMS,
ColorProfile: UIColorProfileStorm,
+ // SnowAccumulationRate 0 => runtime uses 1× (see SnowAccumPerLanding).
+ SnowAccumulationRate: 0,
},
}
}
@@ -114,10 +117,14 @@
show_rain_animation = true
# Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
-# "matrix" (falling code characters), or "garden" (seeds bloom into a meadow,
-# then the rain stops and the sun comes out)
+# "matrix" (falling code characters), "garden" (seeds bloom into a meadow,
+# then the rain stops and the sun comes out), or "snow" (winter scene)
rain_animation_mode = "basic"
+# Animation canvas height: "compact" (5 rows), "comfortable" (8), or "tall" (11).
+# Clamped automatically if the terminal is short.
+rain_panel_size = "comfortable"
+
# Show flavor quotes in the TUI banner
show_startup_quote = true
@@ -133,6 +140,10 @@
# Color profile: "storm", "drizzle", "monsoon", "rainbow", "synthwave"
color_profile = "storm"
+# --- Snow mode (rain_animation_mode = "snow") -------------------------------
+# Ground depth added per landed flake (1–8). 1 = default; 3 ≈ three times faster piling.
+# snow_accumulation_rate = 1
+
# --- Garden mode tuning (advanced) -----------------------------------------
# These keys only affect rain_animation_mode = "garden". Leave them unset
# (or at 0) to use the built-in defaults; tweak to make growth slower or
diff --git a/internal/config/loader.go b/internal/config/loader.go
--- a/internal/config/loader.go
+++ b/internal/config/loader.go
@@ -69,6 +69,8 @@
return nil, fmt.Errorf("invalid config: %w", err)
}
+ cfg.UI.RainPanelSize = NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+
return &cfg, nil
}
@@ -92,6 +94,7 @@
v.SetDefault("ui.show_rain_animation", defaults.UI.ShowRainAnimation)
v.SetDefault("ui.rain_animation_mode", defaults.UI.RainAnimationMode)
+ v.SetDefault("ui.rain_panel_size", defaults.UI.RainPanelSize)
v.SetDefault("ui.show_startup_quote", defaults.UI.ShowStartupQuote)
v.SetDefault("ui.startup_quote_behavior", defaults.UI.StartupQuoteBehavior)
v.SetDefault("ui.startup_quote_interval_sec", defaults.UI.StartupQuoteIntervalSec)
@@ -108,6 +111,7 @@
v.SetDefault("ui.garden_offspring_min", defaults.UI.GardenOffspringMin)
v.SetDefault("ui.garden_offspring_max", defaults.UI.GardenOffspringMax)
v.SetDefault("ui.garden_offspring_spread", defaults.UI.GardenOffspringSpread)
+ v.SetDefault("ui.snow_accumulation_rate", defaults.UI.SnowAccumulationRate)
}
// Bounded lock acquisition for config.toml: SaveConfig runs from the TUI on
diff --git a/internal/config/snow_accum_test.go b/internal/config/snow_accum_test.go
new file mode 100644
--- /dev/null
+++ b/internal/config/snow_accum_test.go
@@ -1,0 +1,25 @@
+package config
+
+import "testing"
+
+func TestSnowAccumPerLanding(t *testing.T) {
+ tests := []struct {
+ rate float64
+ want int
+ }{
+ {0, 1},
+ {-1, 1},
+ {1, 1},
+ {1.4, 1},
+ {1.5, 2},
+ {3, 3},
+ {8, 8},
+ {8.4, 8},
+ {99, 8},
+ }
+ for _, tt := range tests {
+ if got := SnowAccumPerLanding(tt.rate); got != tt.want {
+ t.Errorf("SnowAccumPerLanding(%v) = %d, want %d", tt.rate, got, tt.want)
+ }
+ }
+}
diff --git a/internal/config/types.go b/internal/config/types.go
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -1,6 +1,11 @@
// Package config defines the git-rain configuration schema and related constants.
package config
+import (
+ "math"
+ "strings"
+)
+
// Config represents the complete git-rain configuration
type Config struct {
Global GlobalConfig `mapstructure:"global" toml:"global"`
@@ -57,10 +62,15 @@
ShowRainAnimation bool `mapstructure:"show_rain_animation" toml:"show_rain_animation"`
// Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
- // "matrix" (falling code glyphs in the same column pattern), or "garden"
- // (seeds, rain, growth, then sun).
+ // "matrix" (falling code glyphs in the same column pattern), "garden"
+ // (seeds, rain, growth, then sun), or "snow" (winter scene).
RainAnimationMode string `mapstructure:"rain_animation_mode" toml:"rain_animation_mode"`
+ // RainPanelSize is how tall the animation canvas is in the TUI: "compact",
+ // "comfortable", or "tall". The runtime clamps to the terminal so the panel
+ // still fits (see RainPanelRows).
+ RainPanelSize string `mapstructure:"rain_panel_size" toml:"rain_panel_size"`
+
// Show flavor quotes: TUI banner plus CLI motivation lines.
ShowStartupQuote bool `mapstructure:"show_startup_quote" toml:"show_startup_quote"`
@@ -111,6 +121,11 @@
// GardenOffspringSpread is the half-width X jitter applied around the
// parent column when scattering offspring seeds. 0 = default.
GardenOffspringSpread int `mapstructure:"garden_offspring_spread" toml:"garden_offspring_spread,omitempty"`
+
+ // SnowAccumulationRate scales how much ground snow depth each landed flake
+ // adds when rain_animation_mode = "snow". 1 = default; 2 ≈ twice as fast.
+ // Values are rounded to a whole number of depth units per landing (1..8).
+ SnowAccumulationRate float64 `mapstructure:"snow_accumulation_rate" toml:"snow_accumulation_rate,omitempty"`
}
const (
@@ -127,8 +142,38 @@
UIRainAnimationAdvanced = "advanced"
UIRainAnimationMatrix = "matrix"
UIRainAnimationGarden = "garden"
+ UIRainAnimationSnow = "snow"
+
+ UIRainPanelCompact = "compact"
+ UIRainPanelComfortable = "comfortable"
+ UIRainPanelTall = "tall"
)
+// RainPanelRows returns the target animation height in terminal rows for a
+// panel size preset. Unknown or empty values use comfortable.
+func RainPanelRows(preset string) int {
+ switch strings.ToLower(strings.TrimSpace(preset)) {
+ case UIRainPanelCompact:
+ return 5
+ case UIRainPanelTall:
+ return 11
+ default:
+ return 8
+ }
+}
+
+// NormalizeRainPanelSize returns a canonical preset name.
+func NormalizeRainPanelSize(preset string) string {
+ switch strings.ToLower(strings.TrimSpace(preset)) {
+ case UIRainPanelCompact:
+ return UIRainPanelCompact
+ case UIRainPanelTall:
+ return UIRainPanelTall
+ default:
+ return UIRainPanelComfortable
+ }
+}
+
// UIColorProfiles returns valid built-in UI color profile names.
func UIColorProfiles() []string {
return []string{
@@ -139,3 +184,19 @@
UIColorProfileSynthwave,
}
}
+
+// SnowAccumPerLanding returns ground depth units added per landed snowflake.
+// rate is cfg.UI.SnowAccumulationRate; 0 or negative means 1. Result is in [1, 8].
+func SnowAccumPerLanding(rate float64) int {
+ if rate <= 0 {
+ return 1
+ }
+ n := int(math.Round(rate))
+ if n < 1 {
+ return 1
+ }
+ if n > 8 {
+ return 8
+ }
+ return n
+}
diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go
--- a/internal/ui/config_view.go
+++ b/internal/ui/config_view.go
@@ -42,7 +42,13 @@
config.UIRainAnimationAdvanced,
config.UIRainAnimationMatrix,
config.UIRainAnimationGarden,
+ config.UIRainAnimationSnow,
}},
+ {label: "Rain panel size", kind: configRowEnum, options: []string{
+ config.UIRainPanelCompact,
+ config.UIRainPanelComfortable,
+ config.UIRainPanelTall,
+ }},
{label: "Show flavor quotes", kind: configRowBool},
{label: "Flavor quote behavior", kind: configRowEnum, options: []string{
config.UIQuoteBehaviorRefresh,
@@ -58,37 +64,57 @@
{label: "Custom hex palette", kind: configRowComingSoon},
}
-// Garden settings rows appear in the menu only when rain mode is garden,
-// directly under "Rain animation mode" (see logicalRowIndex).
+// firstModeExtensionVisibleRow is where mode-specific rows are inserted
+// (after "Rain animation mode" and "Rain panel size").
+const firstModeExtensionVisibleRow = 6
+
+// Garden settings rows appear only when rain mode is garden.
var gardenSettingsConfigRows = []configRow{
{label: "Garden growth pace", kind: configRowEnum, options: []string{"calm", "normal", "fast"}},
{label: "Garden seed rate", kind: configRowEnum, options: []string{"rare", "normal", "often"}},
{label: "Garden offspring", kind: configRowEnum, options: []string{"few", "default", "many"}},
}
-func gardenSettingsRowCount(cfg *config.Config) int {
- if cfg != nil && strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationGarden) {
- return len(gardenSettingsConfigRows)
+// Snow settings rows appear only when rain mode is snow.
+var snowSettingsConfigRows = []configRow{
+ {label: "Snow accumulation", kind: configRowEnum, options: []string{"1×", "2×", "3×", "4×", "5×", "6×", "7×", "8×"}},
+}
+
+func modeExtensionRows(cfg *config.Config) []configRow {
+ if cfg == nil {
+ return nil
}
- return 0
+ switch strings.ToLower(strings.TrimSpace(cfg.UI.RainAnimationMode)) {
+ case config.UIRainAnimationGarden:
+ return gardenSettingsConfigRows
+ case config.UIRainAnimationSnow:
+ return snowSettingsConfigRows
+ default:
+ return nil
+ }
}
+func modeExtensionRowCount(cfg *config.Config) int {
+ r := modeExtensionRows(cfg)
+ return len(r)
+}
+
func visibleConfigRowCount(cfg *config.Config) int {
- return len(configRows) + gardenSettingsRowCount(cfg)
+ return len(configRows) + modeExtensionRowCount(cfg)
}
// logicalRowIndex maps a visible settings row to legacy ids 0..len(configRows)-1
-// or len(configRows)+k for garden-only rows.
+// or len(configRows)+k for mode-extension rows (garden or snow).
func logicalRowIndex(visibleI int, cfg *config.Config) int {
- g := gardenSettingsRowCount(cfg)
+ g := modeExtensionRowCount(cfg)
if g == 0 {
return visibleI
}
- if visibleI < 5 {
+ if visibleI < firstModeExtensionVisibleRow {
return visibleI
}
- if visibleI < 5+g {
- return len(configRows) + (visibleI - 5)
+ if visibleI < firstModeExtensionVisibleRow+g {
+ return len(configRows) + (visibleI - firstModeExtensionVisibleRow)
}
return visibleI - g
}
@@ -98,9 +124,10 @@
if li < len(configRows) {
return configRows[li]
}
+ ext := modeExtensionRows(cfg)
gi := li - len(configRows)
- if gi >= 0 && gi < len(gardenSettingsConfigRows) {
- return gardenSettingsConfigRows[gi]
+ if ext != nil && gi >= 0 && gi < len(ext) {
+ return ext[gi]
}
return configRows[len(configRows)-1]
}
@@ -169,6 +196,14 @@
return "default"
}
+func snowAccumLabel(cfg *config.Config) string {
+ if cfg == nil {
+ return "1×"
+ }
+ n := config.SnowAccumPerLanding(cfg.UI.SnowAccumulationRate)
+ return fmt.Sprintf("%d×", n)
+}
+
func configRowValue(visibleI int, cfg *config.Config) string {
if cfg == nil {
return ""
@@ -194,28 +229,33 @@
}
return cfg.UI.RainAnimationMode
case 5:
+ return config.NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+ case 6:
if cfg.UI.ShowStartupQuote {
return "true"
}
return "false"
- case 6:
+ case 7:
return cfg.UI.StartupQuoteBehavior
- case 7:
+ case 8:
return strconv.Itoa(cfg.UI.StartupQuoteIntervalSec)
- case 8:
+ case 9:
if cfg.UI.RainTickMS <= 0 {
return strconv.Itoa(config.DefaultUIRainTickMS)
}
return strconv.Itoa(cfg.UI.RainTickMS)
- case 9:
+ case 10:
return cfg.UI.ColorProfile
- case 10:
+ case 11:
return "coming soon"
- case 11:
+ case 12:
+ if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+ return snowAccumLabel(cfg)
+ }
return gardenGrowthPaceLabel(cfg)
- case 12:
+ case 13:
return gardenSeedRateLabel(cfg)
- case 13:
+ case 14:
return gardenOffspringLabel(cfg)
}
return ""
@@ -234,7 +274,7 @@
cfg.Global.DisableScan = !cfg.Global.DisableScan
case 3:
cfg.UI.ShowRainAnimation = !cfg.UI.ShowRainAnimation
- case 5:
+ case 6:
cfg.UI.ShowStartupQuote = !cfg.UI.ShowStartupQuote
}
case configRowEnum:
@@ -258,27 +298,50 @@
}
case 4:
cfg.UI.RainAnimationMode = opts[idx]
- case 6:
+ case 5:
+ cfg.UI.RainPanelSize = opts[idx]
+ case 7:
cfg.UI.StartupQuoteBehavior = opts[idx]
- case 7:
+ case 8:
sec, err := strconv.Atoi(opts[idx])
if err == nil && sec > 0 {
cfg.UI.StartupQuoteIntervalSec = sec
}
- case 8:
+ case 9:
applyRainTickChange(cfg, opts, dir)
- case 9:
+ case 10:
cfg.UI.ColorProfile = opts[idx]
- case 11:
- switch opts[idx] {
- case "calm":
- cfg.UI.GardenGrowthPace = 1.32
- case "fast":
- cfg.UI.GardenGrowthPace = 0.78
- default:
- cfg.UI.GardenGrowthPace = 0
+ case 12:
+ if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+ switch opts[idx] {
+ case "2×":
+ cfg.UI.SnowAccumulationRate = 2
+ case "3×":
+ cfg.UI.SnowAccumulationRate = 3
+ case "4×":
+ cfg.UI.SnowAccumulationRate = 4
+ case "5×":
+ cfg.UI.SnowAccumulationRate = 5
+ case "6×":
+ cfg.UI.SnowAccumulationRate = 6
+ case "7×":
+ cfg.UI.SnowAccumulationRate = 7
+ case "8×":
+ cfg.UI.SnowAccumulationRate = 8
+ default:
+ cfg.UI.SnowAccumulationRate = 0
+ }
+ } else {
+ switch opts[idx] {
+ case "calm":
+ cfg.UI.GardenGrowthPace = 1.32
+ case "fast":
+ cfg.UI.GardenGrowthPace = 0.78
+ default:
+ cfg.UI.GardenGrowthPace = 0
+ }
}
- case 12:
+ case 13:
switch opts[idx] {
case "rare":
cfg.UI.GardenSeedRate = 0.06
@@ -287,7 +350,7 @@
default:
cfg.UI.GardenSeedRate = 0
}
- case 13:
+ case 14:
switch opts[idx] {
case "few":
cfg.UI.GardenOffspringMin = 1
@@ -497,9 +560,10 @@
m.rainTick = time.Duration(m.cfg.UI.RainTickMS) * time.Millisecond
m.rainAnimationMode = m.cfg.UI.RainAnimationMode
if m.rainBg != nil {
- bgW, h := m.rainBg.Width, m.rainBg.Height
- if m.rainBg.Mode != m.rainAnimationMode {
- m.rainBg = NewRainBackground(bgW, h, m.rainAnimationMode)
+ bgW := resolveRainBackgroundWidth(m.windowWidth)
+ wantH := m.clampedRainBackgroundHeight()
+ if m.rainBg.Mode != m.rainAnimationMode || m.rainBg.Width != bgW || m.rainBg.Height != wantH {
+ m.rainBg = NewRainBackground(bgW, wantH, m.rainAnimationMode)
} else {
m.rainBg.Mode = m.rainAnimationMode
}
diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go
--- a/internal/ui/rain_bg.go
+++ b/internal/ui/rain_bg.go
@@ -364,6 +364,18 @@
GardenPlots []gardenPlot
GardenSunny bool // garden mode: rain finished, sky cleared
Garden GardenTuning // pacing knobs (always resolved to non-zero values)
+
+ // Snow mode (winter scene); used only when Mode == UIRainAnimationSnow.
+ SnowGround []int
+ SnowCabinLeft int
+ SnowTrees []snowTree
+ SnowSmoke []snowSmoke
+ SnowmanPhase int
+ SnowmanX int
+ SnowmanBuild int
+ SnowmanAux int // frame counter for accessory delays
+ SnowCabinFrost int
+ snowAccumPerLanding int // ground depth per landed flake (1..8)
}
// NewRainBackground creates a new rain background
@@ -393,6 +405,18 @@
rb.Garden = ResolveGardenTuning(t)
}
+// SetSnowAccumPerLanding sets how many ground depth units each snowflake adds
+// when it lands (snow mode only). Values are clamped to [1, 8].
+func (rb *RainBackground) SetSnowAccumPerLanding(n int) {
+ if n < 1 {
+ n = 1
+ }
+ if n > 8 {
+ n = 8
+ }
+ rb.snowAccumPerLanding = n
+}
+
func (rb *RainBackground) buildCloudRow() []string {
row := make([]string, rb.Width)
for x := 0; x < rb.Width; x++ {
@@ -412,9 +436,12 @@
if rb.Mode == config.UIRainAnimationGarden && rb.Width > 0 {
rb.GardenPlots = make([]gardenPlot, rb.Width)
}
+ if rb.Mode == config.UIRainAnimationSnow && rb.Width > 0 {
+ rb.initSnowScene()
+ }
startY := 0
- if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
- startY = 1 // leave top row for clouds / sky
+ if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
+ startY = 1 // leave top row for sky / clouds / night
}
targetCount := rb.Width * 2
if rb.Mode == config.UIRainAnimationGarden {
@@ -447,6 +474,10 @@
}
}
}
+ if rb.Mode == config.UIRainAnimationSnow {
+ char = snowflakeChars[rand.Intn(len(snowflakeChars))]
+ speed = 2 + rand.Intn(2)
+ }
drop := RainDrop{
X: rand.Intn(rb.Width),
Y: startY,
@@ -663,9 +694,9 @@
minY := 0
maxDropY := rb.Height - 1
- if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
+ if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
minY = 1
- maxDropY = rb.Height - 2 // leave bottom row for plants / flowers
+ maxDropY = rb.Height - 2 // leave bottom row for ground / plants / snow pile
}
if rb.Mode == config.UIRainAnimationGarden && rb.GardenSunny {
@@ -751,6 +782,16 @@
}
p.Age = p.MaxAge
}
+
+ if rb.Mode == config.UIRainAnimationSnow && p.Y >= maxDropY && p.Y < rb.Height && rb.SnowGround != nil && p.X >= 0 && p.X < len(rb.SnowGround) {
+ add := rb.snowAccumPerLanding
+ if add < 1 {
+ add = 1
+ }
+ rb.SnowGround[p.X] += add
+ rb.snowNoteFlakeLand(p.X)
+ p.Age = p.MaxAge
+ }
}
// Remove dead drops (off screen or expired)
@@ -775,7 +816,7 @@
}
// Periodically refresh cloud row in advanced / garden (storm) mode
- if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden) && rb.Frame%30 == 0 && rb.Width > 0 {
+ if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow) && rb.Frame%30 == 0 && rb.Width > 0 {
rb.CloudRow = rb.buildCloudRow()
}
@@ -783,6 +824,10 @@
rb.gardenAdvancePlots()
rb.gardenMaybeFinishStorm()
}
+
+ if rb.Mode == config.UIRainAnimationSnow {
+ rb.snowAdvanceScene()
+ }
}
// Render returns the rain background as a string
@@ -816,6 +861,8 @@
switch rb.Mode {
case config.UIRainAnimationGarden:
rb.paintGardenOverlays(cells)
+ case config.UIRainAnimationSnow:
+ rb.paintSnowScene(cells)
case config.UIRainAnimationAdvanced:
// Top row: clouds
if len(rb.CloudRow) >= rb.Width {
@@ -1264,6 +1311,24 @@
return result.String()
}
+ if mode == config.UIRainAnimationSnow {
+ night := lipgloss.NewStyle().Foreground(lipgloss.Color("#37474F"))
+ snow := lipgloss.NewStyle().Foreground(lipgloss.Color("#ECEFF1"))
+ for x := 0; x < width; x++ {
+ phase := float64(frame)*0.03 + float64(x)*0.11
+ ch := "·"
+ if int(phase*3)%4 == 0 {
+ ch = " "
+ } else if int(phase*5)%7 == 0 {
+ ch = snow.Render("*")
+ result.WriteString(ch)
+ continue
+ }
+ result.WriteString(night.Render(ch))
+ }
+ return result.String()
+ }
+
if mode == config.UIRainAnimationMatrix {
for x := 0; x < width; x++ {
phase := float64(frame)*0.075 + float64(x)*0.24
@@ -1321,6 +1386,9 @@
if mode == config.UIRainAnimationMatrix {
return matrixRainColors
}
+ if mode == config.UIRainAnimationSnow {
+ return snowRainColors
+ }
if mode == config.UIRainAnimationGarden {
return gardenRainColors
}
@@ -1367,3 +1435,15 @@
lipgloss.Color("#9FA8DA"),
lipgloss.Color("#C5CAE9"),
}
+
+// snowRainColors is a cool snowflake palette (high → light).
+var snowRainColors = []lipgloss.Color{
+ lipgloss.Color("#263238"),
+ lipgloss.Color("#37474F"),
+ lipgloss.Color("#455A64"),
+ lipgloss.Color("#78909C"),
+ lipgloss.Color("#90A4AE"),
+ lipgloss.Color("#B0BEC5"),
+ lipgloss.Color("#CFD8DC"),
+ lipgloss.Color("#ECEFF1"),
+}
diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go
--- a/internal/ui/rain_bg_test.go
+++ b/internal/ui/rain_bg_test.go
@@ -285,3 +285,100 @@
t.Fatalf("expected drops cleared, got %d", len(rb.Drops))
}
}
+
+func TestRenderRainWaveSnowWidth(t *testing.T) {
+ const width = 40
+ s := RenderRainWave(width, 3, config.UIRainAnimationSnow, false)
+ if got := lipgloss.Width(s); got != width {
+ t.Fatalf("lipgloss.Width(RenderRainWave snow) = %d, want %d", got, width)
+ }
+}
+
+func TestRainBackgroundSnowRenderLineWidths(t *testing.T) {
+ const w, h = 32, 8
+ rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+ for i := 0; i < 50; i++ {
+ rb.Update()
+ }
+ out := rb.Render()
+ lines := strings.Split(out, "\n")
+ if len(lines) != h {
+ t.Fatalf("expected %d lines, got %d", h, len(lines))
+ }
+ for i, line := range lines {
+ if got := lipgloss.Width(line); got != w {
+ t.Fatalf("line %d: lipgloss.Width = %d, want %d\n%q", i, got, w, line)
+ }
+ }
+}
+
+func TestSnowGroundDepthIncreases(t *testing.T) {
+ const w, h = 24, 6
+ rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+ rb.SetSnowAccumPerLanding(4)
+ sum0 := 0
+ for _, v := range rb.SnowGround {
+ sum0 += v
+ }
+ for i := 0; i < 120; i++ {
+ rb.Update()
+ }
+ sum1 := 0
+ for _, v := range rb.SnowGround {
+ sum1 += v
+ }
+ if sum1 <= sum0 {
+ t.Fatalf("expected snow ground accumulation, sum0=%d sum1=%d", sum0, sum1)
+ }
+}
+
+func TestSnowTreesPlacedWithSpacingAndScale(t *testing.T) {
+ const w, h = 48, 10
+ rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+ if len(rb.SnowTrees) < 2 {
+ t.Fatalf("expected at least 2 snow trees for %dx%d, got %d", w, h, len(rb.SnowTrees))
+ }
+ minGap := w / 12
+ if minGap < 4 {
+ minGap = 4
+ }
+ if minGap > 10 {
+ minGap = 10
+ }
+ for i := range rb.SnowTrees {
+ tr := rb.SnowTrees[i]
+ if tr.x < 1 || tr.x >= w-1 {
+ t.Fatalf("tree trunk x out of bounds: %d", tr.x)
+ }
+ if !rb.snowTreeSiteFree(tr.x) {
+ t.Fatalf("tree at x=%d overlaps cabin/snowman or clips canopy", tr.x)
+ }
+ for j := i + 1; j < len(rb.SnowTrees); j++ {
+ if d := absInt(tr.x - rb.SnowTrees[j].x); d < minGap {
+ t.Fatalf("trees closer than minGap %d: x=%d and x=%d", minGap, tr.x, rb.SnowTrees[j].x)
+ }
+ }
+ }
+}
+
+func TestSnowTreesAtLeastOneWhenMarginExists(t *testing.T) {
... diff truncated: showing 800 of 1635 linesYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit f5360af. Configure here.
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>

Summary
Adds snow
rain_animation_modewith a winter strip (flakes, drifting ground snow, cabin, smoke, evergreens, staged snowman), rain_panel_size presets (compact / comfortable / tall) with runtime clamping, and follow-up fixes for stability and polish.Fixes (second commit)
rainVisibleno longer calls layout measurement that re-entered the header; measurement usesrainStripIncludedInLayoutMeasure()while the live view still usesrainVisible().snow_accumulation_rate(1–8× depth per landing), settings row in snow mode, loader default, tests.Tests
go test ./...(pass)Docs
README + example TOML updated on the base commit for snow and panel size.
Note
Medium Risk
Moderate UI/config surface-area change: adds a new animation mode with new rendering/state plus new config keys and layout-measurement logic, which could impact TUI sizing or cause regressions on small terminals.
Overview
Adds a new
snowrain_animation_modewith a winter scene (snowflakes with accumulating ground, cabin/smoke, trees, and a staged snowman) and integrates it into the TUI renderer and settings.Introduces
ui.rain_panel_sizepresets (compact/comfortable/tall) with normalization/defaults and runtime clamping so the bordered panels fit short terminals; the TUI now recreates the rain background when mode/width/height change.Adds
ui.snow_accumulation_rate(rounded/clamped 1–8) wired through config loading and the settings menu, and fixes a layout recursion/stack-overflow by separating rain visibility checks from layout measurement. Includes new/expanded tests and README config docs for snow and panel sizing.Reviewed by Cursor Bugbot for commit 639adee. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
New Features
Documentation
Tests